use std::path::{Path, PathBuf};
use std::process::Command;
use crate::ops::status;
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Identical,
Differs(String),
Unchecked(String),
}
impl Verdict {
fn label(&self) -> &str {
match self {
Verdict::Identical => "identical",
Verdict::Differs(_) => "differs",
Verdict::Unchecked(_) => "not checked",
}
}
}
struct Provenance {
repository: String,
commit: String,
dirty: bool,
project: Option<String>,
app_id: Option<String>,
target: Option<String>,
profile: Option<String>,
tools: Vec<(String, String, String)>,
inputs: Vec<(String, String)>,
payload: Vec<(String, String)>,
}
fn kv_list(doc: &serde_json::Value, array: &str, k: &str, v: &str) -> Vec<(String, String)> {
doc.get(array)
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| {
Some((
e.get(k)?.as_str()?.to_string(),
e.get(v)?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default()
}
fn read_json(path: &Path) -> Option<serde_json::Value> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
#[derive(Debug, PartialEq)]
struct SourceFacts {
repository: String,
commit: String,
dirty: bool,
project: Option<String>,
app_id: Option<String>,
}
fn source_facts(doc: &serde_json::Value) -> Option<SourceFacts> {
if let Some(v) = cyclonedx_props(doc) {
return Some(v);
}
let info = doc
.get("packages")?
.as_array()?
.iter()
.find_map(|p| p.get("sourceInfo").and_then(|s| s.as_str()))?;
let field = |k: &str| {
info.split_whitespace()
.find_map(|kv| kv.strip_prefix(&format!("{k}=")))
.map(str::to_string)
.filter(|v| v != "NOASSERTION")
};
Some(SourceFacts {
repository: field("repository")?,
commit: field("commit")?,
dirty: info.contains("dirty=true"),
project: info
.split_whitespace()
.find_map(|kv| kv.strip_prefix("project="))
.filter(|v| *v != "NOASSERTION")
.map(str::to_string),
app_id: field("app-id"),
})
}
fn cyclonedx_props(doc: &serde_json::Value) -> Option<SourceFacts> {
let props = doc.get("metadata")?.get("properties")?.as_array()?;
let get = |k: &str| {
props
.iter()
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some(k))
.and_then(|p| p.get("value"))
.and_then(|v| v.as_str())
.map(str::to_string)
};
Some(SourceFacts {
repository: get("day:repository")?,
commit: get("day:commit")?,
dirty: get("day:dirty").as_deref() == Some("true"),
project: get("day:project"),
app_id: get("day:app-id"),
})
}
fn locate_sbom(artifact: &Path, scratch: &Path) -> Result<serde_json::Value, String> {
let dir = artifact.parent().unwrap_or(Path::new("."));
for name in ["day-sbom.cdx.json", "day-sbom.spdx.json"] {
if let Some(doc) = read_json(&dir.join(name)) {
return Ok(doc);
}
}
let inner = scratch.join("extracted");
std::fs::create_dir_all(&inner).map_err(|e| e.to_string())?;
let ext = artifact
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
let opened = match ext {
"ipa" | "apk" | "aab" | "hap" | "msix" | "zip" => Command::new("unzip")
.args(["-q", "-o"])
.arg(artifact)
.arg("-d")
.arg(&inner)
.status()
.map(|s| s.success())
.unwrap_or(false),
"dmg" if cfg!(target_os = "macos") => mount_dmg(artifact, &inner).is_ok(),
_ => false,
};
if opened
&& let Some(found) = find_file(&inner, "day-sbom.cdx.json")
.or_else(|| find_file(&inner, "day-sbom.spdx.json"))
&& let Some(doc) = read_json(&found)
{
return Ok(doc);
}
Err(format!(
"no SBOM beside or inside {}.\n \
A rebuild needs the source repository and commit. Either place day-sbom.cdx.json next to \
the artifact, or build the app with `sbom = \"embed cyclonedx\"` in Day.toml so it ships \
inside.",
artifact.display()
))
}
fn mount_dmg(dmg: &Path, dest: &Path) -> Result<(), String> {
let mnt = dest.join("_mnt");
std::fs::create_dir_all(&mnt).map_err(|e| e.to_string())?;
let ok = Command::new("hdiutil")
.args(["attach", "-nobrowse", "-readonly", "-quiet", "-mountpoint"])
.arg(&mnt)
.arg(dmg)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return Err(format!("could not mount {}", dmg.display()));
}
let copied = Command::new("ditto")
.arg(&mnt)
.arg(dest.join("content"))
.status();
let _ = Command::new("hdiutil")
.args(["detach", "-quiet"])
.arg(&mnt)
.status();
copied
.map(|_| ())
.map_err(|e| format!("copying from the mounted image: {e}"))
}
fn find_file(root: &Path, name: &str) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
let Ok(meta) = std::fs::symlink_metadata(&p) else {
continue;
};
if meta.is_symlink() {
continue;
}
if meta.is_dir() {
stack.push(p);
} else if p.file_name().and_then(|n| n.to_str()) == Some(name) {
return Some(p);
}
}
}
None
}
fn collect(artifact: &Path, scratch: &Path) -> Result<Provenance, String> {
let sbom = locate_sbom(artifact, scratch)?;
let facts = source_facts(&sbom).ok_or_else(|| {
"the SBOM has no day:repository / day:commit properties — it was written by a different \
tool, or by a day-cli too old to record them"
.to_string()
})?;
let dir = artifact.parent().unwrap_or(Path::new("."));
let info = std::fs::read_dir(dir)
.ok()
.and_then(|rd| {
rd.flatten()
.map(|e| e.path())
.find(|p| p.to_string_lossy().ends_with(".buildinfo.json"))
})
.and_then(|p| read_json(&p));
let (inputs, payload) = match &info {
Some(v) => (
kv_list(v, "inputs", "name", "value"),
kv_list(v, "payload", "path", "sha256"),
),
None => (Vec::new(), Vec::new()),
};
let (target, profile, tools) = match info {
Some(v) => {
let tools = v
.get("tools")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| {
Some((
t.get("key")?.as_str()?.to_string(),
t.get("version")?.as_str()?.to_string(),
t.get("install")
.and_then(|i| i.as_str())
.unwrap_or_default()
.to_string(),
))
})
.collect()
})
.unwrap_or_default();
(
v.get("target").and_then(|t| t.as_str()).map(str::to_string),
v.get("profile")
.and_then(|t| t.as_str())
.map(str::to_string),
tools,
)
}
None => (None, None, Vec::new()),
};
Ok(Provenance {
repository: facts.repository,
commit: facts.commit,
dirty: facts.dirty,
project: facts.project,
app_id: facts.app_id,
target,
profile,
tools,
inputs,
payload,
})
}
fn preflight(target_name: &str) -> Result<&'static crate::targets::Target, String> {
let target = crate::targets::find(target_name)
.ok_or_else(|| format!("unknown target {target_name:?} — this day-cli may be too old"))?;
let host = crate::targets::host_os();
if target.host != "any" && target.host != host {
return Err(format!(
"{} can only be rebuilt on a {} host; this is {}.\n \
Nothing about the environment can be adjusted to change that — rebuild on {} instead.",
target.name, target.host, host, target.host
));
}
let missing: Option<(&str, &str)> = match target.toolkit {
"appkit" | "uikit" if which("xcodebuild").is_none() => Some((
"Xcode",
"install Xcode from https://developer.apple.com/download/all/?q=Xcode, then run \
sudo xcode-select -s /Applications/Xcode.app",
)),
"mdc" if which("gradle").is_none() && which("java").is_none() => Some((
"a JDK and Gradle",
"brew install openjdk gradle / apt install default-jdk gradle",
)),
"gtk" | "qt" if which("flatpak-builder").is_none() => Some((
"flatpak-builder",
"apt install flatpak-builder / dnf install flatpak-builder",
)),
"arkui" if std::env::var("OHOS_BASE_SDK_HOME").is_err() => Some((
"the OpenHarmony SDK",
"set OHOS_BASE_SDK_HOME — see docs/harmonyos.md",
)),
_ => None,
};
if let Some((what, how)) = missing {
return Err(format!(
"{} needs {what}, which is not on this machine.\n {how}",
target.name
));
}
Ok(target)
}
fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|d| d.join(bin))
.find(|p| p.is_file())
}
fn current_version(key: &str) -> Option<String> {
crate::provenance::tool_version(key)
}
fn check_tools(tools: &[(String, String, String)], forced: &[String]) -> Result<(), String> {
let force_all = forced.iter().any(|f| f == "all");
let mut problems = Vec::new();
for (key, recorded, hint) in tools {
let forced_here = force_all || forced.iter().any(|f| f == key);
let Some(found) = current_version(key) else {
if !forced_here {
problems.push(format!(
" {key}: not found on this machine (built with {recorded})\n {hint}"
));
}
continue;
};
if &found == recorded {
continue;
}
if forced_here {
status(
"Forcing",
&format!("{key}: {found} (artifact was built with {recorded})"),
);
} else {
problems.push(format!(
" {key}: this machine has {found}\n the artifact was built with {recorded}\n {hint}"
));
}
}
if problems.is_empty() {
return Ok(());
}
Err(format!(
"the build environment does not match the artifact:\n{}\n\n \
Install the versions above, or re-run with --force-tool=<name> for each tool you want to \
ignore (--force-tool=all ignores every mismatch). Forcing usually changes the output, so \
a forced rebuild that differs proves nothing.",
problems.join("\n")
))
}
fn checkout(repository: &str, commit: &str, dest: &Path) -> Result<(), String> {
status(
"Cloning",
&format!("{repository} @ {}", &commit[..12.min(commit.len())]),
);
let ok = Command::new("git")
.args(["clone", "--quiet", repository])
.arg(dest)
.status()
.map_err(|e| format!("git clone: {e}"))?;
if !ok.success() {
return Err(format!("could not clone {repository}"));
}
let ok = Command::new("git")
.current_dir(dest)
.args(["checkout", "--quiet", commit])
.status()
.map_err(|e| format!("git checkout: {e}"))?;
if !ok.success() {
return Err(format!(
"{repository} has no commit {commit} — the artifact may predate a force-push, or the \
commit was never pushed"
));
}
Ok(())
}
fn digest(path: &Path) -> Result<String, String> {
crate::pack::sha256_file(path)
}
fn compare(
original: &Path,
rebuilt: &Path,
scratch: &Path,
recorded_payload: &[(String, String)],
project_dir: &Path,
target: &'static crate::targets::Target,
) -> (Verdict, Verdict) {
let (a, b) = match (digest(original), digest(rebuilt)) {
(Ok(a), Ok(b)) => (a, b),
_ => {
let why = Verdict::Unchecked("could not hash one of the artifacts".into());
return (
Verdict::Unchecked("could not hash one of the artifacts".into()),
why,
);
}
};
let container = if a == b {
Verdict::Identical
} else {
Verdict::Differs(format!("{}… vs {}…", &a[..16], &b[..16]))
};
if container == Verdict::Identical {
return (Verdict::Identical, container);
}
let (ua, ub) = (scratch.join("cmp/a"), scratch.join("cmp/b"));
let _ = std::fs::remove_dir_all(scratch.join("cmp"));
if !unpack(original, &ua) || !unpack(rebuilt, &ub) {
return (
payload_by_digest(recorded_payload, project_dir, target, original),
container,
);
}
match differing_members(&ua, &ub) {
Ok((diffs, skipped)) if diffs.is_empty() => {
if !skipped.is_empty() {
status(
"Excluded",
&format!(
"{} signature file(s), which no build controls: {}",
skipped.len(),
skipped.join(", ")
),
);
}
(Verdict::Identical, container)
}
Ok((diffs, _)) => (
Verdict::Differs(format!(
"{} file(s) differ after normalization: {}",
diffs.len(),
diffs
.iter()
.take(4)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ")
)),
container,
),
Err(e) => (Verdict::Differs(e), container),
}
}
pub struct Options {
pub force_tools: Vec<String>,
pub keep: bool,
pub strict: bool,
}
pub fn run(artifact: &Path, opts: &Options) -> Result<i32, String> {
if !artifact.is_file() {
return Err(format!("{} is not a file", artifact.display()));
}
let artifact = artifact
.canonicalize()
.map_err(|e| format!("{}: {e}", artifact.display()))?;
let scratch = std::env::temp_dir().join(format!(
"day-rebuild-{}",
artifact
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("artifact")
));
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch).map_err(|e| e.to_string())?;
let prov = collect(&artifact, &scratch)?;
if prov.dirty {
return Err(
"this artifact was built from a working tree with uncommitted changes, so its commit \
does not describe it. No rebuild can reproduce it."
.into(),
);
}
let target_name = prov.target.clone().ok_or_else(|| {
format!(
"no .buildinfo.json beside {} — it records the target and the toolchain.\n \
Place the sidecar `day pack` wrote next to the artifact.",
artifact.display()
)
})?;
let target = preflight(&target_name)?;
let profile = prov.profile.clone().unwrap_or_else(|| "release".into());
if prov.tools.is_empty() {
status(
"Warning",
"no tool versions recorded — rebuilding without environment checks",
);
} else {
check_tools(&prov.tools, &opts.force_tools)?;
status(
"Environment",
&format!("{} tool(s) match the artifact", prov.tools.len()),
);
}
let src = scratch.join("src");
checkout(&prov.repository, &prov.commit, &src)?;
let project_dir = find_project_dir(&src, prov.project.as_deref(), prov.app_id.as_deref())?;
status(
"Rebuilding",
&format!("{} ({profile}) in {}", target.name, project_dir.display()),
);
let day = std::env::current_exe().map_err(|e| e.to_string())?;
let mut pack = Command::new(&day);
pack.current_dir(&project_dir)
.args(["pack", "-p", target.name, "--profile", &profile])
.arg("--no-version-in-name");
for (k, v) in &prov.inputs {
status("Input", &format!("{k}={v}"));
pack.env(k, v);
}
let ok = pack
.status()
.map_err(|e| format!("running day pack: {e}"))?;
if !ok.success() {
return Err("the rebuild failed — see the pack output above".into());
}
let rebuilt_dir = project_dir.join("build/day/dist");
let name = artifact.file_name().unwrap_or_default();
let rebuilt = rebuilt_dir.join(name);
if !rebuilt.is_file() {
return Err(format!(
"the rebuild produced no {}; it made: {}",
name.to_string_lossy(),
std::fs::read_dir(&rebuilt_dir)
.map(|rd| rd
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(", "))
.unwrap_or_default()
));
}
let (payload, container) = compare(
&artifact,
&rebuilt,
&scratch,
&prov.payload,
&project_dir,
target,
);
status("Payload", payload.label());
if let Verdict::Differs(d) | Verdict::Unchecked(d) = &payload {
status("", d);
}
status("Container", container.label());
if let Verdict::Differs(d) | Verdict::Unchecked(d) = &container {
status("", d);
}
if opts.keep {
status("Kept", &format!("{}", scratch.display()));
} else {
let _ = std::fs::remove_dir_all(&scratch);
}
Ok(match payload {
Verdict::Differs(_) => 1,
Verdict::Unchecked(_) if opts.strict => 1,
_ => 0,
})
}
fn day_toml_app_id(day_toml: &Path) -> Option<String> {
let text = std::fs::read_to_string(day_toml).ok()?;
let doc: toml::Value = toml::from_str(&text).ok()?;
doc.get("app")?.get("id")?.as_str().map(str::to_string)
}
fn day_projects(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if dir.join("Day.toml").is_file() && dir.join("Cargo.toml").is_file() {
found.push(dir.clone());
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
let mut kids: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.ends_with(".git") && !p.ends_with("target"))
.collect();
kids.sort();
stack.extend(kids);
}
found.sort();
found
}
fn find_project_dir(
root: &Path,
recorded: Option<&str>,
app_id: Option<&str>,
) -> Result<PathBuf, String> {
if let Some(rel) = recorded {
let dir = if rel.is_empty() {
root.to_path_buf()
} else {
root.join(rel)
};
if dir.join("Day.toml").is_file() {
return Ok(dir);
}
return Err(format!(
"the artifact was packed in `{}` of {}, which does not exist at this commit",
if rel.is_empty() { "." } else { rel },
root.display(),
));
}
let projects = day_projects(root);
if let Some(want) = app_id
&& let Some(hit) = projects
.iter()
.find(|p| day_toml_app_id(&p.join("Day.toml")).as_deref() == Some(want))
{
return Ok(hit.clone());
}
match projects.len() {
0 => Err(format!("no Day project in {}", root.display())),
1 => Ok(projects[0].clone()),
_ => Err(format!(
"this SBOM records neither the project path nor an app id, and the repository holds \
{} Day projects ({}). Re-pack with a current day-cli, which records the path.",
projects.len(),
projects
.iter()
.map(|p| p.strip_prefix(root).unwrap_or(p).display().to_string())
.collect::<Vec<_>>()
.join(", "),
)),
}
}
const LC_UUID: u32 = 0x1B;
fn zero_macho_uuid(buf: &mut [u8]) -> usize {
fn u32_at(b: &[u8], at: usize, big: bool) -> Option<u32> {
let v = b.get(at..at + 4)?.try_into().ok()?;
Some(if big {
u32::from_be_bytes(v)
} else {
u32::from_le_bytes(v)
})
}
fn thin(buf: &mut [u8], off: usize) -> usize {
let Some(magic) = u32_at(buf, off, false) else {
return 0;
};
let (big, is64) = match magic {
0xFEED_FACF => (false, true),
0xFEED_FACE => (false, false),
0xCFFA_EDFE => (true, true),
0xCEFA_EDFE => (true, false),
_ => return 0,
};
let Some(ncmds) = u32_at(buf, off + 16, big) else {
return 0;
};
let mut lc = off + if is64 { 32 } else { 28 };
let mut zeroed = 0;
for _ in 0..ncmds {
let (Some(cmd), Some(size)) = (u32_at(buf, lc, big), u32_at(buf, lc + 4, big)) else {
break;
};
if size < 8 || lc + size as usize > buf.len() {
break;
}
if cmd == LC_UUID && lc + 24 <= buf.len() {
buf[lc + 8..lc + 24].fill(0);
zeroed += 1;
}
lc += size as usize;
}
zeroed
}
match u32_at(buf, 0, true) {
Some(m @ (0xCAFE_BABE | 0xBEBA_FECA)) => {
let big = m == 0xCAFE_BABE;
let Some(n) = u32_at(buf, 4, big) else {
return 0;
};
let mut zeroed = 0;
for i in 0..n as usize {
let arch = 8 + i * 20;
let Some(slice_off) = u32_at(buf, arch + 8, big) else {
break;
};
zeroed += thin(buf, slice_off as usize);
}
zeroed
}
_ => thin(buf, 0),
}
}
fn normalize(path: &Path) -> Result<(), String> {
let Ok(head) = std::fs::read(path) else {
return Ok(());
};
let looks_macho = head.len() > 4
&& matches!(
u32::from_le_bytes(head[0..4].try_into().unwrap_or_default()),
0xFEED_FACF | 0xFEED_FACE | 0xCFFA_EDFE | 0xCEFA_EDFE
)
|| head.starts_with(&[0xCA, 0xFE, 0xBA, 0xBE]);
if !looks_macho {
return Ok(());
}
let _ = Command::new("codesign")
.args(["--remove-signature"])
.arg(path)
.output();
let mut buf = std::fs::read(path).map_err(|e| e.to_string())?;
if zero_macho_uuid(&mut buf) > 0 {
std::fs::write(path, &buf).map_err(|e| e.to_string())?;
}
Ok(())
}
fn payload_by_digest(
recorded: &[(String, String)],
project_dir: &Path,
target: &'static crate::targets::Target,
original: &Path,
) -> Verdict {
let fmt = original
.extension()
.and_then(|e| e.to_str())
.unwrap_or("this format");
if recorded.is_empty() {
return Verdict::Unchecked(format!(
"no extractor for {fmt} on this host, and the artifact recorded no payload digests \
(it was packed by a day-cli too old to write them)"
));
}
let Some(root) = crate::pack::payload_root(project_dir, target) else {
return Verdict::Unchecked(format!(
"no extractor for {fmt} on this host, and {} stages no payload to compare",
target.name
));
};
let rebuilt = crate::pack::payload_digests(&root);
if rebuilt.is_empty() {
return Verdict::Unchecked(format!(
"no extractor for {fmt} on this host, and the rebuild staged no payload under {}",
root.display()
));
}
let want: std::collections::BTreeMap<&str, &str> = recorded
.iter()
.map(|(p, d)| (p.as_str(), d.as_str()))
.collect();
let got: std::collections::BTreeMap<&str, &str> = rebuilt
.iter()
.map(|(p, d)| (p.as_str(), d.as_str()))
.collect();
if want.keys().ne(got.keys()) {
return Verdict::Differs(format!(
"the two builds staged different payload files ({} vs {})",
want.len(),
got.len()
));
}
let diffs: Vec<&str> = want
.iter()
.filter(|(p, d)| got.get(*p) != Some(d))
.map(|(p, _)| *p)
.collect();
if diffs.is_empty() {
Verdict::Identical
} else {
Verdict::Differs(format!(
"{} payload file(s) differ by digest: {}",
diffs.len(),
diffs.join(", ")
))
}
}
fn portable(p: &Path) -> String {
let s = p.to_string_lossy();
if cfg!(windows) {
s.trim_start_matches("\\\\?\\").replace('\\', "/")
} else {
s.into_owned()
}
}
fn unpack(container: &Path, dest: &Path) -> bool {
let _ = std::fs::create_dir_all(dest);
match container.extension().and_then(|e| e.to_str()) {
Some("ipa" | "apk" | "aab" | "hap" | "msix" | "zip") => Command::new("unzip")
.args(["-q", "-o"])
.arg(portable(container))
.arg("-d")
.arg(portable(dest))
.status()
.map(|s| s.success())
.unwrap_or(false),
Some("dmg") if cfg!(target_os = "macos") => mount_dmg(container, dest).is_ok(),
Some("flatpak") => unpack_flatpak(container, dest),
_ => false,
}
}
fn unpack_flatpak(bundle: &Path, dest: &Path) -> bool {
let repo = dest.with_extension("ostree-repo");
let _ = std::fs::remove_dir_all(&repo);
let ok = Command::new("ostree")
.arg("init")
.arg("--mode=archive")
.arg(format!("--repo={}", repo.display()))
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return false;
}
let imported = Command::new("flatpak")
.args(["build-import-bundle", "--no-update-summary"])
.arg(&repo)
.arg(bundle)
.output();
let Ok(out) = imported else { return false };
if !out.status.success() {
return false;
}
let refs = Command::new("ostree")
.arg(format!("--repo={}", repo.display()))
.arg("refs")
.output();
let Ok(refs) = refs else { return false };
let Some(r) = String::from_utf8_lossy(&refs.stdout)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_owned)
else {
return false;
};
Command::new("ostree")
.arg(format!("--repo={}", repo.display()))
.args(["checkout", "--union", &r])
.arg(dest)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn file_list(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
let Ok(meta) = std::fs::symlink_metadata(&p) else {
continue;
};
if meta.is_symlink() {
continue;
}
if meta.is_dir() {
stack.push(p);
} else if let Ok(rel) = p.strip_prefix(root) {
out.push(rel.to_path_buf());
}
}
}
out.sort();
out
}
fn signature_member(rel: &Path) -> bool {
let joined = rel.to_string_lossy().replace('\\', "/");
let parts: Vec<&str> = joined.split('/').collect();
let Some(name) = parts.last().copied() else {
return false;
};
let in_dir = |d: &str| parts.iter().rev().skip(1).any(|c| *c == d);
name == "AppxSignature.p7x"
|| (name == "CodeIntegrity.cat" && in_dir("AppxMetadata"))
|| in_dir("_CodeSignature")
|| name == "embedded.mobileprovision"
|| (in_dir("META-INF")
&& (name.ends_with(".SF")
|| name.ends_with(".RSA")
|| name.ends_with(".DSA")
|| name.ends_with(".EC")
|| name == "MANIFEST.MF"))
}
fn differing_members(a: &Path, b: &Path) -> Result<(Vec<String>, Vec<String>), String> {
let keep = |l: Vec<PathBuf>| -> (Vec<PathBuf>, Vec<String>) {
let (sig, rest): (Vec<PathBuf>, Vec<PathBuf>) =
l.into_iter().partition(|p| signature_member(p));
(rest, sig.iter().map(|p| p.display().to_string()).collect())
};
let (la, siga) = keep(file_list(a));
let (lb, _) = keep(file_list(b));
if la != lb {
return Err("the two builds produced different sets of files".into());
}
let mut diffs = Vec::new();
for rel in la {
let (pa, pb) = (a.join(&rel), b.join(&rel));
normalize(&pa)?;
normalize(&pb)?;
if std::fs::read(&pa).ok() != std::fs::read(&pb).ok() {
diffs.push(rel.display().to_string());
}
}
Ok((diffs, siga))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_target_that_cannot_build_here_is_refused_with_the_reason() {
if crate::targets::host_os() != "windows" {
let err = preflight("windows-xaml").unwrap_err();
assert!(err.contains("windows"), "{err}");
assert!(err.contains("host"), "{err}");
}
}
#[test]
fn a_forced_tool_stops_being_an_error() {
let tools = vec![(
"rust".to_string(),
"rustc 0.0.0-not-a-real-version".to_string(),
"rustup".to_string(),
)];
assert!(check_tools(&tools, &[]).is_err(), "mismatch must fail");
assert!(
check_tools(&tools, &["rust".into()]).is_ok(),
"--force-tool=rust"
);
assert!(
check_tools(&tools, &["all".into()]).is_ok(),
"--force-tool=all"
);
}
#[test]
fn source_facts_come_from_cyclonedx_or_spdx() {
let sbom = crate::provenance::Sbom {
schema: crate::provenance::SCHEMA.into(),
app_id: "dev.example.app".into(),
app_name: "app".into(),
app_version: "1.0.0".into(),
app_build: 1,
repository: Some("https://example.invalid/repo".into()),
commit: Some("abc123".into()),
dirty: false,
project_path: Some("apps/showcase".into()),
components: Vec::new(),
};
let from_cdx = source_facts(&crate::provenance::cyclonedx(&sbom)).expect("cyclonedx");
let from_spdx = source_facts(&crate::provenance::spdx(&sbom)).expect("spdx");
assert_eq!(from_cdx, from_spdx, "the two formats must agree");
assert_eq!(from_cdx.repository, "https://example.invalid/repo");
assert_eq!(from_cdx.commit, "abc123");
assert!(!from_cdx.dirty);
assert_eq!(from_cdx.project.as_deref(), Some("apps/showcase"));
}
#[test]
fn the_project_is_chosen_by_record_then_app_id_never_by_walk_order() {
let tmp = std::env::temp_dir().join(format!("day-rebuild-pick-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let mk = |rel: &str, id: Option<&str>| {
let dir = tmp.join(rel);
std::fs::create_dir_all(&dir).expect("mkdir");
if let Some(id) = id {
std::fs::write(
dir.join("Day.toml"),
format!("schema = 1\n[app]\nid = \"{id}\"\n"),
)
.expect("Day.toml");
std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\n")
.expect("Cargo.toml");
}
};
mk("apps/daylite", Some("dev.daybrite.daylite"));
mk("apps/showcase", Some("dev.daybrite.showcase"));
let tpl = tmp.join("crates/day-cli/templates/app");
std::fs::create_dir_all(&tpl).expect("mkdir");
std::fs::write(
tpl.join("Day.toml"),
"schema = 1\n[app]\nid = \"{{app_id}}\"\n",
)
.expect("template");
assert_eq!(
find_project_dir(&tmp, Some("apps/showcase"), None).expect("recorded"),
tmp.join("apps/showcase"),
);
mk("", Some("dev.example.root"));
assert_eq!(find_project_dir(&tmp, Some(""), None).expect("root"), tmp);
assert_eq!(
find_project_dir(&tmp, None, Some("dev.daybrite.showcase")).expect("by id"),
tmp.join("apps/showcase"),
);
let err = find_project_dir(&tmp, None, None).expect_err("ambiguous");
assert!(
err.contains("apps/daylite") && err.contains("apps/showcase"),
"{err}"
);
assert!(
!err.contains("templates"),
"a template is not a candidate: {err}"
);
assert!(find_project_dir(&tmp, Some("apps/gone"), None).is_err());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn windows_paths_reach_unzip_with_forward_slashes() {
let p = Path::new("relative/dir/showcase.msix");
assert_eq!(portable(p), "relative/dir/showcase.msix");
let win = r"\\?\D:\a\day\day\shipped\showcase.msix";
let fixed = win.trim_start_matches(r"\\?\").replace('\\', "/");
assert_eq!(fixed, "D:/a/day/day/shipped/showcase.msix");
}
#[test]
fn payload_by_digest_decides_when_there_is_no_extractor() {
let tmp = std::env::temp_dir().join(format!("day-payload-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("build/day/flatpak/linux-gtk/stage/bin");
std::fs::create_dir_all(&root).expect("mkdir");
std::fs::write(root.join("showcase-bin"), b"compiled code").expect("write");
let target = crate::targets::find("linux-gtk").expect("target");
let staged = crate::pack::payload_digests(&root);
assert_eq!(staged.len(), 1, "one staged payload file");
let art = Path::new("showcase-gtk-x86_64.flatpak");
assert_eq!(
payload_by_digest(&staged, &tmp, target, art),
Verdict::Identical
);
let tampered = vec![(staged[0].0.clone(), "0".repeat(64))];
match payload_by_digest(&tampered, &tmp, target, art) {
Verdict::Differs(d) => assert!(d.contains("showcase-bin"), "{d}"),
v => panic!("expected Differs, got {v:?}"),
}
match payload_by_digest(&[], &tmp, target, art) {
Verdict::Unchecked(d) => assert!(d.contains("too old"), "{d}"),
v => panic!("expected Unchecked, got {v:?}"),
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn signature_members_are_excluded_from_the_payload_tier() {
for p in [
"AppxSignature.p7x",
"AppxMetadata/CodeIntegrity.cat",
"_CodeSignature/CodeResources",
"embedded.mobileprovision",
"META-INF/CERT.RSA",
"META-INF/MANIFEST.MF",
"Payload/Showcase.app/_CodeSignature/CodeResources",
"Payload/Showcase.app/embedded.mobileprovision",
"content/Day Showcase.app/Contents/_CodeSignature/CodeResources",
] {
assert!(signature_member(Path::new(p)), "{p} is signature material");
}
assert!(signature_member(Path::new(
r"AppxMetadata\CodeIntegrity.cat"
)));
for p in [
"AppxManifest.xml",
"AppxBlockMap.xml",
"showcase.exe",
"lib/arm64-v8a/libshowcase.so",
"META-INF/services/foo",
] {
assert!(!signature_member(Path::new(p)), "{p} is build output");
}
}
#[test]
fn a_differing_signature_alone_is_not_a_payload_difference() {
let tmp = std::env::temp_dir().join(format!("day-sigcmp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let (a, b) = (tmp.join("a"), tmp.join("b"));
for (dir, sig) in [(&a, b"sig-one"), (&b, b"sig-two")] {
std::fs::create_dir_all(dir.join("AppxMetadata")).expect("mkdir");
std::fs::write(dir.join("showcase.exe"), b"identical code").expect("exe");
std::fs::write(dir.join("AppxSignature.p7x"), sig).expect("sig");
std::fs::write(dir.join("AppxMetadata/CodeIntegrity.cat"), sig).expect("cat");
}
let (diffs, skipped) = differing_members(&a, &b).expect("compared");
assert!(
diffs.is_empty(),
"signature-only difference is not a payload difference: {diffs:?}"
);
assert_eq!(skipped.len(), 2, "and both exclusions are reported");
std::fs::write(b.join("showcase.exe"), b"different code").expect("exe");
let (diffs, _) = differing_members(&a, &b).expect("compared");
assert_eq!(diffs, vec!["showcase.exe".to_string()]);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn a_dirty_build_is_reported_as_such() {
let sbom = crate::provenance::Sbom {
schema: crate::provenance::SCHEMA.into(),
app_id: "dev.example.app".into(),
app_name: "app".into(),
app_version: "1.0.0".into(),
app_build: 1,
repository: Some("https://example.invalid/repo".into()),
commit: Some("abc123".into()),
dirty: true,
project_path: Some("apps/showcase".into()),
components: Vec::new(),
};
assert!(source_facts(&crate::provenance::spdx(&sbom)).unwrap().dirty);
assert!(
source_facts(&crate::provenance::cyclonedx(&sbom))
.unwrap()
.dirty
);
}
#[test]
fn zeroing_the_uuid_matches_the_python_normalizer() {
let mut buf = vec![0u8; 64];
buf[0..4].copy_from_slice(&0xFEED_FACFu32.to_le_bytes()); buf[16..20].copy_from_slice(&1u32.to_le_bytes()); let lc = 32;
buf[lc..lc + 4].copy_from_slice(&LC_UUID.to_le_bytes());
buf[lc + 4..lc + 8].copy_from_slice(&24u32.to_le_bytes()); buf[lc + 8..lc + 24].copy_from_slice(&[0xAB; 16]); assert_eq!(zero_macho_uuid(&mut buf), 1, "one uuid zeroed");
assert_eq!(&buf[lc + 8..lc + 24], &[0u8; 16], "uuid cleared");
assert_eq!(&buf[0..4], &0xFEED_FACFu32.to_le_bytes());
assert_eq!(&buf[lc..lc + 4], &LC_UUID.to_le_bytes());
}
#[test]
fn a_non_macho_file_is_left_alone() {
let mut buf = b"#!/bin/sh\necho hello\n".to_vec();
let before = buf.clone();
assert_eq!(zero_macho_uuid(&mut buf), 0);
assert_eq!(buf, before);
}
#[test]
fn an_unknown_target_is_named_in_the_error() {
let err = preflight("not-a-target").unwrap_err();
assert!(err.contains("not-a-target"), "{err}");
}
}