use anyhow::{Context, Result};
use std::{
ffi::OsString,
fs,
path::{Path, PathBuf},
process::Command,
};
use thiserror::Error;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
struct Version4(u32, u32, u32, u32);
impl Version4 {
fn parse(s: &str) -> Result<Self, ()> {
let mut it = s.split('.');
let a = it.next().ok_or(())?.parse().map_err(|_| ())?;
let b = it.next().ok_or(())?.parse().map_err(|_| ())?;
let c = it.next().ok_or(())?.parse().map_err(|_| ())?;
let d = it.next().ok_or(())?.parse().map_err(|_| ())?;
if it.next().is_some() {
return Err(());
}
Ok(Self(a, b, c, d))
}
}
#[derive(Debug, Error)]
pub enum MsixError {
#[error("Windows SDK tool not found: {0}")]
ToolMissing(&'static str),
#[error("MakeAppx failed: {0}")]
MakeAppx(String),
#[error("SignTool failed: {0}")]
SignTool(String),
#[error("MakePri failed: {0}")]
MakePri(String),
#[error("Manifest parse error: {0}")]
Manifest(String),
#[error("Validation failed: {0}")]
Validation(String),
#[error("{0}")]
Other(String),
}
#[derive(Clone, Debug)]
pub enum CertificateSource<'a> {
Pfx {
path: &'a Path,
password: Option<&'a str>,
},
Thumbprint {
sha1: &'a str,
store: Option<&'a str>,
machine_store: bool,
},
}
#[derive(Clone, Debug)]
pub struct SdkTools {
pub makeappx: PathBuf,
pub makepri: Option<PathBuf>,
pub signtool: Option<PathBuf>,
pub appcert: Option<PathBuf>,
}
#[cfg(all(feature = "sdk-discovery", target_os = "windows"))]
pub fn locate_sdk_tools() -> Result<SdkTools> {
use winreg::{RegKey, enums::HKEY_LOCAL_MACHINE};
let roots = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey("SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots")
.context("open Windows Kits registry")?;
let kits_root10: String = roots.get_value("KitsRoot10").context("read KitsRoot10")?;
let bin_dir = PathBuf::from(kits_root10).join("bin");
let mut best: Option<(Version4, PathBuf)> = None;
for e in fs::read_dir(&bin_dir).context("list SDK bin")? {
let e = e?;
if !e.file_type()?.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().into_owned();
if let Ok(v) = Version4::parse(&name) {
let makeappx = e.path().join("x64").join("MakeAppx.exe");
if makeappx.exists() {
if let Some((bv, _)) = &best {
if v > *bv {
best = Some((v, e.path()));
}
} else {
best = Some((v, e.path()));
}
}
}
}
let base = best
.map(|(_, p)| p)
.ok_or(MsixError::ToolMissing("MakeAppx.exe"))?;
let makeappx = base.join("x64").join("MakeAppx.exe");
let signtool = {
let p = base.join("x64").join("signtool.exe");
if p.exists() { Some(p) } else { None }
};
let makepri = {
let p = base.join("x64").join("makepri.exe");
if p.exists() { Some(p) } else { None }
};
let appcert = {
let kits_root10: String = roots.get_value("KitsRoot10").context("read KitsRoot10")?;
let p = PathBuf::from(kits_root10)
.join("App Certification Kit")
.join("appcert.exe");
if p.exists() { Some(p) } else { None }
};
Ok(SdkTools {
makeappx,
makepri,
signtool,
appcert,
})
}
#[derive(Clone, Debug)]
pub struct ManifestInfo {
pub version: String,
pub display_name: String,
}
pub fn read_manifest_info(appx_content_dir: &Path) -> Result<ManifestInfo> {
let path = appx_content_dir.join("AppxManifest.xml");
let xml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let doc = roxmltree::Document::parse(&xml).map_err(|e| MsixError::Manifest(e.to_string()))?;
let pkg = doc
.descendants()
.find(|n| n.has_tag_name("Package"))
.ok_or_else(|| MsixError::Manifest("missing <Package>".into()))?;
let id = pkg
.children()
.find(|n| n.has_tag_name("Identity"))
.ok_or_else(|| MsixError::Manifest("missing <Identity>".into()))?;
let version = id
.attribute("Version")
.ok_or_else(|| MsixError::Manifest("Identity@Version missing".into()))?
.to_string();
let identity_name = id.attribute("Name").unwrap_or("App").to_string();
let display = pkg
.children()
.find(|n| n.has_tag_name("Properties"))
.and_then(|p| p.children().find(|n| n.has_tag_name("DisplayName")))
.and_then(|n| n.text())
.map(|s| s.trim().to_string());
let final_name = match display {
Some(s) if !s.is_empty() && !s.starts_with("ms-resource:") => s,
_ => identity_name,
};
Ok(ManifestInfo {
version,
display_name: sanitize(&final_name),
})
}
fn sanitize(s: &str) -> String {
let bad = ['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
let mut out = String::new();
for ch in s.chars() {
if !bad.contains(&ch) {
out.push(ch);
}
}
if out.is_empty() { "App".into() } else { out }
}
pub struct PriOptions<'a> {
pub appx_content_dir: &'a Path,
pub default_language: &'a str,
pub target_os_version: &'a str,
pub keep_priconfig: bool,
pub overwrite: bool,
pub makepri_override: Option<&'a Path>,
}
pub fn compile_resources_pri(tools: &SdkTools, opts: &PriOptions<'_>) -> Result<PathBuf> {
let makepri = opts
.makepri_override
.or(tools.makepri.as_deref())
.ok_or(MsixError::ToolMissing("makepri.exe"))?;
let manifest = opts.appx_content_dir.join("AppxManifest.xml");
if !manifest.exists() {
return Err(MsixError::Manifest(format!("missing {}", manifest.display())).into());
}
let priconfig = opts.appx_content_dir.join("priconfig.xml");
let resources_pri = opts.appx_content_dir.join("resources.pri");
let createconfig_args = build_makepri_createconfig_args(
&priconfig,
opts.default_language,
opts.target_os_version,
opts.overwrite,
);
let createconfig_status = Command::new(makepri)
.args(createconfig_args)
.status()
.context("run makepri createconfig")?;
if !createconfig_status.success() {
return Err(MsixError::MakePri(format!(
"createconfig {}",
opts.appx_content_dir.display()
))
.into());
}
let new_args = build_makepri_new_args(
opts.appx_content_dir,
&priconfig,
&manifest,
&resources_pri,
opts.overwrite,
);
let new_status = Command::new(makepri)
.args(new_args)
.status()
.context("run makepri new")?;
if !new_status.success() {
return Err(MsixError::MakePri(format!("new {}", opts.appx_content_dir.display())).into());
}
if !opts.keep_priconfig {
let _ = fs::remove_file(&priconfig);
}
Ok(resources_pri)
}
fn build_makepri_createconfig_args(
priconfig: &Path,
default_language: &str,
target_os_version: &str,
overwrite: bool,
) -> Vec<OsString> {
let mut args: Vec<OsString> = vec![
"createconfig".into(),
"/cf".into(),
priconfig.as_os_str().into(),
"/dq".into(),
format!("lang-{default_language}").into(),
"/pv".into(),
target_os_version.into(),
];
if overwrite {
args.push("/o".into());
}
args
}
fn build_makepri_new_args(
appx_content_dir: &Path,
priconfig: &Path,
manifest: &Path,
resources_pri: &Path,
overwrite: bool,
) -> Vec<OsString> {
let mut args: Vec<OsString> = vec![
"new".into(),
"/pr".into(),
appx_content_dir.as_os_str().into(),
"/cf".into(),
priconfig.as_os_str().into(),
"/mn".into(),
manifest.as_os_str().into(),
"/of".into(),
resources_pri.as_os_str().into(),
];
if overwrite {
args.push("/o".into());
}
args
}
pub fn pack_arch(
tools: &SdkTools,
appx_dir: &Path,
out_dir: &Path,
info: &ManifestInfo,
arch: &str,
overwrite: bool,
) -> Result<PathBuf> {
let out = out_dir.join(format!(
"{}_{}_{}.msix",
info.display_name, info.version, arch
));
let mut args: Vec<OsString> = vec![
"pack".into(),
"/d".into(),
appx_dir.as_os_str().into(),
"/p".into(),
out.as_os_str().into(),
"/h".into(),
"SHA256".into(),
];
if overwrite {
args.push("/o".into());
} else {
args.push("/no".into());
}
let status = Command::new(&tools.makeappx)
.args(args)
.status()
.context("run MakeAppx pack")?;
if !status.success() {
return Err(MsixError::MakeAppx(format!("pack {arch}")).into());
}
Ok(out)
}
pub fn build_bundle(
tools: &SdkTools,
out_dir: &Path,
built: &[(String, PathBuf)],
info: &ManifestInfo,
overwrite: bool,
) -> Result<PathBuf> {
let map = out_dir.join("bundlemap.txt");
let mut s = String::from("[Files]\n");
for (_arch, path) in built {
let filename = path
.file_name()
.map(|f| f.to_string_lossy())
.unwrap_or_else(|| path.to_string_lossy());
s.push('"');
s.push_str(&path.to_string_lossy());
s.push('"');
s.push(' ');
s.push('"');
s.push_str(&filename);
s.push('"');
s.push('\n');
}
fs::write(&map, s).context("write bundlemap.txt")?;
let bundle = out_dir.join(format!("{}_{}.msixbundle", info.display_name, info.version));
let mut args: Vec<OsString> = vec![
"bundle".into(),
"/f".into(),
map.as_os_str().into(),
"/p".into(),
bundle.as_os_str().into(),
"/bv".into(),
info.version.clone().into(),
];
if overwrite {
args.push("/o".into());
} else {
args.push("/no".into());
}
let status = Command::new(&tools.makeappx)
.args(args)
.status()
.context("run MakeAppx bundle")?;
if !status.success() {
return Err(MsixError::MakeAppx("bundle".into()).into());
}
Ok(bundle)
}
pub struct SignOptions<'a> {
pub artifact: &'a Path,
pub certificate: CertificateSource<'a>,
pub sip_dll: Option<&'a Path>,
pub timestamp_url: Option<&'a str>,
pub rfc3161: bool,
pub signtool_override: Option<&'a Path>,
}
pub fn sign_artifact(tools: &SdkTools, opts: &SignOptions<'_>) -> Result<()> {
let signtool = opts
.signtool_override
.or(tools.signtool.as_deref())
.ok_or(MsixError::ToolMissing("signtool.exe"))?;
let args = build_sign_args(opts);
let status = Command::new(signtool)
.args(args)
.status()
.context("run signtool sign")?;
if !status.success() {
return Err(MsixError::SignTool(format!("sign {}", opts.artifact.display())).into());
}
Ok(())
}
fn build_sign_args(opts: &SignOptions<'_>) -> Vec<OsString> {
let mut args: Vec<OsString> = vec!["sign".into(), "/fd".into(), "SHA256".into()];
match &opts.certificate {
CertificateSource::Pfx { path, password } => {
args.push("/f".into());
args.push(path.as_os_str().into());
if let Some(pw) = password {
args.push("/p".into());
args.push(OsString::from(*pw));
}
}
CertificateSource::Thumbprint {
sha1,
store,
machine_store,
} => {
args.push("/sha1".into());
args.push(OsString::from(*sha1));
if let Some(s) = store {
args.push("/s".into());
args.push(OsString::from(*s));
}
if *machine_store {
args.push("/sm".into());
}
}
}
if let Some(sip) = opts.sip_dll {
args.push("/dlib".into());
args.push(sip.as_os_str().into());
}
if let Some(url) = opts.timestamp_url {
if opts.rfc3161 {
args.extend(["/td".into(), "SHA256".into(), "/tr".into(), url.into()]);
} else {
args.extend(["/t".into(), url.into()]);
}
}
args.push(opts.artifact.as_os_str().into());
args
}
pub fn verify_signature(tools: &SdkTools, artifact: &Path) -> Result<()> {
let signtool = tools
.signtool
.as_ref()
.ok_or(MsixError::ToolMissing("signtool.exe"))?;
let status = Command::new(signtool)
.args(["verify", "/pa", "/v", &artifact.to_string_lossy()])
.status()
.context("run signtool verify")?;
if !status.success() {
return Err(MsixError::SignTool(format!("verify {}", artifact.display())).into());
}
Ok(())
}
pub fn validate_package(tools: &SdkTools, msix_or_bundle: &Path) -> Result<()> {
let appcert = tools
.appcert
.as_ref()
.ok_or(MsixError::ToolMissing("appcert.exe (WACK)"))?;
let reset_status = Command::new(appcert)
.arg("reset")
.status()
.context("run appcert reset")?;
if !reset_status.success() {
return Err(MsixError::Validation("appcert reset failed".into()).into());
}
let report_file = std::env::temp_dir().join(format!(
"wack_report_{}_{}.xml",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
));
let _ = fs::remove_file(&report_file);
let status = Command::new(appcert)
.args([
"test",
"-appxpackagepath",
&msix_or_bundle.to_string_lossy(),
"-reportoutputpath",
&report_file.to_string_lossy(),
])
.status()
.context("run appcert (WACK)")?;
if !status.success() {
let mut message = msix_or_bundle.display().to_string();
if report_file.exists() {
message.push_str(&format!(" (report: {})", report_file.display()));
}
return Err(MsixError::Validation(message).into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_removes_invalid_chars() {
assert_eq!(sanitize("My:App"), "MyApp");
assert_eq!(sanitize("App<>Name"), "AppName");
assert_eq!(sanitize("Test/App\\Name"), "TestAppName");
}
#[test]
fn sanitize_returns_app_for_empty() {
assert_eq!(sanitize(""), "App");
assert_eq!(sanitize(":::"), "App");
}
#[test]
fn sanitize_preserves_valid_chars() {
assert_eq!(sanitize("MyApp 2.0"), "MyApp 2.0");
assert_eq!(sanitize("App-Name_v1"), "App-Name_v1");
}
#[test]
fn version4_parse_valid() {
let v = Version4::parse("10.0.19041.0").expect("valid version");
assert_eq!(v, Version4(10, 0, 19041, 0));
}
#[test]
fn version4_parse_invalid() {
assert!(Version4::parse("10.0.19041").is_err()); assert!(Version4::parse("10.0.19041.0.0").is_err()); assert!(Version4::parse("abc").is_err());
}
#[test]
fn version4_ordering() {
let v1 = Version4::parse("10.0.19041.0").expect("v1");
let v2 = Version4::parse("10.0.22000.0").expect("v2");
assert!(v2 > v1);
}
#[test]
fn read_manifest_info_valid() {
let dir = tempfile::tempdir().expect("create temp dir");
let manifest = r#"<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
<Identity Name="MyCompany.MyApp" Version="1.2.3.0" Publisher="CN=Test"/>
<Properties>
<DisplayName>My Cool App</DisplayName>
</Properties>
</Package>"#;
std::fs::write(dir.path().join("AppxManifest.xml"), manifest).expect("write manifest");
let info = read_manifest_info(dir.path()).expect("parse manifest");
assert_eq!(info.version, "1.2.3.0");
assert_eq!(info.display_name, "My Cool App");
}
#[test]
fn read_manifest_info_ms_resource_fallback() {
let dir = tempfile::tempdir().expect("create temp dir");
let manifest = r#"<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
<Identity Name="MyCompany.MyApp" Version="1.0.0.0" Publisher="CN=Test"/>
<Properties>
<DisplayName>ms-resource:AppName</DisplayName>
</Properties>
</Package>"#;
std::fs::write(dir.path().join("AppxManifest.xml"), manifest).expect("write manifest");
let info = read_manifest_info(dir.path()).expect("parse manifest");
assert_eq!(info.display_name, "MyCompany.MyApp");
}
fn args_to_strings(args: Vec<OsString>) -> Vec<String> {
args.into_iter()
.map(|s| s.to_string_lossy().into_owned())
.collect()
}
#[test]
fn build_makepri_createconfig_args_with_overwrite() {
let args = args_to_strings(build_makepri_createconfig_args(
Path::new("priconfig.xml"),
"en-us",
"10.0.0",
true,
));
assert_eq!(
args,
vec![
"createconfig",
"/cf",
"priconfig.xml",
"/dq",
"lang-en-us",
"/pv",
"10.0.0",
"/o"
]
);
}
#[test]
fn build_makepri_new_args_without_overwrite() {
let args = args_to_strings(build_makepri_new_args(
Path::new("AppxContent"),
Path::new("AppxContent/priconfig.xml"),
Path::new("AppxContent/AppxManifest.xml"),
Path::new("AppxContent/resources.pri"),
false,
));
assert_eq!(
args,
vec![
"new",
"/pr",
"AppxContent",
"/cf",
"AppxContent/priconfig.xml",
"/mn",
"AppxContent/AppxManifest.xml",
"/of",
"AppxContent/resources.pri"
]
);
}
#[test]
fn build_sign_args_pfx_with_password() {
let artifact = Path::new("test.msix");
let pfx = Path::new("cert.pfx");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Pfx {
path: pfx,
password: Some("secret"),
},
sip_dll: None,
timestamp_url: None,
rfc3161: true,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec![
"sign",
"/fd",
"SHA256",
"/f",
"cert.pfx",
"/p",
"secret",
"test.msix"
]
);
}
#[test]
fn build_sign_args_pfx_without_password() {
let artifact = Path::new("test.msix");
let pfx = Path::new("cert.pfx");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Pfx {
path: pfx,
password: None,
},
sip_dll: None,
timestamp_url: None,
rfc3161: true,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec!["sign", "/fd", "SHA256", "/f", "cert.pfx", "test.msix"]
);
}
#[test]
fn build_sign_args_thumbprint_default_store() {
let artifact = Path::new("test.msix");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Thumbprint {
sha1: "ABC123",
store: Some("My"),
machine_store: false,
},
sip_dll: None,
timestamp_url: None,
rfc3161: true,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec![
"sign",
"/fd",
"SHA256",
"/sha1",
"ABC123",
"/s",
"My",
"test.msix"
]
);
}
#[test]
fn build_sign_args_thumbprint_custom_store() {
let artifact = Path::new("test.msix");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Thumbprint {
sha1: "ABC123",
store: Some("Root"),
machine_store: false,
},
sip_dll: None,
timestamp_url: None,
rfc3161: true,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec![
"sign",
"/fd",
"SHA256",
"/sha1",
"ABC123",
"/s",
"Root",
"test.msix"
]
);
}
#[test]
fn build_sign_args_thumbprint_machine_store() {
let artifact = Path::new("test.msix");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Thumbprint {
sha1: "ABC123",
store: Some("My"),
machine_store: true,
},
sip_dll: None,
timestamp_url: None,
rfc3161: true,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec![
"sign",
"/fd",
"SHA256",
"/sha1",
"ABC123",
"/s",
"My",
"/sm",
"test.msix"
]
);
}
#[test]
fn build_sign_args_thumbprint_no_store() {
let artifact = Path::new("test.msix");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Thumbprint {
sha1: "ABC123",
store: None,
machine_store: false,
},
sip_dll: None,
timestamp_url: None,
rfc3161: true,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec!["sign", "/fd", "SHA256", "/sha1", "ABC123", "test.msix"]
);
}
#[test]
fn build_sign_args_with_timestamp_rfc3161() {
let artifact = Path::new("test.msix");
let pfx = Path::new("cert.pfx");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Pfx {
path: pfx,
password: None,
},
sip_dll: None,
timestamp_url: Some("http://timestamp.example.com"),
rfc3161: true,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec![
"sign",
"/fd",
"SHA256",
"/f",
"cert.pfx",
"/td",
"SHA256",
"/tr",
"http://timestamp.example.com",
"test.msix"
]
);
}
#[test]
fn build_sign_args_with_timestamp_authenticode() {
let artifact = Path::new("test.msix");
let pfx = Path::new("cert.pfx");
let opts = SignOptions {
artifact,
certificate: CertificateSource::Pfx {
path: pfx,
password: None,
},
sip_dll: None,
timestamp_url: Some("http://timestamp.example.com"),
rfc3161: false,
signtool_override: None,
};
let args = args_to_strings(build_sign_args(&opts));
assert_eq!(
args,
vec![
"sign",
"/fd",
"SHA256",
"/f",
"cert.pfx",
"/t",
"http://timestamp.example.com",
"test.msix"
]
);
}
}