Files
RedBear-OS/src/cook/cook_build.rs
T
vasilito 6ce94d1c9e cookbook: track Cargo path-dep source trees in the dep-hash cache
The content-hash cache covered recipe-level PKGARs and the recipe's own
source tree, but not Cargo 'path =' dependency sources inside a crate —
the exact pattern the local fork model mandates (redox_syscall, libredox,
redox-driver-*, pcid_interface). Editing a path-dep's source left the
dependent recipe cached and stale. Observed live during the
driver-manager QEMU gate: 'cook driver-manager - cached' while
redox-driver-core sources had changed; the markers never reached the ISO.

Fix:
- New cook::cargo_path_deps module: resolves path deps from Cargo.toml
  ([dependencies], dev/build deps, target-specific deps, [patch.*]
  tables, workspace members) recursively with cycle protection, and
  hashes each resolved source tree (sorted relpath+content walk,
  excluding .git/target — same policy as SourceContentHash).
- DepHashes gains a cargo_path_deps table (serde default for backward
  compatibility; a missing field invalidates once, then re-syncs).
- The rebuild decision now ORs recipe-PKGAR changes with cargo path-dep
  tree changes; the post-build write records the new map.

Acceptance: cook -> cached; content edit in redox-driver-core ->
'DEBUG: cargo path-dep source hashes changed' + rebuild; revert ->
rebuild once -> cached. 48 tests pass (43 cookbook lib + 5 new).
2026-07-23 23:54:41 +09:00

1766 lines
67 KiB
Rust

use pkg::PackageError;
use pkg::{Package, PackageName};
use crate::config::CookConfig;
use crate::cook::package::{package_source_paths, package_target};
use crate::cook::pty::PtyOut;
use crate::cook::script::*;
use crate::cook::{fetch, fs::*};
use crate::recipe::{Recipe, SourceRecipe};
use crate::recipe::{AutoDeps, CookRecipe};
use crate::recipe::{BuildKind, OptionalPackageRecipe};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::VecDeque;
use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
process::Command,
str,
time::SystemTime,
};
use crate::{is_redox, log_to_pty, wrap_io_err};
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct DepHashes {
#[serde(flatten)]
pub hashes: BTreeMap<String, String>,
/// Content hashes of Cargo `path =` dependency source trees (keyed by
/// canonical path). Recipe PKGAR hashes above cannot see these — see
/// `crate::cook::cargo_path_deps`.
#[serde(default)]
pub cargo_path_deps: BTreeMap<String, String>,
}
impl DepHashes {
/// Read a previously stored dep_hashes.toml.
///
/// Returns:
/// - `Ok(Some(_))` — file exists and parsed cleanly
/// - `Ok(None)` — file does not exist (legitimate first-build state)
/// - `Err(_)` — file exists but is unreadable or unparseable (corrupt)
///
/// Callers MUST treat `Err` as a loud signal (force rebuild) and NOT
/// silently fall back to mtime comparison — a corrupt dep_hashes.toml
/// means we cannot trust content-based caching at all.
pub fn read(path: &Path) -> std::result::Result<Option<Self>, String> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("failed to read {}: {e}", path.display())),
};
toml::from_str(&content)
.map(Some)
.map_err(|e| format!("failed to parse {}: {e}", path.display()))
}
pub fn write(&self, path: &Path) -> Result<(), String> {
let content = toml::to_string(self)
.map_err(|e| format!("failed to serialize dep_hashes.toml: {e}"))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("failed to create dep_hashes.toml parent dir: {e:?}"))?;
}
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, &content)
.map_err(|e| format!("failed to write dep_hashes.toml tmp: {e:?}"))?;
fs::rename(&tmp_path, path)
.map_err(|e| format!("failed to rename dep_hashes.toml: {e:?}"))?;
Ok(())
}
}
/// Per-file BLAKE3 manifest for binary store integrity verification.
/// Maps each published file name (relative to `repo/<arch>/`) to its
/// BLAKE3 hex digest. Written at publish time; verified at restore time.
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct BinaryStoreManifest {
#[serde(flatten)]
pub hashes: BTreeMap<String, String>,
}
impl BinaryStoreManifest {
pub fn read(path: &Path) -> std::result::Result<Option<Self>, String> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("failed to read {}: {e}", path.display())),
};
toml::from_str(&content)
.map(Some)
.map_err(|e| format!("failed to parse {}: {e}", path.display()))
}
}
/// Source content hash: BLAKE3 hash of every file in the source tree
/// (excluding `.git/`, `target/`, and other build artifacts), combined
/// with the recipe.toml and patch files. Used as a content-stable
/// cache invalidation signal that survives operations which change file
/// content without updating mtime (e.g. `git checkout`, `git bisect`,
/// `cp -a`, `rsync -a`).
///
/// The hash is written to `target/<arch>/source_hash.txt` after each
/// successful build. On the next build, the hash is recomputed and
/// compared against the stored value. If they differ, the source tree
/// has changed in a way that mtime alone would have missed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceContentHash(String);
impl SourceContentHash {
/// Compute the source content hash from a source directory,
/// recipe.toml, and patches. Sorts file paths for determinism
/// across filesystems and OS versions.
pub fn compute(
source_dir: &Path,
recipe_toml: &Path,
patches: &[PathBuf],
) -> std::io::Result<Self> {
let mut hasher = blake3::Hasher::new();
// Hash the source directory contents.
let mut paths = Vec::new();
collect_files_recursive(source_dir, source_dir, &mut paths)?;
paths.sort();
for rel_path in &paths {
let abs_path = source_dir.join(rel_path);
// Skip build artifacts and VCS metadata. The source dir is
// already a clean checkout, but target/ may exist if a
// previous build left it behind (e.g. make live was run
// directly against this source dir).
let rel_str = rel_path.to_string_lossy();
if rel_str.starts_with(".git/")
|| rel_str.starts_with("target/")
|| rel_str.contains("/target/")
|| rel_str.ends_with(".swp")
|| rel_str.ends_with(".tmp")
{
continue;
}
hasher.update(rel_str.as_bytes());
hasher.update(b"\0");
// If the file is unreadable (broken symlink, permission
// denied), hash the path only and continue. This produces
// a hash that changes when the path list changes but does
// not falsely report "source modified" for an unreadable
// file that hasn't actually changed content.
let content = fs::read(&abs_path).unwrap_or_else(|_| b"<unreadable>".to_vec());
hasher.update(&content);
hasher.update(b"\0");
}
// Hash the recipe.toml.
hasher.update(b"recipe.toml\0");
let content = fs::read(recipe_toml).unwrap_or_else(|_| b"<unreadable>".to_vec());
hasher.update(&content);
hasher.update(b"\0");
// Hash each patch file. Patch filenames are hashed alongside
// contents so reordering or renaming patches invalidates the
// cache (which is correct: the order matters for atomic apply).
for patch in patches {
let name_bytes = patch
.file_name()
.map(|n| n.as_encoded_bytes())
.unwrap_or(&[]);
hasher.update(name_bytes);
hasher.update(b"\0");
let content = fs::read(patch).unwrap_or_else(|_| b"<unreadable>".to_vec());
hasher.update(&content);
hasher.update(b"\0");
}
Ok(Self(hasher.finalize().to_hex().to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
/// Read a previously stored hash from disk. Returns None if the file
/// doesn't exist or is unreadable. A None result means "no prior
/// build" and triggers a rebuild — this is correct: we don't know
/// what the previous build was, so we must rebuild to be safe.
pub fn read(path: &Path) -> Option<Self> {
let content = fs::read_to_string(path).ok()?;
Some(Self(content.trim().to_string()))
}
/// Write the hash to disk using atomic write (tmp + rename). If
/// the rename fails (e.g. concurrent build), the tmp file is
/// cleaned up and the original (if any) is preserved.
pub fn write(&self, path: &Path) -> std::io::Result<()> {
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, self.0.as_bytes())?;
if let Err(e) = fs::rename(&tmp_path, path) {
// Best-effort cleanup of the tmp file on rename failure.
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
Ok(())
}
}
/// Walk a directory recursively and collect every regular file's
/// relative path. Symlinks are not followed (avoids cycles and
/// surprises across the sysroot). I/O errors at the top level are
/// propagated; per-file errors are silently skipped (we treat
/// unreadable files as having "no content change" — see hash logic).
fn collect_files_recursive(
dir: &Path,
root: &Path,
out: &mut Vec<PathBuf>,
) -> std::io::Result<()> {
if !dir.exists() {
return Ok(());
}
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let file_type = entry.file_type()?;
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
if file_type.is_dir() {
collect_files_recursive(&path, root, out)?;
} else if file_type.is_file() || file_type.is_symlink() {
out.push(rel);
}
}
Ok(())
}
fn collect_current_dep_hashes(
dep_pkgars: &BTreeSet<(PackageName, PathBuf)>,
) -> BTreeMap<String, String> {
let mut hashes = BTreeMap::new();
for (name, pkgar_path) in dep_pkgars {
let toml_path = pkgar_path.with_extension("toml");
if toml_path.is_file()
&& let Ok(toml_content) = fs::read_to_string(&toml_path)
&& let Ok(pkg) = toml::from_str::<Package>(&toml_content)
&& !pkg.blake3.is_empty() {
// Key by full package name (including host: prefix) to avoid
// collisions between host and target deps with the same base name.
hashes.insert(name.to_string(), pkg.blake3);
}
// Missing .toml or parse failure: dep is excluded from current hashes.
// If it was previously tracked, dep_hashes_changed() will detect the
// length mismatch and trigger a rebuild (conservative behavior).
}
hashes
}
fn dep_hashes_changed(
stored: &BTreeMap<String, String>,
current: &BTreeMap<String, String>,
) -> bool {
if stored.len() != current.len() {
return true;
}
for (key, stored_hash) in stored {
match current.get(key) {
Some(current_hash) if current_hash == stored_hash => continue,
_ => return true,
}
}
false
}
fn auto_deps_from_dynamic_linking(
stage_dirs: &[PathBuf],
dep_pkgars: &BTreeSet<(PackageName, PathBuf)>,
logger: &PtyOut,
) -> BTreeSet<PackageName> {
let mut paths = BTreeSet::new();
let mut visited = BTreeSet::new();
let verbose = crate::config::get_config().cook.verbose;
// Base directories may need to be updated for packages that place binaries in odd locations.
let mut walk = VecDeque::new();
for stage_dir in stage_dirs {
walk.push_back((stage_dir, stage_dir.join("usr/bin")));
walk.push_back((stage_dir, stage_dir.join("usr/games")));
walk.push_back((stage_dir, stage_dir.join("usr/lib")));
walk.push_back((stage_dir, stage_dir.join("usr/libexec")));
}
// Recursively (DFS) walk each directory to ensure nested libs and bins are checked.
while let Some((rel_path, dir)) = walk.pop_front() {
let Ok(dir) = dir.canonicalize() else {
continue;
};
if visited.contains(&dir) {
#[cfg(debug_assertions)]
log_to_pty!(
logger,
"DEBUG: auto_deps => Skipping `{dir:?}` (already visited)"
);
continue;
}
assert!(
visited.insert(dir.clone()),
"Directory `{:?}` should not be in visited\nVisited: {:#?}",
dir,
visited
);
let Ok(read_dir) = fs::read_dir(&dir) else {
continue;
};
for entry_res in read_dir {
let Ok(entry) = entry_res else { continue };
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_file() {
paths.insert((rel_path, entry.path()));
} else if file_type.is_dir() {
walk.push_front((rel_path, entry.path()));
}
}
}
let mut needed = BTreeSet::new();
for (rel_path, path) in paths {
let Ok(file) = fs::File::open(&path) else {
continue;
};
let read_cache = object::ReadCache::new(file);
let Ok(object) = object::build::elf::Builder::read(&read_cache) else {
continue;
};
let Some(dynamic_data) = object.dynamic_data() else {
continue;
};
for dynamic in dynamic_data {
let object::build::elf::Dynamic::String { tag, val } = dynamic else {
continue;
};
if *tag == object::elf::DT_NEEDED {
let Ok(name) = str::from_utf8(val) else {
continue;
};
if let Ok(relative_path) = path.strip_prefix(rel_path)
&& verbose {
log_to_pty!(logger, "DEBUG: {} needs {}", relative_path.display(), name);
}
needed.insert(name.to_string());
}
}
}
let mut missing = needed.clone();
// relibc and friends will always be installed
for preinstalled in &["libc.so.6", "libgcc_s.so.1", "libstdc++.so.6"] {
missing.remove(*preinstalled);
}
let mut deps = BTreeSet::new();
if let Ok(key_file) = pkgar_keys::PublicKeyFile::open("build/id_ed25519.pub.toml") {
for (dep, archive_path) in dep_pkgars.iter() {
let Ok(mut package) = pkgar::PackageFile::new(archive_path, &key_file.pkey) else {
continue;
};
let Ok(entries) = pkgar_core::PackageSrc::read_entries(&mut package) else {
continue;
};
for entry in entries {
let Ok(entry_path) = pkgar::ext::EntryExt::check_path(&entry) else {
continue;
};
for prefix in &["lib", "usr/lib"] {
let Ok(child_path) = entry_path.strip_prefix(prefix) else {
continue;
};
let Some(child_name) = child_path.to_str() else {
continue;
};
if needed.contains(child_name) {
if verbose {
log_to_pty!(logger, "DEBUG: {} provides {}", dep, child_name);
}
deps.insert(dep.with_prefix(pkg::PackagePrefix::Any));
missing.remove(child_name);
}
}
}
}
}
if verbose {
for name in missing {
log_to_pty!(logger, "INFO: {} missing", name);
}
}
deps
}
fn auto_deps_from_static_package_deps(
build_dep_pkgars: &BTreeSet<(PackageName, PathBuf)>,
dynamic_dep_pkgars: &BTreeSet<PackageName>,
) -> Result<BTreeSet<PackageName>, PackageError> {
let static_dep_pkgars: Vec<PackageName> = build_dep_pkgars
.iter()
.map(|x| x.0.clone())
.filter(|x| !dynamic_dep_pkgars.contains(x))
.collect();
let pkgs = CookRecipe::get_package_deps_recursive(&static_dep_pkgars, false)?;
Ok(pkgs.into_iter().collect())
}
pub struct BuildResult {
pub stage_dirs: Vec<PathBuf>,
pub auto_deps: BTreeSet<PackageName>,
pub cached: bool,
}
impl BuildResult {
pub fn new(stage_dirs: Vec<PathBuf>, auto_deps: BTreeSet<PackageName>) -> Self {
BuildResult {
stage_dirs,
auto_deps,
cached: false,
}
}
pub fn cached(stage_dirs: Vec<PathBuf>, auto_deps: BTreeSet<PackageName>) -> Self {
BuildResult {
stage_dirs,
auto_deps,
cached: true,
}
}
}
pub fn build(
recipe_dir: &Path,
source_dir: &Path,
target_dir: &Path,
cook_recipe: &CookRecipe,
cook_config: &CookConfig,
logger: &PtyOut,
) -> Result<BuildResult, String> {
let recipe = &cook_recipe.recipe;
let name = &cook_recipe.name;
crate::cook::fetch::cleanup_workspace_pollution(recipe_dir, logger);
let check_source = !cook_recipe.is_deps;
let sysroot_dir = get_sub_target_dir(target_dir, "sysroot");
let toolchain_dir = get_sub_target_dir(target_dir, "toolchain");
let auto_deps_file = get_sub_target_dir(target_dir, "auto_deps.toml");
let stage_dirs = get_stage_dirs(&recipe.optional_packages, target_dir);
let stage_pkgars: Vec<PathBuf> = stage_dirs
.iter()
.map(|p| p.with_added_extension("pkgar"))
.collect();
let cli_verbose = cook_config.verbose;
let cli_jobs = cook_config.jobs;
if recipe.build.kind == BuildKind::None {
// metapackages don't need to do anything here
return Ok(BuildResult::new(stage_dirs, BTreeSet::new()));
}
let mut dep_pkgars = BTreeSet::new();
let mut dep_host_pkgars = BTreeSet::new();
let build_deps = [
&recipe.build.dependencies[..],
&recipe.build.dev_dependencies[..],
]
.concat();
let build_deps =
CookRecipe::get_build_deps_recursive(&build_deps, false).map_err(|e| format!("{:?}", e))?;
for dependency in build_deps.iter() {
let (_, pkgar, _) = dependency.stage_paths();
if dependency.name.is_host() {
dep_host_pkgars.insert((dependency.name.clone(), pkgar));
} else {
dep_pkgars.insert((dependency.name.clone(), pkgar));
}
}
macro_rules! make_auto_deps {
($cached:expr) => {
build_auto_deps(
recipe,
&auto_deps_file,
&stage_dirs,
$cached,
cook_config,
dep_pkgars,
logger,
)
};
}
if !check_source {
// TODO: when stage_dirs does not exist due to clean_target was true, extract from stage.pkgar?
let stage_present = stage_pkgars.iter().all(|file| file.is_file());
if stage_present && auto_deps_file.is_file() {
if cli_verbose {
log_to_pty!(logger, "DEBUG: using cached build, not checking source");
}
let auto_deps = make_auto_deps!(true)?;
return Ok(BuildResult::cached(stage_dirs, auto_deps));
}
}
// Try to restore stage artifacts from repo/ binary store when missing
let stage_present = stage_pkgars.iter().all(|file| file.is_file());
if !stage_present && !cook_config.force_rebuild && recipe.build.kind != BuildKind::Remote {
let repo_target = crate::cross_target()
.map(|t| PathBuf::from("repo").join(t))
.unwrap_or_else(|| PathBuf::from("repo").join(redoxer::target()));
let recipe_name = name.name();
let pkey_path = "build/id_ed25519.pub.toml";
let pkey_exists = Path::new(pkey_path).is_file();
let mut all_restored = true;
for (stage_dir, stage_pkgar) in stage_dirs.iter().zip(stage_pkgars.iter()) {
if stage_pkgar.is_file() && stage_dir.is_dir() {
continue;
}
// Derive the repo pkgar name from the stage dir name.
// "stage" → recipe_name, "stage.feature" → recipe_name.feature
let stage_suffix = stage_dir
.file_name()
.and_then(|n| n.to_str())
.and_then(|s| s.strip_prefix("stage"))
.unwrap_or("");
let repo_pkgar_name = format!("{}{}.pkgar", recipe_name, stage_suffix);
let repo_toml_name = format!("{}{}.toml", recipe_name, stage_suffix);
let repo_pkgar = repo_target.join(&repo_pkgar_name);
let repo_toml = repo_target.join(&repo_toml_name);
if !repo_pkgar.is_file() || !repo_toml.is_file() {
if stage_suffix.is_empty() {
log_to_pty!(logger, "WARN: binary store missing {} — will rebuild", repo_pkgar.display());
}
all_restored = false;
continue;
}
if !stage_dir.is_dir() {
if pkey_exists {
if let Err(e) = pkgar::extract(pkey_path, &repo_pkgar, stage_dir) {
log_to_pty!(logger, "WARN: pkgar extract failed for {}: {e}", repo_pkgar.display());
all_restored = false;
continue;
}
} else {
log_to_pty!(logger, "WARN: signing key {} missing — cannot extract {}", pkey_path, repo_pkgar.display());
all_restored = false;
continue;
}
}
if !stage_pkgar.is_file()
&& let Err(e) = fs::copy(&repo_pkgar, stage_pkgar) {
log_to_pty!(logger, "WARN: failed to copy {}: {e}", repo_pkgar.display());
all_restored = false;
}
let stage_toml = stage_pkgar.with_extension("toml");
if !stage_toml.is_file()
&& let Err(e) = fs::copy(&repo_toml, &stage_toml) {
log_to_pty!(logger, "WARN: failed to copy {}: {e}", repo_toml.display());
}
}
let repo_dep_hashes = repo_target.join(format!("{}.dep_hashes.toml", recipe_name));
let local_dep_hashes = get_sub_target_dir(target_dir, "dep_hashes.toml");
if repo_dep_hashes.is_file() && !local_dep_hashes.is_file() && all_restored
&& let Err(e) = fs::copy(&repo_dep_hashes, &local_dep_hashes) {
log_to_pty!(logger, "WARN: failed to copy dep_hashes.toml: {e}");
}
// Prefer the preserved auto_deps.toml from the binary store — it
// retains the full auto-dep graph (incl. ELF dynamic deps). Only
// fall back to declared-depends-only reconstruction when the
// preserved copy is absent (e.g. published by an older cookbook).
let repo_auto_deps = repo_target.join(format!("{}.auto_deps.toml", recipe_name));
if !auto_deps_file.is_file() && all_restored {
if repo_auto_deps.is_file() {
if let Err(e) = fs::copy(&repo_auto_deps, &auto_deps_file) {
log_to_pty!(logger, "WARN: failed to copy auto_deps.toml: {e}");
}
if cli_verbose {
log_to_pty!(logger, "DEBUG: auto_deps.toml restored from binary store");
}
} else {
let main_toml = repo_target.join(format!("{}.toml", recipe_name));
if let Ok(toml_content) = fs::read_to_string(&main_toml)
&& let Ok(pkg_meta) = toml::from_str::<Package>(&toml_content) {
let auto: BTreeSet<PackageName> = pkg_meta.depends.into_iter().collect();
let wrapper = AutoDeps { packages: auto };
if let Err(e) = serialize_and_write(&auto_deps_file, &wrapper) {
log_to_pty!(logger, "WARN: failed to write auto_deps.toml: {e}");
}
if cli_verbose {
log_to_pty!(logger, "WARN: auto_deps.toml reconstructed from declared depends only (no preserved copy in binary store — ELF dynamic deps may be missing)");
}
}
}
}
// BLAKE3 manifest catches silent disk corruption that pkgar signatures don't.
// Missing manifest = backward compatible (older cookbook published without one).
if all_restored {
let repo_manifest = repo_target.join(format!("{}.manifest.toml", recipe_name));
match BinaryStoreManifest::read(&repo_manifest) {
Ok(Some(manifest)) => {
for (filename, expected_hash) in &manifest.hashes {
let file_path = repo_target.join(filename);
if !file_path.is_file() {
log_to_pty!(
logger,
"WARN: binary store integrity check FAILED — manifest lists {} but file is missing",
filename
);
all_restored = false;
continue;
}
match compute_file_blake3_hex(&file_path) {
Ok(actual_hash) if actual_hash == *expected_hash => {}
Ok(actual_hash) => {
log_to_pty!(
logger,
"WARN: binary store integrity check FAILED for {} — expected {}, got {}",
filename,
expected_hash,
actual_hash
);
all_restored = false;
}
Err(e) => {
log_to_pty!(
logger,
"WARN: failed to hash {} for integrity check: {e}",
filename
);
all_restored = false;
}
}
}
if all_restored && cli_verbose {
log_to_pty!(
logger,
"DEBUG: binary store integrity verified ({} files via BLAKE3 manifest)",
manifest.hashes.len()
);
}
}
Ok(None) => {}
Err(e) => {
log_to_pty!(
logger,
"WARN: binary store manifest unreadable for {}: {e} — skipping integrity check",
recipe_name
);
}
}
}
}
let mut source_modified = modified_dir_ignore_git(source_dir).unwrap_or_else(|_| {
log_to_pty!(logger, "WARN: source dir {} inaccessible — treating as modified", source_dir.display());
SystemTime::now()
});
if let Ok(recipe_modified) = modified(&recipe_dir.join("recipe.toml")) {
source_modified = source_modified.max(recipe_modified);
}
let recipe_patches: &[String] = match &recipe.source {
Some(SourceRecipe::Git { patches, .. }) => patches,
Some(SourceRecipe::Tar { patches, .. }) => patches,
_ => &[],
};
for patch in recipe_patches {
let patch_path = recipe_dir.join(patch);
if let Ok(patch_modified) = modified(&patch_path) {
source_modified = source_modified.max(patch_modified);
}
}
// Authoritative content-hash check: even if mtime says nothing
// changed (e.g. `git checkout` preserved timestamps), the hash
// catches content modifications. Computed lazily — only when mtime
// says "source is newer than stage" would have been true, OR when
// the stored source_hash.txt is missing (first build on this recipe).
// Below the fast path: if mtime says "no change" and we have a
// stored hash, trust mtime. If mtime says "changed" or no stored
// hash exists, compute and compare.
let source_hash_path = get_sub_target_dir(target_dir, "source_hash.txt");
let stored_source_hash = SourceContentHash::read(&source_hash_path);
let mut source_hash_changed = false;
if cook_config.force_rebuild {
source_hash_changed = true;
} else if stored_source_hash.is_none() {
// First build for this recipe (no prior hash) — must rebuild.
source_hash_changed = true;
} else {
// mtime fast path: if stage is newer than source mtime, mtime
// alone says "no change". Trust it and skip the expensive hash
// compute. The stored hash will be re-verified on the next
// build where mtime does indicate a change.
let stage_modified_now = modified_all(&stage_pkgars, modified).unwrap_or(SystemTime::UNIX_EPOCH);
if stage_modified_now >= source_modified {
// mtime says no change. Verify hash matches as a safety net
// against `cp -a` / `git checkout` style operations that
// preserve timestamps but change content. This costs one
// hash computation per build but catches the bug class that
// motivated this improvement.
let patch_paths: Vec<PathBuf> = recipe_patches
.iter()
.map(|p| recipe_dir.join(p))
.collect();
match SourceContentHash::compute(source_dir, &recipe_dir.join("recipe.toml"), &patch_paths) {
Ok(current_hash) => {
if Some(&current_hash) != stored_source_hash.as_ref() {
log_to_pty!(logger, "DEBUG: source content hash mismatch (mtime unchanged but content differs)");
source_hash_changed = true;
}
}
Err(e) => {
log_to_pty!(logger, "WARN: source hash compute failed: {e} — assuming modified");
source_hash_changed = true;
}
}
} else {
// mtime says changed. Skip hash compute (it would also
// detect the change, but at higher I/O cost). Force rebuild.
source_hash_changed = true;
}
}
let deps_modified = modified_all_btree(
dep_pkgars.iter().map(|(_dep, pkgar)| pkgar.as_path()),
modified,
)
.unwrap_or(SystemTime::UNIX_EPOCH);
let deps_host_modified = modified_all_btree(
dep_host_pkgars.iter().map(|(_dep, pkgar)| pkgar.as_path()),
modified,
)
.unwrap_or(SystemTime::UNIX_EPOCH);
// check stage dir modified against pkgar files, any files missing will result in UNIX_EPOCH
let stage_modified = modified_all(&stage_pkgars, modified).unwrap_or(SystemTime::UNIX_EPOCH);
let dep_hashes_file = get_sub_target_dir(target_dir, "dep_hashes.toml");
let source_changed = stage_modified < source_modified || source_hash_changed;
let deps_changed = if cook_config.force_rebuild {
if cli_verbose {
log_to_pty!(logger, "DEBUG: force rebuild requested");
}
true
} else {
match DepHashes::read(&dep_hashes_file) {
Ok(Some(stored_hashes)) => {
let current = collect_current_dep_hashes(&dep_pkgars);
let current_host = collect_current_dep_hashes(&dep_host_pkgars);
let mut all_current = current;
all_current.extend(current_host);
let changed = dep_hashes_changed(&stored_hashes.hashes, &all_current);
let cargo_deps_current = crate::cook::cargo_path_deps::compute_all(source_dir);
let cargo_changed = stored_hashes.cargo_path_deps != cargo_deps_current;
if cli_verbose {
if changed {
log_to_pty!(logger, "DEBUG: dependency blake3 hashes changed");
} else {
log_to_pty!(logger, "DEBUG: dependency blake3 hashes unchanged (content-based cache)");
}
if cargo_changed {
log_to_pty!(logger, "DEBUG: cargo path-dep source hashes changed");
}
}
changed || cargo_changed
}
Ok(None) => {
if cli_verbose {
log_to_pty!(logger, "DEBUG: no dep_hashes.toml, falling back to mtime comparison");
}
stage_modified < deps_modified || stage_modified < deps_host_modified
}
Err(e) => {
log_to_pty!(logger, "WARN: {}: forcing rebuild (content cache cannot be trusted)", e);
true
}
}
};
if source_changed || deps_changed || !auto_deps_file.is_file()
{
for stage_dir in &stage_dirs {
if stage_dir.is_dir() {
log_to_pty!(logger, "DEBUG: updating '{}'", stage_dir.display());
remove_stage_dir(stage_dir)?;
}
}
} else {
if cli_verbose {
log_to_pty!(logger, "DEBUG: using cached build");
}
// stop early otherwise we'll end up rebuilding
let auto_deps = make_auto_deps!(true)?;
return Ok(BuildResult::cached(stage_dirs, auto_deps));
}
// Rebuild sysroot if source is newer
if recipe.build.kind != BuildKind::Remote {
let updated = build_deps_dir(
logger,
&sysroot_dir,
if name.is_host() {
&dep_host_pkgars
} else {
&dep_pkgars
},
source_modified,
deps_modified,
)?;
if cli_verbose && !updated {
log_to_pty!(logger, "DEBUG: using cached sysroot");
}
}
if recipe.build.kind != BuildKind::Remote && !name.is_host() && !dep_host_pkgars.is_empty() {
let updated = build_deps_dir(
logger,
&toolchain_dir,
&dep_host_pkgars,
source_modified,
deps_host_modified,
)?;
if cli_verbose && !updated {
log_to_pty!(logger, "DEBUG: using cached toolchain");
}
}
let stage_dir = stage_dirs
.last()
.expect("Should have atleast one stage dir");
let build_dir = get_sub_target_dir(target_dir, "build");
if !stage_dir.is_dir() {
// Create stage.tmp
let stage_dir_tmp = target_dir.join("stage.tmp");
create_dir_clean(&stage_dir_tmp)?;
// Create build dir, if it does not exist
if cook_config.clean_build || !build_dir.is_dir() {
create_dir_clean(&build_dir)?;
}
let flags_fn = |name, flags: &Vec<String>| {
format!(
"{name}+=(\n{}\n)\n",
flags
.iter()
.map(|s| format!(" \"{s}\""))
.collect::<Vec<String>>()
.join("\n")
)
};
if recipe.build.kind == BuildKind::Remote {
return build_remote(stage_dirs, recipe, target_dir, cook_config);
}
let mut allow_cargo_offline = false;
//TODO: better integration with redoxer (library instead of binary)
//TODO: configurable target
//TODO: Add more configurability, convert scripts to Rust?
let script = match &recipe.build.kind {
BuildKind::Cargo {
cargopath,
cargoflags,
cargopackages,
cargoexamples,
} => {
allow_cargo_offline = true;
let mut script = format!(
"DYNAMIC_INIT\n{}\nCOOKBOOK_CARGO_PATH={} ",
flags_fn("COOKBOOK_CARGO_FLAGS", cargoflags),
cargopath.as_deref().unwrap_or(".")
);
if cargopackages.is_empty() && cargoexamples.is_empty() {
script += "cookbook_cargo\n"
} else {
if !cargopackages.is_empty() {
script += "cookbook_cargo_packages";
for package in cargopackages {
script += " ";
script += package;
}
script += "\n";
}
if !cargoexamples.is_empty() {
script += "cookbook_cargo_examples";
for example in cargoexamples {
script += " ";
script += example;
}
script += "\n";
}
}
script
}
BuildKind::Configure { configureflags } => format!(
"DYNAMIC_INIT\n{}cookbook_configure",
flags_fn("COOKBOOK_CONFIGURE_FLAGS", configureflags),
),
BuildKind::Cmake { cmakeflags } => format!(
"DYNAMIC_INIT\n{}cookbook_cmake",
flags_fn("COOKBOOK_CMAKE_FLAGS", cmakeflags),
),
BuildKind::Meson { mesonflags } => format!(
"DYNAMIC_INIT\n{}cookbook_meson",
flags_fn("COOKBOOK_MESON_FLAGS", mesonflags),
),
BuildKind::Custom { script } => script.clone(),
BuildKind::Remote => unreachable!(),
BuildKind::None => "".to_owned(),
};
let command = {
let cookbook_build = build_dir
.canonicalize()
.map_err(wrap_io_err!(build_dir, "Canonicalize build dir"))?;
let cookbook_recipe = recipe_dir
.canonicalize()
.map_err(wrap_io_err!(recipe_dir, "Canonicalize recipe dir"))?;
let cookbook_root = Path::new(".")
.canonicalize()
.map_err(wrap_io_err!(Path::new("."), "Canonicalize cookbook root"))?;
let cookbook_stage = stage_dir_tmp
.canonicalize()
.map_err(wrap_io_err!(stage_dir_tmp, "Canonicalize stage dir"))?;
let cookbook_source = source_dir
.canonicalize()
.map_err(wrap_io_err!(source_dir, "Canonicalize source dir"))?;
let cookbook_sysroot = sysroot_dir
.canonicalize()
.map_err(wrap_io_err!(sysroot_dir, "Canonicalize sysroot dir"))?;
let cookbook_toolchain = toolchain_dir.canonicalize().ok();
let bash_args = if cli_verbose { "-ex" } else { "-e" };
let local_redoxer = Path::new("target/release/cookbook_redbear_redoxer");
let mut command = if is_redox() && !local_redoxer.is_file() {
let mut command = Command::new("cookbook_redbear_redoxer");
command.env("COOKBOOK_REDOXER", "cookbook_redbear_redoxer");
command
} else {
let cookbook_redoxer = local_redoxer
.canonicalize()
.unwrap_or(PathBuf::from("/bin/false"));
let mut command = Command::new(&cookbook_redoxer);
command.env("COOKBOOK_REDOXER", &cookbook_redoxer);
command
};
command.arg("env").arg("bash").arg(bash_args);
command.current_dir(&cookbook_build);
command.env("TARGET", package_target(name));
command.env("COOKBOOK_BUILD", &cookbook_build);
command.env("COOKBOOK_NAME", name.name());
command.env("COOKBOOK_HOST_TARGET", redoxer::host_target());
command.env("COOKBOOK_RECIPE", &cookbook_recipe);
command.env("COOKBOOK_ROOT", &cookbook_root);
command.env("COOKBOOK_STAGE", &cookbook_stage);
command.env("COOKBOOK_SOURCE", &cookbook_source);
command.env("COOKBOOK_SYSROOT", &cookbook_sysroot);
if let Some(cookbook_toolchain) = &cookbook_toolchain {
command.env("COOKBOOK_TOOLCHAIN", cookbook_toolchain);
} else if name.is_host() {
command.env("COOKBOOK_TOOLCHAIN", &cookbook_sysroot);
}
command.env("COOKBOOK_MAKE_JOBS", cli_jobs.to_string());
if cli_verbose {
command.env("COOKBOOK_VERBOSE", "1");
}
if cook_config.offline && allow_cargo_offline {
command.env("COOKBOOK_OFFLINE", "1");
} else {
command.env_remove("COOKBOOK_OFFLINE");
}
if let Ok(ident_source) = fetch::fetch_get_source_info(cook_recipe) {
command.env("COOKBOOK_SOURCE_IDENT", ident_source.source_identifier);
command.env("COOKBOOK_COMMIT_IDENT", ident_source.commit_identifier);
}
command
};
let full_script = format!(
"{}\n{}\n{}\n{}",
BUILD_PRESCRIPT, SHARED_PRESCRIPT, script, BUILD_POSTSCRIPT
);
run_command_stdin(command, full_script.as_bytes(), logger)?;
// Move to each features dir
let mut globs = Vec::new();
for (i, feat) in recipe.optional_packages.iter().enumerate() {
let stage_dir = &stage_dirs[i];
create_dir_clean(stage_dir)?;
for path in &feat.files {
let glob = globset::Glob::new(path).map_err(|e| format!("{}", e))?;
globs.push((glob.compile_matcher(), stage_dir.clone()));
}
}
move_dir_all_fn(
&stage_dir_tmp,
&|path: PathBuf| {
for (glob, dst) in &globs {
if glob.is_match(&path) {
return Some(dst.as_path());
}
}
None
},
)
.map_err(|e| format!("Unable to move {e:?}"))?;
// Move stage.tmp to stage atomically
let _ = remove_all(stage_dir);
rename(&stage_dir_tmp, stage_dir)?;
}
if cook_config.clean_target {
remove_all(&build_dir)?;
remove_all(&sysroot_dir)?;
if toolchain_dir.is_dir() {
remove_all(&toolchain_dir)?;
}
// don't remove stage dir yet
}
let current_dep_hashes = {
let current = collect_current_dep_hashes(&dep_pkgars);
let current_host = collect_current_dep_hashes(&dep_host_pkgars);
let mut all_current = current;
all_current.extend(current_host);
all_current
};
let auto_deps = make_auto_deps!(false)?;
let dep_hashes = DepHashes {
hashes: current_dep_hashes,
cargo_path_deps: crate::cook::cargo_path_deps::compute_all(source_dir),
};
if let Err(e) = dep_hashes.write(&dep_hashes_file) {
log_to_pty!(logger, "WARN: failed to write dep_hashes.toml: {e}");
}
// Write the source content hash so the next build can verify the
// source tree matches what produced this pkgar. The hash must be
// recomputed now (after the build completed) because the build
// itself may have modified the source tree (e.g. cargo's
// target/ directory inside the source). We re-exclude target/
// and .git/ inside compute() so this doesn't matter — but we
// write AFTER the build so any build-time mutations of tracked
// files (rare) don't taint the hash.
let recipe_patches_post: Vec<PathBuf> = match &recipe.source {
Some(SourceRecipe::Git { patches, .. }) => patches.iter().map(|p| recipe_dir.join(p)).collect(),
Some(SourceRecipe::Tar { patches, .. }) => patches.iter().map(|p| recipe_dir.join(p)).collect(),
_ => Vec::new(),
};
match SourceContentHash::compute(source_dir, &recipe_dir.join("recipe.toml"), &recipe_patches_post) {
Ok(hash) => {
let hash_path = get_sub_target_dir(target_dir, "source_hash.txt");
if let Err(e) = hash.write(&hash_path) {
log_to_pty!(logger, "WARN: failed to write source_hash.txt: {e}");
}
}
Err(e) => {
log_to_pty!(logger, "WARN: failed to compute source_hash.txt: {e}");
}
}
Ok(BuildResult::new(stage_dirs, auto_deps))
}
pub fn remove_stage_dir(stage_dir: &Path) -> crate::Result<()> {
if stage_dir.is_dir() {
remove_all(stage_dir)?;
}
let stage_file = stage_dir.with_added_extension("pkgar");
if stage_file.is_file() {
remove_all(&stage_file)?;
}
let stage_meta = stage_dir.with_added_extension("toml");
if stage_meta.is_file() {
remove_all(&stage_meta)?;
}
let stage_files = stage_dir.with_added_extension("files");
if stage_files.is_file() {
remove_all(&stage_files)?;
}
Ok(())
}
pub fn get_stage_dirs(features: &Vec<OptionalPackageRecipe>, target_dir: &Path) -> Vec<PathBuf> {
let mut target_dir = target_dir.to_path_buf();
if let Some(cross_target) = crate::cross_target() {
// TODO: automatically pass COOKBOOK_CROSS_GNU_TARGET?
target_dir = target_dir.join(cross_target)
}
let mut v = Vec::new();
for f in features {
v.push(target_dir.join(format!("stage.{}", f.name)));
}
// intentionally added last as it contains leftover files from package features
v.push(target_dir.join("stage"));
v
}
pub fn get_sub_target_dir(target_dir: &Path, sub_path: &str) -> PathBuf {
let mut target_dir = target_dir.to_path_buf();
if let Some(cross_target) = crate::cross_target() {
// TODO: automatically pass COOKBOOK_CROSS_GNU_TARGET?
target_dir = target_dir.join(cross_target)
}
target_dir.join(sub_path)
}
fn build_deps_dir(
logger: &PtyOut,
deps_dir: &Path,
dep_pkgars: &BTreeSet<(PackageName, PathBuf)>,
source_modified: SystemTime,
deps_modified: SystemTime,
) -> Result<bool, String> {
let deps_dir_tmp = deps_dir.with_added_extension("tmp");
if deps_dir.is_dir() {
let tags_dir = deps_dir.join(".tags");
let sysroot_modified = modified_dir(&tags_dir).unwrap_or(SystemTime::UNIX_EPOCH);
if sysroot_modified < source_modified
|| sysroot_modified < deps_modified
|| !check_files_present(
&tags_dir,
&dep_pkgars
.iter()
.map(|(name, _)| name.without_prefix())
.collect(),
)?
{
log_to_pty!(logger, "DEBUG: updating '{}'", deps_dir.display());
remove_all(deps_dir)?;
}
}
if !deps_dir.is_dir() {
// Create sysroot.tmp
create_dir_clean(&deps_dir_tmp)?;
let tags_dir = deps_dir_tmp.join(".tags");
let usr_dir = deps_dir_tmp.join("usr");
create_dir(&tags_dir)?;
create_dir(&usr_dir)?;
for folder in &["bin", "include", "lib", "share"] {
// Make sure sysroot/usr/$folder exists
create_dir(&usr_dir.join(folder))?;
// Link sysroot/$folder sysroot/usr/$folder
symlink(Path::new("usr").join(folder), deps_dir_tmp.join(folder))?;
}
let pkey_path = "build/id_ed25519.pub.toml";
for (name, archive_path) in dep_pkgars {
let archive_path: PathBuf = if archive_path.is_file() {
archive_path.clone()
} else {
let repo_path = std::path::PathBuf::from("repo")
.join(
crate::cross_target()
.as_deref()
.unwrap_or("x86_64-unknown-redox"),
)
.join(format!("{}.pkgar", name.without_prefix()));
if repo_path.is_file() {
log_to_pty!(
logger,
"DEBUG: using repo pkgar for {}: {}",
name,
repo_path.display()
);
repo_path
} else {
archive_path.clone()
}
};
let tag_file = tags_dir.join(name.without_prefix());
fs::write(&tag_file, "")
.map_err(|e| format!("failed to write tag file {}: {:?}", tag_file.display(), e))?;
pkgar::extract(pkey_path, &archive_path, &deps_dir_tmp).map_err(
|err| {
format!(
"failed to install '{}' in '{}': {:?}",
archive_path.display(),
deps_dir_tmp.display(),
err
)
},
)?;
}
// Move sysroot.tmp to sysroot atomically
rename(&deps_dir_tmp, deps_dir)?;
return Ok(true);
}
Ok(false)
}
/// Calculate automatic dependencies
fn build_auto_deps(
recipe: &Recipe,
auto_deps_path: &Path,
stage_dirs: &[PathBuf],
cached: bool,
cook_config: &CookConfig,
mut dep_pkgars: BTreeSet<(PackageName, PathBuf)>,
logger: &PtyOut,
) -> Result<BTreeSet<PackageName>, String> {
if auto_deps_path.is_file() && !cached {
if cook_config.verbose {
log_to_pty!(logger, "DEBUG: updating {}", auto_deps_path.display());
}
remove_all(auto_deps_path)?;
}
let auto_deps = if auto_deps_path.exists() {
let toml_content =
fs::read_to_string(auto_deps_path).map_err(|_| "failed to read cached auto_deps")?;
let wrapper: AutoDeps =
toml::from_str(&toml_content).map_err(|_| "failed to deserialize cached auto_deps")?;
wrapper.packages
} else {
let mut dynamic_deps = auto_deps_from_dynamic_linking(stage_dirs, &dep_pkgars, logger);
dep_pkgars.retain(|x| recipe.build.dependencies.contains(&x.0));
let package_deps =
auto_deps_from_static_package_deps(&dep_pkgars, &dynamic_deps).unwrap_or_default();
dynamic_deps.extend(package_deps);
let wrapper = AutoDeps {
packages: dynamic_deps,
};
serialize_and_write(auto_deps_path, &wrapper)?;
wrapper.packages
};
Ok(auto_deps)
}
pub fn build_remote(
stage_dirs: Vec<PathBuf>,
recipe: &Recipe,
target_dir: &Path,
cook_config: &CookConfig,
) -> Result<BuildResult, String> {
let source_toml = target_dir.join("source.toml");
let source_pubkey = "build/remotes/pub_key_static.redox-os.org.toml";
let packages = recipe.get_packages_list();
for (i, package) in packages.into_iter().enumerate() {
// declare pkg dependencies as autodeps dependency
let stage_dir = &stage_dirs[i];
if cook_config.clean_target && stage_dir.with_added_extension("pkgar").is_file() {
continue;
}
if !stage_dir.is_dir() {
let (_, source_pkgar, _) = package_source_paths(package, target_dir);
let stage_dir_tmp = target_dir.join("stage.tmp");
pkgar::extract(source_pubkey, &source_pkgar, &stage_dir_tmp).map_err(|err| {
format!(
"failed to install '{}' in '{}': {:?}",
source_pkgar.display(),
stage_dir_tmp.display(),
err
)
})?;
// Move stage.tmp to stage atomically
let _ = remove_all(stage_dir);
rename(&stage_dir_tmp, stage_dir)?;
}
}
let auto_deps_path = target_dir.join("auto_deps.toml");
if auto_deps_path.is_file() && !cook_config.clean_target
&& modified(&auto_deps_path)? < modified_all(&stage_dirs, modified)? {
remove_all(&auto_deps_path)?
}
let auto_deps = if auto_deps_path.exists() {
let toml_content =
fs::read_to_string(&auto_deps_path).map_err(|_| "failed to read cached auto_deps")?;
let wrapper: AutoDeps =
toml::from_str(&toml_content).map_err(|_| "failed to deserialize cached auto_deps")?;
wrapper.packages
} else {
let toml_content =
fs::read_to_string(&source_toml).map_err(|_| "failed to read source.toml")?;
let pkg_toml: Package =
toml::from_str(&toml_content).map_err(|_| "failed to deserialize source.toml")?;
let wrapper = AutoDeps {
packages: pkg_toml.depends.into_iter().collect(),
};
serialize_and_write(&auto_deps_path, &wrapper)?;
wrapper.packages
};
Ok(BuildResult::new(stage_dirs, auto_deps))
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Write;
use std::os::unix;
use std::path::{Path, PathBuf};
use super::{
BinaryStoreManifest, DepHashes, SourceContentHash, collect_current_dep_hashes,
dep_hashes_changed,
};
use pkg::PackageName;
fn temp_dir(name: &str) -> PathBuf {
let mut root = std::env::temp_dir();
root.push(format!("redbear_cookbook_test_{name}_{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create temp dir");
root
}
#[test]
fn file_system_loop_no_infinite_loop() {
let root = temp_dir("fs_loop");
let dir = root.join("loop");
unix::fs::symlink(&root, &dir).expect("Linking {dir:?} to {root:?}");
assert_eq!(
root.canonicalize().unwrap(),
dir.canonicalize().unwrap(),
"Expected a loop where {dir:?} points to {root:?}"
);
let entries =
super::auto_deps_from_dynamic_linking(&vec![root.clone()], &Default::default(), &None);
assert!(
entries.is_empty(),
"auto_deps shouldn't have yielded any libraries"
);
}
#[test]
fn dep_hashes_roundtrip_preserves_entries() {
let dir = temp_dir("dep_hashes_roundtrip");
let path = dir.join("dep_hashes.toml");
let original = DepHashes {
hashes: {
let mut m = BTreeMap::new();
m.insert("redoxfs".to_string(), "abc123".to_string());
m.insert("relibc".to_string(), "def456".to_string());
m
},
cargo_path_deps: BTreeMap::new(),
};
original.write(&path).expect("write should succeed");
assert!(path.exists(), "dep_hashes.toml should exist after write");
let loaded = DepHashes::read(&path)
.expect("read should not error on a file we just wrote")
.expect("read should return Some after write");
assert_eq!(
loaded.hashes, original.hashes,
"round-trip must preserve all key-value pairs"
);
}
#[test]
fn dep_hashes_read_returns_ok_none_for_missing_file() {
let dir = temp_dir("dep_hashes_missing");
let path = dir.join("nonexistent.toml");
match DepHashes::read(&path) {
Ok(None) => (),
other => panic!("read on a missing path must return Ok(None), got {other:?}"),
}
}
#[test]
fn dep_hashes_read_returns_err_for_corrupt_toml() {
let dir = temp_dir("dep_hashes_corrupt");
let path = dir.join("dep_hashes.toml");
fs::write(&path, b"this is not valid toml [[[").expect("write corrupt file");
match DepHashes::read(&path) {
Err(_) => (),
Ok(Some(_)) => panic!("read on corrupt TOML must return Err, got Ok(Some)"),
Ok(None) => panic!("read on corrupt TOML must return Err, got Ok(None)"),
}
}
#[test]
fn dep_hashes_write_is_atomic_no_tmp_left_behind() {
let dir = temp_dir("dep_hashes_atomic");
let path = dir.join("subdir/dep_hashes.toml");
let hashes = DepHashes::default();
hashes.write(&path).expect("write should succeed");
let tmp = path.with_extension("tmp");
assert!(
!tmp.exists(),
"atomic write must not leave a .tmp file behind on success"
);
assert!(path.exists(), "target file must exist after atomic write");
}
#[test]
fn dep_hashes_changed_identical_maps_return_false() {
let stored = BTreeMap::from([
("a".to_string(), "hash_a".to_string()),
("b".to_string(), "hash_b".to_string()),
]);
let current = stored.clone();
assert!(
!dep_hashes_changed(&stored, &current),
"identical maps must report no change"
);
}
#[test]
fn dep_hashes_changed_different_length_returns_true() {
let stored = BTreeMap::from([("a".to_string(), "h".to_string())]);
let current = BTreeMap::from([
("a".to_string(), "h".to_string()),
("b".to_string(), "h".to_string()),
]);
assert!(
dep_hashes_changed(&stored, &current),
"length mismatch must always report changed"
);
}
#[test]
fn dep_hashes_changed_hash_value_differs_returns_true() {
let stored = BTreeMap::from([("a".to_string(), "old_hash".to_string())]);
let current = BTreeMap::from([("a".to_string(), "new_hash".to_string())]);
assert!(
dep_hashes_changed(&stored, &current),
"a changed hash value for an existing key must be detected"
);
}
#[test]
fn dep_hashes_changed_missing_key_returns_true() {
let stored = BTreeMap::from([
("a".to_string(), "h".to_string()),
("b".to_string(), "h".to_string()),
]);
let current = BTreeMap::from([("a".to_string(), "h".to_string())]);
assert!(
dep_hashes_changed(&stored, &current),
"a missing key in current must be detected as a change"
);
}
#[test]
fn dep_hashes_changed_both_empty_return_false() {
let stored: BTreeMap<String, String> = BTreeMap::new();
let current: BTreeMap<String, String> = BTreeMap::new();
assert!(
!dep_hashes_changed(&stored, &current),
"two empty maps must report no change (no deps = no change)"
);
}
fn make_source_tree(root: &Path, files: &[(&str, &[u8])]) {
for (rel, content) in files {
let full = root.join(rel);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
let mut f = fs::File::create(&full).expect("create file");
f.write_all(content).expect("write content");
}
}
#[test]
fn source_content_hash_is_deterministic_for_identical_inputs() {
let dir1 = temp_dir("sch_deterministic_1");
let dir2 = temp_dir("sch_deterministic_2");
let files = [("src/main.rs", b"fn main() {}" as &[u8])];
make_source_tree(&dir1, &files);
make_source_tree(&dir2, &files);
let recipe1 = dir1.join("recipe.toml");
let recipe2 = dir2.join("recipe.toml");
fs::write(&recipe1, b"[build]\n").unwrap();
fs::write(&recipe2, b"[build]\n").unwrap();
let h1 = SourceContentHash::compute(&dir1, &recipe1, &[]).expect("compute h1");
let h2 = SourceContentHash::compute(&dir2, &recipe2, &[]).expect("compute h2");
assert_eq!(
h1, h2,
"identical source trees must produce identical hashes"
);
}
#[test]
fn source_content_hash_detects_content_change() {
let dir = temp_dir("sch_content_change");
let files = [("src/lib.rs", b"pub fn f() {}" as &[u8])];
make_source_tree(&dir, &files);
let recipe = dir.join("recipe.toml");
fs::write(&recipe, b"[build]\n").unwrap();
let h1 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h1");
fs::write(dir.join("src/lib.rs"), b"pub fn g() {}").unwrap();
let h2 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h2");
assert_ne!(
h1, h2,
"changed file content must produce a different hash"
);
}
#[test]
fn source_content_hash_detects_added_file() {
let dir = temp_dir("sch_added_file");
make_source_tree(&dir, &[("src/a.rs", b"a" as &[u8])]);
let recipe = dir.join("recipe.toml");
fs::write(&recipe, b"[build]\n").unwrap();
let h1 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h1");
make_source_tree(&dir, &[("src/b.rs", b"b" as &[u8])]);
let h2 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h2");
assert_ne!(h1, h2, "adding a file must invalidate the hash");
}
#[test]
fn source_content_hash_detects_recipe_toml_change() {
let dir = temp_dir("sch_recipe_change");
make_source_tree(&dir, &[("src/a.rs", b"a" as &[u8])]);
let recipe = dir.join("recipe.toml");
fs::write(&recipe, b"[build]\n").unwrap();
let h1 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h1");
fs::write(&recipe, b"[build]\nflag = true\n").unwrap();
let h2 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h2");
assert_ne!(
h1, h2,
"a change to recipe.toml must invalidate the hash"
);
}
#[test]
fn source_content_hash_detects_patch_change() {
let dir = temp_dir("sch_patch_change");
make_source_tree(&dir, &[("src/a.rs", b"a" as &[u8])]);
let recipe = dir.join("recipe.toml");
fs::write(&recipe, b"[build]\n").unwrap();
let patch1 = dir.join("P1.patch");
fs::write(&patch1, b"diff --git a/src/a.rs b/src/a.rs\n").unwrap();
let h1 =
SourceContentHash::compute(&dir, &recipe, &[patch1.clone()]).expect("compute h1");
fs::write(&patch1, b"diff --git a/src/a.rs b/src/a.rs\n+changed\n").unwrap();
let h2 =
SourceContentHash::compute(&dir, &recipe, &[patch1.clone()]).expect("compute h2");
assert_ne!(
h1, h2,
"a change to a patch file must invalidate the hash"
);
}
#[test]
fn source_content_hash_ignores_target_and_git_dirs() {
let dir = temp_dir("sch_ignores_artifacts");
make_source_tree(
&dir,
&[
("src/main.rs", b"fn main() {}" as &[u8]),
("target/debug/incremental/ignore.me", b"build artifact"),
(".git/HEAD", b"ref: refs/heads/main"),
("file.swp", b"swap"),
("file.tmp", b"temp"),
],
);
let recipe = dir.join("recipe.toml");
fs::write(&recipe, b"[build]\n").unwrap();
let h1 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h1");
fs::write(dir.join("target/debug/incremental/ignore.me"), b"changed").unwrap();
fs::write(dir.join(".git/HEAD"), b"ref: refs/heads/other").unwrap();
fs::write(dir.join("file.swp"), b"swapped").unwrap();
fs::write(dir.join("file.tmp"), b"templed").unwrap();
let h2 = SourceContentHash::compute(&dir, &recipe, &[]).expect("compute h2");
assert_eq!(
h1, h2,
"changes to target/, .git/, .swp, .tmp must NOT affect the hash"
);
}
#[test]
fn source_content_hash_read_write_roundtrip() {
let dir = temp_dir("sch_rw_roundtrip");
let path = dir.join("source_hash.txt");
let hash = SourceContentHash("deadbeef".to_string());
hash.write(&path).expect("write should succeed");
let loaded = SourceContentHash::read(&path).expect("read should return Some");
assert_eq!(hash, loaded, "round-trip must preserve the hash value");
}
#[test]
fn source_content_hash_read_returns_none_for_missing_file() {
let dir = temp_dir("sch_read_missing");
let path = dir.join("nonexistent.txt");
assert!(
SourceContentHash::read(&path).is_none(),
"read on a missing path must return None"
);
}
#[test]
fn source_content_hash_handles_empty_source_dir() {
let dir = temp_dir("sch_empty_source");
let recipe = dir.join("recipe.toml");
fs::write(&recipe, b"[build]\n").unwrap();
let hash =
SourceContentHash::compute(&dir, &recipe, &[]).expect("compute on empty dir");
assert!(
!hash.as_str().is_empty(),
"empty source dir must still produce a valid hash (of just recipe.toml)"
);
}
#[test]
fn collect_current_dep_hashes_reads_blake3_from_sidecar_toml() {
let dir = temp_dir("ccd_reads_blake3");
let pkgar_path = dir.join("redoxfs.pkgar");
fs::write(&pkgar_path, b"binary content placeholder").unwrap();
let toml_path = pkgar_path.with_extension("toml");
fs::write(
&toml_path,
b"blake3 = \"aabbccdd\"\nversion = \"1.0\"\n",
)
.unwrap();
let name = PackageName::new("redoxfs").unwrap();
let deps = BTreeSet::from([(name, pkgar_path)]);
let hashes = collect_current_dep_hashes(&deps);
assert_eq!(
hashes.get("redoxfs").map(|s| s.as_str()),
Some("aabbccdd"),
"the blake3 field from the sidecar .toml must be collected"
);
}
#[test]
fn collect_current_dep_hashes_skips_missing_sidecar_toml() {
let dir = temp_dir("ccd_missing_toml");
let pkgar_path = dir.join("orphan.pkgar");
fs::write(&pkgar_path, b"binary").unwrap();
let name = PackageName::new("orphan").unwrap();
let deps = BTreeSet::from([(name, pkgar_path)]);
let hashes = collect_current_dep_hashes(&deps);
assert!(
hashes.is_empty(),
"a dep with no sidecar .toml must be excluded from current hashes"
);
}
#[test]
fn collect_current_dep_hashes_skips_empty_blake3() {
let dir = temp_dir("ccd_empty_blake3");
let pkgar_path = dir.join("empty.pkgar");
let toml_path = pkgar_path.with_extension("toml");
fs::write(&toml_path, b"blake3 = \"\"\n").unwrap();
let name = PackageName::new("empty").unwrap();
let deps = BTreeSet::from([(name, pkgar_path)]);
let hashes = collect_current_dep_hashes(&deps);
assert!(
hashes.is_empty(),
"a dep with empty blake3 must be excluded from current hashes"
);
}
#[test]
fn binary_store_manifest_roundtrip_preserves_entries() {
let dir = temp_dir("bsm_roundtrip");
let path = dir.join("recipe.manifest.toml");
fs::write(
&path,
b"\"recipe.pkgar\" = \"aabbccdd\"\n\"recipe.toml\" = \"eeff0011\"\n",
)
.expect("write manifest");
let loaded = BinaryStoreManifest::read(&path)
.expect("read should not error on valid TOML")
.expect("read should return Some for existing file");
assert_eq!(
loaded.hashes.get("recipe.pkgar").map(|s| s.as_str()),
Some("aabbccdd"),
"first entry must survive round-trip"
);
assert_eq!(
loaded.hashes.get("recipe.toml").map(|s| s.as_str()),
Some("eeff0011"),
"second entry must survive round-trip"
);
}
#[test]
fn binary_store_manifest_read_returns_ok_none_for_missing_file() {
let dir = temp_dir("bsm_missing");
let path = dir.join("nonexistent.manifest.toml");
match BinaryStoreManifest::read(&path) {
Ok(None) => (),
other => panic!(
"read on a missing manifest must return Ok(None) for backward compat, got {other:?}"
),
}
}
#[test]
fn binary_store_manifest_read_returns_err_for_corrupt_toml() {
let dir = temp_dir("bsm_corrupt");
let path = dir.join("recipe.manifest.toml");
fs::write(&path, b"this is not valid toml [[[").expect("write corrupt manifest");
match BinaryStoreManifest::read(&path) {
Err(_) => (),
Ok(Some(_)) => panic!("read on corrupt TOML must return Err, got Ok(Some)"),
Ok(None) => panic!("read on corrupt TOML must return Err, got Ok(None)"),
}
}
}