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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user