1 parent
24b624301c
commit
6213fec7d2
29 files changed
+4058
-5
No files matched your search
@@ -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"] }
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t.project)? == serde_json::to_value(&plan.project)?,
|
||||
"PlanMismatch: 프로젝트 스냅샷이 변경되었습니다."
|
||||
);
|
||||
let report = package_engine::validate(&MsixBackend, ¤t.project, ¤t.local, root);
|
||||
ensure!(
|
||||
report.diagnostics.is_empty(),
|
||||
"InputValidation: {}",
|
||||
serde_json::to_string(&report.diagnostics)?
|
||||
);
|
||||
ensure!(
|
||||
report.files == plan.files,
|
||||
"StalePlan: 입력 파일이 변경되었습니다."
|
||||
);
|
||||
let tool = MsixBackend.tool(¤t.local)?;
|
||||
ensure!(
|
||||
tool.sha256 == plan.tool.sha256 && tool.path == plan.tool.path,
|
||||
"ToolchainMismatch"
|
||||
);
|
||||
let signing = MsixBackend.signing_plan(¤t.project, ¤t.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()
|
||||
}
|
||||
Reference in new issue
Block a user