use super::super::test_helpers::{EnvVarGuard, isolated_cache_dir, lock_env};
use super::*;
#[test]
fn reject_insecure_url_rejects_http() {
let e = reject_insecure_url("http://example.com/model.gguf").unwrap_err();
assert!(
format!("{e:#}").contains("non-HTTPS"),
"unexpected err: {e:#}"
);
}
#[test]
fn reject_insecure_url_accepts_https() {
reject_insecure_url("https://example.com/model.gguf").unwrap();
}
#[test]
fn check_sha256_matches_empty_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), []).unwrap();
let expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(check_sha256(tmp.path(), expected).unwrap());
}
#[test]
fn check_sha256_mismatch_returns_false() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), b"not empty").unwrap();
let empty_sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(!check_sha256(tmp.path(), empty_sha).unwrap());
}
#[test]
fn check_sha256_is_case_insensitive() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), []).unwrap();
let upper = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855";
assert!(check_sha256(tmp.path(), upper).unwrap());
}
#[test]
fn check_sha256_rejects_malformed_hex_length() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), []).unwrap();
let err = check_sha256(tmp.path(), "tooshort").unwrap_err();
assert!(format!("{err:#}").contains("64 chars"), "err: {err:#}");
}
#[test]
fn check_sha256_rejects_non_hex_chars() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), []).unwrap();
let bad = "????????????????????????????????????????????????????????????????";
let err = check_sha256(tmp.path(), bad).unwrap_err();
assert!(format!("{err:#}").contains("non-hex"), "err: {err:#}");
}
#[test]
fn validate_sha256_hex_flags_empty_as_length_error() {
let err = validate_sha256_hex("").unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("64 chars"),
"empty string must surface the length-kind diagnostic \
(substring \"64 chars\"); got: {rendered}",
);
}
#[test]
fn validate_sha256_hex_flags_nonhex_chars_at_correct_length() {
let sixty_four_nonhex = "?".repeat(64);
let err = validate_sha256_hex(&sixty_four_nonhex).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("non-hex"),
"64-char non-hex string must surface the hex-kind \
diagnostic (substring \"non-hex\"); got: {rendered}",
);
assert!(
!rendered.contains("64 chars"),
"length gate passed on a 64-char input — diagnostic \
must NOT mention \"64 chars\"; got: {rendered}",
);
}
#[test]
fn validate_sha256_hex_accepts_well_formed_pin() {
let pin = "0".repeat(64);
validate_sha256_hex(&pin).unwrap();
let mixed = "0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789ABCDEF";
assert_eq!(mixed.len(), 64);
validate_sha256_hex(mixed).unwrap();
}
#[test]
fn check_sha256_matches_abc() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), b"abc").unwrap();
let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
assert!(check_sha256(tmp.path(), expected).unwrap());
}
#[test]
fn check_sha256_matches_multi_chunk_file() {
use sha2::{Digest, Sha256};
let tmp = tempfile::NamedTempFile::new().unwrap();
let data: Vec<u8> = std::iter::repeat_n(b'a', 192 * 1024).collect();
std::fs::write(tmp.path(), &data).unwrap();
let mut h = Sha256::new();
h.update(&data);
let expected_bytes = h.finalize();
let expected_hex = hex::encode(expected_bytes);
assert!(check_sha256(tmp.path(), &expected_hex).unwrap());
let mut tampered = data;
*tampered.last_mut().unwrap() = b'b';
std::fs::write(tmp.path(), &tampered).unwrap();
assert!(!check_sha256(tmp.path(), &expected_hex).unwrap());
}
#[test]
fn check_sha256_errors_on_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist.bin");
let valid_hex = "0".repeat(64);
let err = check_sha256(&missing, &valid_hex).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("open "),
"error must carry 'open <path>' context: {rendered}"
);
assert!(
rendered.contains("does-not-exist.bin"),
"error must include the missing path: {rendered}"
);
}
#[test]
fn bytes_from_statvfs_parts_saturates_on_overflow() {
assert_eq!(bytes_from_statvfs_parts(u64::MAX, 2), u64::MAX);
assert_eq!(bytes_from_statvfs_parts(2, u64::MAX), u64::MAX);
assert_eq!(bytes_from_statvfs_parts(u64::MAX, u64::MAX), u64::MAX);
assert_eq!(bytes_from_statvfs_parts(u64::MAX, 0), 0);
assert_eq!(bytes_from_statvfs_parts(0, u64::MAX), 0);
assert_eq!(bytes_from_statvfs_parts(1_000, 4_096), 4_096_000);
assert_eq!(bytes_from_statvfs_parts(0, 4_096), 0);
}
#[test]
fn ensure_free_space_saturates_on_u64_max_spec() {
let dir = std::env::temp_dir();
let spec = ModelSpec {
file_name: "saturate-u64-max",
url: "https://placeholder.example/saturate-u64-max",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: u64::MAX,
};
let err = ensure_free_space(&dir, &spec)
.expect_err("u64::MAX size must saturate and trip the bail, not wrap past the gate");
let rendered = format!("{err:#}");
assert!(
rendered.starts_with("Need "),
"bail must report Need/have gap, got: {rendered}"
);
}
#[test]
fn default_model_sha_is_valid_shape() {
assert!(
is_valid_sha256_hex(DEFAULT_MODEL.sha256_hex),
"DEFAULT_MODEL.sha256_hex must be 64 ASCII hex chars: {:?}",
DEFAULT_MODEL.sha256_hex
);
}
#[test]
fn default_model_url_is_https() {
assert!(
DEFAULT_MODEL.url.starts_with("https://"),
"DEFAULT_MODEL.url must be HTTPS: {:?}",
DEFAULT_MODEL.url
);
}
#[test]
fn default_model_file_name_ends_with_gguf() {
assert!(
DEFAULT_MODEL.file_name.ends_with(".gguf"),
"DEFAULT_MODEL.file_name must end with .gguf: {:?}",
DEFAULT_MODEL.file_name
);
}
#[test]
fn all_model_specs_registers_only_default_model() {
assert_eq!(
ALL_MODEL_SPECS.len(),
1,
"post-migration ALL_MODEL_SPECS holds the GGUF only — \
{} entries registered: {:?}",
ALL_MODEL_SPECS.len(),
ALL_MODEL_SPECS
.iter()
.map(|s| s.file_name)
.collect::<Vec<_>>(),
);
assert_eq!(
ALL_MODEL_SPECS[0].file_name, DEFAULT_MODEL.file_name,
"the single registered spec must be DEFAULT_MODEL"
);
}
#[test]
fn is_all_hex_ascii_empty_string_returns_true() {
assert!(
is_all_hex_ascii(""),
"empty string must return true — no byte fails the hex check",
);
}
#[test]
fn is_all_hex_ascii_boundary_chars_all_accepted() {
for s in &["0", "9", "a", "f", "A", "F", "0123456789", "abcdefABCDEF"] {
assert!(
is_all_hex_ascii(s),
"boundary input {s:?} must be accepted by is_all_hex_ascii",
);
}
}
#[test]
fn is_all_hex_ascii_adjacent_non_hex_chars_rejected() {
for s in &["/", ":", "@", "G", "`", "g"] {
assert!(
!is_all_hex_ascii(s),
"adjacent-to-hex input {s:?} (hex byte {:#x}) must be rejected",
s.as_bytes()[0],
);
}
}
#[test]
fn is_all_hex_ascii_multibyte_utf8_rejected() {
let s = "🦀";
assert_eq!(s.len(), 4, "setup: emoji must be 4 UTF-8 bytes");
assert!(
!is_all_hex_ascii(s),
"multi-byte UTF-8 input {s:?} must be rejected — every byte has the high bit set",
);
}
#[test]
fn is_all_hex_ascii_mixed_hex_and_non_hex_rejected() {
assert!(
!is_all_hex_ascii("0123g"),
"hex prefix + non-hex byte must fail — iteration must reach the non-hex byte",
);
assert!(
!is_all_hex_ascii("g0123"),
"non-hex prefix + hex suffix must fail — iteration must fail at the first non-hex byte",
);
}
#[test]
fn is_all_hex_ascii_whitespace_and_nul_rejected() {
for s in &[" ", "\t", "\n", "\0", "abc\n", "\0abc"] {
assert!(
!is_all_hex_ascii(s),
"whitespace/NUL input {s:?} must be rejected",
);
}
}
#[test]
fn is_valid_sha256_hex_rejects_non_canonical_inputs() {
assert!(!is_valid_sha256_hex(&"a".repeat(63)));
assert!(!is_valid_sha256_hex(&"a".repeat(65)));
let unicode_digit = format!("{}٠", "0".repeat(62));
assert_eq!(unicode_digit.len(), 64, "setup: must be exactly 64 bytes");
assert!(
!is_valid_sha256_hex(&unicode_digit),
"non-ASCII Unicode digit must fail is_ascii_hexdigit even at correct byte length"
);
assert!(is_valid_sha256_hex(&"0".repeat(64)));
}
#[test]
fn reject_insecure_url_rejects_non_https_schemes() {
let cases: &[&str] = &[
"ftp://example.com/model.gguf",
"file:///tmp/model.gguf",
"example.com/model.gguf",
"",
"https:/example.com/model.gguf",
"HTTPS://example.com/model.gguf",
];
for url in cases {
let err = reject_insecure_url(url).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("non-HTTPS"),
"URL {url:?} must be rejected, got: {rendered}"
);
}
}
#[test]
fn ensure_bails_with_non_https_error_on_http_url() {
let _lock = lock_env();
let _cache = isolated_cache_dir();
let _env_offline = EnvVarGuard::remove(OFFLINE_ENV);
let spec = ModelSpec {
file_name: "http-url.gguf",
url: "http://placeholder.example/http-url.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
let err = ensure(&spec).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("non-HTTPS"),
"expected reject_insecure_url error through ensure→fetch, got: {rendered}"
);
}
#[test]
fn ensure_under_offline_bails_on_stale_cache_sha_mismatch() {
let _lock = lock_env();
let cache = isolated_cache_dir();
let _env_offline = EnvVarGuard::set(OFFLINE_ENV, "1");
let spec = ModelSpec {
file_name: "stale.gguf",
url: "https://placeholder.example/stale.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 16,
};
let on_disk = cache.path().join(spec.file_name);
std::fs::write(&on_disk, b"wrong bytes for pin").unwrap();
let st = status(&spec).expect("status should not error on valid-shape pin");
assert!(
matches!(st.sha_verdict, ShaVerdict::Mismatches),
"file exists with bytes that don't hash to zero-pin; \
verdict must be ShaVerdict::Mismatches (cached + \
checked + didn't match); got: {:?}",
st.sha_verdict,
);
let err = ensure(&spec).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains(OFFLINE_ENV),
"expected offline-gate bail on stale cache, got: {rendered}"
);
assert!(
!rendered.contains("non-HTTPS"),
"expected offline-path bail, not the URL-scheme path: {rendered}"
);
assert!(
rendered.contains("do not match"),
"expected stale-cache branch wording, got: {rendered}"
);
}
#[cfg(unix)]
#[test]
fn ensure_under_offline_bails_on_check_failed_cache() {
use std::os::unix::fs::PermissionsExt;
let _lock = lock_env();
let cache = isolated_cache_dir();
let _env_offline = EnvVarGuard::set(OFFLINE_ENV, "1");
let spec = ModelSpec {
file_name: "unreadable-offline.gguf",
url: "https://placeholder.example/unreadable-offline.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); offline-gate CheckFailed \
arm cannot be exercised here"
);
}
let st = status(&spec).expect("valid-shape pin; status must not error");
let underlying_err = match &st.sha_verdict {
ShaVerdict::CheckFailed(e) => e.clone(),
other => {
std::fs::set_permissions(&on_disk, std::fs::Permissions::from_mode(0o644)).unwrap();
panic!(
"0o000 on a readable-shape pin must yield \
ShaVerdict::CheckFailed; got: {other:?}",
);
}
};
let err = ensure(&spec).unwrap_err();
std::fs::set_permissions(&on_disk, std::fs::Permissions::from_mode(0o644)).unwrap();
let rendered = format!("{err:#}");
assert!(
rendered.contains(OFFLINE_ENV),
"expected offline-gate bail on CheckFailed cache, got: {rendered}"
);
assert!(
rendered.contains("SHA-256 check could not complete"),
"expected CheckFailed branch wording \
(\"SHA-256 check could not complete\"), got: {rendered}"
);
assert!(
rendered.contains(&underlying_err),
"expected the underlying I/O error {underlying_err:?} \
to appear verbatim in the offline-gate bail; got: \
{rendered}"
);
assert!(
!rendered.contains("do not match"),
"CheckFailed bail must not emit the stale-cache \
\"do not match\" wording, got: {rendered}"
);
assert!(
!rendered.contains("is not cached"),
"CheckFailed bail must not emit the not-cached \
\"is not cached\" wording, got: {rendered}"
);
}
#[test]
fn fetch_timeout_for_size_zero_returns_floor() {
assert_eq!(
fetch_timeout_for_size(0),
std::time::Duration::from_secs(60)
);
}
#[test]
fn fetch_timeout_for_size_small_artifact_hits_floor() {
let got = fetch_timeout_for_size(11 * 1024 * 1024);
assert_eq!(got, std::time::Duration::from_secs(60));
}
#[test]
fn fetch_timeout_for_size_model_scales_up() {
let got = fetch_timeout_for_size(DEFAULT_MODEL.size_bytes);
assert_eq!(got, std::time::Duration::from_secs(913));
}
#[test]
fn fetch_timeout_for_size_is_linear_above_floor() {
let small_bytes: u64 = 300 * 1024 * 1024; let large_bytes: u64 = 3000 * 1024 * 1024; let small = fetch_timeout_for_size(small_bytes);
let large = fetch_timeout_for_size(large_bytes);
assert!(
large > small,
"larger artifact must exceed smaller once both clear the floor: {large:?} vs {small:?}"
);
let expected_delta = large_bytes / 3_000_000 - small_bytes / 3_000_000;
assert_eq!(
large - small,
std::time::Duration::from_secs(expected_delta)
);
}
#[test]
fn fetch_timeout_for_size_floor_applies_uniformly_below_crossover() {
let tiny = fetch_timeout_for_size(1024);
let small = fetch_timeout_for_size(11 * 1024 * 1024);
assert_eq!(tiny, std::time::Duration::from_secs(60));
assert_eq!(small, std::time::Duration::from_secs(60));
}
#[test]
fn fetch_timeout_for_size_clamps_to_ceiling_on_oversized_pin() {
let twenty_gib: u64 = 20 * 1024 * 1024 * 1024;
let got = fetch_timeout_for_size(twenty_gib);
assert_eq!(
got,
std::time::Duration::from_secs(1800),
"20 GiB pin must clamp to the 30-minute ceiling, not scale linearly",
);
let forty_gib: u64 = 40 * 1024 * 1024 * 1024;
let got_double = fetch_timeout_for_size(forty_gib);
assert_eq!(
got_double, got,
"doubling size past the ceiling must NOT double the timeout — \
ceiling is the thing being pinned",
);
}
#[test]
fn fetch_timeout_for_size_ceiling_crossover_at_5_4gb() {
const CROSSOVER_BYTES: u64 = 1800 * 3_000_000;
assert_eq!(
fetch_timeout_for_size(CROSSOVER_BYTES),
std::time::Duration::from_secs(1800),
"exactly 5.4 GB must sit right at the ceiling",
);
assert_eq!(
fetch_timeout_for_size(CROSSOVER_BYTES - 3_000_000),
std::time::Duration::from_secs(1799),
"one body-second below the crossover must return 1799 s, \
proving the ceiling clamp hasn't moved",
);
assert_eq!(
fetch_timeout_for_size(CROSSOVER_BYTES + 3_000_000),
std::time::Duration::from_secs(1800),
"one body-second above the crossover must clamp to the \
ceiling (1800 s), not return 1801",
);
}
#[test]
fn filesystem_available_bytes_returns_positive_on_tempdir() {
let tmp = tempfile::tempdir().expect("create tempdir");
let bytes = filesystem_available_bytes(tmp.path()).expect("statvfs");
assert!(
bytes > 0,
"tempdir filesystem must report some available space, got {bytes}"
);
}
#[test]
fn filesystem_available_bytes_errors_on_missing_path() {
let tmp = tempfile::tempdir().expect("create tempdir");
let missing = tmp.path().join("does-not-exist");
let err = filesystem_available_bytes(&missing).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.contains("statvfs"),
"error must carry 'statvfs' context: {rendered}"
);
assert!(
rendered.contains("does-not-exist"),
"error must name the missing path: {rendered}"
);
}
#[test]
fn compute_margin_respects_floor_and_scales_linearly() {
assert_eq!(
compute_margin(0),
1,
"compute_margin(0): `/ 10` = 0, the max(1) floor MUST \
win so the free-space gate retains positive headroom \
even when called with a degenerate zero input",
);
for size in [1u64, 5, 9] {
assert_eq!(
compute_margin(size),
1,
"compute_margin({size}): floor at 1 must beat the \
zero produced by integer `/ 10`",
);
}
assert_eq!(
compute_margin(10),
1,
"compute_margin(10): 10/10 = 1 — the `/ 10` branch \
wins, floor is a no-op",
);
assert_eq!(
compute_margin(100),
10,
"compute_margin(100): 10% = 10, `/ 10` dominates",
);
assert_eq!(
compute_margin(u64::MAX),
u64::MAX / 10,
"compute_margin(u64::MAX): integer division, no \
overflow; floor is a no-op",
);
}
#[test]
fn format_free_space_error_includes_fuse_hint_iff_available_is_zero() {
let parent = std::path::Path::new("/tmp/ktstr-fuse-test");
let with_hint = format_free_space_error(1_000_000, parent, 0);
assert!(
with_hint.contains("Need") && with_hint.contains("/tmp/ktstr-fuse-test"),
"base message shape must survive the hint append; \
got: {with_hint}",
);
assert!(
with_hint.contains("FUSE") && with_hint.contains("quota"),
"available == 0 must append the FUSE/quota hint; \
got: {with_hint}",
);
assert!(
with_hint.contains("blocks_available reported 0"),
"hint must name the specific value (0) so a user \
sees the trigger; got: {with_hint}",
);
let without_hint = format_free_space_error(1_000_000, parent, 500_000);
assert!(
without_hint.contains("Need") && without_hint.contains("/tmp/ktstr-fuse-test"),
"base message shape unchanged; got: {without_hint}",
);
assert!(
!without_hint.contains("FUSE") && !without_hint.contains("blocks_available"),
"available > 0 must NOT append the FUSE hint (would \
clutter normal full-disk bails with irrelevant \
quota speculation); got: {without_hint}",
);
}
#[test]
fn ensure_free_space_ok_when_space_sufficient() {
let tmp = tempfile::tempdir().expect("create tempdir");
let tiny = ModelSpec {
file_name: "tiny.gguf",
url: "https://placeholder.example/tiny.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: 1,
};
ensure_free_space(tmp.path(), &tiny).expect("1-byte spec must fit");
}
#[test]
fn ensure_free_space_bails_when_space_insufficient() {
let tmp = tempfile::tempdir().expect("create tempdir");
let huge = ModelSpec {
file_name: "ginormous.gguf",
url: "https://placeholder.example/ginormous.gguf",
sha256_hex: "0000000000000000000000000000000000000000000000000000000000000000",
size_bytes: u64::MAX / 2,
};
let err = ensure_free_space(tmp.path(), &huge).unwrap_err();
let rendered = format!("{err:#}");
assert!(
rendered.starts_with("Need "),
"error must lead with 'Need ': {rendered}"
);
assert!(
rendered.contains(" free at "),
"error must carry ' free at ' infix: {rendered}"
);
assert!(
rendered.contains("; have "),
"error must carry '; have ' separator: {rendered}"
);
assert!(
rendered.contains(&format!("{}", tmp.path().display())),
"error must echo the parent path: {rendered}"
);
let rendered_after_need = rendered
.strip_prefix("Need ")
.expect("starts_with 'Need ' above");
let needed_portion = rendered_after_need
.split_once(" free at ")
.expect("infix present")
.0;
assert!(
["KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]
.iter()
.any(|p| needed_portion.contains(p)),
"needed size must render with an IEC prefix, got: {needed_portion:?}"
);
}
#[test]
fn human_bytes_rendering_is_pinned_for_default_model_size() {
let size_only = DEFAULT_MODEL.size_bytes;
let size_plus_margin = size_only + size_only / 10;
assert_eq!(format!("{}", indicatif::HumanBytes(size_only)), "2.55 GiB");
assert_eq!(
format!("{}", indicatif::HumanBytes(size_plus_margin)),
"2.81 GiB"
);
}