feat: add shared Rust MSIX engine and CLI

Closes #2
This commit is contained in:
jychoi4 committed 2026-11-02 13:44:00 +09:00
1 parent 24b624301c
commit 6213fec7d2
29 files changed
+4058 -5

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
[package]
name = "package-infra"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
fs2.workspace = true
uuid.workspace = true
package-domain.workspace = true
package-engine.workspace = true
globset = "0.4"
walkdir = "2"
png = "0.18"
roxmltree = "0.21"
[dev-dependencies]
schemars.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_Security", "Win32_System_Threading", "Win32_System_LibraryLoader"] }
+229
View File
@@ -0,0 +1,229 @@
use anyhow::{bail, ensure, Context, Result};
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use package_domain::{safe_relative, LocalConfig, Project};
use package_engine::{cancelled, ResolvedFile};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
fs::{self, File},
io::{BufReader, Read, Seek, SeekFrom, Write},
path::Path,
sync::atomic::AtomicBool,
};
use walkdir::WalkDir;
pub fn is_link(meta: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
meta.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
meta.file_type().is_symlink()
}
}
pub fn reject_links(path: &Path) -> Result<()> {
for ancestor in path.ancestors() {
if ancestor.exists() {
ensure!(
!is_link(&fs::symlink_metadata(ancestor)?),
"ReparsePoint: {}",
ancestor.display()
);
}
}
Ok(())
}
fn patterns(values: &[String]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for value in values {
ensure!(
!value.contains('\\')
&& !value.starts_with('/')
&& !value.split('/').any(|v| v == "..")
&& !value.contains(':'),
"UnsafeGlob: {value}"
);
builder.add(
GlobBuilder::new(value)
.literal_separator(true)
.case_insensitive(true)
.build()?,
);
}
Ok(builder.build()?)
}
pub fn resolve(
project: &Project,
local: &LocalConfig,
project_root: &Path,
) -> Result<Vec<ResolvedFile>> {
let mut files = BTreeMap::new();
for rule in &project.files {
let configured = local.sources.get(&rule.source).with_context(|| {
format!(
"MissingSource: {}의 실제 폴더를 local 설정에 지정하세요.",
rule.source
)
})?;
let source = project_root.join(configured);
reject_links(&source)?;
let source = source
.canonicalize()
.with_context(|| format!("MissingSource: {}", source.display()))?;
ensure!(source.is_dir(), "SourceNotDirectory: {}", source.display());
// Fixed work/release roots may never be collected as input, including ancestor roots.
for output in [project_root.join(".work"), project_root.join("releases")] {
ensure!(
!output.starts_with(&source) && !source.starts_with(&output),
"OutputInsideSource: 소스는 프로젝트 .work/releases와 분리하세요."
);
}
let include = patterns(&rule.include)?;
let exclude = patterns(&rule.exclude)?;
let mut count = 0;
for entry in WalkDir::new(&source)
.follow_links(false)
.sort_by_file_name()
{
let entry = entry?;
ensure!(
!is_link(&fs::symlink_metadata(entry.path())?),
"ReparsePoint: {}",
entry.path().display()
);
if entry.file_type().is_dir() {
continue;
}
ensure!(entry.file_type().is_file(), "UnsupportedFileType");
let relative = entry
.path()
.strip_prefix(&source)?
.to_str()
.context("NonUnicodePath")?
.replace('\\', "/");
if !include.is_match(&relative) || exclude.is_match(&relative) {
continue;
}
let target = if rule.target_relative_path.is_empty() {
relative
} else {
format!("{}/{}", rule.target_relative_path, relative)
};
ensure!(safe_relative(&target, false), "UnsafePath: {target}");
let key = target.to_lowercase();
ensure!(
!["registry.dat", "user.dat", "user.classes.dat"]
.contains(&key.split('/').next().unwrap()),
"ReservedPath: {target}; use project registry settings"
);
ensure!(
![
"appxmanifest.xml",
"appxblockmap.xml",
"appxsignature.p7x",
"[content_types].xml"
]
.contains(&key.as_str())
&& !key.starts_with("appxmetadata/"),
"ReservedPath: {target}"
);
ensure!(!files.contains_key(&key), "TargetPathCollision: {target}");
// Reject file/directory collisions across separate rules as well.
ensure!(
!files
.keys()
.any(|existing: &String| key.starts_with(&format!("{existing}/"))
|| existing.starts_with(&format!("{key}/"))),
"TargetPathCollision: {target}"
);
files.insert(
key,
ResolvedFile {
source: entry.path().to_path_buf(),
target,
bytes: entry.metadata()?.len().to_string(),
sha256: super::hash_file(entry.path())?,
},
);
count += 1;
}
ensure!(
!rule.required || count > 0,
"MissingRequiredFile: {}",
rule.id
);
}
let app = &project.formats.msix.applications[0];
let get = |path: &str| -> Result<&ResolvedFile> {
files
.get(&path.to_lowercase())
.with_context(|| format!("MissingRequiredFile: {path}"))
};
verify_pe(
&get(&app.executable)?.source,
&project.formats.msix.identity.architecture,
)?;
for (path, size) in [(&app.square44x44_logo, 44), (&app.square150x150_logo, 150)] {
let file = get(path)?;
let decoder = png::Decoder::new(BufReader::new(File::open(&file.source)?));
let mut reader = decoder
.read_info()
.with_context(|| format!("InvalidPng: {path}"))?;
ensure!(
reader.info().width == size && reader.info().height == size,
"InvalidAssetSize: {path} must be {size}x{size} PNG (scale-100)"
);
let mut pixels = vec![0; reader.output_buffer_size().context("PngTooLarge")?];
reader.next_frame(&mut pixels)?;
}
Ok(files.into_values().collect())
}
fn verify_pe(path: &Path, architecture: &str) -> Result<()> {
let mut file = File::open(path)?;
let mut dos = [0; 64];
file.read_exact(&mut dos).context("InvalidPE: DOS header")?;
ensure!(&dos[..2] == b"MZ", "InvalidPE: MZ");
let offset = u32::from_le_bytes(dos[60..64].try_into().unwrap());
file.seek(SeekFrom::Start(offset as u64))?;
let mut pe = [0; 24];
file.read_exact(&mut pe).context("InvalidPE: COFF header")?;
ensure!(&pe[..4] == b"PE\0\0", "InvalidPE: signature");
let machine = u16::from_le_bytes([pe[4], pe[5]]);
let expected = match architecture {
"x64" => 0x8664,
"x86" => 0x14c,
"arm64" => 0xaa64,
_ => bail!("UnsupportedArchitecture"),
};
ensure!(
machine == expected,
"ArchitectureMismatch: 실행 파일과 패키지 아키텍처가 다릅니다."
);
ensure!(
u16::from_le_bytes([pe[22], pe[23]]) & 0x2000 == 0,
"InvalidExecutable: DLL은 실행 항목으로 사용할 수 없습니다."
);
Ok(())
}
pub fn copy_hash(source: &Path, target: &Path, cancel: &AtomicBool) -> Result<String> {
reject_links(source)?;
let mut input = File::open(source)?;
let mut output = File::options().write(true).create_new(true).open(target)?;
let mut hash = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
cancelled(cancel)?;
let size = input.read(&mut buffer)?;
if size == 0 {
break;
}
hash.update(&buffer[..size]);
output.write_all(&buffer[..size])?;
}
output.sync_all()?;
Ok(format!("{:x}", hash.finalize()))
}
+154
View File
@@ -0,0 +1,154 @@
mod files;
mod process;
mod store;
pub use store::*;
use anyhow::{ensure, Context, Result};
use package_domain::{LocalConfig, Project};
use package_engine::{cancelled, PackageBackend, Plan, ResolvedFile, Tool};
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::AtomicBool,
};
pub struct MsixBackend;
impl PackageBackend for MsixBackend {
fn signing_plan(
&self,
project: &Project,
_local: &LocalConfig,
) -> Result<Option<package_engine::DevelopmentSigningPlan>> {
ensure!(project.signing.mode == package_domain::SigningMode::Unsigned, "UnsupportedSigning: developer signing is not enabled yet");
Ok(None)
}
fn resolve(
&self,
project: &Project,
local: &LocalConfig,
root: &Path,
) -> Result<Vec<ResolvedFile>> {
ensure!(project.registry.is_empty(), "UnsupportedRegistry: registry packaging is not enabled yet");
files::resolve(project, local, root)
}
fn tool(&self, local: &LocalConfig) -> Result<Tool> {
let path = local.make_appx.as_ref().context(
"MissingTool: local 설정의 makeAppx에 Windows SDK MakeAppx.exe 절대 경로를 지정하세요.",
)?;
let path = Path::new(path);
ensure!(
path.is_absolute(),
"UnsafeTool: 도구는 절대 경로여야 합니다."
);
ensure!(
path.file_name()
.is_some_and(|n| n.eq_ignore_ascii_case("makeappx.exe")),
"UnsupportedTool: MakeAppx.exe만 허용합니다."
);
ensure!(path.is_file(), "MissingTool: {}", path.display());
Ok(Tool {
path: path.canonicalize()?,
sha256: hash_file(path)?,
})
}
fn pack(&self, plan: &Plan, work: &Path, cancel: &AtomicBool) -> Result<(PathBuf, String)> {
let stage = work.join("staging");
fs::create_dir(&stage)?;
for file in &plan.files {
cancelled(cancel)?;
let dest = stage.join(&file.target);
fs::create_dir_all(dest.parent().unwrap())?;
// Hash while streaming the copy; check source again to detect in-flight changes.
ensure!(
files::copy_hash(&file.source, &dest, cancel)? == file.sha256,
"InputChanged: {}",
file.target
);
ensure!(
hash_file(&file.source)? == file.sha256,
"InputChanged: {}",
file.target
);
}
let registry_hash: Option<String> = None;
let manifest = package_domain::manifest(&plan.project);
fs::write(stage.join("AppxManifest.xml"), &manifest)?;
fs::write(work.join("AppxManifest.xml"), &manifest)?;
ensure!(
hash_file(&plan.tool.path)? == plan.tool.sha256,
"ToolchainMismatch"
);
let package = work.join("package.msix");
process::run(
&plan.tool.path,
&[
"pack".as_ref(),
"/d".as_ref(),
stage.as_os_str(),
"/p".as_ref(),
package.as_os_str(),
"/h".as_ref(),
"SHA256".as_ref(),
],
&work.join("pack.log"),
cancel,
)?;
let unpack = work.join("inspection");
process::run(
&plan.tool.path,
&[
"unpack".as_ref(),
"/p".as_ref(),
package.as_os_str(),
"/d".as_ref(),
unpack.as_os_str(),
],
&work.join("inspect.log"),
cancel,
)?;
inspect_manifest(
&fs::read_to_string(unpack.join("AppxManifest.xml"))?,
&plan.project,
)?;
for file in &plan.files {
cancelled(cancel)?;
ensure!(
hash_file(&unpack.join(&file.target))? == file.sha256,
"ArtifactMismatch: {}",
file.target
);
}
atomic_json(
&work.join("registry-report.json"),
&serde_json::json!({
"entryCount": plan.project.registry.len(), "sha256": registry_hash,
"status": if plan.project.registry.is_empty() { "notRequested" } else { "verified" },
"liveRegistryChanged": false,
"entries": plan.project.registry.iter().map(|entry| serde_json::json!({"id": entry.id, "path": package_domain::registry_path(entry), "name": entry.name, "type": entry.value_type})).collect::<Vec<_>>()
}),
)?;
let sha256 = hash_file(&package)?;
Ok((package, sha256))
}
}
fn inspect_manifest(xml: &str, project: &Project) -> Result<()> {
let doc = roxmltree::Document::parse(xml)?;
let identity = doc
.descendants()
.find(|n| n.has_tag_name("Identity"))
.context("MissingIdentity")?;
let expected = &project.formats.msix.identity;
for (key, value) in [
("Name", expected.name.as_str()),
("Publisher", expected.publisher.as_deref().unwrap_or("")),
("Version", &expected.version),
("ProcessorArchitecture", &expected.architecture),
] {
ensure!(
identity.attribute(key) == Some(value),
"IdentityMismatch: {key}"
);
}
Ok(())
}
+113
View File
@@ -0,0 +1,113 @@
use anyhow::{ensure, Context, Result};
use package_engine::cancelled;
use std::{
ffi::OsStr,
fs::File,
path::Path,
process::{Command, Stdio},
sync::atomic::AtomicBool,
thread,
time::{Duration, Instant},
};
pub fn run(tool: &Path, args: &[&OsStr], log: &Path, cancel: &AtomicBool) -> Result<()> {
cancelled(cancel)?;
let output = File::create(log)?;
let mut command = Command::new(tool);
command
.args(args)
.stdin(Stdio::null())
.stdout(output.try_clone()?)
.stderr(output);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let mut child = command.spawn().context("ToolStartFailed")?;
#[cfg(windows)]
let _job = match job::Job::attach(&child) {
Ok(job) => job,
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
};
let start = Instant::now();
loop {
if let Err(error) = cancelled(cancel) {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
if start.elapsed() > Duration::from_secs(900) {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("ToolTimeout: 15분 제한. {}", log.display());
}
if let Some(status) = child.try_wait()? {
ensure!(
status.success(),
"ToolFailed: {status}; 로그: {}",
log.display()
);
break;
}
thread::sleep(Duration::from_millis(100));
}
Ok(())
}
#[cfg(windows)]
mod job {
use anyhow::{ensure, Result};
use std::{
mem::{size_of, zeroed},
os::windows::io::AsRawHandle,
process::Child,
};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::JobObjects::*,
};
pub struct Job(HANDLE);
impl Job {
pub fn attach(child: &Child) -> Result<Self> {
// The handle is owned by this RAII guard; closing it terminates remaining tool children.
unsafe {
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
ensure!(
!handle.is_null(),
"JobObjectCreateFailed: {}",
std::io::Error::last_os_error()
);
let job = Self(handle);
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
ensure!(
SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&info as *const _ as _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32
) != 0,
"JobObjectConfigureFailed"
);
ensure!(
AssignProcessToJobObject(handle, child.as_raw_handle() as _) != 0,
"JobObjectAssignFailed: {}",
std::io::Error::last_os_error()
);
Ok(job)
}
}
}
impl Drop for Job {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
}
+437
View File
@@ -0,0 +1,437 @@
use super::{files::reject_links, MsixBackend};
use anyhow::{ensure, Context, Result};
use fs2::FileExt;
use package_domain::{LocalConfig, Project};
use package_engine::{digest, BuildResult, PackageBackend, Plan};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File},
io::{Read, Write},
path::{Path, PathBuf},
sync::atomic::AtomicBool,
time::{SystemTime, UNIX_EPOCH},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectDocument {
pub path: PathBuf,
pub revision: String,
pub project: Project,
pub local: LocalConfig,
pub local_revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JobRecord {
pub id: String,
pub status: String,
pub updated_at_unix: u64,
pub message: String,
pub result: Option<BuildResult>,
}
pub fn hash_file(path: &Path) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("MissingFile: {}", path.display()))?;
let mut hash = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
let size = file.read(&mut buffer)?;
if size == 0 {
break;
}
hash.update(&buffer[..size]);
}
Ok(format!("{:x}", hash.finalize()))
}
pub fn atomic_json(path: &Path, value: &impl Serialize) -> Result<()> {
let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n');
atomic_bytes(path, &bytes)
}
fn atomic_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
reject_links(path)?;
let mut temp = tempfile::NamedTempFile::new_in(path.parent().context("MissingParent")?)?;
temp.write_all(bytes)?;
temp.as_file().sync_all()?;
temp.persist(path).map_err(|error| error.error)?;
Ok(())
}
pub fn appended_path(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
pub fn open_project(path: &Path) -> Result<ProjectDocument> {
reject_links(path)?;
let path = path.canonicalize()?;
let bytes = fs::read(&path)?;
let project: Project = serde_json::from_slice(&bytes)
.context("InvalidProject: v2 MSIX JSON만 지원하며 미지 필드, EXE/v1 입력은 거부합니다.")?;
ensure!(
project.schema_version == 2,
"UnsupportedSchema: v2 프로젝트만 열 수 있습니다. 원본을 자동 변환하지 않습니다."
);
let local_path = path.parent().unwrap().join("package-project.local.json");
reject_links(&local_path)?;
let local_bytes = if local_path.exists() {
fs::read(&local_path)?
} else {
b"{\"sources\":{},\"makeAppx\":null}".to_vec()
};
let local = serde_json::from_slice(&local_bytes).context("InvalidLocalConfig")?;
Ok(ProjectDocument {
path,
revision: digest(&bytes),
project,
local,
local_revision: digest(&local_bytes),
})
}
pub fn lock_project(root: &Path) -> Result<File> {
reject_links(root)?;
let path = root.join(".builder.lockfile");
reject_links(&path)?;
let lock = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
lock.try_lock_exclusive()
.context("JobLocked: 다른 GUI/CLI 작업이 이 프로젝트를 사용 중입니다.")?;
Ok(lock)
}
pub fn save_project(document: &ProjectDocument) -> Result<ProjectDocument> {
let _lock = lock_project(document.path.parent().context("MissingParent")?)?;
let current = open_project(&document.path)?;
ensure!(
current.revision == document.revision && current.local_revision == document.local_revision,
"RevisionConflict: 파일이 변경되었습니다. 다시 열어 변경 내용을 확인하세요."
);
// A draft may be incomplete, but never silently accept a different schema.
ensure!(document.project.schema_version == 2, "UnsupportedSchema");
let project_backup = document.path.with_extension("json.bak");
let local_path = document.path.parent().unwrap().join("package-project.local.json");
let local_backup = appended_path(&local_path, ".bak");
for backup in [&project_backup, &local_backup] {
reject_links(backup)?;
ensure!(!backup.exists() || backup.is_file(), "InvalidBackupPath");
}
let old_local = if local_path.exists() {
Some(fs::read(&local_path)?)
} else {
None
};
fs::copy(&document.path, &project_backup)?;
if let Some(bytes) = &old_local {
atomic_bytes(&local_backup, bytes)?;
}
// Commit local settings first, restoring them if the project replacement fails.
atomic_json(&local_path, &document.local)?;
if let Err(error) = atomic_json(&document.path, &document.project) {
let rollback = match old_local {
Some(bytes) => atomic_bytes(&local_path, &bytes),
None => fs::remove_file(&local_path).map_err(Into::into),
};
rollback.context(format!("SaveRollbackFailed: {error}"))?;
return Err(error);
}
open_project(&document.path)
}
pub fn create_project(path: &Path) -> Result<ProjectDocument> {
ensure!(path.file_name().is_some(), "MissingFilename");
reject_links(path)?;
fs::create_dir_all(path.parent().context("MissingParent")?)?;
let _lock = lock_project(path.parent().unwrap())?;
let mut value: serde_json::Value =
serde_json::from_str(include_str!("../../../fixtures/package-project.json"))?;
value["formats"]["msix"]["identity"]["publisher"] =
serde_json::Value::String("CN=Example Publisher".into());
let mut file = File::options()
.write(true)
.create_new(true)
.open(path)
.context("ProjectAlreadyExists")?;
serde_json::to_writer_pretty(&mut file, &value)?;
file.sync_all()?;
open_project(path)
}
fn checked_id(id: &str) -> Result<()> {
uuid::Uuid::parse_str(id).context("InvalidId")?;
Ok(())
}
fn work_root(root: &Path) -> Result<PathBuf> {
let path = root.join(".work");
reject_links(&path)?;
fs::create_dir_all(&path)?;
Ok(path)
}
pub fn plan_release(path: &Path, target: &str) -> Result<Plan> {
let doc = open_project(path)?;
let root = doc.path.parent().unwrap();
let _lock = lock_project(root)?;
let plan = package_engine::plan(
&MsixBackend,
doc.path.clone(),
doc.revision,
doc.local_revision,
doc.project,
&doc.local,
target,
)?;
let plans = work_root(root)?.join("plans");
reject_links(&plans)?;
fs::create_dir_all(&plans)?;
atomic_json(&plans.join(format!("{}.json", plan.id)), &plan)?;
Ok(plan)
}
pub fn load_plan(path: &Path, id: &str) -> Result<Plan> {
checked_id(id)?;
let doc = open_project(path)?;
let plan_path = doc
.path
.parent()
.unwrap()
.join(".work/plans")
.join(format!("{id}.json"));
reject_links(&plan_path)?;
let plan: Plan = serde_json::from_slice(&fs::read(plan_path)?)?;
ensure!(
plan.project_path == doc.path && plan.id == id,
"PlanMismatch"
);
Ok(plan)
}
pub fn build(path: &Path, id: &str, cancel: &AtomicBool) -> Result<BuildResult> {
let doc = open_project(path)?;
let root = doc.path.parent().unwrap();
let _lock = lock_project(root)?;
let plan = load_plan(path, id)?;
let current = open_project(path)?;
ensure!(
current.revision == plan.revision && current.local_revision == plan.local_revision,
"StalePlan: 프로젝트 설정이 변경되었습니다."
);
ensure!(
serde_json::to_value(&current.project)? == serde_json::to_value(&plan.project)?,
"PlanMismatch: 프로젝트 스냅샷이 변경되었습니다."
);
let report = package_engine::validate(&MsixBackend, &current.project, &current.local, root);
ensure!(
report.diagnostics.is_empty(),
"InputValidation: {}",
serde_json::to_string(&report.diagnostics)?
);
ensure!(
report.files == plan.files,
"StalePlan: 입력 파일이 변경되었습니다."
);
let tool = MsixBackend.tool(&current.local)?;
ensure!(
tool.sha256 == plan.tool.sha256 && tool.path == plan.tool.path,
"ToolchainMismatch"
);
let signing = MsixBackend.signing_plan(&current.project, &current.local)?;
ensure!(
signing.is_some() == plan.development_signing.is_some(),
"SigningPlanMismatch"
);
if let (Some(actual), Some(expected)) = (&signing, &plan.development_signing) {
ensure!(
actual.tool.path == expected.tool.path
&& actual.tool.sha256 == expected.tool.sha256
&& actual.certificate.sha256 == expected.certificate.sha256
&& actual.certificate.thumbprint == expected.certificate.thumbprint,
"SigningPlanMismatch: certificate or SignTool changed"
);
}
ensure!(
plan.project
.targets
.iter()
.any(|target| target.id == plan.target_id),
"UnknownTarget"
);
let jobs = work_root(root)?.join("jobs");
reject_links(&jobs)?;
fs::create_dir_all(&jobs)?;
let work = jobs.join(id);
reject_links(&work)?;
fs::create_dir(&work)
.context("DuplicateBuild: 이 계획은 이미 실행되었습니다. 재검사하여 새 계획을 만드세요.")?;
let mut job = JobRecord {
id: id.into(),
status: "Running".into(),
updated_at_unix: now(),
message: "MSIX 생성 및 내용 검사".into(),
result: None,
};
atomic_json(&work.join("job.json"), &job)?;
let operation = (|| -> Result<BuildResult> {
atomic_json(&work.join("input.snapshot.json"), &plan)?;
let (artifact, sha256) = MsixBackend.pack(&plan, &work, cancel)?;
package_engine::cancelled(cancel)?;
let releases = root.join("releases");
reject_links(&releases)?;
fs::create_dir_all(&releases)?;
let release = releases.join(id);
ensure!(!release.exists(), "ReleaseAlreadyExists");
let temp = tempfile::tempdir_in(&releases)?;
let destination = temp.path().join("package.msix");
fs::copy(artifact, &destination)?;
ensure!(hash_file(&destination)? == sha256, "ArtifactMismatch");
let signing_report = if plan.development_signing.is_some() {
Some(serde_json::from_slice::<package_engine::SigningReport>(
&fs::read(work.join("signing-report.json"))?,
)?)
} else {
None
};
let result = BuildResult {
build_id: id.into(),
status: "ArtifactVerified".into(),
artifact: release.join("package.msix"),
sha256,
signing: if signing_report.is_some() {
"selfSignedDevelopment"
} else {
"unsignedDevelopment"
}
.into(),
signing_report,
installation_test: "notRun".into(),
deployment_ready: false,
};
atomic_json(&temp.path().join("release.json"), &result)?;
atomic_json(&temp.path().join("input.snapshot.json"), &plan)?;
for name in [
"AppxManifest.xml",
"pack.log",
"inspect.log",
"registry-report.json",
] {
fs::copy(work.join(name), temp.path().join(name))?;
}
for name in [
"signing-report.json",
"development-signing.cer",
"Install-TestCertificate.ps1",
"sign.log",
] {
if work.join(name).is_file() {
fs::copy(work.join(name), temp.path().join(name))?;
}
}
fs::rename(temp.path(), &release)?;
Ok(result)
})();
job.updated_at_unix = now();
match &operation {
Ok(result) => {
job.status = result.status.clone();
job.message = if result.signing_report.is_some() {
"개발자 서명 결과 · 테스트 전용"
} else {
"미서명 개발 결과 · 배포 불가"
}
.into();
job.result = Some(result.clone());
}
Err(error) => {
job.status = if error.to_string().starts_with("Cancelled") {
"Cancelled"
} else {
"Failed"
}
.into();
job.message = error.to_string();
}
}
atomic_json(&work.join("job.json"), &job)?;
operation
}
pub fn job_status(path: &Path, id: &str) -> Result<JobRecord> {
checked_id(id)?;
let doc = open_project(path)?;
let root = doc.path.parent().unwrap();
let record = root.join(".work/jobs").join(id).join("job.json");
reject_links(&record)?;
let mut job: JobRecord = serde_json::from_slice(&fs::read(&record)?)?;
if job.status == "Running" {
if let Ok(_lock) = lock_project(root) {
// No owner remains: never automatically resume or mark the artifact deployable.
job.status = "Interrupted".into();
job.message = "작업 프로세스가 종료되었습니다. 새 계획으로 재시도하세요.".into();
job.updated_at_unix = now();
atomic_json(&record, &job)?;
}
}
Ok(job)
}
pub fn export_release(path: &Path, id: &str, destination: &Path) -> Result<PathBuf> {
checked_id(id)?;
let doc = open_project(path)?;
let root = doc.path.parent().unwrap();
let _lock = lock_project(root)?;
let source = root.join("releases").join(id);
reject_links(&source)?;
let record: BuildResult = serde_json::from_slice(&fs::read(source.join("release.json"))?)?;
ensure!(
hash_file(&source.join("package.msix"))? == record.sha256,
"ArtifactMismatch"
);
reject_links(destination)?;
ensure!(
!destination.exists(),
"ExportAlreadyExists: 내보낼 새 폴더를 선택하세요."
);
let parent = destination.parent().context("MissingParent")?;
ensure!(parent.is_dir(), "MissingExportParent");
let parent = parent.canonicalize()?;
for configured in doc.local.sources.values() {
let input = root.join(configured).canonicalize()?;
ensure!(!parent.starts_with(&input), "OutputInsideSource");
}
let temp = tempfile::tempdir_in(&parent)?;
for name in [
"package.msix",
"release.json",
"input.snapshot.json",
"AppxManifest.xml",
"pack.log",
"inspect.log",
] {
fs::copy(source.join(name), temp.path().join(name))?;
}
// Releases produced before registry support do not contain this report.
if source.join("registry-report.json").is_file() {
fs::copy(
source.join("registry-report.json"),
temp.path().join("registry-report.json"),
)?;
}
for name in [
"signing-report.json",
"development-signing.cer",
"Install-TestCertificate.ps1",
"sign.log",
] {
if source.join(name).is_file() {
fs::copy(source.join(name), temp.path().join(name))?;
}
}
ensure!(
hash_file(&temp.path().join("package.msix"))? == record.sha256,
"ArtifactMismatch"
);
fs::rename(temp.path(), destination)?;
Ok(destination.to_path_buf())
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}