feat: add development certificate signing

Closes #4
This commit is contained in:
jychoi4 committed 2026-11-04 16:14:00 +09:00
1 parent fdbc9a9d1b
commit 024f244f52
10 files changed
+910 -3

No files matched your search

@@ -0,0 +1,104 @@
param([Parameter(Mandatory=$true)][string]$RequestPath)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function NativePath([string]$Path) {
if ($Path.StartsWith('\\?\UNC\')) { return '\\' + $Path.Substring(8) }
if ($Path.StartsWith('\\?\')) { return $Path.Substring(4) }
return $Path
}
$request = Get-Content -LiteralPath (NativePath $RequestPath) -Raw -Encoding UTF8 | ConvertFrom-Json
$resultPath = NativePath $request.resultPath
$utf8 = New-Object Text.UTF8Encoding($false)
function Info($cert) {
$hash = [Security.Cryptography.SHA256]::Create()
try { $digest = ([BitConverter]::ToString($hash.ComputeHash($cert.RawData))).Replace('-','').ToLowerInvariant() }
finally { $hash.Dispose() }
return [ordered]@{
thumbprint=$cert.Thumbprint; subject=$cert.Subject; notAfter=$cert.NotAfter.ToUniversalTime().ToString('o'); sha256=$digest
trustedPeople=(Test-Path -LiteralPath ('Cert:\LocalMachine\TrustedPeople\' + $cert.Thumbprint))
}
}
function Eligible($cert) {
return $cert.HasPrivateKey -and $cert.Subject -ceq $request.publisher -and $cert.Subject -ceq $cert.Issuer -and `
$cert.NotBefore -le (Get-Date) -and $cert.NotAfter -gt (Get-Date) -and `
('1.3.6.1.5.5.7.3.3' -in @($cert.EnhancedKeyUsageList | ForEach-Object { [string]$_.ObjectId }))
}
try {
if ($request.operation -eq 'list') {
$result = @(Get-ChildItem Cert:\CurrentUser\My | Where-Object { Eligible $_ } | ForEach-Object { Info $_ })
} elseif ($request.operation -eq 'create') {
$cert = New-SelfSignedCertificate -Type Custom -Subject $request.publisher `
-FriendlyName 'MSIX Studio development signing' -KeyAlgorithm RSA -KeyLength 2048 `
-HashAlgorithm SHA256 -KeyExportPolicy NonExportable -KeyUsage DigitalSignature `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3','2.5.29.19={text}') `
-CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddMonths(3)
if (-not (Eligible $cert)) { throw 'Created certificate is incompatible with the exact package Publisher.' }
$result = Info $cert
} else {
if ($request.thumbprint -notmatch '^[A-Fa-f0-9]{40}$') { throw 'Invalid certificate thumbprint.' }
$cert = Get-Item -LiteralPath ('Cert:\CurrentUser\My\' + $request.thumbprint)
if (-not (Eligible $cert)) { throw 'Certificate must be self-issued, unexpired, code-signing enabled, have a private key, and exactly match Publisher.' }
$info = Info $cert
if ($request.operation -eq 'inspect') { $result = $info }
elseif ($request.operation -eq 'sign') {
if ($info.sha256 -cne $request.certificateSha256) { throw 'Signing certificate differs from build plan.' }
$tool = NativePath $request.tool
$package = NativePath $request.package
$output = Split-Path -Parent $package
if ((Get-AuthenticodeSignature -LiteralPath $tool).Status -ne 'Valid') { throw 'SignTool signature is not trusted.' }
& $tool sign /v /fd SHA256 /s My /sha1 $cert.Thumbprint $package > (Join-Path $output 'sign.log')
if ($LASTEXITCODE -ne 0) { throw "SignTool signing failed: $LASTEXITCODE" }
Add-Type -AssemblyName System.IO.Compression.FileSystem
Add-Type -AssemblyName System.Security
$zip = [IO.Compression.ZipFile]::OpenRead($package)
try {
$entry = $zip.GetEntry('AppxSignature.p7x')
if ($null -eq $entry) { throw 'Package signature missing.' }
$stream = $entry.Open(); $memory = New-Object IO.MemoryStream
try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $stream.Dispose(); $memory.Dispose() }
} finally { $zip.Dispose() }
if ($bytes.Length -lt 5 -or [Text.Encoding]::ASCII.GetString($bytes,0,4) -cne 'PKCX') { throw 'Invalid package signature header.' }
$cms = New-Object Security.Cryptography.Pkcs.SignedCms
$cms.Decode([byte[]]$bytes[4..($bytes.Length-1)])
$cms.CheckSignature($true)
if ($cms.SignerInfos.Count -ne 1 -or $cms.SignerInfos[0].Certificate.Thumbprint -ine $cert.Thumbprint) { throw 'Package signer mismatch.' }
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class DevelopmentTrust {
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
struct FileInfo { public uint size; [MarshalAs(UnmanagedType.LPWStr)] public string path; public IntPtr file; public IntPtr subject; }
[StructLayout(LayoutKind.Sequential)]
struct TrustData {
public uint size; public IntPtr policy; public IntPtr sip; public uint ui; public uint revocation; public uint choice;
public IntPtr info; public uint action; public IntPtr state; public IntPtr url; public uint flags; public uint context; public IntPtr settings;
}
[DllImport("wintrust.dll", ExactSpelling=true, PreserveSig=true)]
static extern int WinVerifyTrust(IntPtr window, ref Guid action, ref TrustData data);
public static uint Verify(string path) {
var file = new FileInfo { size=(uint)Marshal.SizeOf(typeof(FileInfo)), path=path };
var pointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(FileInfo)));
Marshal.StructureToPtr(file,pointer,false);
var action = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
var data = new TrustData { size=(uint)Marshal.SizeOf(typeof(TrustData)), ui=2, choice=1, info=pointer, action=1 };
try { return unchecked((uint)WinVerifyTrust(new IntPtr(-1),ref action,ref data)); }
finally { data.action=2; WinVerifyTrust(new IntPtr(-1),ref action,ref data); Marshal.DestroyStructure(pointer,typeof(FileInfo)); Marshal.FreeHGlobal(pointer); }
}
}
'@
$trust = [DevelopmentTrust]::Verify($package)
# Only a successful trust check or an untrusted self-signed root is allowed.
# CMS validation and post-sign package unpack/hash inspection are separate gates.
if ($trust -ne 0 -and $trust -ne [uint32]2148204809) { throw ('Package verification failed: 0x{0:X8}' -f $trust) }
Export-Certificate -Cert $cert -FilePath (Join-Path $output 'development-signing.cer') -Type CERT | Out-Null
$result = [ordered]@{ mode='development'; certificate=$info; cmsSignatureValid=$true;
trustVerification=$(if ($trust -eq 0) { 'passed' } else { 'notTrusted' });
winVerifyTrust=('0x{0:X8}' -f $trust); timestamped=$false }
} else { throw 'Unknown signing operation.' }
}
$json = ConvertTo-Json -InputObject $result -Depth 10
[IO.File]::WriteAllText($resultPath, $json, $utf8)
} catch {
[IO.File]::WriteAllText($resultPath, (ConvertTo-Json @{error=$_.Exception.Message}), $utf8)
exit 1
}
+12 -3
View File
@@ -1,6 +1,8 @@
mod files;
mod process;
mod registry;
mod signing;
pub use signing::{configure_developer_certificate, CertificateSettings};
mod store;
pub use store::*;
@@ -18,10 +20,9 @@ impl PackageBackend for MsixBackend {
fn signing_plan(
&self,
project: &Project,
_local: &LocalConfig,
local: &LocalConfig,
) -> Result<Option<package_engine::DevelopmentSigningPlan>> {
ensure!(project.signing.mode == package_domain::SigningMode::Unsigned, "UnsupportedSigning: developer signing is not enabled yet");
Ok(None)
signing::plan(project, local)
}
fn resolve(
&self,
@@ -102,6 +103,14 @@ impl PackageBackend for MsixBackend {
&work.join("pack.log"),
cancel,
)?;
ensure!(
(plan.project.signing.mode == package_domain::SigningMode::Development)
== plan.development_signing.is_some(),
"SigningPlanMismatch"
);
if let Some(signing) = &plan.development_signing {
signing::sign(signing, &plan.project, &package, work, cancel)?;
}
let unpack = work.join("inspection");
process::run(
&plan.tool.path,
+181
View File
@@ -0,0 +1,181 @@
use anyhow::{ensure, Context, Result};
use package_domain::{LocalConfig, Project, SigningMode};
use package_engine::{DeveloperCertificate, DevelopmentSigningPlan, SigningReport, Tool};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::{json, Value};
use std::{fs, path::Path, sync::atomic::AtomicBool};
fn request<T: DeserializeOwned>(mut value: Value, cancel: &AtomicBool) -> Result<T> {
ensure!(
cfg!(windows),
"SigningPlatform: developer signing requires Windows"
);
let temp = tempfile::tempdir()?;
let script = temp.path().join("developer-signing.ps1");
let input = temp.path().join("request.json");
let output = temp.path().join("result.json");
value["resultPath"] = json!(output);
fs::write(&script, include_bytes!("developer-signing.ps1"))?;
super::atomic_json(&input, &value)?;
let powershell = std::path::PathBuf::from(
std::env::var_os("SystemRoot").context("MissingWindowsDirectory")?,
)
.join("System32/WindowsPowerShell/v1.0/powershell.exe");
let operation = super::process::run(
&powershell,
&[
"-NoProfile".as_ref(),
"-NonInteractive".as_ref(),
"-File".as_ref(),
script.as_os_str(),
"-RequestPath".as_ref(),
input.as_os_str(),
],
&temp.path().join("operation.log"),
cancel,
);
if let Err(error) = operation {
if output.is_file() {
let report: Value = serde_json::from_slice(&fs::read(&output)?)?;
if let Some(message) = report["error"].as_str() {
anyhow::bail!("DeveloperSigning: {message}");
}
}
return Err(error);
}
Ok(serde_json::from_slice(&fs::read(output)?)?)
}
fn publisher(project: &Project) -> Result<&str> {
let value = project
.formats
.msix
.identity
.publisher
.as_deref()
.context("MissingPublisher: enter the certificate Subject first")?;
ensure!(
value.starts_with("CN=") && value.len() > 3 && !value.contains('\0'),
"InvalidPublisher"
);
Ok(value)
}
pub fn inspect(
project: &Project,
thumbprint: &str,
cancel: &AtomicBool,
) -> Result<DeveloperCertificate> {
ensure!(
thumbprint.len() == 40 && thumbprint.bytes().all(|c| c.is_ascii_hexdigit()),
"InvalidCertificateThumbprint"
);
request(
json!({"operation":"inspect", "publisher":publisher(project)?, "thumbprint":thumbprint}),
cancel,
)
}
pub fn plan(project: &Project, local: &LocalConfig) -> Result<Option<DevelopmentSigningPlan>> {
if project.signing.mode == SigningMode::Unsigned {
return Ok(None);
}
let certificate = inspect(
project,
local.developer_certificate_thumbprint.as_deref().context(
"MissingDeveloperCertificate: 서명 탭에서 테스트 인증서를 선택하거나 생성하세요.",
)?,
&AtomicBool::new(false),
)?;
let path = Path::new(
local
.make_appx
.as_deref()
.context("MissingTool: select Windows SDK MakeAppx.exe")?,
)
.with_file_name("signtool.exe");
ensure!(
path.is_absolute() && path.is_file(),
"MissingSignTool: Windows SDK MakeAppx.exe와 같은 폴더의 SignTool.exe가 필요합니다."
);
let tool = Tool {
path: path.canonicalize()?,
sha256: super::hash_file(&path)?,
};
Ok(Some(DevelopmentSigningPlan { tool, certificate }))
}
pub fn sign(
plan: &DevelopmentSigningPlan,
project: &Project,
package: &Path,
work: &Path,
cancel: &AtomicBool,
) -> Result<()> {
ensure!(
super::hash_file(&plan.tool.path)? == plan.tool.sha256,
"ToolchainMismatch: SignTool changed"
);
let report: SigningReport = request(
json!({"operation":"sign", "publisher":publisher(project)?, "thumbprint":plan.certificate.thumbprint, "certificateSha256":plan.certificate.sha256, "tool":plan.tool.path, "package":package}),
cancel,
)?;
ensure!(
report.certificate.sha256 == plan.certificate.sha256 && report.cms_signature_valid,
"SignatureMismatch"
);
super::atomic_json(&work.join("signing-report.json"), &report)?;
// No automatic machine trust changes; export a narrowly scoped administrator script.
let trust_script = format!(
r#"#Requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$file = Join-Path $PSScriptRoot 'development-signing.cer'
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $file
if ($cert.Thumbprint -cne '{}') {{ throw 'Unexpected certificate' }}
Import-Certificate -FilePath $file -CertStoreLocation Cert:\LocalMachine\TrustedPeople
"#,
report.certificate.thumbprint
);
fs::write(work.join("Install-TestCertificate.ps1"), trust_script)?;
Ok(())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CertificateSettings {
pub document: super::ProjectDocument,
pub certificates: Vec<DeveloperCertificate>,
}
pub fn configure_developer_certificate(
path: &Path,
action: &str,
thumbprint: Option<&str>,
cancel: &AtomicBool,
) -> Result<CertificateSettings> {
let mut doc = super::open_project(path)?;
ensure!(
doc.project.signing.mode == SigningMode::Development,
"DevelopmentModeRequired: 개발자 서명 모드를 먼저 저장하세요."
);
let selected = match action {
"list" => None,
"create" => Some(request::<DeveloperCertificate>(
json!({"operation":"create", "publisher":publisher(&doc.project)?}),
cancel,
)?),
"select" => Some(inspect(
&doc.project,
thumbprint.context("MissingCertificateThumbprint")?,
cancel,
)?),
_ => anyhow::bail!("UnsupportedCertificateAction"),
};
if let Some(cert) = selected {
doc.local.developer_certificate_thumbprint = Some(cert.thumbprint);
doc = super::save_project(&doc)?;
}
let certificates = request(
json!({"operation":"list", "publisher":publisher(&doc.project)?}),
cancel,
)?;
Ok(CertificateSettings {
document: doc,
certificates,
})
}
+313
View File
@@ -0,0 +1,313 @@
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("package-project.json");
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"
);
}
}