use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use crate::core::config;
const KEY_DOMAIN_TAG: &str = "zccache-meson-cache-v3";
const DEFAULT_INPUT_ENV: &[&str] = &[
"CC",
"CXX",
"CFLAGS",
"CXXFLAGS",
"LDFLAGS",
"PKG_CONFIG_PATH",
];
const MESON_INPUT_FILENAMES: &[&str] = &["meson.build", "meson.options", "meson_options.txt"];
pub(crate) fn cmd_configure(
source_dir: PathBuf,
build_dir: PathBuf,
meson_bin: Option<PathBuf>,
extra_input_env: Vec<String>,
extra_input_file: Vec<String>,
no_walk: bool,
meson_args: Vec<String>,
) -> ExitCode {
if !source_dir.exists() {
eprintln!(
"[zccache-meson] error: source dir {} does not exist",
source_dir.display()
);
return ExitCode::FAILURE;
}
let source_abs = absolutise(&source_dir);
let build_abs = absolutise(&build_dir);
let meson_bin = meson_bin.unwrap_or_else(|| PathBuf::from("meson"));
let meson_version = match capture_meson_version(&meson_bin) {
Ok(v) => v,
Err(e) => {
eprintln!("[zccache-meson] error: cannot read `meson --version`: {e}");
return ExitCode::FAILURE;
}
};
let mut env_inputs: Vec<&str> = DEFAULT_INPUT_ENV.to_vec();
for extra in &extra_input_env {
if !env_inputs.iter().any(|s| s == extra) {
env_inputs.push(extra.as_str());
}
}
env_inputs.sort_unstable();
let env_pairs: Vec<(String, String)> = env_inputs
.iter()
.map(|name| ((*name).to_string(), std::env::var(name).unwrap_or_default()))
.collect();
let input_files = if no_walk {
BTreeMap::new()
} else {
match discover_meson_inputs(&source_abs) {
Ok(set) => set,
Err(e) => {
eprintln!("[zccache-meson] error: scanning source dir failed: {e}");
return ExitCode::FAILURE;
}
}
};
if !no_walk && input_files.is_empty() {
eprintln!(
"[zccache-meson] error: no meson input files found under {}",
source_abs.display()
);
return ExitCode::FAILURE;
}
if no_walk && extra_input_file.is_empty() {
eprintln!(
"[zccache-meson] error: --no-walk requires at least one --input-file (otherwise the cache key has no source-content contribution)"
);
return ExitCode::FAILURE;
}
let key_hex = match compute_cache_key(
&input_files,
&source_abs,
&build_abs,
&meson_version,
&env_pairs,
&extra_input_file,
&meson_args,
) {
Ok(hex) => hex,
Err(e) => {
eprintln!("[zccache-meson] error: hashing inputs failed: {e}");
return ExitCode::FAILURE;
}
};
let cache_root = config::default_cache_dir();
let entry_dir = Path::new(cache_root.as_path())
.join("meson-configure")
.join(&key_hex);
let payload_path = entry_dir.join("build-dir.tar");
let stdout_path = entry_dir.join("stdout.bin");
let stderr_path = entry_dir.join("stderr.bin");
if payload_path.exists() {
match restore_from_cache(&payload_path, &stdout_path, &stderr_path, &build_abs) {
Ok(()) => {
eprintln!(
"[zccache-meson] hit key={} build_dir={}",
&key_hex[..16],
build_abs.display()
);
return ExitCode::SUCCESS;
}
Err(e) => {
eprintln!(
"[zccache-meson] warning: cache restore failed ({e}); falling back to fresh meson setup"
);
}
}
}
eprintln!(
"[zccache-meson] miss key={} build_dir={}",
&key_hex[..16],
build_abs.display()
);
let _ = std::fs::create_dir_all(&build_abs);
let mut cmd = Command::new(&meson_bin);
cmd.arg("setup").arg(&build_abs).arg(&source_abs);
for arg in &meson_args {
cmd.arg(arg);
}
let output = match cmd.output() {
Ok(o) => o,
Err(e) => {
eprintln!(
"[zccache-meson] error: failed to run `{} setup`: {e}",
meson_bin.display()
);
return ExitCode::FAILURE;
}
};
let _ = std::io::Write::write_all(&mut std::io::stdout(), &output.stdout);
let _ = std::io::Write::write_all(&mut std::io::stderr(), &output.stderr);
if !output.status.success() {
return crate::cli::commands::util::exit_code_from_i32(output.status.code().unwrap_or(1));
}
if stdout_contains_already_configured(&output.stdout) {
eprintln!(
"[zccache-meson] skip persist key={} build_dir={} reason=already-configured (issue #710)",
&key_hex[..16],
build_abs.display()
);
return ExitCode::SUCCESS;
}
if let Err(e) = persist_to_cache(
&entry_dir,
&payload_path,
&stdout_path,
&stderr_path,
&output.stdout,
&output.stderr,
&build_abs,
) {
eprintln!("[zccache-meson] warning: failed to persist cache entry: {e}");
}
ExitCode::SUCCESS
}
fn absolutise(p: &Path) -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(p)
}
}
fn capture_meson_version(meson_bin: &Path) -> std::io::Result<String> {
let out = Command::new(meson_bin).arg("--version").output()?;
if !out.status.success() {
return Err(std::io::Error::other(format!(
"`{} --version` exit={}",
meson_bin.display(),
out.status
)));
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(s)
}
fn discover_meson_inputs(root: &Path) -> std::io::Result<BTreeMap<String, PathBuf>> {
let mut out = BTreeMap::new();
walk_meson_dir(root, root, &mut out)?;
Ok(out)
}
fn walk_meson_dir(
root: &Path,
dir: &Path,
acc: &mut BTreeMap<String, PathBuf>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if matches!(
name,
".git"
| ".hg"
| ".svn"
| "build"
| ".build"
| "target"
| "node_modules"
| ".venv"
| "venv"
| ".cargo"
) {
continue;
}
}
walk_meson_dir(root, &path, acc)?;
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !MESON_INPUT_FILENAMES.contains(&name) {
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
acc.insert(rel, path);
}
Ok(())
}
fn compute_cache_key(
inputs: &BTreeMap<String, PathBuf>,
source_abs: &Path,
build_abs: &Path,
meson_version: &str,
env_pairs: &[(String, String)],
extra_input_files: &[String],
meson_args: &[String],
) -> std::io::Result<String> {
let mut hasher = blake3::Hasher::new_derive_key(KEY_DOMAIN_TAG);
hash_str_with_tag(&mut hasher, "source", &source_abs.to_string_lossy());
hash_str_with_tag(&mut hasher, "build", &build_abs.to_string_lossy());
hash_str_with_tag(&mut hasher, "meson-version", meson_version);
hasher.update(b"env\0");
for (k, v) in env_pairs {
hasher.update(k.as_bytes());
hasher.update(b"=");
hasher.update(v.as_bytes());
hasher.update(b"\0");
}
hasher.update(b"args\0");
for a in meson_args {
hasher.update(a.as_bytes());
hasher.update(b"\0");
}
hasher.update(b"inputs\0");
for (rel, abs) in inputs {
let bytes = std::fs::read(abs)?;
hasher.update(rel.as_bytes());
hasher.update(b"\0");
hasher.update(&bytes);
hasher.update(b"\0");
}
hasher.update(b"extra-inputs\0");
let mut sorted: Vec<&String> = extra_input_files.iter().collect();
sorted.sort();
sorted.dedup();
for path in sorted {
let bytes = std::fs::read(path)
.map_err(|e| std::io::Error::new(e.kind(), format!("--input-file {path}: {e}")))?;
hasher.update(path.as_bytes());
hasher.update(b"\0");
hasher.update(&bytes);
hasher.update(b"\0");
}
Ok(hasher.finalize().to_hex().to_string())
}
fn hash_str_with_tag(hasher: &mut blake3::Hasher, tag: &str, value: &str) {
hasher.update(tag.as_bytes());
hasher.update(b"=");
hasher.update(value.as_bytes());
hasher.update(b"\0");
}
fn persist_to_cache(
entry_dir: &Path,
payload_path: &Path,
stdout_path: &Path,
stderr_path: &Path,
stdout_bytes: &[u8],
stderr_bytes: &[u8],
build_abs: &Path,
) -> std::io::Result<()> {
std::fs::create_dir_all(entry_dir)?;
let tmp = payload_path.with_extension("tar.tmp");
{
let f = std::fs::File::create(&tmp)?;
let mut writer = std::io::BufWriter::new(f);
archive_dir(build_abs, build_abs, &mut writer)?;
std::io::Write::flush(&mut writer)?;
}
std::fs::rename(&tmp, payload_path)?;
std::fs::write(stdout_path, stdout_bytes)?;
std::fs::write(stderr_path, stderr_bytes)?;
Ok(())
}
fn is_configure_output(rel: &str) -> bool {
matches!(rel, "build.ninja" | "compile_commands.json")
|| rel.starts_with("meson-info/")
|| rel.starts_with("meson-private/")
|| rel.starts_with("meson-logs/")
}
fn is_configure_output_dir(rel: &str) -> bool {
matches!(rel, "meson-info" | "meson-private" | "meson-logs")
|| rel.starts_with("meson-info/")
|| rel.starts_with("meson-private/")
|| rel.starts_with("meson-logs/")
}
fn archive_dir(
root: &Path,
dir: &Path,
writer: &mut std::io::BufWriter<std::fs::File>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
if is_configure_output_dir(&rel) {
archive_dir(root, &path, writer)?;
}
continue;
}
if !is_configure_output(&rel) {
continue;
}
let rel_bytes = rel.as_bytes();
let content = std::fs::read(&path)?;
use std::io::Write;
writer.write_all(&(rel_bytes.len() as u32).to_le_bytes())?;
writer.write_all(rel_bytes)?;
writer.write_all(&(content.len() as u64).to_le_bytes())?;
writer.write_all(&content)?;
}
Ok(())
}
fn restore_from_cache(
payload_path: &Path,
stdout_path: &Path,
stderr_path: &Path,
build_abs: &Path,
) -> std::io::Result<()> {
if !build_abs.exists() {
std::fs::create_dir_all(build_abs)?;
}
let parent = build_abs.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"build_abs has no parent — cannot stage restore",
)
})?;
let nonce = restore_staging_nonce();
let staging = parent.join(format!(".zccache-meson-restore-{nonce}"));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging)?;
let staged_paths = match extract_payload_into(payload_path, &staging) {
Ok(p) => p,
Err(e) => {
let _ = std::fs::remove_dir_all(&staging);
return Err(e);
}
};
for rel in &staged_paths {
let dest = build_abs.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
if dest.exists() {
let _ = std::fs::remove_file(&dest);
}
std::fs::rename(staging.join(rel), &dest).inspect_err(|_| {
let _ = std::fs::remove_dir_all(&staging);
})?;
}
let _ = std::fs::remove_dir_all(&staging);
if let Ok(bytes) = std::fs::read(stdout_path) {
let _ = std::io::Write::write_all(&mut std::io::stdout(), &bytes);
}
if let Ok(bytes) = std::fs::read(stderr_path) {
let _ = std::io::Write::write_all(&mut std::io::stderr(), &bytes);
}
Ok(())
}
fn extract_payload_into(payload_path: &Path, staging: &Path) -> std::io::Result<Vec<String>> {
use std::io::Read;
let f = std::fs::File::open(payload_path)?;
let mut reader = std::io::BufReader::new(f);
let mut staged = Vec::new();
loop {
let mut len_buf = [0u8; 4];
match reader.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e),
}
let path_len = u32::from_le_bytes(len_buf) as usize;
let mut path_bytes = vec![0u8; path_len];
reader.read_exact(&mut path_bytes)?;
let rel = String::from_utf8(path_bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("non-UTF8 path in archive: {e}"),
)
})?;
let mut content_len_buf = [0u8; 8];
reader.read_exact(&mut content_len_buf)?;
let content_len = u64::from_le_bytes(content_len_buf) as usize;
let mut content = vec![0u8; content_len];
reader.read_exact(&mut content)?;
let staged_path = staging.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
if let Some(parent) = staged_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&staged_path, &content)?;
staged.push(rel);
}
Ok(staged)
}
fn restore_staging_nonce() -> String {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{pid}-{nanos}")
}
fn stdout_contains_already_configured(stdout: &[u8]) -> bool {
const NEEDLE: &[u8] = b"Directory already configured.";
stdout.windows(NEEDLE.len()).any(|w| w == NEEDLE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allowlist_keeps_meson_configure_outputs() {
assert!(is_configure_output("build.ninja"));
assert!(is_configure_output("compile_commands.json"));
assert!(is_configure_output("meson-info/intro-targets.json"));
assert!(is_configure_output("meson-private/coredata.dat"));
assert!(is_configure_output("meson-logs/meson-log.txt"));
}
#[test]
fn allowlist_rejects_build_outputs_and_pch_sidecars() {
assert!(!is_configure_output("tests/test_pch.h.pch"));
assert!(!is_configure_output("tests/test_pch.h.pch.input_hash"));
assert!(!is_configure_output("tests/test_pch.h.d.cache"));
assert!(!is_configure_output("tests/foo.obj"));
assert!(!is_configure_output("tests/foo.o"));
assert!(!is_configure_output("libfastled.a"));
assert!(!is_configure_output("libfastled.dll"));
assert!(!is_configure_output("libfastled.so"));
assert!(!is_configure_output("test_pch.exe"));
assert!(!is_configure_output("test_pch.pdb"));
assert!(!is_configure_output("examples/Blink/Blink.dll"));
assert!(!is_configure_output("subprojects/lib/whatever.obj"));
assert!(!is_configure_output(".ninja_log"));
assert!(!is_configure_output(".ninja_deps"));
}
#[test]
fn dir_allowlist_lets_walker_skip_target_subdirs() {
assert!(is_configure_output_dir("meson-info"));
assert!(is_configure_output_dir("meson-private"));
assert!(is_configure_output_dir("meson-logs"));
assert!(is_configure_output_dir("meson-info/sub"));
assert!(!is_configure_output_dir("tests"));
assert!(!is_configure_output_dir("examples"));
assert!(!is_configure_output_dir("subprojects"));
assert!(!is_configure_output_dir("CMakeFiles"));
}
fn write_payload(payload_path: &Path, entries: &[(&str, &[u8])]) {
use std::io::Write;
let f = std::fs::File::create(payload_path).unwrap();
let mut writer = std::io::BufWriter::new(f);
for (rel, content) in entries {
let rel_bytes = rel.as_bytes();
writer
.write_all(&(rel_bytes.len() as u32).to_le_bytes())
.unwrap();
writer.write_all(rel_bytes).unwrap();
writer
.write_all(&(content.len() as u64).to_le_bytes())
.unwrap();
writer.write_all(content).unwrap();
}
writer.flush().unwrap();
}
#[test]
fn restore_preserves_user_owned_file_outside_allowlist() {
let tmp = tempfile::tempdir().unwrap();
let build_abs = tmp.path().join("build");
std::fs::create_dir_all(&build_abs).unwrap();
let native_file = build_abs.join("meson_native.txt");
let native_content = b"[binaries]\nc = 'clang'\n";
std::fs::write(&native_file, native_content).unwrap();
assert!(!is_configure_output("meson_native.txt"));
let payload_path = tmp.path().join("payload");
let stdout_path = tmp.path().join("stdout.bin");
let stderr_path = tmp.path().join("stderr.bin");
write_payload(
&payload_path,
&[
("build.ninja", b"# regenerated by restore"),
("meson-private/coredata.dat", b"\0\0coredata"),
],
);
std::fs::write(&stdout_path, b"").unwrap();
std::fs::write(&stderr_path, b"").unwrap();
restore_from_cache(&payload_path, &stdout_path, &stderr_path, &build_abs)
.expect("restore should succeed");
assert_eq!(
std::fs::read(build_abs.join("build.ninja")).unwrap(),
b"# regenerated by restore"
);
assert_eq!(
std::fs::read(build_abs.join("meson-private/coredata.dat")).unwrap(),
b"\0\0coredata"
);
assert!(
native_file.exists(),
"meson_native.txt was deleted by the restore — FastLED/FastLED#3048"
);
assert_eq!(std::fs::read(&native_file).unwrap(), native_content);
}
#[test]
fn restore_failure_leaves_destination_untouched() {
let tmp = tempfile::tempdir().unwrap();
let build_abs = tmp.path().join("build");
std::fs::create_dir_all(&build_abs).unwrap();
let native_file = build_abs.join("meson_native.txt");
let native_content = b"[binaries]\nc = 'clang'\n";
std::fs::write(&native_file, native_content).unwrap();
let payload_path = tmp.path().join("payload");
let stdout_path = tmp.path().join("stdout.bin");
let stderr_path = tmp.path().join("stderr.bin");
let mut bad = Vec::new();
bad.extend_from_slice(&16u32.to_le_bytes()); bad.extend_from_slice(b"abcd"); std::fs::write(&payload_path, &bad).unwrap();
std::fs::write(&stdout_path, b"").unwrap();
std::fs::write(&stderr_path, b"").unwrap();
let result = restore_from_cache(&payload_path, &stdout_path, &stderr_path, &build_abs);
assert!(
result.is_err(),
"truncated payload should surface as Err so the caller can fall back"
);
assert!(
native_file.exists(),
"meson_native.txt must survive a failed restore — FastLED/FastLED#3048 root cause"
);
assert_eq!(std::fs::read(&native_file).unwrap(), native_content);
}
#[test]
fn no_op_reconfigure_detected_in_stdout() {
let sample =
b"The Meson build system\nVersion: 1.6.0\nDirectory already configured.\n\nJust run your build command (e.g. ninja) and Meson will regenerate as necessary.\n";
assert!(stdout_contains_already_configured(sample));
}
#[test]
fn no_op_detection_does_not_false_positive_on_normal_configure() {
let normal = b"The Meson build system\nVersion: 1.6.0\nSource dir: /tmp/src\nBuild dir: /tmp/build\nBuild type: native build\n";
assert!(!stdout_contains_already_configured(normal));
}
#[test]
fn archive_dir_only_captures_allowlisted_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.ninja"), b"# ninja").unwrap();
std::fs::write(root.join("compile_commands.json"), b"[]").unwrap();
std::fs::create_dir_all(root.join("meson-info")).unwrap();
std::fs::write(root.join("meson-info/intro-targets.json"), b"[]").unwrap();
std::fs::create_dir_all(root.join("meson-private")).unwrap();
std::fs::write(root.join("meson-private/coredata.dat"), b"\0\0").unwrap();
std::fs::create_dir_all(root.join("meson-logs")).unwrap();
std::fs::write(root.join("meson-logs/meson-log.txt"), b"ok").unwrap();
std::fs::create_dir_all(root.join("tests")).unwrap();
std::fs::write(root.join("tests/test_pch.h.pch"), b"STALEPCH").unwrap();
std::fs::write(root.join("tests/test_pch.h.pch.input_hash"), b"deadbeef").unwrap();
std::fs::write(root.join("tests/test_pch.h.d.cache"), b"depcache").unwrap();
std::fs::write(root.join("tests/foo.obj"), b"OBJECTBYTES").unwrap();
std::fs::create_dir_all(root.join("examples/Blink")).unwrap();
std::fs::write(root.join("examples/Blink/Blink.dll"), b"DLLBYTES").unwrap();
std::fs::write(root.join(".ninja_log"), b"log").unwrap();
let tar_path = tmp.path().join("out.tar");
{
let f = std::fs::File::create(&tar_path).unwrap();
let mut writer = std::io::BufWriter::new(f);
archive_dir(root, root, &mut writer).unwrap();
std::io::Write::flush(&mut writer).unwrap();
}
let mut captured: Vec<String> = Vec::new();
let f = std::fs::File::open(&tar_path).unwrap();
let mut reader = std::io::BufReader::new(f);
loop {
use std::io::Read;
let mut len_buf = [0u8; 4];
match reader.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(e) => panic!("read failed: {e}"),
}
let path_len = u32::from_le_bytes(len_buf) as usize;
let mut path_bytes = vec![0u8; path_len];
reader.read_exact(&mut path_bytes).unwrap();
let rel = String::from_utf8(path_bytes).unwrap();
let mut content_len_buf = [0u8; 8];
reader.read_exact(&mut content_len_buf).unwrap();
let content_len = u64::from_le_bytes(content_len_buf) as usize;
let mut content = vec![0u8; content_len];
reader.read_exact(&mut content).unwrap();
captured.push(rel);
}
captured.sort();
for rel in &captured {
assert!(
is_configure_output(rel),
"archive captured a non-configure path: {rel}"
);
}
for poison in &[
"tests/test_pch.h.pch",
"tests/test_pch.h.pch.input_hash",
"tests/test_pch.h.d.cache",
"tests/foo.obj",
"examples/Blink/Blink.dll",
".ninja_log",
] {
assert!(
!captured.iter().any(|r| r == poison),
"archive captured poison file: {poison}"
);
}
for needed in &[
"build.ninja",
"compile_commands.json",
"meson-info/intro-targets.json",
"meson-private/coredata.dat",
"meson-logs/meson-log.txt",
] {
assert!(
captured.iter().any(|r| r == needed),
"archive missed required configure output: {needed}"
);
}
}
}