use super::super::test_helpers::{EnvVarGuard, isolated_cache_dir, lock_env};
use super::*;
#[test]
fn resolve_cache_root_honors_ktstr_cache_dir() {
let _lock = lock_env();
let _env = EnvVarGuard::set("KTSTR_CACHE_DIR", "/explicit/override");
let root = resolve_cache_root().unwrap();
assert_eq!(root, PathBuf::from("/explicit/override"));
}
#[test]
fn ensure_in_offline_mode_fails_loudly_when_uncached() {
let _lock = lock_env();
let _cache = isolated_cache_dir();
let _env_offline = EnvVarGuard::set(OFFLINE_ENV, "1");
let fake = ModelSpec {
file_name: "does-not-exist.gguf",
url: "https://placeholder.example/none.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let err = ensure(&fake).unwrap_err();
let rendered = format!("{err:#}");
assert!(rendered.contains(OFFLINE_ENV), "err: {rendered}");
assert!(
rendered.contains("is not cached"),
"expected not-cached branch wording, got: {rendered}"
);
}
#[test]
fn ensure_surfaces_sha_shape_error_before_offline_gate() {
let _lock = lock_env();
let _cache = isolated_cache_dir();
let _env_offline = EnvVarGuard::set(OFFLINE_ENV, "1");
let bad_pin = ModelSpec {
file_name: "placeholder-pin.gguf",
url: "https://placeholder.example/placeholder-pin.gguf",
sha256_hex: "????????????????????????????????????????????????????????????????",
size_bytes: 1,
};
let err = ensure(&bad_pin).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("placeholder or malformed"),
"expected SHA-shape error, got: {rendered}"
);
assert!(
!rendered.contains(&format!("{OFFLINE_ENV}=")),
"shape error must NOT mention the offline gate: {rendered}"
);
}
#[test]
fn status_reports_matches_for_correctly_pinned_file() {
use sha2::{Digest, Sha256};
let _lock = lock_env();
let cache = isolated_cache_dir();
let bytes: &[u8] = b"model body pinned by its own hash";
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hex::encode(hasher.finalize());
let pin: &'static str = Box::leak(digest.into_boxed_str());
let spec = ModelSpec {
file_name: "pinned.gguf",
url: "https://placeholder.example/pinned.gguf",
sha256_hex: pin,
size_bytes: bytes.len() as u64,
};
let on_disk = cache.path().join(spec.file_name);
std::fs::write(&on_disk, bytes).unwrap();
let st = status(&spec).expect("status on well-pinned file must not error");
assert_eq!(st.path, on_disk);
assert!(
matches!(st.sha_verdict, ShaVerdict::Matches),
"bytes hash to their declared pin — verdict must be \
ShaVerdict::Matches (fast path in ensure() depends on \
this); got: {:?}",
st.sha_verdict,
);
assert!(
st.sha_verdict.is_match(),
"Matches variant must answer true to .is_match(); if \
this fails but the variant is Matches, the helper is \
broken — see sha_verdict_helpers_match_variant_semantics",
);
}
#[test]
fn status_reports_not_cached_when_file_absent() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "absent.gguf",
url: "https://placeholder.example/absent.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let st = status(&spec).expect("status on absent file must not error");
assert_eq!(st.path, cache.path().join(spec.file_name));
assert!(
matches!(st.sha_verdict, ShaVerdict::NotCached),
"absent file must produce ShaVerdict::NotCached (no \
check performed); got: {:?}",
st.sha_verdict,
);
}
#[test]
fn clean_removes_artifact_and_sidecar_and_reports_freed_bytes() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "to-clean.gguf",
url: "https://placeholder.example/to-clean.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let artifact_path = cache.path().join(spec.file_name);
let sidecar_path = mtime_size_sidecar_path(&artifact_path);
let artifact_bytes = b"fake gguf body, exact length pinned by the assertion below";
let sidecar_bytes = b"KTSTR_SHA_MTIME_SIZE_V1\n123 456\n";
std::fs::write(&artifact_path, artifact_bytes).expect("plant artifact");
std::fs::write(&sidecar_path, sidecar_bytes).expect("plant sidecar");
let report = clean(&spec).expect("clean must succeed when files exist");
assert_eq!(report.artifact_path, artifact_path);
assert_eq!(report.sidecar_path, sidecar_path);
assert_eq!(
report.artifact_freed_bytes,
Some(artifact_bytes.len() as u64),
"artifact_freed_bytes must equal the planted artifact size",
);
assert_eq!(
report.sidecar_freed_bytes,
Some(sidecar_bytes.len() as u64),
"sidecar_freed_bytes must equal the planted sidecar size",
);
assert!(
!artifact_path.exists(),
"artifact must be removed from disk after clean",
);
assert!(
!sidecar_path.exists(),
"sidecar must be removed from disk after clean",
);
assert!(
!report.is_empty(),
"is_empty() must be false when at least one file was removed",
);
assert_eq!(
report.total_freed_bytes(),
(artifact_bytes.len() + sidecar_bytes.len()) as u64,
"total_freed_bytes() must sum artifact + sidecar bytes",
);
}
#[test]
fn clean_empty_cache_reports_is_empty() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "absent.gguf",
url: "https://placeholder.example/absent.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let report = clean(&spec).expect("clean must succeed when nothing is cached");
assert_eq!(report.artifact_path, cache.path().join(spec.file_name));
assert_eq!(
report.sidecar_path,
mtime_size_sidecar_path(&cache.path().join(spec.file_name)),
);
assert!(
report.artifact_freed_bytes.is_none(),
"artifact_freed_bytes must be None when artifact was absent; got {:?}",
report.artifact_freed_bytes,
);
assert!(
report.sidecar_freed_bytes.is_none(),
"sidecar_freed_bytes must be None when sidecar was absent; got {:?}",
report.sidecar_freed_bytes,
);
assert!(
report.is_empty(),
"is_empty() must be true when no files were removed",
);
assert_eq!(
report.total_freed_bytes(),
0,
"total_freed_bytes() must be 0 on an empty cache",
);
}
#[test]
fn clean_removes_orphaned_sidecar_when_artifact_absent() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "orphan.gguf",
url: "https://placeholder.example/orphan.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let artifact_path = cache.path().join(spec.file_name);
let sidecar_path = mtime_size_sidecar_path(&artifact_path);
let sidecar_bytes = b"KTSTR_SHA_MTIME_SIZE_V1\n111 222\n";
std::fs::write(&sidecar_path, sidecar_bytes).expect("plant orphan sidecar");
let report = clean(&spec).expect("clean must succeed on a sidecar-only cache");
assert!(
report.artifact_freed_bytes.is_none(),
"no artifact on disk → artifact_freed_bytes must be None",
);
assert_eq!(
report.sidecar_freed_bytes,
Some(sidecar_bytes.len() as u64),
"orphaned sidecar must be removed and its size reported",
);
assert!(
!sidecar_path.exists(),
"orphaned sidecar must be removed from disk",
);
assert!(
!report.is_empty(),
"is_empty() must be false when the sidecar was removed",
);
}
#[test]
fn clean_removes_artifact_when_sidecar_absent() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "artifact-only.gguf",
url: "https://placeholder.example/artifact-only.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let artifact_path = cache.path().join(spec.file_name);
let sidecar_path = mtime_size_sidecar_path(&artifact_path);
let artifact_bytes = b"artifact-only body, sidecar will not be planted";
std::fs::write(&artifact_path, artifact_bytes).expect("plant artifact-only");
let report = clean(&spec).expect("clean must succeed on an artifact-only cache");
assert_eq!(
report.artifact_freed_bytes,
Some(artifact_bytes.len() as u64),
"artifact must be removed and its size reported",
);
assert!(
report.sidecar_freed_bytes.is_none(),
"no sidecar on disk → sidecar_freed_bytes must be None",
);
assert!(
!artifact_path.exists(),
"artifact must be removed from disk",
);
assert!(
!sidecar_path.exists(),
"sidecar that was never planted must remain absent",
);
assert!(
!report.is_empty(),
"is_empty() must be false when the artifact was removed",
);
}
#[test]
fn sha_verdict_helpers_match_variant_semantics() {
let v = ShaVerdict::NotCached;
assert!(
!v.is_cached(),
"NotCached.is_cached() must be false; got true for {v:?}",
);
assert!(
!v.is_match(),
"NotCached.is_match() must be false; got true for {v:?}",
);
assert_eq!(
v.check_error(),
None,
"NotCached.check_error() must be None; got Some for {v:?}",
);
let v = ShaVerdict::Matches;
assert!(
v.is_cached(),
"Matches.is_cached() must be true; got false for {v:?}",
);
assert!(
v.is_match(),
"Matches.is_match() must be true; got false for {v:?}",
);
assert_eq!(
v.check_error(),
None,
"Matches.check_error() must be None; got Some for {v:?}",
);
let v = ShaVerdict::Mismatches;
assert!(
v.is_cached(),
"Mismatches.is_cached() must be true; got false for {v:?}",
);
assert!(
!v.is_match(),
"Mismatches.is_match() must be false; got true for {v:?}",
);
assert_eq!(
v.check_error(),
None,
"Mismatches.check_error() must be None (the check ran \
to completion); got Some for {v:?}",
);
let err = "open /tmp/x: Permission denied (os error 13)";
let v = ShaVerdict::CheckFailed(err.to_string());
assert!(
v.is_cached(),
"CheckFailed.is_cached() must be true (file exists, \
couldn't check it); got false for {v:?}",
);
assert!(
!v.is_match(),
"CheckFailed.is_match() must be false (check didn't \
complete successfully); got true for {v:?}",
);
assert_eq!(
v.check_error(),
Some(err),
"CheckFailed.check_error() must surface the carried \
string verbatim so the CLI readout and the offline \
bail can name the underlying failure; got: {:?}",
v.check_error(),
);
}
#[test]
fn status_reports_cached_but_sha_mismatch_for_garbage_bytes() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "bogus.gguf",
url: "https://placeholder.example/bogus.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 16,
};
let on_disk = cache.path().join(spec.file_name);
std::fs::write(&on_disk, b"definitely-not-zero-sha").unwrap();
let st = status(&spec).unwrap();
assert_eq!(st.path, on_disk);
assert!(
matches!(st.sha_verdict, ShaVerdict::Mismatches),
"SHA is a fixed zero pin — garbage bytes must hash to a \
non-matching digest, producing ShaVerdict::Mismatches \
(not CheckFailed, not NotCached); got: {:?}",
st.sha_verdict,
);
}
#[cfg(unix)]
#[test]
fn status_captures_io_error_for_unreadable_cached_file() {
use std::os::unix::fs::PermissionsExt;
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "unreadable.gguf",
url: "https://placeholder.example/unreadable.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let on_disk = cache.path().join(spec.file_name);
std::fs::write(&on_disk, b"any content").unwrap();
std::fs::set_permissions(&on_disk, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::File::open(&on_disk).is_ok() {
std::fs::set_permissions(&on_disk, std::fs::Permissions::from_mode(0o644)).unwrap();
skip!(
"open(0o000) succeeded — process has a DAC bypass (root, \
CAP_DAC_OVERRIDE, or equivalent)"
);
}
let st = status(&spec).unwrap();
std::fs::set_permissions(&on_disk, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = match &st.sha_verdict {
ShaVerdict::CheckFailed(e) => e.as_str(),
other => panic!(
"metadata().is_file() passed despite 0o000 and \
check_sha256 hit EACCES — status must report \
ShaVerdict::CheckFailed(_); got: {other:?}",
),
};
assert!(
err.contains("ermission") || err.contains("denied"),
"expected permission-denied error in rendered chain, got: {err}"
);
}
#[test]
fn status_surfaces_malformed_pin_error_for_cached_file() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "malformed-pin.gguf",
url: "https://placeholder.example/malformed-pin.gguf",
sha256_hex: "????????????????????????????????????????????????????????????????",
size_bytes: 1,
};
let on_disk = cache.path().join(spec.file_name);
std::fs::write(&on_disk, b"any bytes will do").unwrap();
let err = status(&spec).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("non-hex"),
"expected malformed-pin error from check_sha256, got: {rendered}"
);
assert!(
rendered.contains(spec.file_name),
"expected status() context to name the file, got: {rendered}"
);
}
#[test]
fn status_surfaces_length_fail_pin_error_for_cached_file() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let spec = ModelSpec {
file_name: "short-pin.gguf",
url: "https://placeholder.example/short-pin.gguf",
sha256_hex: "000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let on_disk = cache.path().join(spec.file_name);
std::fs::write(&on_disk, b"any bytes will do").unwrap();
let err = status(&spec).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("64 chars"),
"expected length-fail error from check_sha256, got: {rendered}"
);
assert!(
rendered.contains(spec.file_name),
"expected status() context to name the file, got: {rendered}"
);
}
#[test]
fn resolve_cache_root_honors_xdg_cache_home() {
let _lock = lock_env();
let _env_ktstr = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _env_xdg = EnvVarGuard::set("XDG_CACHE_HOME", "/xdg/caches");
let root = resolve_cache_root().unwrap();
assert_eq!(
root,
PathBuf::from("/xdg/caches").join("ktstr").join("models"),
);
}
#[test]
fn resolve_cache_root_falls_back_to_home_cache() {
let _lock = lock_env();
let _env_ktstr = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _env_xdg = EnvVarGuard::remove("XDG_CACHE_HOME");
let _env_home = EnvVarGuard::set("HOME", "/home/fake");
let root = resolve_cache_root().unwrap();
assert_eq!(
root,
PathBuf::from("/home/fake")
.join(".cache")
.join("ktstr")
.join("models"),
);
}
#[test]
fn resolve_cache_root_treats_empty_ktstr_cache_dir_as_unset() {
let _lock = lock_env();
let _env_ktstr = EnvVarGuard::set("KTSTR_CACHE_DIR", "");
let _env_xdg = EnvVarGuard::set("XDG_CACHE_HOME", "/xdg/caches");
let root = resolve_cache_root().unwrap();
assert_eq!(
root,
PathBuf::from("/xdg/caches").join("ktstr").join("models"),
"empty KTSTR_CACHE_DIR must be treated as unset so XDG wins",
);
}
#[test]
fn resolve_cache_root_rejects_root_slash_home() {
let _lock = lock_env();
let _env_ktstr = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _env_xdg = EnvVarGuard::remove("XDG_CACHE_HOME");
let _env_home = EnvVarGuard::set("HOME", "/");
let err = resolve_cache_root().unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("HOME is `/`"),
"expected HOME=/ specific rejection, got: {msg}"
);
assert!(
msg.contains("/.cache/ktstr"),
"diagnostic must cite the offending cache path, got: {msg}"
);
assert!(
msg.contains("KTSTR_CACHE_DIR"),
"error must suggest KTSTR_CACHE_DIR, got: {msg}"
);
}
#[test]
fn resolve_cache_root_rejects_empty_home() {
let _lock = lock_env();
let _env_ktstr = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _env_xdg = EnvVarGuard::remove("XDG_CACHE_HOME");
let _env_home = EnvVarGuard::set("HOME", "");
let err = resolve_cache_root().unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("HOME is set to the empty string"),
"expected empty-HOME-specific rejection, got: {msg}"
);
}
#[test]
fn resolve_cache_root_rejects_unset_home() {
let _lock = lock_env();
let _env_ktstr = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _env_xdg = EnvVarGuard::remove("XDG_CACHE_HOME");
let _env_home = EnvVarGuard::remove("HOME");
let err = resolve_cache_root().unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("HOME is unset"),
"expected unset-HOME-specific rejection, got: {msg}"
);
assert!(
!msg.contains("HOME is set to the empty string"),
"unset HOME must NOT use the empty-string diagnostic, got: {msg}",
);
}
#[test]
fn resolve_cache_root_rejects_relative_home() {
let _lock = lock_env();
let _env_ktstr = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _env_xdg = EnvVarGuard::remove("XDG_CACHE_HOME");
let _env_home = EnvVarGuard::set("HOME", "relative/dir");
let err = resolve_cache_root().unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("not an absolute path"),
"expected relative-path rejection, got: {msg}"
);
assert!(
msg.contains("relative/dir"),
"diagnostic must cite the offending HOME value, got: {msg}"
);
}
#[test]
#[cfg(unix)]
fn resolve_cache_root_rejects_non_utf8_ktstr_cache_dir() {
let _lock = lock_env();
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let bytes: &[u8] = b"/tmp/ktstr-\xFFmodels";
let value = OsStr::from_bytes(bytes);
let _env_ktstr = EnvVarGuard::set("KTSTR_CACHE_DIR", value);
let err = resolve_cache_root()
.expect_err("non-UTF-8 KTSTR_CACHE_DIR must bail through the shared helper");
let msg = err.to_string();
assert!(
msg.contains("KTSTR_CACHE_DIR"),
"error must name the offending variable, got: {msg}",
);
assert!(
msg.contains("non-UTF-8"),
"error must mention non-UTF-8, got: {msg}",
);
}
#[test]
fn sanitize_env_value_replaces_control_chars() {
assert_eq!(sanitize_env_value("1"), "1");
assert_eq!(sanitize_env_value("true"), "true");
assert_eq!(sanitize_env_value("/path/to/thing"), "/path/to/thing");
assert_eq!(sanitize_env_value("a\nb"), "a?b");
assert_eq!(sanitize_env_value("a\tb"), "a?b");
assert_eq!(sanitize_env_value("a\x1bb"), "a?b");
assert_eq!(sanitize_env_value("\x08"), "?");
assert_eq!(sanitize_env_value("\r\n"), "??");
}
#[test]
fn sanitize_env_value_truncates_overlong_value() {
let raw: String = "x".repeat(200);
let out = sanitize_env_value(&raw);
assert!(out.ends_with("..."), "truncation marker missing: {out:?}");
assert_eq!(out.len(), 67);
}
#[test]
fn sanitize_env_value_at_exact_cap_does_not_truncate() {
let raw: String = "x".repeat(64);
let out = sanitize_env_value(&raw);
assert_eq!(out, raw, "64-byte input must pass through unchanged");
assert!(
!out.ends_with("..."),
"64-byte input must not gain a truncation marker: {out:?}"
);
}
#[test]
fn sanitize_env_value_truncates_on_char_boundary_for_utf8_straddle() {
let raw: String = format!("{}β", "x".repeat(63));
assert_eq!(raw.len(), 65, "setup: input must be 65 bytes");
let out = sanitize_env_value(&raw);
assert_eq!(out.len(), 66, "63 truncated + 3 marker = 66 bytes");
assert!(out.ends_with("..."), "marker missing: {out:?}");
assert_eq!(&out[..63], &"x".repeat(63), "prefix must be 63 x's");
assert!(
!out.contains('β'),
"straddling codepoint must be dropped whole: {out:?}"
);
}
#[test]
fn ensure_offline_error_sanitizes_env_value_in_message() {
let _lock = lock_env();
let _cache = isolated_cache_dir();
let hostile = format!("inject\nbreak{}", "z".repeat(200));
let _env_offline = EnvVarGuard::set(OFFLINE_ENV, &hostile);
let fake = ModelSpec {
file_name: "not-here.gguf",
url: "https://placeholder.example/not-here.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let msg = format!("{:#}", ensure(&fake).unwrap_err());
assert!(!msg.contains('\n'), "raw newline leaked: {msg:?}");
assert!(
!msg.contains(&"z".repeat(200)),
"overlong tail leaked un-truncated: {msg:?}"
);
assert!(
msg.contains("inject?break"),
"sanitized stem missing: {msg:?}"
);
}
#[test]
fn mtime_size_sidecar_path_appends_suffix() {
let artifact = std::path::Path::new("/tmp/model.gguf");
assert_eq!(
mtime_size_sidecar_path(artifact),
std::path::PathBuf::from("/tmp/model.gguf.mtime-size"),
);
let bare = std::path::Path::new("/tmp/model");
assert_eq!(
mtime_size_sidecar_path(bare),
std::path::PathBuf::from("/tmp/model.mtime-size"),
);
}
#[test]
fn write_then_read_mtime_size_sidecar_roundtrips() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact.bin");
std::fs::write(&artifact, b"hello world").unwrap();
write_mtime_size_sidecar(&artifact).expect("write must succeed");
let meta = std::fs::metadata(&artifact).unwrap();
let expected = mtime_size_from_metadata(&meta).unwrap();
let read_back = read_mtime_size_sidecar(&artifact).expect("sidecar must read back");
assert_eq!(
read_back, expected,
"round-trip must recover the (mtime, size) tuple written",
);
}
#[test]
fn sidecar_confirms_match_tracks_mtime_change() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact.bin");
std::fs::write(&artifact, b"contents").unwrap();
write_mtime_size_sidecar(&artifact).expect("write must succeed");
let meta = std::fs::metadata(&artifact).unwrap();
assert!(
sidecar_confirms_prior_sha_match(&artifact, &meta),
"fresh sidecar must confirm match for unchanged file",
);
let meta_before = std::fs::metadata(&artifact).unwrap();
let now = meta_before.modified().unwrap() + std::time::Duration::from_secs(2);
filetime_set(&artifact, now);
let meta_after = std::fs::metadata(&artifact).unwrap();
assert!(
!sidecar_confirms_prior_sha_match(&artifact, &meta_after),
"mtime bump must invalidate the sidecar match so the \
slow SHA path re-runs",
);
}
#[test]
fn read_mtime_size_sidecar_missing_file_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact-never-had-sidecar.bin");
std::fs::write(&artifact, b"x").unwrap();
assert!(
read_mtime_size_sidecar(&artifact).is_none(),
"absent sidecar must return None, not silently default",
);
}
#[test]
fn read_mtime_size_sidecar_empty_file_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact.bin");
std::fs::write(&artifact, b"x").unwrap();
std::fs::write(mtime_size_sidecar_path(&artifact), b"").unwrap();
assert!(
read_mtime_size_sidecar(&artifact).is_none(),
"empty sidecar must fail the magic-header gate",
);
}
#[test]
fn read_mtime_size_sidecar_magic_only_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact.bin");
std::fs::write(&artifact, b"x").unwrap();
std::fs::write(
mtime_size_sidecar_path(&artifact),
format!("{MTIME_SIZE_SIDECAR_MAGIC}\n"),
)
.unwrap();
assert!(
read_mtime_size_sidecar(&artifact).is_none(),
"sidecar missing the mtime/size payload must fail parse",
);
}
#[test]
fn read_mtime_size_sidecar_wrong_magic_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact.bin");
std::fs::write(&artifact, b"x").unwrap();
std::fs::write(mtime_size_sidecar_path(&artifact), b"12345 100\n").unwrap();
assert!(
read_mtime_size_sidecar(&artifact).is_none(),
"sidecar missing the magic header must fail the version gate",
);
std::fs::write(
mtime_size_sidecar_path(&artifact),
b"KTSTR_SHA_MTIME_SIZE_V2\n12345 100\n",
)
.unwrap();
assert!(
read_mtime_size_sidecar(&artifact).is_none(),
"sidecar with a newer magic must fail the v1 gate",
);
}
#[test]
fn read_mtime_size_sidecar_malformed_payload_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact.bin");
std::fs::write(&artifact, b"x").unwrap();
std::fs::write(
mtime_size_sidecar_path(&artifact),
format!("{MTIME_SIZE_SIDECAR_MAGIC}\nnot-a-number 100\n"),
)
.unwrap();
assert!(read_mtime_size_sidecar(&artifact).is_none());
std::fs::write(
mtime_size_sidecar_path(&artifact),
format!("{MTIME_SIZE_SIDECAR_MAGIC}\n12345\n"),
)
.unwrap();
assert!(read_mtime_size_sidecar(&artifact).is_none());
}
#[test]
fn remove_mtime_size_sidecar_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let artifact = tmp.path().join("artifact.bin");
std::fs::write(&artifact, b"x").unwrap();
write_mtime_size_sidecar(&artifact).unwrap();
assert!(mtime_size_sidecar_path(&artifact).exists());
remove_mtime_size_sidecar(&artifact);
assert!(!mtime_size_sidecar_path(&artifact).exists());
remove_mtime_size_sidecar(&artifact);
}
fn filetime_set(path: &std::path::Path, new_mtime: std::time::SystemTime) {
use std::os::unix::ffi::OsStrExt;
let secs = new_mtime
.duration_since(std::time::UNIX_EPOCH)
.expect("mtime before UNIX_EPOCH")
.as_secs() as i64;
let times = [
libc::timeval {
tv_sec: secs,
tv_usec: 0,
},
libc::timeval {
tv_sec: secs,
tv_usec: 0,
},
];
let cstr = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
let rc = unsafe { libc::utimes(cstr.as_ptr(), times.as_ptr()) };
assert_eq!(rc, 0, "utimes must succeed for the test helper");
}