pub mod components;
pub mod prefix;
use haz_domain::action::TaskAction;
use haz_domain::settings::cache::HashAlgo;
use crate::hasher::Hasher;
use crate::hex::{self, HexError};
use crate::key::components::{
contribute_action, contribute_env, contribute_input_files, contribute_predecessors,
};
pub use crate::key::components::{EnvContribution, InputFile, PredecessorStreams};
pub use crate::key::prefix::{CHAPTER_REVISION, hash_function_id, schema_version_prefix};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CacheKey([u8; 32]);
impl CacheKey {
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[must_use]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[must_use]
pub fn to_hex(&self) -> String {
hex::encode_32(&self.0)
}
pub fn from_hex(s: &str) -> Result<Self, HexError> {
Ok(Self(hex::decode_32(s)?))
}
}
pub struct CacheKeyInputs<'a> {
pub action: &'a TaskAction,
pub input_files: &'a [InputFile<'a>],
pub hard_predecessors: &'a [PredecessorStreams<'a>],
pub env: &'a EnvContribution<'a>,
}
pub struct CacheKeyBuilder {
hasher: Hasher,
}
impl CacheKeyBuilder {
#[must_use]
pub fn new(algo: HashAlgo) -> Self {
let mut hasher = Hasher::new(algo);
hasher.update(&schema_version_prefix(algo));
Self { hasher }
}
#[must_use]
pub fn finish(mut self, inputs: &CacheKeyInputs<'_>) -> CacheKey {
contribute_action(&mut self.hasher, inputs.action);
contribute_input_files(&mut self.hasher, inputs.input_files);
contribute_predecessors(&mut self.hasher, inputs.hard_predecessors);
contribute_env(&mut self.hasher, inputs.env);
CacheKey(self.hasher.finalize())
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use haz_domain::action::{ShellType, TaskAction};
use haz_domain::env::EnvVarName;
use haz_domain::name::{ProjectName, TaskName};
use haz_domain::settings::cache::HashAlgo;
use nonempty::NonEmpty;
use crate::key::components::{EnvContribution, InputFile, PredecessorStreams};
use crate::key::{CacheKey, CacheKeyBuilder, CacheKeyInputs};
fn key_of(action: &TaskAction, algo: HashAlgo) -> CacheKey {
let host: BTreeMap<EnvVarName, Option<String>> = BTreeMap::new();
let overrides: BTreeMap<EnvVarName, String> = BTreeMap::new();
let env = EnvContribution {
from_host: &host,
overrides: &overrides,
};
let inputs = CacheKeyInputs {
action,
input_files: &[],
hard_predecessors: &[],
env: &env,
};
CacheKeyBuilder::new(algo).finish(&inputs)
}
fn cmd(args: &[&str]) -> TaskAction {
TaskAction::Command(
NonEmpty::from_vec(args.iter().map(|s| (*s).to_owned()).collect())
.expect("non-empty argv"),
)
}
fn shell(script: &str, shell_type: ShellType) -> TaskAction {
TaskAction::Shell {
script: script.to_owned(),
shell: shell_type,
}
}
#[test]
fn cache_009_to_hex_is_64_lowercase_chars() {
let key = key_of(&cmd(&["true"]), HashAlgo::Blake3);
let h = key.to_hex();
assert_eq!(h.len(), 64);
assert!(
h.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
);
}
#[test]
fn cache_key_is_copy() {
let key = key_of(&cmd(&["true"]), HashAlgo::Blake3);
let copied = key;
assert_eq!(key.as_bytes(), copied.as_bytes());
}
#[test]
fn cache_002_blake3_and_sha256_keys_diverge_on_same_inputs() {
let action = cmd(&["true"]);
let blake = key_of(&action, HashAlgo::Blake3);
let sha = key_of(&action, HashAlgo::Sha256);
assert_ne!(
blake.as_bytes(),
sha.as_bytes(),
"hash_function_id byte must be in the prefix per CACHE-003"
);
}
#[test]
fn cache_005_command_and_shell_with_same_text_diverge() {
let cmd_key = key_of(&cmd(&["foo"]), HashAlgo::Blake3);
let shell_key = key_of(&shell("foo", ShellType::Sh), HashAlgo::Blake3);
assert_ne!(cmd_key.as_bytes(), shell_key.as_bytes());
}
#[test]
fn cache_005_shell_type_change_changes_key() {
let sh_key = key_of(&shell("echo hi", ShellType::Sh), HashAlgo::Blake3);
let bash_key = key_of(&shell("echo hi", ShellType::Bash), HashAlgo::Blake3);
assert_ne!(sh_key.as_bytes(), bash_key.as_bytes());
}
#[test]
fn cache_005_argv_order_changes_key() {
let a = key_of(&cmd(&["echo", "a", "b"]), HashAlgo::Blake3);
let b = key_of(&cmd(&["echo", "b", "a"]), HashAlgo::Blake3);
assert_ne!(a.as_bytes(), b.as_bytes());
}
#[test]
fn cache_005_empty_string_argument_is_distinct_from_no_argument() {
let with_empty = key_of(&cmd(&["echo", ""]), HashAlgo::Blake3);
let without_arg = key_of(&cmd(&["echo"]), HashAlgo::Blake3);
assert_ne!(with_empty.as_bytes(), without_arg.as_bytes());
}
fn key_with_inputs(files: &[InputFile<'_>]) -> CacheKey {
let host: BTreeMap<EnvVarName, Option<String>> = BTreeMap::new();
let overrides: BTreeMap<EnvVarName, String> = BTreeMap::new();
let env = EnvContribution {
from_host: &host,
overrides: &overrides,
};
let action = cmd(&["true"]);
let inputs = CacheKeyInputs {
action: &action,
input_files: files,
hard_predecessors: &[],
env: &env,
};
CacheKeyBuilder::new(HashAlgo::Blake3).finish(&inputs)
}
#[test]
fn cache_006_input_file_order_does_not_matter() {
let a = InputFile {
workspace_absolute_path: "/p/a",
content_hash: [0xAA; 32],
};
let b = InputFile {
workspace_absolute_path: "/p/b",
content_hash: [0xBB; 32],
};
let ab = key_with_inputs(&[a, b]);
let ba = key_with_inputs(&[
InputFile {
workspace_absolute_path: "/p/b",
content_hash: [0xBB; 32],
},
InputFile {
workspace_absolute_path: "/p/a",
content_hash: [0xAA; 32],
},
]);
assert_eq!(ab.as_bytes(), ba.as_bytes());
}
#[test]
fn cache_006_input_file_count_changes_key() {
let a = InputFile {
workspace_absolute_path: "/p/a",
content_hash: [0xAA; 32],
};
let with_one = key_with_inputs(&[a]);
let empty = key_with_inputs(&[]);
assert_ne!(with_one.as_bytes(), empty.as_bytes());
}
#[test]
fn cache_006_input_file_path_change_changes_key() {
let original = key_with_inputs(&[InputFile {
workspace_absolute_path: "/p/a",
content_hash: [0xAA; 32],
}]);
let renamed = key_with_inputs(&[InputFile {
workspace_absolute_path: "/p/b",
content_hash: [0xAA; 32],
}]);
assert_ne!(original.as_bytes(), renamed.as_bytes());
}
#[test]
fn cache_006_input_file_content_change_changes_key() {
let original = key_with_inputs(&[InputFile {
workspace_absolute_path: "/p/a",
content_hash: [0xAA; 32],
}]);
let edited = key_with_inputs(&[InputFile {
workspace_absolute_path: "/p/a",
content_hash: [0xBB; 32],
}]);
assert_ne!(original.as_bytes(), edited.as_bytes());
}
fn key_with_predecessors(preds: &[PredecessorStreams<'_>]) -> CacheKey {
let host: BTreeMap<EnvVarName, Option<String>> = BTreeMap::new();
let overrides: BTreeMap<EnvVarName, String> = BTreeMap::new();
let env = EnvContribution {
from_host: &host,
overrides: &overrides,
};
let action = cmd(&["true"]);
let inputs = CacheKeyInputs {
action: &action,
input_files: &[],
hard_predecessors: preds,
env: &env,
};
CacheKeyBuilder::new(HashAlgo::Blake3).finish(&inputs)
}
#[test]
fn cache_007_predecessor_order_does_not_matter() {
let p_a = ProjectName::try_new("alpha").unwrap();
let p_b = ProjectName::try_new("beta").unwrap();
let t_x = TaskName::try_new("x").unwrap();
let t_y = TaskName::try_new("y").unwrap();
let pred_a = PredecessorStreams {
project: &p_a,
task: &t_x,
stdout_hash: [0x01; 32],
stderr_hash: [0x02; 32],
};
let pred_b = PredecessorStreams {
project: &p_b,
task: &t_y,
stdout_hash: [0x03; 32],
stderr_hash: [0x04; 32],
};
let ab = key_with_predecessors(&[pred_a, pred_b]);
let ba = key_with_predecessors(&[
PredecessorStreams {
project: &p_b,
task: &t_y,
stdout_hash: [0x03; 32],
stderr_hash: [0x04; 32],
},
PredecessorStreams {
project: &p_a,
task: &t_x,
stdout_hash: [0x01; 32],
stderr_hash: [0x02; 32],
},
]);
assert_eq!(ab.as_bytes(), ba.as_bytes());
}
#[test]
fn cache_007_predecessor_stdout_stderr_swap_changes_key() {
let p = ProjectName::try_new("alpha").unwrap();
let t = TaskName::try_new("x").unwrap();
let original = key_with_predecessors(&[PredecessorStreams {
project: &p,
task: &t,
stdout_hash: [0x01; 32],
stderr_hash: [0x02; 32],
}]);
let swapped = key_with_predecessors(&[PredecessorStreams {
project: &p,
task: &t,
stdout_hash: [0x02; 32],
stderr_hash: [0x01; 32],
}]);
assert_ne!(original.as_bytes(), swapped.as_bytes());
}
fn name(s: &str) -> EnvVarName {
EnvVarName::try_new(s).unwrap()
}
fn key_with_env(env: &EnvContribution<'_>) -> CacheKey {
let action = cmd(&["true"]);
let inputs = CacheKeyInputs {
action: &action,
input_files: &[],
hard_predecessors: &[],
env,
};
CacheKeyBuilder::new(HashAlgo::Blake3).finish(&inputs)
}
#[test]
fn from_host_value_change_changes_key() {
let mut a_host = BTreeMap::new();
a_host.insert(name("PATH"), Some("/usr/bin".to_owned()));
let mut b_host = BTreeMap::new();
b_host.insert(name("PATH"), Some("/usr/local/bin".to_owned()));
let overrides = BTreeMap::new();
let a = key_with_env(&EnvContribution {
from_host: &a_host,
overrides: &overrides,
});
let b = key_with_env(&EnvContribution {
from_host: &b_host,
overrides: &overrides,
});
assert_ne!(a.as_bytes(), b.as_bytes());
}
#[test]
fn from_host_absent_differs_from_empty_string() {
let mut absent_host = BTreeMap::new();
absent_host.insert(name("X"), None);
let mut empty_host = BTreeMap::new();
empty_host.insert(name("X"), Some(String::new()));
let overrides = BTreeMap::new();
let absent = key_with_env(&EnvContribution {
from_host: &absent_host,
overrides: &overrides,
});
let empty = key_with_env(&EnvContribution {
from_host: &empty_host,
overrides: &overrides,
});
assert_ne!(absent.as_bytes(), empty.as_bytes());
}
#[test]
fn override_wins_over_from_host_on_name_collision() {
let mut host_a = BTreeMap::new();
host_a.insert(name("X"), Some("host-a".to_owned()));
let mut host_b = BTreeMap::new();
host_b.insert(name("X"), Some("host-b".to_owned()));
let mut overrides = BTreeMap::new();
overrides.insert(name("X"), "fixed".to_owned());
let a = key_with_env(&EnvContribution {
from_host: &host_a,
overrides: &overrides,
});
let b = key_with_env(&EnvContribution {
from_host: &host_b,
overrides: &overrides,
});
assert_eq!(a.as_bytes(), b.as_bytes());
}
#[test]
fn from_host_and_override_with_same_bytes_still_distinct() {
let mut host = BTreeMap::new();
host.insert(name("X"), Some("v".to_owned()));
let empty_overrides = BTreeMap::new();
let only_host = key_with_env(&EnvContribution {
from_host: &host,
overrides: &empty_overrides,
});
let empty_host = BTreeMap::new();
let mut overrides = BTreeMap::new();
overrides.insert(name("X"), "v".to_owned());
let only_overrides = key_with_env(&EnvContribution {
from_host: &empty_host,
overrides: &overrides,
});
assert_ne!(only_host.as_bytes(), only_overrides.as_bytes());
}
#[test]
fn empty_env_is_distinct_from_any_named_env() {
let empty_host = BTreeMap::new();
let empty_overrides = BTreeMap::new();
let empty = key_with_env(&EnvContribution {
from_host: &empty_host,
overrides: &empty_overrides,
});
let mut single_entry = BTreeMap::new();
single_entry.insert(name("X"), None);
let one_absent = key_with_env(&EnvContribution {
from_host: &single_entry,
overrides: &empty_overrides,
});
assert_ne!(empty.as_bytes(), one_absent.as_bytes());
}
#[test]
fn cache_001_identical_inputs_yield_identical_keys() {
let a = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
let b = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
assert_eq!(a.as_bytes(), b.as_bytes());
}
#[test]
fn cache_001_task_identity_does_not_contribute() {
let a = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
let b = key_of(&cmd(&["echo", "hi"]), HashAlgo::Blake3);
assert_eq!(a.as_bytes(), b.as_bytes());
}
}