use serde::{Deserialize, Serialize};
use std::path::Path;
pub const CACHE_KEY_VERSION: u32 = 31;
pub const GATED_EMIT_KINDS: [&str; 8] = [
"link", "metadata", "obj", "dep-info", "asm", "llvm-ir", "llvm-bc", "mir",
];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EntryMeta {
pub cache_key: String,
#[serde(default)]
pub key_schema: u32,
pub crate_name: String,
pub crate_types: Vec<String>,
pub files: Vec<CachedFile>,
pub stdout: String,
pub stderr: String,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub target: String,
#[serde(default)]
pub profile: String,
#[serde(default)]
pub compile_time_ms: u64,
#[serde(default)]
pub emit_kinds: Vec<String>,
}
impl EntryMeta {
pub fn covers_requested_emit(&self, requested: &[String]) -> bool {
if self.emit_kinds.is_empty() {
return true;
}
requested
.iter()
.filter(|kind| GATED_EMIT_KINDS.contains(&kind.as_str()))
.all(|kind| self.emit_kinds.iter().any(|have| have == kind))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CachedFile {
pub name: String,
pub size: u64,
pub hash: String,
#[serde(default)]
pub executable: bool,
}
pub fn is_valid_cache_key(s: &str) -> bool {
s.len() == 64
&& s.bytes()
.all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
}
pub fn is_valid_crate_name(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& !s.contains("..")
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
}
pub fn is_blob_hash(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn is_safe_artifact_name(name: &str) -> bool {
use std::path::Component;
let mut components = Path::new(name).components();
matches!(
(components.next(), components.next()),
(Some(Component::Normal(_)), None)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_valid_cache_key_rejects_traversal_and_malformed() {
assert!(!is_valid_cache_key(""));
assert!(!is_valid_cache_key("abc123")); assert!(!is_valid_cache_key(&"a".repeat(63)));
assert!(!is_valid_cache_key(&"a".repeat(65)));
assert!(!is_valid_cache_key(&"A".repeat(64))); assert!(!is_valid_cache_key(&"g".repeat(64))); assert!(!is_valid_cache_key(&format!(
"{:/<64}",
"../../../etc/passwd"
)));
assert!(!is_valid_cache_key(&format!("{:0<64}", "/abs/path")));
assert!(!is_valid_cache_key(&format!("{:0<63}\n", "x"))); }
#[test]
fn is_valid_crate_name_accepts_real_names() {
for name in [
"serde",
"tokio_stream",
"foo-bar",
"build_script_build",
"a.out",
"x",
] {
assert!(is_valid_crate_name(name), "{name} should be valid");
}
assert!(is_valid_crate_name(&"a".repeat(128)));
}
#[test]
fn is_valid_crate_name_rejects_path_escapes() {
assert!(!is_valid_crate_name(""));
assert!(!is_valid_crate_name("../evil"));
assert!(!is_valid_crate_name("a/b"));
assert!(!is_valid_crate_name("a\\b"));
assert!(!is_valid_crate_name("a..b")); assert!(!is_valid_crate_name("nul\0byte"));
assert!(!is_valid_crate_name("tab\there"));
assert!(!is_valid_crate_name(&"a".repeat(129))); }
#[test]
fn is_blob_hash_accepts_only_blake3_hex() {
assert!(is_blob_hash(&"a".repeat(64)));
assert!(is_blob_hash(&"0123456789abcdef".repeat(4)));
assert!(!is_blob_hash(""));
assert!(!is_blob_hash("ab"));
assert!(!is_blob_hash(&"a".repeat(63)));
assert!(!is_blob_hash(&"a".repeat(65)));
assert!(!is_blob_hash(&"g".repeat(64))); assert!(!is_blob_hash("../../etc/passwd"));
}
#[test]
fn is_safe_artifact_name_requires_single_normal_component() {
assert!(is_safe_artifact_name("libfoo-abc123.rlib"));
assert!(is_safe_artifact_name("foo.d"));
assert!(!is_safe_artifact_name(""));
assert!(!is_safe_artifact_name("/etc/passwd"));
assert!(!is_safe_artifact_name("../escape"));
assert!(!is_safe_artifact_name("a/b"));
assert!(!is_safe_artifact_name("./a"));
assert!(!is_safe_artifact_name(".."));
}
#[test]
fn covers_requested_emit_semantics() {
let mk = |kinds: &[&str]| EntryMeta {
cache_key: "k".into(),
key_schema: CACHE_KEY_VERSION,
crate_name: "c".into(),
crate_types: vec![],
files: vec![],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: String::new(),
compile_time_ms: 0,
emit_kinds: kinds.iter().map(|s| s.to_string()).collect(),
};
let req = |kinds: &[&str]| -> Vec<String> { kinds.iter().map(|s| s.to_string()).collect() };
assert!(mk(&["dep-info", "link", "metadata"]).covers_requested_emit(&req(&["link"])));
assert!(
mk(&["dep-info", "metadata"]).covers_requested_emit(&req(&["dep-info", "metadata"]))
);
assert!(!mk(&["link"]).covers_requested_emit(&req(&["link", "obj"])));
assert!(mk(&[]).covers_requested_emit(&req(&["link", "obj"])));
assert!(mk(&["link"]).covers_requested_emit(&req(&["link", "future-exotic"])));
}
}