kache_format/lib.rs
1//! Cache-entry metadata and validation shared by local and remote storage.
2
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6/// Cache-key recipe version written into entry metadata.
7pub const CACHE_KEY_VERSION: u32 = 32;
8
9/// Emit kinds represented by the current entry format.
10pub const GATED_EMIT_KINDS: [&str; 8] = [
11 "link", "metadata", "obj", "dep-info", "asm", "llvm-ir", "llvm-bc", "mir",
12];
13
14/// Metadata stored alongside cached artifacts.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct EntryMeta {
17 pub cache_key: String,
18 /// Cache-key recipe version that produced `cache_key`.
19 ///
20 /// Entries written before this field existed deserialize as `0` (unknown),
21 /// so an explicit stale-schema sweep can reclaim them without making old
22 /// stores unreadable during an ordinary upgrade.
23 #[serde(default)]
24 pub key_schema: u32,
25 pub crate_name: String,
26 pub crate_types: Vec<String>,
27 pub files: Vec<CachedFile>,
28 pub stdout: String,
29 pub stderr: String,
30 #[serde(default)]
31 pub features: Vec<String>,
32 #[serde(default)]
33 pub target: String,
34 #[serde(default)]
35 pub profile: String,
36 #[serde(default)]
37 pub compile_time_ms: u64,
38 /// Canonical rustc `--emit` kinds this entry actually contains, derived
39 /// from the stored output files at put time (kunobi-ninja/kache#325). Lookup
40 /// uses it to reject an entry that doesn't cover what the invocation's
41 /// `--emit` requested. `#[serde(default)]` keeps pre-gate `meta.json` (no
42 /// field) deserializable — an empty set means "unknown", so the lookup gate
43 /// skips the check rather than mass-invalidating old entries.
44 #[serde(default)]
45 pub emit_kinds: Vec<String>,
46}
47
48impl EntryMeta {
49 /// Whether this entry's recorded outputs cover every `--emit` kind the
50 /// caller requested (kunobi-ninja/kache#325). Superset-tolerant: an entry
51 /// that contains more kinds than requested still covers it (a lib
52 /// `--emit=link` legitimately also produces `.rmeta`).
53 ///
54 /// Returns `true` when `emit_kinds` is empty — pre-gate entries recorded no
55 /// coverage, so the check is skipped rather than mass-invalidating them.
56 /// Requested kinds that map to no stored file class (e.g. an exotic emit
57 /// kache doesn't model) are ignored so the gate never rejects on a kind it
58 /// can't reason about.
59 pub fn covers_requested_emit(&self, requested: &[String]) -> bool {
60 if self.emit_kinds.is_empty() {
61 return true;
62 }
63 requested
64 .iter()
65 .filter(|kind| GATED_EMIT_KINDS.contains(&kind.as_str()))
66 .all(|kind| self.emit_kinds.iter().any(|have| have == kind))
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct CachedFile {
72 /// Filename relative to the cache entry directory
73 pub name: String,
74 /// Size in bytes
75 pub size: u64,
76 /// blake3 hash of file content
77 pub hash: String,
78 /// Whether the source file had the executable bit set at store time.
79 /// Folded into the local content-dedup hash so two entries differing only
80 /// by which file is executable can't collide (kunobi-ninja/kache#324).
81 /// `#[serde(default)]` keeps old `meta.json` (no field) deserializable.
82 #[serde(default)]
83 pub executable: bool,
84}
85
86/// Is `s` a well-formed cache key: exactly 64 lowercase hex chars, matching
87/// the `blake3::Hash::to_hex()` output produced by cache-key recipes?
88///
89/// Cache keys that arrive from an untrusted source — a prefetch planner
90/// response or an S3 bucket listing — get interpolated into local filesystem
91/// paths (`store_dir().join(cache_key)`) and S3 object keys. An unvalidated
92/// value like `../../../home/user/.config` is a path-traversal / prefix-escape
93/// primitive (`PathBuf::join` walks up on `..` and resets on an absolute
94/// component). Callers must **reject** such keys, never sanitize them.
95pub fn is_valid_cache_key(s: &str) -> bool {
96 s.len() == 64
97 && s.bytes()
98 .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
99}
100
101/// Is `s` a crate name safe to use as an S3 object-key path component?
102///
103/// Permissive enough for real crate names and cc source basenames
104/// (`[A-Za-z0-9_.-]`) but rejects anything that could escape a path or key
105/// prefix: separators, `..` traversal, NUL/control chars, the empty string,
106/// or an absurd length. Like [`is_valid_cache_key`], this guards values that
107/// cross the untrusted-remote boundary; reject, do not sanitize.
108pub fn is_valid_crate_name(s: &str) -> bool {
109 !s.is_empty()
110 && s.len() <= 128
111 && !s.contains("..")
112 && s.bytes()
113 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
114}
115
116/// A blob hash is a 64-char blake3 hex digest. Validated where untrusted
117/// `meta.json` enters (download/import) so a malformed hash can never reach
118/// path construction or the integrity gate (#211).
119pub fn is_blob_hash(s: &str) -> bool {
120 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
121}
122
123/// A cached artifact's `name` must be a single, normal path component — no
124/// absolute/rooted path, no `..`, no separators. `meta.json` names are
125/// attacker-influenced for a shared/MITM'd bucket, and `Path::join` with an
126/// absolute or `..`-bearing component escapes the entry/target dir (e.g.
127/// `dir.join("/etc/x") == "/etc/x"`), giving an arbitrary read/overwrite
128/// primitive. Enforced at the import and restore trust boundaries (#211).
129pub fn is_safe_artifact_name(name: &str) -> bool {
130 use std::path::Component;
131 let mut components = Path::new(name).components();
132 matches!(
133 (components.next(), components.next()),
134 (Some(Component::Normal(_)), None)
135 )
136}
137
138/// A cached artifact's `name` as the local writers may commit it: one normal
139/// path component (`rustc` and `cc` outputs), or the build-script form
140/// `out/<relative>` that `build_script.rs` stores OUT_DIR contents under.
141///
142/// [`is_safe_artifact_name`] intentionally stays single-component: it guards
143/// the untrusted boundaries (remote import, restore), where a name is joined
144/// onto a directory and a separator-bearing name is always a bug. This is the
145/// corresponding predicate for the local writers, so `Store::put` and the
146/// index checks over committed metadata agree on what an entry may contain.
147/// Both reject everything `Path::join` could escape with: rooted paths, `..`,
148/// `.`, and the empty name — and `out/` is only accepted with a component
149/// after it. Every consumer of that form strips the prefix and re-validates
150/// the relative path (`build_script.rs::checked_relative`).
151pub fn is_safe_stored_artifact_name(name: &str) -> bool {
152 use std::path::Component;
153 let mut components = Path::new(name).components();
154 let Some(Component::Normal(first)) = components.next() else {
155 return false;
156 };
157 let tail: Vec<Component> = components.collect();
158 if !tail.iter().all(|c| matches!(c, Component::Normal(_))) {
159 return false;
160 }
161 if first == std::ffi::OsStr::new("out") {
162 !tail.is_empty()
163 } else {
164 tail.is_empty()
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 #[test]
172 fn is_valid_cache_key_rejects_traversal_and_malformed() {
173 assert!(!is_valid_cache_key(""));
174 assert!(!is_valid_cache_key("abc123")); // too short
175 assert!(!is_valid_cache_key(&"a".repeat(63)));
176 assert!(!is_valid_cache_key(&"a".repeat(65)));
177 assert!(!is_valid_cache_key(&"A".repeat(64))); // uppercase not produced by to_hex
178 assert!(!is_valid_cache_key(&"g".repeat(64))); // non-hex
179 // Path-traversal / prefix-escape attempts, padded to 64 chars.
180 assert!(!is_valid_cache_key(&format!(
181 "{:/<64}",
182 "../../../etc/passwd"
183 )));
184 assert!(!is_valid_cache_key(&format!("{:0<64}", "/abs/path")));
185 assert!(!is_valid_cache_key(&format!("{:0<63}\n", "x"))); // newline
186 }
187 #[test]
188 fn is_valid_crate_name_accepts_real_names() {
189 for name in [
190 "serde",
191 "tokio_stream",
192 "foo-bar",
193 "build_script_build",
194 "a.out",
195 "x",
196 ] {
197 assert!(is_valid_crate_name(name), "{name} should be valid");
198 }
199 assert!(is_valid_crate_name(&"a".repeat(128)));
200 }
201 #[test]
202 fn is_valid_crate_name_rejects_path_escapes() {
203 assert!(!is_valid_crate_name(""));
204 assert!(!is_valid_crate_name("../evil"));
205 assert!(!is_valid_crate_name("a/b"));
206 assert!(!is_valid_crate_name("a\\b"));
207 assert!(!is_valid_crate_name("a..b")); // traversal substring
208 assert!(!is_valid_crate_name("nul\0byte"));
209 assert!(!is_valid_crate_name("tab\there"));
210 assert!(!is_valid_crate_name(&"a".repeat(129))); // too long
211 }
212 /// #211: the trust-boundary hash validator accepts only a 64-char blake3
213 /// hex digest and rejects everything a hostile/corrupt meta.json might
214 /// carry (empty, short, wrong length, non-hex, traversal-shaped).
215 #[test]
216 fn is_blob_hash_accepts_only_blake3_hex() {
217 assert!(is_blob_hash(&"a".repeat(64)));
218 assert!(is_blob_hash(&"0123456789abcdef".repeat(4)));
219 assert!(!is_blob_hash(""));
220 assert!(!is_blob_hash("ab"));
221 assert!(!is_blob_hash(&"a".repeat(63)));
222 assert!(!is_blob_hash(&"a".repeat(65)));
223 assert!(!is_blob_hash(&"g".repeat(64))); // non-hex
224 assert!(!is_blob_hash("../../etc/passwd"));
225 }
226 /// #211: a cached artifact name must be a single normal component — reject
227 /// absolute, rooted, parent-dir, separator-bearing, and empty names.
228 #[test]
229 fn is_safe_artifact_name_requires_single_normal_component() {
230 assert!(is_safe_artifact_name("libfoo-abc123.rlib"));
231 assert!(is_safe_artifact_name("foo.d"));
232 assert!(!is_safe_artifact_name(""));
233 assert!(!is_safe_artifact_name("/etc/passwd"));
234 assert!(!is_safe_artifact_name("../escape"));
235 assert!(!is_safe_artifact_name("a/b"));
236 assert!(!is_safe_artifact_name("./a"));
237 assert!(!is_safe_artifact_name(".."));
238 }
239 /// The local writers store one normal component (`rustc`/`cc`), or the
240 /// build-script `out/<relative>` form (`build_script.rs`). Both are valid
241 /// committed metadata; everything `Path::join` could escape with is not.
242 #[test]
243 fn is_safe_stored_artifact_name_allows_only_the_out_prefix_beyond_one_component() {
244 assert!(is_safe_stored_artifact_name("libfoo-abc123.rlib"));
245 assert!(is_safe_stored_artifact_name("foo.d"));
246 assert!(is_safe_stored_artifact_name("out/asm.s"));
247 assert!(is_safe_stored_artifact_name("out/nested/asm.o"));
248 assert!(!is_safe_stored_artifact_name(""));
249 assert!(!is_safe_stored_artifact_name("/etc/passwd"));
250 assert!(!is_safe_stored_artifact_name("../escape"));
251 assert!(!is_safe_stored_artifact_name(".."));
252 assert!(!is_safe_stored_artifact_name("./a"));
253 assert!(!is_safe_stored_artifact_name("a/b"));
254 // A directory the writer never names: `out/` always carries a file.
255 assert!(!is_safe_stored_artifact_name("out"));
256 assert!(!is_safe_stored_artifact_name("out/"));
257 // The prefix does not license the traversal the strict predicate bans.
258 assert!(!is_safe_stored_artifact_name("out/../escape"));
259 }
260 /// kunobi-ninja/kache#325: the lookup gate is superset-tolerant, skips empty
261 /// (pre-gate) entries, and rejects genuinely-missing kinds.
262 #[test]
263 fn covers_requested_emit_semantics() {
264 let mk = |kinds: &[&str]| EntryMeta {
265 cache_key: "k".into(),
266 key_schema: CACHE_KEY_VERSION,
267 crate_name: "c".into(),
268 crate_types: vec![],
269 files: vec![],
270 stdout: String::new(),
271 stderr: String::new(),
272 features: vec![],
273 target: String::new(),
274 profile: String::new(),
275 compile_time_ms: 0,
276 emit_kinds: kinds.iter().map(|s| s.to_string()).collect(),
277 };
278 let req = |kinds: &[&str]| -> Vec<String> { kinds.iter().map(|s| s.to_string()).collect() };
279
280 // Superset: entry has link+metadata+dep-info, request just link.
281 assert!(mk(&["dep-info", "link", "metadata"]).covers_requested_emit(&req(&["link"])));
282 // Exact.
283 assert!(
284 mk(&["dep-info", "metadata"]).covers_requested_emit(&req(&["dep-info", "metadata"]))
285 );
286 // Missing the requested obj → not covered.
287 assert!(!mk(&["link"]).covers_requested_emit(&req(&["link", "obj"])));
288 // Pre-gate entry (no recorded kinds) → skip the check.
289 assert!(mk(&[]).covers_requested_emit(&req(&["link", "obj"])));
290 // A requested kind the gate can't map to a file is ignored, not rejected.
291 assert!(mk(&["link"]).covers_requested_emit(&req(&["link", "future-exotic"])));
292 }
293}