pub(crate) mod android;
mod flatpak;
mod ios;
mod macos;
mod msix;
mod nsis;
mod ohos;
pub mod settings;
use std::path::{Path, PathBuf};
use crate::meta::Project;
use crate::ops::status;
use crate::targets::Target;
pub use settings::PackOptions;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SignTier {
Unsigned,
DevSigned,
Release,
}
impl SignTier {
pub fn as_str(self) -> &'static str {
match self {
SignTier::Unsigned => "unsigned",
SignTier::DevSigned => "dev-signed",
SignTier::Release => "release",
}
}
}
pub struct Artifact {
pub path: PathBuf,
pub kind: &'static str,
pub sha256: String,
pub tier: SignTier,
}
pub struct PackOutcome {
pub target: &'static str,
pub artifacts: Vec<Artifact>,
pub seconds: f64,
}
pub enum PackError {
Sign(String),
Other(String),
}
impl From<String> for PackError {
fn from(s: String) -> Self {
PackError::Other(s)
}
}
impl PackError {
pub fn message(&self) -> &str {
match self {
PackError::Sign(m) | PackError::Other(m) => m,
}
}
pub fn exit_code(&self) -> i32 {
match self {
PackError::Sign(_) => 6,
PackError::Other(_) => 4,
}
}
}
fn default_formats(target: &Target) -> Result<Vec<&'static str>, String> {
Ok(match target.name {
"macos-appkit" => vec!["dmg"],
"ios-uikit" => vec!["ipa"], "android-mdc" => vec!["apk", "aab"],
"linux-gtk" | "linux-qt" => vec!["flatpak"],
"windows-xaml" => vec!["msix", "nsis"],
"harmony-arkui" => vec!["hap"],
"macos-gtk" | "macos-qt" | "windows-gtk" | "windows-qt" => {
return Err(format!(
"pack for {} means bundling the toolkit into the package — deferred (DESIGN.md \
DP-7). Pack the platform-native target instead, or `day launch -p {}` for development.",
target.name, target.name
));
}
other => return Err(format!("pack does not support {other}")),
})
}
pub fn run(
project: &Project,
target: &'static Target,
opts: &PackOptions,
) -> Result<PackOutcome, PackError> {
let start = std::time::Instant::now();
let defaults = default_formats(target)?;
let formats: Vec<String> = match &opts.formats {
Some(list) => {
for f in list {
if !defaults.contains(&f.as_str()) {
return Err(PackError::Other(format!(
"format {f:?} is not available for {} (available: {})",
target.name,
defaults.join(", ")
)));
}
}
list.clone()
}
None => defaults.iter().map(|s| s.to_string()).collect(),
};
let dist = project.root.join("build/day/dist");
std::fs::create_dir_all(&dist).map_err(|e| PackError::Other(e.to_string()))?;
let sbom_cfg = project.manifest.sbom.clone();
let sbom = crate::provenance::collect_sbom(project);
if !sbom_cfg.is_off() && sbom.dirty {
status(
"Warning",
"the working tree has uncommitted changes — the recorded commit does not describe \
this artifact, and `day rebuild` cannot reproduce it",
);
}
let sbom_dir = project.root.join("build/day/sbom");
let _ = std::fs::remove_dir_all(&sbom_dir);
if !sbom_cfg.is_off() {
crate::provenance::write_sbom(&sbom_dir, &sbom, &sbom_cfg.formats)
.map_err(PackError::Other)?;
}
let mut artifacts: Vec<Artifact> = Vec::new();
let mut payload_root: Option<std::path::PathBuf> = None;
match target.name {
"macos-appkit" => {
artifacts.push(macos::pack(project, target, opts, &dist)?);
}
"ios-uikit" => {
artifacts.push(ios::pack(project, target, opts, &dist)?);
}
"android-mdc" => {
artifacts.extend(android::pack(project, target, opts, &dist, &formats)?);
}
"linux-gtk" | "linux-qt" => {
artifacts.push(flatpak::pack(project, target, opts, &dist)?);
payload_root = payload_root_for(project, target);
}
"windows-xaml" => {
let staged = msix::stage_payload(project, target, opts)?;
payload_root = payload_root_for(project, target);
if formats.iter().any(|f| f == "msix") {
artifacts.push(msix::pack(project, opts, &staged, &dist)?);
}
if formats.iter().any(|f| f == "nsis") {
artifacts.push(nsis::pack(project, opts, &staged, &dist)?);
}
}
"harmony-arkui" => {
artifacts.push(ohos::pack(project, target, opts, &dist)?);
}
other => return Err(PackError::Other(format!("pack does not support {other}"))),
}
for a in &mut artifacts {
a.sha256 = sha256_file(&a.path).map_err(PackError::Other)?;
status(
"Packed",
&format!(
"{} ({}, {}) sha256:{}…",
a.path.display(),
a.kind,
a.tier.as_str(),
&a.sha256[..12]
),
);
if a.tier != SignTier::Release {
status(
"Warning",
&format!(
"{} is {} — NOT distributable (configure Day.toml `signing:` for release signing)",
a.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("artifact"),
a.tier.as_str()
),
);
}
}
let mut info = crate::provenance::collect_buildinfo(target, &opts.profile);
info.inputs = build_inputs(target);
if let Some(root) = &payload_root {
info.payload = payload_digests(root);
}
info.artifacts = artifacts
.iter()
.map(|a| {
(
a.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string(),
a.sha256.clone(),
)
})
.collect();
if crate::store::is_store_target(target) {
match crate::store::read(project) {
Ok(listing) if !listing.is_empty() => {
let out = crate::store::stage_dir(project, target);
match crate::store::stage(project, target, &listing, &out) {
Ok(files) => status(
"Listing",
&format!(
"{} ({} file(s), {} locale(s))",
out.display(),
files.len(),
listing.locales.len()
),
),
Err(e) => status("Warning", &format!("store listing: {e}")),
}
}
Ok(_) => {}
Err(e) => status("Warning", &format!("store listing: {e}")),
}
}
if sbom_cfg.mode == crate::meta::SbomMode::Sidecar {
for f in &sbom_cfg.formats {
let from = sbom_dir.join(f.file_name());
if from.is_file() {
std::fs::copy(&from, dist.join(f.file_name()))
.map_err(|e| PackError::Other(e.to_string()))?;
}
}
}
let sidecar = dist.join(format!("{}.buildinfo.json", target.name));
crate::provenance::write_buildinfo(&sidecar, &info).map_err(PackError::Other)?;
if matches!(target.toolkit, "gtk" | "qt") {
let deb = crate::provenance::debian_buildinfo(
&sbom,
&info,
&project.root,
Some(flatpak::runtime_for(target)),
);
let name = crate::provenance::debian_buildinfo_name(&sbom);
std::fs::write(dist.join(&name), deb).map_err(|e| PackError::Other(e.to_string()))?;
status(
"Provenance",
&format!("{} (deb822)", dist.join(&name).display()),
);
}
status(
"Provenance",
&format!(
"{} ({} tools) · sbom: {}",
sidecar.display(),
info.tools.len(),
if sbom_cfg.is_off() {
"none".to_string()
} else {
format!(
"{:?} {} component(s), {}",
sbom_cfg.mode,
sbom.components.len(),
sbom_cfg
.formats
.iter()
.map(|f| f.file_name())
.collect::<Vec<_>>()
.join(" + ")
)
.to_lowercase()
}
),
);
Ok(PackOutcome {
target: target.name,
artifacts,
seconds: start.elapsed().as_secs_f64(),
})
}
pub(crate) fn msix_check(project: &Project) -> Result<(), String> {
msix::resolve_signing(project)
.map(|_| ())
.map_err(|e| e.message().to_string())
}
pub(crate) fn windows_kit_tool_probe(tool: &str) -> Option<String> {
msix::windows_kit_tool(tool).map(|p| p.display().to_string())
}
pub fn sha256_file(path: &Path) -> Result<String, String> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file =
std::fs::File::open(path).map_err(|e| format!("checksum {}: {e}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file
.read(&mut buf)
.map_err(|e| format!("checksum {}: {e}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect())
}
pub(crate) fn run_tool(cmd: &mut std::process::Command, what: &str) -> Result<(), String> {
let out = cmd.output().map_err(|e| format!("{what}: {e}"))?;
if out.status.success() {
return Ok(());
}
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let tail: Vec<&str> = text.lines().rev().take(25).collect();
Err(format!(
"{what} failed:\n{}",
tail.into_iter().rev().collect::<Vec<_>>().join("\n")
))
}
pub(crate) fn copy_tree(src: &Path, dst: &Path) -> Result<(), String> {
std::fs::create_dir_all(dst).map_err(|e| format!("mkdir {}: {e}", dst.display()))?;
let entries = std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
for e in entries.flatten() {
let from = e.path();
let to = dst.join(e.file_name());
if from.is_dir() {
copy_tree(&from, &to)?;
} else {
std::fs::copy(&from, &to)
.map_err(|e| format!("copy {} → {}: {e}", from.display(), to.display()))?;
}
}
Ok(())
}
pub(crate) fn reproducible_epoch() -> i64 {
const ZIP_FLOOR: i64 = 315_532_800; const DEFAULT: i64 = 1_577_836_800; std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|v| v.trim().parse::<i64>().ok())
.filter(|e| *e >= ZIP_FLOOR)
.unwrap_or(DEFAULT)
}
pub(crate) fn normalize_mtimes(root: &Path) -> Result<(), String> {
let stamp = filetime::FileTime::from_unix_time(reproducible_epoch(), 0);
let mut stack = vec![root.to_path_buf()];
let mut entries = Vec::new();
while let Some(dir) = stack.pop() {
let read =
std::fs::read_dir(&dir).map_err(|e| format!("reading {}: {e}", dir.display()))?;
for entry in read {
let path = entry
.map_err(|e| format!("reading {}: {e}", dir.display()))?
.path();
let meta = std::fs::symlink_metadata(&path)
.map_err(|e| format!("stat {}: {e}", path.display()))?;
if meta.is_dir() {
stack.push(path.clone());
}
entries.push(path);
}
}
entries.push(root.to_path_buf());
entries.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
for path in entries {
filetime::set_symlink_file_times(&path, stamp, stamp)
.map_err(|e| format!("set mtime on {}: {e}", path.display()))?;
}
Ok(())
}
fn dos_datetime() -> (u16, u16) {
let secs = reproducible_epoch();
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u16;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u16;
let year = (yoe + era * 400 + i64::from(month <= 2)) as u16;
let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let date = (year.saturating_sub(1980) << 9) | (month << 5) | day;
let time = ((hour as u16) << 11) | ((minute as u16) << 5) | (second as u16 / 2);
(date, time)
}
pub(crate) fn normalize_zip_mtimes(archive: &Path) -> Result<(), String> {
const EOCD_SIG: &[u8] = b"PK\x05\x06";
const CD_SIG: [u8; 4] = *b"PK\x01\x02";
const LFH_SIG: [u8; 4] = *b"PK\x03\x04";
let mut buf =
std::fs::read(archive).map_err(|e| format!("reading {}: {e}", archive.display()))?;
let eocd = (0..buf.len().saturating_sub(21))
.rev()
.find(|&i| buf[i..i + 4] == *EOCD_SIG)
.ok_or_else(|| format!("{}: no ZIP end-of-central-directory", archive.display()))?;
let count = u16::from_le_bytes([buf[eocd + 10], buf[eocd + 11]]) as usize;
let mut cd = u32::from_le_bytes([
buf[eocd + 16],
buf[eocd + 17],
buf[eocd + 18],
buf[eocd + 19],
]) as usize;
let (date, time) = dos_datetime();
let epoch = reproducible_epoch();
for _ in 0..count {
if cd + 46 > buf.len() || buf[cd..cd + 4] != CD_SIG {
return Err(format!(
"{}: malformed central directory",
archive.display()
));
}
buf[cd + 12..cd + 14].copy_from_slice(&time.to_le_bytes());
buf[cd + 14..cd + 16].copy_from_slice(&date.to_le_bytes());
let name = u16::from_le_bytes([buf[cd + 28], buf[cd + 29]]) as usize;
let extra = u16::from_le_bytes([buf[cd + 30], buf[cd + 31]]) as usize;
let comment = u16::from_le_bytes([buf[cd + 32], buf[cd + 33]]) as usize;
let lfh =
u32::from_le_bytes([buf[cd + 42], buf[cd + 43], buf[cd + 44], buf[cd + 45]]) as usize;
normalize_extra_timestamps(&mut buf, cd + 46 + name, extra, epoch);
if lfh + 30 <= buf.len() && buf[lfh..lfh + 4] == LFH_SIG {
buf[lfh + 10..lfh + 12].copy_from_slice(&time.to_le_bytes());
buf[lfh + 12..lfh + 14].copy_from_slice(&date.to_le_bytes());
let lname = u16::from_le_bytes([buf[lfh + 26], buf[lfh + 27]]) as usize;
let lextra = u16::from_le_bytes([buf[lfh + 28], buf[lfh + 29]]) as usize;
normalize_extra_timestamps(&mut buf, lfh + 30 + lname, lextra, epoch);
} else {
return Err(format!(
"{}: central directory points at {lfh}, which is not a local file header",
archive.display()
));
}
cd += 46 + name + extra + comment;
}
std::fs::write(archive, &buf).map_err(|e| format!("writing {}: {e}", archive.display()))
}
fn normalize_extra_timestamps(buf: &mut [u8], mut at: usize, len: usize, epoch: i64) {
const EXTENDED: u16 = 0x5455;
const NTFS: u16 = 0x000a;
let filetime = ((epoch + 11_644_473_600) as u64).saturating_mul(10_000_000);
let end = (at + len).min(buf.len());
while at + 4 <= end {
let id = u16::from_le_bytes([buf[at], buf[at + 1]]);
let size = u16::from_le_bytes([buf[at + 2], buf[at + 3]]) as usize;
let body = at + 4;
if body + size > end {
return; }
match id {
EXTENDED if size >= 5 => {
let mut p = body + 1;
while p + 4 <= body + size {
buf[p..p + 4].copy_from_slice(&(epoch as u32).to_le_bytes());
p += 4;
}
}
NTFS if size >= 32 => {
let mut p = body + 8;
while p + 8 <= body + size {
buf[p..p + 8].copy_from_slice(&filetime.to_le_bytes());
p += 8;
}
}
_ => {}
}
at = body + size;
}
}
#[cfg(test)]
mod repro_tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn epoch_honours_source_date_epoch_above_the_zip_floor() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("SOURCE_DATE_EPOCH") };
assert_eq!(
reproducible_epoch(),
1_577_836_800,
"default is 2020-01-01Z"
);
unsafe { std::env::set_var("SOURCE_DATE_EPOCH", "1234567890") };
assert_eq!(
reproducible_epoch(),
1_234_567_890,
"an in-range value wins"
);
unsafe { std::env::set_var("SOURCE_DATE_EPOCH", "100") };
assert_eq!(reproducible_epoch(), 1_577_836_800);
unsafe { std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number") };
assert_eq!(reproducible_epoch(), 1_577_836_800);
unsafe { std::env::remove_var("SOURCE_DATE_EPOCH") };
}
#[test]
fn dos_datetime_packs_the_default_epoch() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("SOURCE_DATE_EPOCH") };
let (date, time) = dos_datetime();
assert_eq!(date >> 9, 2020 - 1980, "year");
assert_eq!((date >> 5) & 0xF, 1, "month");
assert_eq!(date & 0x1F, 1, "day");
assert_eq!(time, 0, "midnight packs to zero");
}
#[test]
fn extended_timestamp_extra_field_is_rewritten() {
let epoch: i64 = 1_577_836_800;
let mut buf = Vec::new();
buf.extend_from_slice(&0x5455u16.to_le_bytes());
buf.extend_from_slice(&9u16.to_le_bytes()); buf.push(0x03);
buf.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
buf.extend_from_slice(&0xFEED_FACEu32.to_le_bytes());
let len = buf.len();
normalize_extra_timestamps(&mut buf, 0, len, epoch);
assert_eq!(
u32::from_le_bytes(buf[5..9].try_into().unwrap()),
epoch as u32
);
assert_eq!(
u32::from_le_bytes(buf[9..13].try_into().unwrap()),
epoch as u32
);
assert_eq!(
u16::from_le_bytes(buf[0..2].try_into().unwrap()),
0x5455,
"id preserved"
);
assert_eq!(
u16::from_le_bytes(buf[2..4].try_into().unwrap()),
9,
"length preserved"
);
}
#[test]
fn a_malformed_extra_field_is_left_alone_rather_than_corrupted() {
let mut buf = vec![0x55, 0x54, 0xFF, 0xFF, 0x03, 1, 2, 3, 4];
let before = buf.clone();
let len = buf.len();
normalize_extra_timestamps(&mut buf, 0, len, 1_577_836_800);
assert_eq!(buf, before);
}
}
fn build_inputs(target: &'static Target) -> Vec<(String, String)> {
match target.toolkit {
"mdc" => vec![(
"DAY_ANDROID_ABI".to_string(),
crate::mobile::android_build_abis().join(","),
)],
"arkui" => vec![(
"DAY_OHOS_ARCH".to_string(),
crate::ohos::build_abis().join(","),
)],
_ => Vec::new(),
}
}
fn payload_root_for(project: &Project, target: &'static Target) -> Option<PathBuf> {
payload_root(&project.root, target)
}
pub(crate) fn payload_root(project_root: &Path, target: &'static Target) -> Option<PathBuf> {
match target.name {
"linux-gtk" | "linux-qt" => Some(
project_root
.join("build/day/flatpak")
.join(target.name)
.join("stage/bin"),
),
"windows-xaml" => Some(project_root.join("build/day/pack/windows-payload")),
_ => None,
}
}
pub(crate) fn payload_digests(root: &Path) -> Vec<(String, String)> {
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();
if p.is_dir() {
stack.push(p);
} else if let Ok(d) = sha256_file(&p)
&& let Ok(rel) = p.strip_prefix(root)
{
out.push((rel.to_string_lossy().replace('\\', "/"), d));
}
}
}
out.sort();
out
}
#[cfg(test)]
mod input_tests {
use super::*;
#[test]
fn build_inputs_names_the_variables_that_shape_each_package() {
let keys = |name: &str| {
build_inputs(crate::targets::find(name).expect("target"))
.into_iter()
.map(|(k, _)| k)
.collect::<Vec<_>>()
};
assert_eq!(keys("android-mdc"), ["DAY_ANDROID_ABI"]);
assert_eq!(keys("harmony-arkui"), ["DAY_OHOS_ARCH"]);
assert!(keys("macos-appkit").is_empty());
assert!(keys("linux-gtk").is_empty());
}
#[test]
fn payload_roots_match_where_pack_stages() {
let root = Path::new("/proj");
assert_eq!(
payload_root(root, crate::targets::find("linux-gtk").unwrap()),
Some(root.join("build/day/flatpak/linux-gtk/stage/bin"))
);
assert_eq!(
payload_root(root, crate::targets::find("windows-xaml").unwrap()),
Some(root.join("build/day/pack/windows-payload"))
);
assert_eq!(
payload_root(root, crate::targets::find("macos-appkit").unwrap()),
None
);
}
}