mitts 0.3.26

Rust-first build and package tooling with a shared artifact cache
Documentation
/*
 * Mitts Build — Rust-first build and package tooling with shared artifact cache
 * Copyright (c) 2026 Alexander Panasenko
 *
 * Contact: alex@prod.codes
 * Author: https://prod.codes/about/
 * Project: https://github.com/alex09x/mitts
 * SPDX-License-Identifier: MIT OR Apache-2.0
 */

use crate::adapter::rust::manifest::CargoToml;
use crate::core::reflink::reflink_or_copy_dir_recursive;
use std::fs;
use std::path::Path;

use super::build_script_directives::{compute_execution_key, BuildScriptDirectives};

/// Minimum age of `.last_used` before a warm CAS hit refreshes its mtime on disk.
/// Coalesces concurrent warm-hit reads while keeping active entries fresh for GC.
pub const LAST_USED_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);

fn touch_last_used_throttled(entry_dir: &Path) {
    let last_used = entry_dir.join(".last_used");
    let should_refresh = match fs::metadata(&last_used).and_then(|m| m.modified()) {
        Ok(mtime) => std::time::SystemTime::now()
            .duration_since(mtime)
            .map(|age| age >= LAST_USED_REFRESH_INTERVAL)
            .unwrap_or(true),
        Err(_) => true,
    };
    if should_refresh {
        let _ = fs::write(last_used, b"");
    }
}

pub fn remap_out_dir(directives: &mut BuildScriptDirectives, from: &str, to: &str) {
    if from.is_empty() || from == to {
        return;
    }
    for s in &mut directives.link_searches {
        if s.contains(from) {
            *s = s.replace(from, to);
        }
    }
    for val in directives.envs.values_mut() {
        if val.contains(from) {
            *val = val.replace(from, to);
        }
    }
    for val in directives.metadata.values_mut() {
        if val.contains(from) {
            *val = val.replace(from, to);
        }
    }
}

pub fn try_restore_build_script_cas(
    cas_artifacts_dir: Option<&Path>,
    build_key: &str,
    pkg_dir: &Path,
    pkg_toml: &CargoToml,
    profile_name: &str,
    clean_crate: &str,
    out_dir: Option<&Path>,
) -> Option<BuildScriptDirectives> {
    let artifacts_dir = cas_artifacts_dir?;
    let cas_root = artifacts_dir.parent().unwrap_or(artifacts_dir);
    let script_cas_dir = cas_root.join("build_scripts").join(build_key);

    if !script_cas_dir.is_dir() {
        return None;
    }

    let entries = fs::read_dir(&script_cas_dir).ok()?;
    for entry in entries.flatten() {
        let entry_dir = entry.path();
        let file_name = entry_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
        if file_name.starts_with('.') || !entry_dir.is_dir() {
            continue;
        }
        let meta_file = entry_dir.join("meta.toml");
        let mut cached_out_dir = entry_dir
            .join(profile_name)
            .join("build")
            .join(clean_crate)
            .join("out");
        if !cached_out_dir.is_dir() {
            cached_out_dir = entry_dir.join("out");
        }
        if !meta_file.is_file() {
            continue;
        }
        if let Ok(content) = fs::read_to_string(&meta_file) {
            if let Ok(mut directives) = toml::from_str::<BuildScriptDirectives>(&content) {
                if directives.is_fresh(pkg_dir) {
                    touch_last_used_throttled(&entry_dir);
                    let persistent_out = if cached_out_dir.is_dir() {
                        cached_out_dir
                    } else if let Some(od) = out_dir {
                        od.to_path_buf()
                    } else {
                        cached_out_dir
                    };
                    let persistent_str = persistent_out.to_string_lossy().to_string();
                    if let Some(prev) = directives
                        .out_dir
                        .as_ref()
                        .map(|p| p.to_string_lossy().to_string())
                    {
                        remap_out_dir(&mut directives, &prev, &persistent_str);
                    }
                    directives.out_dir = Some(persistent_out);
                    directives.links = pkg_toml.package.as_ref().and_then(|p| p.links.clone());
                    return Some(directives);
                }
            }
        }
    }

    None
}

pub fn save_build_script_cas(
    cas_artifacts_dir: Option<&Path>,
    build_key: &str,
    directives: &mut BuildScriptDirectives,
    profile_name: &str,
    clean_crate: &str,
    out_dir: &Path,
) {
    let artifacts_dir = match cas_artifacts_dir {
        Some(d) => d,
        None => return,
    };

    let cas_root = artifacts_dir.parent().unwrap_or(artifacts_dir);
    let script_cas_dir = cas_root.join("build_scripts").join(build_key);
    let exec_base = format!("{}:{}", build_key, directives.freshness_digest());
    let mut exec_key = compute_execution_key(
        &exec_base,
        &directives.rerun_if_changed_hashes,
        &directives.envs,
    );
    // Missing watched paths require a fresh execution on each build. Preserve each
    // execution's own outputs even when the watched-input digest is unchanged.
    if directives
        .rerun_if_changed_hashes
        .values()
        .any(|hash| hash == "<missing>")
    {
        use std::sync::atomic::{AtomicU64, Ordering};
        static NONCE: AtomicU64 = AtomicU64::new(0);
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        exec_key = format!(
            "{exec_key}-{}-{nanos}-{}",
            std::process::id(),
            NONCE.fetch_add(1, Ordering::Relaxed)
        );
    }
    let entry_dir = script_cas_dir.join(&exec_key);
    let cas_out = entry_dir
        .join(profile_name)
        .join("build")
        .join(clean_crate)
        .join("out");
    let out_str = out_dir.to_string_lossy();
    let cas_out_str = cas_out.to_string_lossy();

    // An entry without its directives can never be restored: an interrupted writer or a
    // cleanup that could not delete the read-only output tree leaves one behind, and every
    // later build would re-run the script while believing the entry exists. Replace it.
    if entry_dir.exists() && !entry_dir.join("meta.toml").is_file() {
        let _ = remove_dir_all_readonly_safe(&entry_dir);
    }

    if !entry_dir.exists() {
        let _ = fs::create_dir_all(&script_cas_dir);
        let pid = std::process::id();
        let tmp_entry_dir = script_cas_dir.join(format!(".tmp.{}.{}", exec_key, pid));
        let _ = remove_dir_all_readonly_safe(&tmp_entry_dir);

        if fs::create_dir_all(&tmp_entry_dir).is_ok() {
            let mut ok = true;
            if out_dir.is_dir() {
                let tmp_out = tmp_entry_dir
                    .join(profile_name)
                    .join("build")
                    .join(clean_crate)
                    .join("out");
                if let Some(p) = tmp_out.parent() {
                    let _ = fs::create_dir_all(p);
                }
                if reflink_or_copy_dir_recursive(out_dir, &tmp_out).is_err() {
                    ok = false;
                } else {
                    rewrite_out_dir_in_text_files(&tmp_out, &out_str, &cas_out_str);
                    make_tree_readonly_recursive(&tmp_out);
                }
            }
            if ok {
                let _ = fs::write(tmp_entry_dir.join(".last_used"), b"");
                let mut cas_d = directives.clone();
                cas_d.out_dir = Some(cas_out.clone());
                remap_out_dir(&mut cas_d, &out_str, &cas_out_str);
                if let Ok(toml_str) = toml::to_string(&cas_d) {
                    if fs::write(tmp_entry_dir.join("meta.toml"), toml_str).is_ok() {
                        if fs::rename(&tmp_entry_dir, &entry_dir).is_err() {
                            let _ = remove_dir_all_readonly_safe(&tmp_entry_dir);
                        }
                    } else {
                        let _ = remove_dir_all_readonly_safe(&tmp_entry_dir);
                    }
                } else {
                    let _ = remove_dir_all_readonly_safe(&tmp_entry_dir);
                }
            } else {
                let _ = remove_dir_all_readonly_safe(&tmp_entry_dir);
            }
        }
    }

    if cas_out.is_dir() {
        directives.out_dir = Some(cas_out.clone());
        remap_out_dir(directives, &out_str, &cas_out_str);
    }
}

/// Rewrites the staging `OUT_DIR` path inside the text files of a saved output tree. A
/// build script that writes the absolute `OUT_DIR` into its generated sources (grpc-xds:
/// `#[path = "<OUT_DIR>/generated/cel/mod.rs"] pub mod cel;`) works under Cargo because
/// Cargo's `OUT_DIR` is permanent; Mitts ran the script in a staging directory that is
/// gone by the next build, so every restore would hand rustc files pointing nowhere. The
/// directives get the same remap (`remap_out_dir`); binary files are left alone.
fn rewrite_out_dir_in_text_files(dir: &Path, from: &str, to: &str) {
    if from.is_empty() || from == to {
        return;
    }
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let Ok(ft) = entry.file_type() else {
            continue;
        };
        if ft.is_dir() {
            rewrite_out_dir_in_text_files(&path, from, to);
            continue;
        }
        if !ft.is_file() {
            continue;
        }
        let Ok(bytes) = fs::read(&path) else {
            continue;
        };
        if !bytes.windows(from.len()).any(|w| w == from.as_bytes()) {
            continue;
        }
        let Ok(text) = std::str::from_utf8(&bytes) else {
            continue;
        };
        let rewritten = text.replace(from, to);
        // A fresh inode: the copy may share one with the staging file (hard link), and
        // the staging tree is what the running compile may still read.
        let name = entry.file_name();
        let tmp = dir.join(format!(".{}.mitts-rewrite", name.to_string_lossy()));
        if fs::write(&tmp, rewritten).is_err() {
            let _ = fs::remove_file(&tmp);
            continue;
        }
        if let Ok(meta) = fs::metadata(&path) {
            let _ = fs::set_permissions(&tmp, meta.permissions());
        }
        if fs::rename(&tmp, &path).is_err() {
            let _ = fs::remove_file(&tmp);
        }
    }
}

/// Sets files to `0o444` (`0o555` when they were executable: a vendored `protoc` written
/// into `OUT_DIR` must still run from the store) and directories to `0o555` recursively
/// (post-order on directories) so shared CAS build script outputs cannot be mutated by
/// downstream build scripts.
pub fn make_tree_readonly_recursive(dir: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(entries) = fs::read_dir(dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                let Ok(ft) = entry.file_type() else {
                    continue;
                };
                if ft.is_symlink() {
                    continue;
                }
                if ft.is_dir() {
                    make_tree_readonly_recursive(&path);
                } else if ft.is_file() {
                    let executable = entry
                        .metadata()
                        .map(|m| m.permissions().mode() & 0o111 != 0)
                        .unwrap_or(false);
                    let mode = if executable { 0o555 } else { 0o444 };
                    let _ = fs::set_permissions(&path, fs::Permissions::from_mode(mode));
                }
            }
        }
        let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o555));
    }
    #[cfg(not(unix))]
    {
        let _ = dir;
    }
}

/// Restores `0o755` on directories (and `0o644` on files) before `fs::remove_dir_all`
/// so read-only `0o555` directories inside `cas/build_scripts` can be unlinked cleanly.
pub fn remove_dir_all_readonly_safe(dir: &Path) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        fn restore_writable(p: &Path) {
            use std::os::unix::fs::PermissionsExt;
            let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o755));
            if let Ok(entries) = fs::read_dir(p) {
                for entry in entries.flatten() {
                    let child = entry.path();
                    let Ok(ft) = entry.file_type() else {
                        continue;
                    };
                    if ft.is_dir() {
                        restore_writable(&child);
                    } else if ft.is_file() {
                        let _ = fs::set_permissions(&child, fs::Permissions::from_mode(0o644));
                    }
                }
            }
        }
        if dir.exists() {
            restore_writable(dir);
        }
    }
    fs::remove_dir_all(dir)
}