migrate: remove patch system, adopt direct source ownership
BREAKING CHANGE: The patch-based build system is removed. All Red Bear source now lives in local/sources/<component>/ as git repos. Changes: - src/recipe.rs: remove patches field from SourceRecipe::Git/Tar, add Local variant - src/cook/fetch.rs: delete fetch_apply_patches, validate_patches, normalize_patch, fetch_compute_patches_hash, fetch_write_patches_state, fetch_patches_state_stale, fetch_validate_patch_symlinks, fetch_is_patches_newer. Simplify fetch and fetch_offline. Remove recipe_has_patches. Add Local source handler. - src/bin/repo.rs: remove validate-patches command and handle_validate_patches - 70 recipe.toml files: remove patches arrays, convert core recipes to Local source - 272 .patch symlinks deleted from recipe directories - integrate-redbear.sh: replace patch symlink logic with source fork validation - Makefile: replace validate-patches with validate-sources target - AGENTS.md: remove 369 lines of patch documentation, add source ownership model - local/docs/PATCH-GOVERNANCE.md: deleted (replaced by SOURCE-OWNERSHIP-MODEL.md) - local/docs/SOURCE-OWNERSHIP-MODEL.md: new canonical reference - local/sources/: Red Bear fork repos created (kernel, relibc, base, bootloader, installer) from frozen 0.1.0 pre-patched archives - .gitignore: exclude local/sources/ (separate git repos) - create-forks.sh: new script for initializing fork repos Build: cargo check passes (5 warnings, 0 errors). Developer workflow is now: edit local/sources/ → repo cook → test. No patches.
This commit is contained in:
+1
-28
@@ -2,7 +2,7 @@ use ansi_to_tui::IntoText;
|
||||
use anyhow::{Context, anyhow, bail};
|
||||
use cookbook::config::{CookConfig, get_config, init_config};
|
||||
use cookbook::cook::cook_build::{build, get_stage_dirs, remove_stage_dir};
|
||||
use cookbook::cook::fetch::{FetchResult, fetch, fetch_offline, validate_patches};
|
||||
use cookbook::cook::fetch::{FetchResult, fetch, fetch_offline};
|
||||
use cookbook::cook::fs::{create_target_dir, run_command};
|
||||
use cookbook::cook::ident;
|
||||
use cookbook::cook::package::{package, package_handle_push};
|
||||
@@ -122,7 +122,6 @@ enum CliCommand {
|
||||
Push,
|
||||
PushTree,
|
||||
Find,
|
||||
ValidatePatches,
|
||||
}
|
||||
|
||||
impl CliCommand {
|
||||
@@ -156,7 +155,6 @@ impl FromStr for CliCommand {
|
||||
"push-tree" => Ok(CliCommand::PushTree),
|
||||
"cook-tree" => Ok(CliCommand::CookTree),
|
||||
"find" => Ok(CliCommand::Find),
|
||||
"validate-patches" => Ok(CliCommand::ValidatePatches),
|
||||
_ => Err(anyhow!("Unknown command '{}'\n{}\n", s, REPO_HELP_STR)),
|
||||
}
|
||||
}
|
||||
@@ -174,7 +172,6 @@ impl ToString for CliCommand {
|
||||
CliCommand::PushTree => "push-tree".to_string(),
|
||||
CliCommand::CookTree => "cook-tree".to_string(),
|
||||
CliCommand::Find => "find".to_string(),
|
||||
CliCommand::ValidatePatches => "validate-patches".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,26 +416,6 @@ fn repo_inner(
|
||||
println!("{}", recipe.dir.display());
|
||||
false
|
||||
}
|
||||
CliCommand::ValidatePatches => {
|
||||
let validate_fn = move |logger: &PtyOut| -> Result<bool, anyhow::Error> {
|
||||
handle_validate_patches(recipe, logger)?;
|
||||
Ok(false)
|
||||
};
|
||||
let Some(_log_path) = &config.logs_dir else {
|
||||
return validate_fn(&None);
|
||||
};
|
||||
let (status_tx, status_rx) = mpsc::channel::<StatusUpdate>();
|
||||
let (mut stdout_writer, mut stderr_writer) = setup_logger(&status_tx, &recipe.name);
|
||||
let mut logger = Some((&mut stdout_writer, &mut stderr_writer));
|
||||
let result = validate_fn(&logger);
|
||||
if let Err(ref e) = result {
|
||||
write_to_pty(&logger, &format!("\n{:?}", e));
|
||||
}
|
||||
flush_pty(&mut logger);
|
||||
drop(status_tx);
|
||||
let _ = status_rx.recv_timeout(std::time::Duration::from_millis(100));
|
||||
result?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -812,10 +789,6 @@ fn handle_cook(
|
||||
Ok(build_result.cached)
|
||||
}
|
||||
|
||||
fn handle_validate_patches(recipe: &CookRecipe, logger: &PtyOut) -> anyhow::Result<()> {
|
||||
validate_patches(recipe, logger).map_err(|e| anyhow!("{}", e))
|
||||
}
|
||||
|
||||
/// delete stage artifacts upon nonstop failure to let repo_builder know
|
||||
fn handle_nonstop_fail(recipe: &CookRecipe) -> cookbook::Result<()> {
|
||||
let target_dir = recipe.target_dir();
|
||||
|
||||
+34
-673
@@ -91,29 +91,8 @@ fn redbear_allow_protected_fetch() -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if a recipe has patches that would be at risk from upstream source changes.
|
||||
/// Recipes with patches should be protected from online re-fetching because:
|
||||
/// 1. Upstream source changes can break patch context lines
|
||||
/// 2. The atomic patch system expects patches to apply cleanly against the frozen source
|
||||
/// 3. Re-fetching from upstream could pull incompatible changes that invalidate all patches
|
||||
fn recipe_has_patches(recipe: &CookRecipe) -> bool {
|
||||
match &recipe.recipe.source {
|
||||
Some(SourceRecipe::Git { patches, .. }) => !patches.is_empty(),
|
||||
Some(SourceRecipe::Tar { patches, .. }) => !patches.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a recipe should be protected from online re-fetching.
|
||||
/// A recipe is protected if:
|
||||
/// 1. It's on the explicit protected list (redbear_protected_recipe), OR
|
||||
/// 2. It has patches that would be at risk from upstream source changes
|
||||
///
|
||||
/// This ensures that ANY recipe carrying patches — whether explicitly listed or not —
|
||||
/// is automatically shielded from accidental upstream overwrites. The explicit list
|
||||
/// covers recipes that need protection even without patches (e.g., custom source recipes).
|
||||
fn redbear_should_protect(recipe: &CookRecipe) -> bool {
|
||||
redbear_protected_recipe(recipe.name.name()) || recipe_has_patches(recipe)
|
||||
redbear_protected_recipe(recipe.name.name())
|
||||
}
|
||||
|
||||
fn redbear_release() -> Option<String> {
|
||||
@@ -287,12 +266,26 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
|
||||
fetch_make_symlink(&source_dir, &same_as)?;
|
||||
r
|
||||
}
|
||||
Some(SourceRecipe::Local { path }) => {
|
||||
let source_path = recipe_dir.join(path);
|
||||
if !source_path.exists() {
|
||||
bail_other_err!("local source not found: {}", source_path.display());
|
||||
}
|
||||
if source_dir.is_symlink() || !source_dir.exists() {
|
||||
let _ = fs::remove_file(&source_dir);
|
||||
symlink(&source_path, &source_dir)
|
||||
.map_err(|e| format!("failed to symlink local source: {e}"))?;
|
||||
}
|
||||
let ident = get_git_head_rev(&source_path)
|
||||
.map(|(r, _)| r)
|
||||
.unwrap_or_else(|_| "local".to_string());
|
||||
FetchResult::cached(source_dir, ident)
|
||||
}
|
||||
Some(SourceRecipe::Git {
|
||||
git: _,
|
||||
upstream: _,
|
||||
branch: _,
|
||||
rev,
|
||||
patches,
|
||||
script,
|
||||
shallow_clone: _,
|
||||
}) => {
|
||||
@@ -322,43 +315,12 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
|
||||
);
|
||||
}
|
||||
}
|
||||
// Validate all patch symlinks resolve before touching source.
|
||||
fetch_validate_patch_symlinks(recipe_dir, patches)?;
|
||||
|
||||
if (!patches.is_empty() || script.is_some())
|
||||
&& fetch_patches_state_stale(recipe_dir, patches, script, &source_dir)
|
||||
{
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[INFO] patches state stale or missing — re-applying"
|
||||
);
|
||||
// Reset source to clean state, including submodules.
|
||||
let mut clean_cmd = Command::new("git");
|
||||
clean_cmd.arg("-C").arg(&source_dir);
|
||||
clean_cmd.arg("clean").arg("-ffdx");
|
||||
let _ = run_command(clean_cmd, logger);
|
||||
let mut reset_cmd = Command::new("git");
|
||||
reset_cmd.arg("-C").arg(&source_dir);
|
||||
reset_cmd.arg("reset").arg("--hard");
|
||||
run_command(reset_cmd, logger)?;
|
||||
// Recursively reset submodules if any exist.
|
||||
if source_dir.join(".gitmodules").exists() {
|
||||
let mut sub_cmd = Command::new("git");
|
||||
sub_cmd.arg("-C").arg(&source_dir);
|
||||
sub_cmd.arg("submodule").arg("foreach");
|
||||
sub_cmd.arg("--recursive");
|
||||
sub_cmd.arg("git reset --hard && git clean -ffdx");
|
||||
run_command(sub_cmd, logger)?;
|
||||
}
|
||||
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
|
||||
}
|
||||
FetchResult::cached(source_dir, head_rev)
|
||||
}
|
||||
}
|
||||
Some(SourceRecipe::Tar {
|
||||
tar: _,
|
||||
blake3,
|
||||
patches,
|
||||
script,
|
||||
}) => {
|
||||
let ident = blake3.clone().unwrap_or("no_tar_blake3_hash_info".into());
|
||||
@@ -375,9 +337,7 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
|
||||
}
|
||||
create_dir(&source_dir)?;
|
||||
fetch_extract_tar(source_tar, &source_dir, logger)?;
|
||||
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
|
||||
} else {
|
||||
// need to trust this tar file
|
||||
bail_other_err!(
|
||||
"Please add blake3 = {source_tar_blake3:?} to {recipe:?}",
|
||||
recipe = recipe_dir.join("recipe.toml").display(),
|
||||
@@ -397,30 +357,14 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
|
||||
|
||||
pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result<FetchResult> {
|
||||
if redbear_should_protect(recipe) && !redbear_allow_protected_fetch() {
|
||||
let reason = if redbear_protected_recipe(recipe.name.name()) {
|
||||
"explicitly protected"
|
||||
} else {
|
||||
"has patches (auto-protected)"
|
||||
};
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[INFO]: {} recipe {} uses local source (fetch disabled; use --allow-protected flag or set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
|
||||
reason,
|
||||
"[INFO]: protected recipe {} (fetch disabled; use --allow-protected flag or set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
|
||||
recipe.name.name()
|
||||
);
|
||||
return fetch_offline(recipe, logger);
|
||||
}
|
||||
|
||||
// Warn when --allow-protected bypasses protection on a patched recipe.
|
||||
// Upstream source changes may break patch context lines.
|
||||
if redbear_allow_protected_fetch() && recipe_has_patches(recipe) {
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[WARN]: recipe {} has patches but --allow-protected is set — upstream source changes may break patches",
|
||||
recipe.name.name()
|
||||
);
|
||||
}
|
||||
|
||||
let recipe_dir = &recipe.dir;
|
||||
let source_dir = recipe_dir.join("source");
|
||||
match recipe.recipe.build.kind {
|
||||
@@ -461,12 +405,26 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
|
||||
}
|
||||
FetchResult::new(source_dir, "local_source".to_string(), cached)
|
||||
}
|
||||
Some(SourceRecipe::Local { path }) => {
|
||||
let source_path = recipe_dir.join(path);
|
||||
if !source_path.exists() {
|
||||
bail_other_err!("local source not found: {}", source_path.display());
|
||||
}
|
||||
if source_dir.is_symlink() || !source_dir.exists() {
|
||||
let _ = fs::remove_file(&source_dir);
|
||||
symlink(&source_path, &source_dir)
|
||||
.map_err(|e| format!("failed to symlink local source: {e}"))?;
|
||||
}
|
||||
let ident = get_git_head_rev(&source_path)
|
||||
.map(|(r, _)| r)
|
||||
.unwrap_or_else(|_| "local".to_string());
|
||||
FetchResult::cached(source_dir, ident)
|
||||
}
|
||||
Some(SourceRecipe::Git {
|
||||
git,
|
||||
upstream,
|
||||
branch,
|
||||
rev,
|
||||
patches,
|
||||
script,
|
||||
shallow_clone,
|
||||
}) => {
|
||||
@@ -575,32 +533,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
|
||||
|
||||
if !cached {
|
||||
if let Some(_upstream) = upstream {
|
||||
//TODO: set upstream URL (is this needed?)
|
||||
// git remote set-url upstream "$GIT_UPSTREAM" &> /dev/null ||
|
||||
// git remote add upstream "$GIT_UPSTREAM"
|
||||
// git fetch upstream
|
||||
}
|
||||
|
||||
if !patches.is_empty() || script.is_some() {
|
||||
if is_local_overlay(recipe_dir) && !redbear_allow_protected_fetch() {
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[WARN] skipping git reset --hard for local overlay recipe at {} \
|
||||
(set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
|
||||
recipe_dir.display()
|
||||
);
|
||||
} else {
|
||||
let mut clean_cmd = Command::new("git");
|
||||
clean_cmd.arg("-C").arg(&source_dir);
|
||||
clean_cmd.arg("clean").arg("-fd");
|
||||
let _ = run_command(clean_cmd, logger);
|
||||
|
||||
// Hard reset
|
||||
let mut command = Command::new("git");
|
||||
command.arg("-C").arg(&source_dir);
|
||||
command.arg("reset").arg("--hard");
|
||||
run_command(command, logger)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rev) = rev {
|
||||
@@ -654,9 +586,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
|
||||
}
|
||||
manual_git_recursive_submodule(logger, &source_dir, cmds)?;
|
||||
}
|
||||
|
||||
fetch_validate_patch_symlinks(recipe_dir, patches)?;
|
||||
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
|
||||
}
|
||||
|
||||
let (head_rev, _) = get_git_head_rev(&source_dir)?;
|
||||
@@ -665,7 +594,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
|
||||
Some(SourceRecipe::Tar {
|
||||
tar,
|
||||
blake3,
|
||||
patches,
|
||||
script,
|
||||
}) => {
|
||||
let source_tar = recipe_dir.join("source.tar");
|
||||
@@ -708,21 +636,8 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
|
||||
}
|
||||
let mut cached = true;
|
||||
if source_dir.is_dir() {
|
||||
if tar_updated || fetch_is_patches_newer(recipe_dir, patches, &source_dir)? {
|
||||
if is_local_overlay(recipe_dir) && !redbear_allow_protected_fetch() {
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[WARN] refusing to wipe source for local overlay recipe at {} \
|
||||
(set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
|
||||
recipe_dir.display()
|
||||
);
|
||||
} else {
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"DEBUG: source tar or patches is newer than the source directory"
|
||||
);
|
||||
remove_all(&source_dir)?
|
||||
}
|
||||
if tar_updated {
|
||||
remove_all(&source_dir)?
|
||||
}
|
||||
}
|
||||
if !source_dir.is_dir() {
|
||||
@@ -730,7 +645,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
|
||||
let source_dir_tmp = recipe_dir.join("source.tmp");
|
||||
create_dir_clean(&source_dir_tmp)?;
|
||||
fetch_extract_tar(source_tar, &source_dir_tmp, logger)?;
|
||||
fetch_apply_patches(recipe_dir, patches, script, &source_dir_tmp, logger)?;
|
||||
|
||||
// Move source.tmp to source atomically
|
||||
rename(&source_dir_tmp, &source_dir)?;
|
||||
@@ -1074,559 +988,6 @@ fn read_source_toml(source_toml: &Path) -> Result<pkg::Package> {
|
||||
Ok(pkg_toml)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_is_patches_newer(
|
||||
recipe_dir: &Path,
|
||||
patches: &Vec<String>,
|
||||
source_dir: &PathBuf,
|
||||
) -> Result<bool> {
|
||||
// don't check source files inside as it can be mixed with user patches
|
||||
let source_time = modified(&source_dir)?;
|
||||
for patch_name in patches {
|
||||
let patch_file = recipe_dir.join(patch_name);
|
||||
if !patch_file.is_file() {
|
||||
bail_other_err!("Failed to find patch file {:?}", patch_file.display());
|
||||
}
|
||||
|
||||
let patch_time = modified(&patch_file)?;
|
||||
if patch_time > source_time {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_apply_patches(
|
||||
recipe_dir: &Path,
|
||||
patches: &Vec<String>,
|
||||
script: &Option<String>,
|
||||
source_dir_tmp: &PathBuf,
|
||||
logger: &PtyOut,
|
||||
) -> Result<()> {
|
||||
if patches.is_empty() && script.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read and normalize all patch files.
|
||||
let mut patch_contents: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for patch_name in patches {
|
||||
let patch_file = recipe_dir.join(patch_name);
|
||||
if !patch_file.is_file() {
|
||||
bail_other_err!("Failed to find patch file {:?}", patch_file.display());
|
||||
}
|
||||
let raw = fs::read(&patch_file).map_err(|err| {
|
||||
format!(
|
||||
"failed to read patch file '{}': {err}",
|
||||
patch_file.display()
|
||||
)
|
||||
})?;
|
||||
let normalized = normalize_patch(&raw);
|
||||
patch_contents.push((patch_name.clone(), normalized));
|
||||
}
|
||||
|
||||
// Apply all patches atomically to a staging directory.
|
||||
// If any patch fails, the staging directory is discarded and the
|
||||
// original source tree is left untouched.
|
||||
// Uses cp -al (hard links) for zero-copy staging.
|
||||
let staging_dir = source_dir_tmp.with_extension("staging");
|
||||
let _ = fs::remove_dir_all(&staging_dir);
|
||||
Command::new("cp")
|
||||
.arg("-al")
|
||||
.arg(source_dir_tmp)
|
||||
.arg(&staging_dir)
|
||||
.status()
|
||||
.map_err(|e| format!("failed to create staging copy via cp -al: {e}"))?;
|
||||
|
||||
// Snapshot pre-existing .orig files in the source tree (some upstreams
|
||||
// ship .orig files in their tarballs — e.g. glib test data). Only .orig
|
||||
// files created by the patch command should be flagged as failures.
|
||||
let preexisting_origs: std::collections::HashSet<String> = {
|
||||
let out = Command::new("find")
|
||||
.arg(&staging_dir)
|
||||
.arg("-name")
|
||||
.arg("*.orig")
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect(),
|
||||
Err(_) => std::collections::HashSet::new(),
|
||||
}
|
||||
};
|
||||
|
||||
let result = (|| -> Result<Vec<String>> {
|
||||
let mut applied = Vec::new();
|
||||
for (patch_name, patch_data) in &patch_contents {
|
||||
let mut command = Command::new("patch");
|
||||
command.arg("--directory").arg(&staging_dir);
|
||||
command.arg("--strip=1");
|
||||
command.arg("--batch");
|
||||
command.arg("--fuzz=3");
|
||||
command.arg("--no-backup-if-mismatch");
|
||||
run_command_stdin(command, patch_data.as_slice(), logger)
|
||||
.map_err(|e| format!("patch {patch_name} FAILED: {e}"))?;
|
||||
|
||||
// .rej files always indicate failure — check unconditionally.
|
||||
let rej_check = Command::new("find")
|
||||
.arg(&staging_dir)
|
||||
.arg("-name")
|
||||
.arg("*.rej")
|
||||
.arg("-print")
|
||||
.arg("-quit")
|
||||
.output();
|
||||
if let Ok(out) = rej_check {
|
||||
if !out.stdout.is_empty() {
|
||||
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
bail_other_err!(
|
||||
"patch {patch_name} left .rej file (hunks failed to apply): {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// .orig files: only flag newly-created ones (not pre-existing).
|
||||
let orig_check = Command::new("find")
|
||||
.arg(&staging_dir)
|
||||
.arg("-name")
|
||||
.arg("*.orig")
|
||||
.output();
|
||||
if let Ok(out) = orig_check {
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let trimmed = line.trim().to_string();
|
||||
if !trimmed.is_empty() && !preexisting_origs.contains(&trimmed) {
|
||||
bail_other_err!(
|
||||
"patch {patch_name} left .orig file (hunks failed to apply): {trimmed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applied.push(patch_name.clone());
|
||||
}
|
||||
Ok(applied)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(applied) => {
|
||||
let backup_dir = source_dir_tmp.with_extension("backup");
|
||||
let _ = fs::remove_dir_all(&backup_dir);
|
||||
fs::rename(source_dir_tmp, &backup_dir)
|
||||
.map_err(|e| format!("failed to rename source to backup: {e}"))?;
|
||||
fs::rename(&staging_dir, source_dir_tmp)
|
||||
.map_err(|e| format!("failed to promote staging to source: {e}"))?;
|
||||
let _ = fs::remove_dir_all(&backup_dir);
|
||||
|
||||
fetch_write_patches_state(recipe_dir, &applied, source_dir_tmp, script, logger)?;
|
||||
|
||||
if let Some(script) = script {
|
||||
let mut command = Command::new("bash");
|
||||
command.arg("-ex");
|
||||
command.current_dir(source_dir_tmp);
|
||||
run_command_stdin(
|
||||
command,
|
||||
format!("{SHARED_PRESCRIPT}\n{script}").as_bytes(),
|
||||
logger,
|
||||
)?;
|
||||
}
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[ATOMIC] {n}/{n} patches applied",
|
||||
n = applied.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&staging_dir);
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[ATOMIC] patch application rolled back — source tree unchanged"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_patches(recipe: &CookRecipe, logger: &PtyOut) -> Result<()> {
|
||||
let recipe_dir = &recipe.dir;
|
||||
let source_dir = recipe_dir.join("source");
|
||||
|
||||
if !source_dir.is_dir() {
|
||||
bail_other_err!(
|
||||
"Source directory does not exist: {}. Fetch first.",
|
||||
source_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
let (patches, script) = match &recipe.recipe.source {
|
||||
Some(SourceRecipe::Git {
|
||||
patches, script, ..
|
||||
})
|
||||
| Some(SourceRecipe::Tar {
|
||||
patches, script, ..
|
||||
}) => (patches.clone(), script.clone()),
|
||||
_ => {
|
||||
log_to_pty!(logger, "[INFO] Recipe has no patches to validate");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if patches.is_empty() && script.is_none() {
|
||||
log_to_pty!(logger, "[INFO] No patches to validate");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate patch symlinks
|
||||
fetch_validate_patch_symlinks(recipe_dir, &patches)?;
|
||||
|
||||
// Read and normalize all patch files
|
||||
let mut patch_contents: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for patch_name in &patches {
|
||||
let patch_file = recipe_dir.join(patch_name);
|
||||
if !patch_file.is_file() {
|
||||
bail_other_err!("Failed to find patch file {:?}", patch_file.display());
|
||||
}
|
||||
let raw = fs::read(&patch_file).map_err(|err| {
|
||||
format!(
|
||||
"failed to read patch file '{}': {err}",
|
||||
patch_file.display()
|
||||
)
|
||||
})?;
|
||||
let normalized = normalize_patch(&raw);
|
||||
patch_contents.push((patch_name.clone(), normalized));
|
||||
}
|
||||
|
||||
// Create temp staging directory on same filesystem as source (for hard link support)
|
||||
let staging_dir = source_dir.with_extension("validate-staging");
|
||||
let _ = fs::remove_dir_all(&staging_dir);
|
||||
|
||||
// cp -al (hard link copy, zero cost) from source to staging
|
||||
Command::new("cp")
|
||||
.arg("-al")
|
||||
.arg(&source_dir)
|
||||
.arg(&staging_dir)
|
||||
.status()
|
||||
.map_err(|e| format!("failed to create staging copy via cp -al: {e}"))?;
|
||||
|
||||
// Clean the staging copy to pristine upstream state.
|
||||
// Only git-sourced recipes have a .git directory — tarball sources
|
||||
// are already pristine from the cp -al copy.
|
||||
if staging_dir.join(".git").exists() {
|
||||
let _ = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&staging_dir)
|
||||
.arg("clean")
|
||||
.arg("-ffdx")
|
||||
.status();
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&staging_dir)
|
||||
.arg("reset")
|
||||
.arg("--hard")
|
||||
.status()
|
||||
.map_err(|e| format!("failed to reset staging to clean state: {e}"))?;
|
||||
}
|
||||
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for (patch_name, patch_data) in &patch_contents {
|
||||
log_to_pty!(logger, " {} ...", patch_name);
|
||||
let mut command = Command::new("patch");
|
||||
command.arg("--directory").arg(&staging_dir);
|
||||
command.arg("--strip=1");
|
||||
command.arg("--batch");
|
||||
command.arg("--fuzz=3");
|
||||
command.arg("--no-backup-if-mismatch");
|
||||
|
||||
match run_command_stdin(command, patch_data.as_slice(), logger) {
|
||||
Ok(_) => {
|
||||
// Check for .rej files
|
||||
let rej_check = Command::new("find")
|
||||
.arg(&staging_dir)
|
||||
.arg("-name")
|
||||
.arg("*.rej")
|
||||
.arg("-print")
|
||||
.arg("-quit")
|
||||
.output();
|
||||
if let Ok(out) = rej_check {
|
||||
if !out.stdout.is_empty() {
|
||||
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
log_to_pty!(
|
||||
logger,
|
||||
" [FAIL] {} → {} has rejected hunks",
|
||||
patch_name,
|
||||
path
|
||||
);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log_to_pty!(logger, " [PASS] {}", patch_name);
|
||||
passed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log_to_pty!(logger, " [FAIL] {} → {}", patch_name, e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run script if present
|
||||
if let Some(script) = script {
|
||||
log_to_pty!(logger, " [script] ...");
|
||||
let mut command = Command::new("bash");
|
||||
command.arg("-ex");
|
||||
command.current_dir(&staging_dir);
|
||||
match run_command_stdin(
|
||||
command,
|
||||
format!("{}\n{}", SHARED_PRESCRIPT, script).as_bytes(),
|
||||
logger,
|
||||
) {
|
||||
Ok(_) => {
|
||||
log_to_pty!(logger, " [PASS] script");
|
||||
passed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log_to_pty!(logger, " [FAIL] script → {}", e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up staging directory
|
||||
let _ = fs::remove_dir_all(&staging_dir);
|
||||
|
||||
if failed > 0 {
|
||||
bail_other_err!(
|
||||
"[SUMMARY] {}/{} patches applied, {} failed",
|
||||
passed,
|
||||
passed + failed,
|
||||
failed
|
||||
);
|
||||
}
|
||||
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[SUMMARY] All {} patches validated successfully",
|
||||
passed
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Normalizes a patch for compatibility with the `patch` command by stripping
|
||||
/// git-specific headers (`diff --git`, `index`, `new file mode`, etc.) that
|
||||
/// `patch` does not recognize.
|
||||
fn normalize_patch(raw: &[u8]) -> Vec<u8> {
|
||||
let text = String::from_utf8_lossy(raw);
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut prev_empty = true;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("diff --git ")
|
||||
|| trimmed.starts_with("diff -ruN ")
|
||||
|| trimmed.starts_with("index ")
|
||||
|| trimmed.starts_with("new file mode ")
|
||||
|| trimmed.starts_with("deleted file mode ")
|
||||
|| trimmed.starts_with("rename from ")
|
||||
|| trimmed.starts_with("rename to ")
|
||||
|| trimmed.starts_with("similarity index ")
|
||||
|| trimmed.starts_with("dissimilarity index ")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !prev_empty || !line.is_empty() {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
prev_empty = line.is_empty();
|
||||
}
|
||||
}
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
/// Computes a BLAKE3 hash over all patch file contents (in order).
|
||||
fn fetch_compute_patches_hash(recipe_dir: &Path, patches: &[String]) -> Result<String> {
|
||||
// BLAKE3 is already a project dependency (used for source verification).
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for patch_name in patches {
|
||||
let patch_file = recipe_dir.join(patch_name);
|
||||
let content = fs::read(&patch_file).map_err(|err| {
|
||||
format!(
|
||||
"failed to read patch for hashing '{}': {err}",
|
||||
patch_file.display()
|
||||
)
|
||||
})?;
|
||||
hasher.update(&content);
|
||||
}
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// Writes a .patches-state file into the recipe's *target* directory
|
||||
/// (NOT the source checkout — git clean would delete it otherwise).
|
||||
/// Contains: upstream commit, ordered patch list, composite hash, script hash,
|
||||
/// and state schema version for forward-compatibility.
|
||||
/// Computes a BLAKE3 hash over all tracked files in the source directory,
|
||||
/// so that manual source edits (outside the patch system) are detected
|
||||
/// and trigger re-patching on the next build.
|
||||
fn fetch_compute_source_hash(source_dir: &Path) -> String {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(source_dir)
|
||||
.args(["ls-files", "-z"])
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) if !out.stdout.is_empty() => {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
// Hash file paths in sorted order for stability.
|
||||
let mut files: Vec<&str> = out
|
||||
.stdout
|
||||
.split(|&b| b == 0)
|
||||
.filter_map(|s| std::str::from_utf8(s).ok())
|
||||
.collect();
|
||||
files.sort();
|
||||
for path in &files {
|
||||
hasher.update(path.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
// Hash file contents for integrity.
|
||||
if let Ok(content) = fs::read(source_dir.join(path)) {
|
||||
hasher.update(&content);
|
||||
}
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
hasher.finalize().to_hex().to_string()
|
||||
}
|
||||
_ => "no-git".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_write_patches_state(
|
||||
recipe_dir: &Path,
|
||||
applied: &[String],
|
||||
source_dir: &Path,
|
||||
script: &Option<String>,
|
||||
logger: &PtyOut,
|
||||
) -> Result<()> {
|
||||
let head_rev = get_git_head_rev(&source_dir.to_path_buf())
|
||||
.map(|(r, _)| r)
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let hash = fetch_compute_patches_hash(recipe_dir, applied)
|
||||
.unwrap_or_else(|_| "hash-error".to_string());
|
||||
let script_hash = script
|
||||
.as_ref()
|
||||
.map(|s| blake3::hash(s.as_bytes()).to_hex().to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
|
||||
// State goes in target/ so git clean/reset won't delete it.
|
||||
let state_dir = recipe_dir.join("target");
|
||||
let _ = fs::create_dir_all(&state_dir);
|
||||
let state_file = state_dir.join(".patches-state");
|
||||
|
||||
let source_hash = fetch_compute_source_hash(source_dir);
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("schema: 1\n");
|
||||
content.push_str(&format!("upstream-rev: {head_rev}\n"));
|
||||
content.push_str(&format!("patches-hash: {hash}\n"));
|
||||
content.push_str(&format!("script-hash: {script_hash}\n"));
|
||||
content.push_str(&format!("source-hash: {source_hash}\n"));
|
||||
for (i, name) in applied.iter().enumerate() {
|
||||
content.push_str(&format!("patch[{}]: {name}\n", i + 1));
|
||||
}
|
||||
fs::write(&state_file, &content)
|
||||
.map_err(|err| format!("failed to write .patches-state: {err}"))?;
|
||||
log_to_pty!(
|
||||
logger,
|
||||
"[OK] wrote .patches-state ({}/{} patches)",
|
||||
applied.len(),
|
||||
applied.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates that every patch file path resolves to a real file before we
|
||||
/// touch the source tree. Fails early with a clear message if any symlink
|
||||
/// is broken or file is missing.
|
||||
fn fetch_validate_patch_symlinks(recipe_dir: &Path, patches: &[String]) -> Result<()> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for patch_name in patches {
|
||||
let patch_file = recipe_dir.join(patch_name);
|
||||
if !patch_file.is_file() {
|
||||
bail_other_err!(
|
||||
"patch file not found: {:?} (broken symlink or missing file in {})",
|
||||
patch_file.display(),
|
||||
recipe_dir.display()
|
||||
);
|
||||
}
|
||||
// Canonicalize to catch symlink chains
|
||||
let canonical = patch_file.canonicalize().map_err(|e| {
|
||||
format!(
|
||||
"cannot resolve patch path {:?}: {e} (broken symlink?)",
|
||||
patch_file.display()
|
||||
)
|
||||
})?;
|
||||
if !seen.insert(canonical) {
|
||||
bail_other_err!(
|
||||
"duplicate patch after canonicalization: {:?} (listed twice in recipe?)",
|
||||
patch_name
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks whether the source directory's .patches-state matches the
|
||||
/// recipe's current patch list. Returns true if patches should be
|
||||
/// (re-)applied.
|
||||
fn fetch_patches_state_stale(
|
||||
recipe_dir: &Path,
|
||||
patches: &[String],
|
||||
script: &Option<String>,
|
||||
source_dir: &Path,
|
||||
) -> bool {
|
||||
let state_file = recipe_dir.join("target/.patches-state");
|
||||
let state_content = match fs::read_to_string(&state_file) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return true,
|
||||
};
|
||||
|
||||
let expected_hash = match fetch_compute_patches_hash(recipe_dir, patches) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return true,
|
||||
};
|
||||
let expected_script_hash = script
|
||||
.as_ref()
|
||||
.map(|s| blake3::hash(s.as_bytes()).to_hex().to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
let current_source_hash = fetch_compute_source_hash(source_dir);
|
||||
|
||||
let mut found_hash = false;
|
||||
let mut found_script = false;
|
||||
let mut found_source = false;
|
||||
for line in state_content.lines() {
|
||||
if let Some(stored) = line.strip_prefix("patches-hash: ") {
|
||||
if stored.trim() != expected_hash {
|
||||
return true;
|
||||
}
|
||||
found_hash = true;
|
||||
}
|
||||
if let Some(stored) = line.strip_prefix("script-hash: ") {
|
||||
if stored.trim() != expected_script_hash {
|
||||
return true;
|
||||
}
|
||||
found_script = true;
|
||||
}
|
||||
if let Some(stored) = line.strip_prefix("source-hash: ") {
|
||||
if stored.trim() != current_source_hash {
|
||||
return true;
|
||||
}
|
||||
found_source = true;
|
||||
}
|
||||
}
|
||||
|
||||
!found_hash || !found_script || !found_source
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_apply_source_info(
|
||||
recipe: &CookRecipe,
|
||||
|
||||
+8
-11
@@ -42,9 +42,6 @@ pub enum SourceRecipe {
|
||||
rev: Option<String>,
|
||||
/// The optional config to clone with treeless clone. Default is true if "rev" added
|
||||
shallow_clone: Option<bool>,
|
||||
/// A list of patch files to apply to the source
|
||||
#[serde(default)]
|
||||
patches: Vec<String>,
|
||||
/// Optional script to run to prepare the source
|
||||
script: Option<String>,
|
||||
},
|
||||
@@ -55,12 +52,16 @@ pub enum SourceRecipe {
|
||||
/// The optional blake3 sum of the tar file. Please specify this to make reproducible
|
||||
/// builds more reliable
|
||||
blake3: Option<String>,
|
||||
/// A list of patch files to apply to the source
|
||||
#[serde(default)]
|
||||
patches: Vec<String>,
|
||||
/// Optional script to run to prepare the source, such as ./autogen.sh
|
||||
script: Option<String>,
|
||||
},
|
||||
/// Direct local source — for Red Bear-owned forks.
|
||||
/// Source lives in local/sources/<component>/ and is directly editable.
|
||||
/// No fetching, no patching, no cloning.
|
||||
Local {
|
||||
/// Path to the source directory, relative to the recipe directory
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Specifies how to build a recipe
|
||||
@@ -485,7 +486,6 @@ impl CookRecipe {
|
||||
SourceRecipe::Tar {
|
||||
tar,
|
||||
blake3: _,
|
||||
patches: _,
|
||||
script: _,
|
||||
} => {
|
||||
if let Some(ver) = re.extract_ver(&tar) {
|
||||
@@ -498,7 +498,6 @@ impl CookRecipe {
|
||||
branch,
|
||||
rev,
|
||||
shallow_clone: _,
|
||||
patches: _,
|
||||
script: _,
|
||||
} => {
|
||||
if let Some(rev) = rev {
|
||||
@@ -679,9 +678,8 @@ mod tests {
|
||||
upstream: None,
|
||||
branch: Some("master".to_string()),
|
||||
rev: Some("06344744d3d55a5ac9a62a6059cb363d40699bbc".to_string()),
|
||||
patches: Vec::new(),
|
||||
script: None,
|
||||
shallow_clone: None,
|
||||
script: None,
|
||||
}),
|
||||
build: BuildRecipe::new(BuildKind::Cargo {
|
||||
cargopath: None,
|
||||
@@ -720,7 +718,6 @@ mod tests {
|
||||
"8220c0e4082fa26c07b10bfe31f641d2e33ebe1d1bb0b20221b7016bc8b78a3a"
|
||||
.to_string()
|
||||
),
|
||||
patches: Vec::new(),
|
||||
script: None,
|
||||
}),
|
||||
build: BuildRecipe::new(BuildKind::Custom {
|
||||
|
||||
Reference in New Issue
Block a user