314 lines
12 KiB
Rust
314 lines
12 KiB
Rust
use package_domain::Project;
|
|
use package_engine::PackageBackend;
|
|
use package_infra::{self as infra, MsixBackend};
|
|
use std::{
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
sync::atomic::AtomicBool,
|
|
};
|
|
|
|
fn png(path: &Path, size: u32) {
|
|
let file = fs::File::create(path).unwrap();
|
|
let mut encoder = png::Encoder::new(file, size, size);
|
|
encoder.set_color(png::ColorType::Rgba);
|
|
let mut writer = encoder.write_header().unwrap();
|
|
writer
|
|
.write_image_data(&[50, 100, 220, 255].repeat((size * size) as usize))
|
|
.unwrap();
|
|
}
|
|
fn fixture() -> (tempfile::TempDir, PathBuf) {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let path = temp.path().join("InstallerProject.isproj");
|
|
let mut doc = infra::create_project(&path).unwrap();
|
|
let payload = temp.path().join("payload");
|
|
fs::create_dir_all(payload.join("Assets")).unwrap();
|
|
let mut pe = vec![0; 256];
|
|
pe[..2].copy_from_slice(b"MZ");
|
|
pe[60] = 64;
|
|
pe[64..68].copy_from_slice(b"PE\0\0");
|
|
pe[68..70].copy_from_slice(&0x8664u16.to_le_bytes());
|
|
fs::write(payload.join("SampleTool.exe"), pe).unwrap();
|
|
png(&payload.join("Assets/Logo44.png"), 44);
|
|
png(&payload.join("Assets/Logo150.png"), 150);
|
|
fs::write(payload.join("ignore.pdb"), "debug").unwrap();
|
|
doc.project.files[0].exclude = vec!["**/*.pdb".into()];
|
|
doc.local
|
|
.sources
|
|
.insert("appPayload".into(), "payload".into());
|
|
infra::save_project(&doc).unwrap();
|
|
(temp, path)
|
|
}
|
|
#[test]
|
|
fn deterministic_resolution_and_exclusions() {
|
|
let (_temp, path) = fixture();
|
|
let doc = infra::open_project(&path).unwrap();
|
|
let first = MsixBackend
|
|
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
|
.unwrap();
|
|
let second = MsixBackend
|
|
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
|
.unwrap();
|
|
assert_eq!(first, second);
|
|
assert_eq!(first.len(), 3);
|
|
assert_eq!(first[0].target, "Assets/Logo150.png");
|
|
}
|
|
|
|
#[test]
|
|
fn signing_defaults_and_local_selection_are_backward_compatible() {
|
|
use package_domain::SigningMode;
|
|
let (_temp, path) = fixture();
|
|
let mut json: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
|
json.as_object_mut().unwrap().remove("signing");
|
|
fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap();
|
|
let mut doc = infra::open_project(&path).unwrap();
|
|
assert_eq!(doc.project.signing.mode, SigningMode::Unsigned);
|
|
assert!(doc.local.developer_certificate_thumbprint.is_none());
|
|
doc.project.signing.mode = SigningMode::Development;
|
|
doc.local.developer_certificate_thumbprint = Some("A".repeat(40));
|
|
let saved = infra::save_project(&doc).unwrap();
|
|
assert_eq!(saved.project.signing.mode, SigningMode::Development);
|
|
assert_eq!(
|
|
saved.local.developer_certificate_thumbprint,
|
|
Some("A".repeat(40))
|
|
);
|
|
assert!(!fs::read_to_string(&path).unwrap().contains(&"A".repeat(40)));
|
|
json["signing"] = serde_json::json!({"mode":"production"});
|
|
assert!(serde_json::from_value::<Project>(json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn development_signing_requires_explicit_certificate_without_unsigned_fallback() {
|
|
use package_domain::SigningMode;
|
|
let (_temp, path) = fixture();
|
|
let mut doc = infra::open_project(&path).unwrap();
|
|
assert!(MsixBackend
|
|
.signing_plan(&doc.project, &doc.local)
|
|
.unwrap()
|
|
.is_none());
|
|
doc.project.signing.mode = SigningMode::Development;
|
|
assert!(MsixBackend
|
|
.signing_plan(&doc.project, &doc.local)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("MissingDeveloperCertificate"));
|
|
doc.local.developer_certificate_thumbprint = Some("not-a-thumbprint".into());
|
|
assert!(MsixBackend
|
|
.signing_plan(&doc.project, &doc.local)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("InvalidCertificateThumbprint"));
|
|
}
|
|
#[test]
|
|
fn save_rejects_stale_revision_and_preserves_backup() {
|
|
let (_temp, path) = fixture();
|
|
let mut stale = infra::open_project(&path).unwrap();
|
|
let mut edited = stale.clone();
|
|
edited.project.product.display_name = "new name".into();
|
|
infra::save_project(&edited).unwrap();
|
|
stale.project.product.display_name = "old editor".into();
|
|
assert!(infra::save_project(&stale)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("RevisionConflict"));
|
|
let backup: Project =
|
|
serde_json::from_slice(&fs::read(infra::appended_path(&path, ".bak")).unwrap()).unwrap();
|
|
assert_ne!(backup.product.display_name, "new name");
|
|
}
|
|
#[test]
|
|
fn collision_missing_asset_and_bad_pe_are_rejected() {
|
|
let (temp, path) = fixture();
|
|
let mut doc = infra::open_project(&path).unwrap();
|
|
let mut duplicate = doc.project.files[0].clone();
|
|
duplicate.id = "other".into();
|
|
doc.project.files.push(duplicate);
|
|
assert!(MsixBackend
|
|
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Collision"));
|
|
doc.project.files.pop();
|
|
png(&temp.path().join("payload/Assets/Logo44.png"), 45);
|
|
assert!(MsixBackend
|
|
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("InvalidAssetSize"));
|
|
fs::write(
|
|
temp.path().join("payload/SampleTool.exe"),
|
|
"not an executable",
|
|
)
|
|
.unwrap();
|
|
assert!(MsixBackend
|
|
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("InvalidPE"));
|
|
}
|
|
#[test]
|
|
fn source_may_not_contain_work_or_release_outputs() {
|
|
let (_temp, path) = fixture();
|
|
let mut doc = infra::open_project(&path).unwrap();
|
|
doc.local.sources.insert("appPayload".into(), ".".into());
|
|
assert!(MsixBackend
|
|
.resolve(&doc.project, &doc.local, doc.path.parent().unwrap())
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("OutputInsideSource"));
|
|
}
|
|
#[test]
|
|
fn project_lock_blocks_concurrent_writers() {
|
|
let (_temp, path) = fixture();
|
|
let doc = infra::open_project(&path).unwrap();
|
|
let _lock = infra::lock_project(path.parent().unwrap()).unwrap();
|
|
assert!(infra::save_project(&doc)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("JobLocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_edits_round_trip_and_old_projects_default_to_empty() {
|
|
let (_temp, path) = fixture();
|
|
let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
|
value.as_object_mut().unwrap().remove("registry");
|
|
fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
|
let mut doc = infra::open_project(&path).unwrap();
|
|
assert!(doc.project.registry.is_empty());
|
|
doc.project.registry = serde_json::from_value(serde_json::json!([
|
|
{"id":"max", "hive":"HKLM", "key":"Software\\Baron", "name":"Maximum", "view":"64", "valueType":"REG_QWORD", "data":"18446744073709551615"}
|
|
])).unwrap();
|
|
let mut saved = infra::save_project(&doc).unwrap();
|
|
assert_eq!(saved.project.registry[0].data, "18446744073709551615");
|
|
assert_ne!(saved.revision, doc.revision);
|
|
saved.project.registry.clear();
|
|
infra::save_project(&saved).unwrap();
|
|
assert!(infra::open_project(&path)
|
|
.unwrap()
|
|
.project
|
|
.registry
|
|
.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn source_cannot_override_generated_registry_hives() {
|
|
for relative in [
|
|
"Registry.dat",
|
|
"registry.dat/nested.txt",
|
|
"User.dat",
|
|
"User.Classes.dat",
|
|
] {
|
|
let (temp, path) = fixture();
|
|
let file = temp.path().join("payload").join(relative);
|
|
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
|
fs::write(&file, "untrusted hive").unwrap();
|
|
let doc = infra::open_project(&path).unwrap();
|
|
assert!(MsixBackend
|
|
.resolve(&doc.project, &doc.local, path.parent().unwrap())
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("ReservedPath"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opening_unsupported_schema_does_not_modify_original() {
|
|
let (_temp, path) = fixture();
|
|
let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
|
value["schemaVersion"] = serde_json::json!(1);
|
|
let original = serde_json::to_vec(&value).unwrap();
|
|
fs::write(&path, &original).unwrap();
|
|
assert!(infra::open_project(&path)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("UnsupportedSchema"));
|
|
assert_eq!(fs::read(path).unwrap(), original);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_file_matches_rust_contract() {
|
|
// The CLI emits this same schema; this catches a stale checked-in schema.
|
|
let actual = serde_json::to_value(schemars::schema_for!(Project)).unwrap();
|
|
let expected: serde_json::Value =
|
|
serde_json::from_str(include_str!("../../../schemas/package-project.schema.json")).unwrap();
|
|
assert_eq!(actual, expected);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "Requires a real Windows SDK: set MSIX_TEST_MAKEAPPX and run --ignored"]
|
|
fn real_sdk_packages_two_products_and_rejects_stale_plans() {
|
|
let tool = std::env::var("MSIX_TEST_MAKEAPPX").expect("MSIX_TEST_MAKEAPPX is required");
|
|
for name in ["FixtureAlpha", "FixtureBeta"] {
|
|
let (temp, path) = fixture();
|
|
let mut doc = infra::open_project(&path).unwrap();
|
|
doc.project.formats.msix.identity.name = format!("ExamplePublisher.{name}");
|
|
doc.project.product.display_name = name.into();
|
|
doc.local.make_appx = Some(tool.clone());
|
|
doc.project.registry = serde_json::from_value(serde_json::json!([
|
|
{"id":"caption", "hive":"HKLM", "key":"Software\\MSIXStudioTest", "name":"", "view":"64", "valueType":"REG_SZ", "data":"한글 기본값"},
|
|
{"id":"mode", "hive":"HKLM", "key":"Software\\MSIXStudioTest", "name":"Mode", "view":"32", "valueType":"REG_DWORD", "data":"0xFFFFFFFF"}
|
|
])).unwrap();
|
|
// A real compiler-produced PE; this checks packaging, not app installation or launch.
|
|
fs::copy(
|
|
std::env::current_exe().unwrap(),
|
|
temp.path().join("payload/SampleTool.exe"),
|
|
)
|
|
.unwrap();
|
|
infra::save_project(&doc).unwrap();
|
|
let plan = infra::plan_release(&path, "msix-direct").unwrap();
|
|
fs::write(temp.path().join("payload/changed.txt"), "changed").unwrap();
|
|
assert!(infra::build(&path, &plan.id, &AtomicBool::new(false))
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("StalePlan"));
|
|
let plan = infra::plan_release(&path, "msix-direct").unwrap();
|
|
let result = infra::build(&path, &plan.id, &AtomicBool::new(false)).unwrap();
|
|
let registry_report: serde_json::Value = serde_json::from_slice(
|
|
&fs::read(
|
|
result
|
|
.artifact
|
|
.parent()
|
|
.unwrap()
|
|
.join("registry-report.json"),
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(registry_report["entryCount"], 2);
|
|
assert_eq!(registry_report["status"], "verified");
|
|
assert!(temp
|
|
.path()
|
|
.join(".work/jobs")
|
|
.join(&plan.id)
|
|
.join("inspection/registry.dat")
|
|
.is_file());
|
|
assert_eq!(result.status, "ArtifactVerified");
|
|
assert!(!result.deployment_ready);
|
|
assert_eq!(result.installation_test, "notRun");
|
|
assert_eq!(infra::hash_file(&result.artifact).unwrap(), result.sha256);
|
|
assert!(infra::build(&path, &plan.id, &AtomicBool::new(false))
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("DuplicateBuild"));
|
|
let export = temp.path().join("exported");
|
|
infra::export_release(&path, &plan.id, &export).unwrap();
|
|
assert!(export.join("registry-report.json").is_file());
|
|
assert!(infra::export_release(&path, &plan.id, &export).is_err());
|
|
fs::write(result.artifact, "tamper").unwrap();
|
|
assert!(
|
|
infra::export_release(&path, &plan.id, &temp.path().join("tampered"))
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("ArtifactMismatch")
|
|
);
|
|
let cancel_plan = infra::plan_release(&path, "msix-direct").unwrap();
|
|
assert!(infra::build(&path, &cancel_plan.id, &AtomicBool::new(true))
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Cancelled"));
|
|
assert_eq!(
|
|
infra::job_status(&path, &cancel_plan.id).unwrap().status,
|
|
"Cancelled"
|
|
);
|
|
}
|
|
}
|