Skip to main content

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/// Whether rustc's `stderr` reports that a debug-info tool failed after
71/// linking (kunobi-ninja/kache#1326). rustc only warns when `rust-objcopy`
72/// cannot strip a binary or `dsymutil` cannot package its debug info, and the
73/// build still succeeds with the output unstripped or without its dSYM. Such
74/// a compile must not be cached: the key does not change when the toolchain
75/// is repaired, so the degraded output would be served from then on.
76///
77/// Matches both rustc's human and JSON diagnostic formats.
78pub fn reports_debug_info_tool_failure(stderr: &str) -> bool {
79    stderr
80        .lines()
81        .any(|line| line.contains("debug info with `") && line.contains("` failed: "))
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct CachedFile {
86    /// Filename relative to the cache entry directory
87    pub name: String,
88    /// Size in bytes
89    pub size: u64,
90    /// blake3 hash of file content
91    pub hash: String,
92    /// Whether the source file had the executable bit set at store time.
93    /// Folded into the local content-dedup hash so two entries differing only
94    /// by which file is executable can't collide (kunobi-ninja/kache#324).
95    /// `#[serde(default)]` keeps old `meta.json` (no field) deserializable.
96    #[serde(default)]
97    pub executable: bool,
98}
99
100/// Is `s` a well-formed cache key: exactly 64 lowercase hex chars, matching
101/// the `blake3::Hash::to_hex()` output produced by cache-key recipes?
102///
103/// Cache keys that arrive from an untrusted source — a prefetch planner
104/// response or an S3 bucket listing — get interpolated into local filesystem
105/// paths (`store_dir().join(cache_key)`) and S3 object keys. An unvalidated
106/// value like `../../../home/user/.config` is a path-traversal / prefix-escape
107/// primitive (`PathBuf::join` walks up on `..` and resets on an absolute
108/// component). Callers must **reject** such keys, never sanitize them.
109pub fn is_valid_cache_key(s: &str) -> bool {
110    s.len() == 64
111        && s.bytes()
112            .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
113}
114
115/// Is `s` a crate name safe to use as an S3 object-key path component?
116///
117/// Permissive enough for real crate names and cc source basenames
118/// (`[A-Za-z0-9_.-]`) but rejects anything that could escape a path or key
119/// prefix: separators, `..` traversal, NUL/control chars, the empty string,
120/// or an absurd length. Like [`is_valid_cache_key`], this guards values that
121/// cross the untrusted-remote boundary; reject, do not sanitize.
122pub fn is_valid_crate_name(s: &str) -> bool {
123    !s.is_empty()
124        && s.len() <= 128
125        && !s.contains("..")
126        && s.bytes()
127            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
128}
129
130/// A blob hash is a 64-char blake3 hex digest. Validated where untrusted
131/// `meta.json` enters (download/import) so a malformed hash can never reach
132/// path construction or the integrity gate (#211).
133pub fn is_blob_hash(s: &str) -> bool {
134    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
135}
136
137/// A cached artifact's `name` must be a single, normal path component — no
138/// absolute/rooted path, no `..`, no separators. `meta.json` names are
139/// attacker-influenced for a shared/MITM'd bucket, and `Path::join` with an
140/// absolute or `..`-bearing component escapes the entry/target dir (e.g.
141/// `dir.join("/etc/x") == "/etc/x"`), giving an arbitrary read/overwrite
142/// primitive. Enforced at the import and restore trust boundaries (#211).
143pub fn is_safe_artifact_name(name: &str) -> bool {
144    use std::path::Component;
145    let mut components = Path::new(name).components();
146    matches!(
147        (components.next(), components.next()),
148        (Some(Component::Normal(_)), None)
149    )
150}
151
152/// A cached artifact's `name` as the local writers may commit it: one normal
153/// path component (`rustc` and `cc` outputs), or a build-script tree
154/// `out/<relative>` (`OUT_DIR`) or `outside/<relative>` (a file under the
155/// target directory that the script named from outside `OUT_DIR`).
156///
157/// [`is_safe_artifact_name`] intentionally stays single-component: it guards
158/// the untrusted boundaries (remote import, restore), where a name is joined
159/// onto a directory and a separator-bearing name is always a bug. This is the
160/// corresponding predicate for the local writers, so `Store::put` and the
161/// index checks over committed metadata agree on what an entry may contain.
162/// Both reject everything `Path::join` could escape with: rooted paths, `..`,
163/// `.`, and the empty name — and a tree prefix is only accepted with a
164/// component after it. Every consumer of that form strips the prefix and
165/// re-validates the relative path (`build_script.rs::checked_relative`).
166pub fn is_safe_stored_artifact_name(name: &str) -> bool {
167    use std::path::Component;
168    let mut components = Path::new(name).components();
169    let Some(Component::Normal(first)) = components.next() else {
170        return false;
171    };
172    let tail: Vec<Component> = components.collect();
173    if !tail.iter().all(|c| matches!(c, Component::Normal(_))) {
174        return false;
175    }
176    if first == std::ffi::OsStr::new("out") || first == std::ffi::OsStr::new("outside") {
177        !tail.is_empty()
178    } else {
179        tail.is_empty()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    #[test]
187    fn debug_info_tool_failure_is_detected_in_both_diagnostic_formats() {
188        // What rustc 1.98 prints when `rust-objcopy` cannot load libLLVM.
189        assert!(reports_debug_info_tool_failure(
190            "warning: stripping debug info with `rust-objcopy` failed: signal: 6 (SIGABRT)\n\
191             \n\
192             warning: 1 warning emitted\n"
193        ));
194        assert!(reports_debug_info_tool_failure(
195            r#"{"$message_type":"diagnostic","message":"stripping debug info with `rust-objcopy` failed: signal: 6 (SIGABRT)","level":"warning"}"#
196        ));
197        assert!(reports_debug_info_tool_failure(
198            "warning: processing debug info with `dsymutil` failed: exit status: 1\n"
199        ));
200    }
201    #[test]
202    fn debug_info_tool_failure_ignores_clean_and_unrelated_output() {
203        assert!(!reports_debug_info_tool_failure(""));
204        assert!(!reports_debug_info_tool_failure(
205            "warning: unused variable: `x`\n --> src/main.rs:2:9\n"
206        ));
207        // Each half of the message alone is not a tool failure.
208        assert!(!reports_debug_info_tool_failure(
209            "note: stripping debug info with `strip`\n"
210        ));
211        assert!(!reports_debug_info_tool_failure(
212            "warning: build script `foo` failed: exit status: 1\n"
213        ));
214    }
215    #[test]
216    fn is_valid_cache_key_rejects_traversal_and_malformed() {
217        assert!(!is_valid_cache_key(""));
218        assert!(!is_valid_cache_key("abc123")); // too short
219        assert!(!is_valid_cache_key(&"a".repeat(63)));
220        assert!(!is_valid_cache_key(&"a".repeat(65)));
221        assert!(!is_valid_cache_key(&"A".repeat(64))); // uppercase not produced by to_hex
222        assert!(!is_valid_cache_key(&"g".repeat(64))); // non-hex
223        // Path-traversal / prefix-escape attempts, padded to 64 chars.
224        assert!(!is_valid_cache_key(&format!(
225            "{:/<64}",
226            "../../../etc/passwd"
227        )));
228        assert!(!is_valid_cache_key(&format!("{:0<64}", "/abs/path")));
229        assert!(!is_valid_cache_key(&format!("{:0<63}\n", "x"))); // newline
230    }
231    #[test]
232    fn is_valid_crate_name_accepts_real_names() {
233        for name in [
234            "serde",
235            "tokio_stream",
236            "foo-bar",
237            "build_script_build",
238            "a.out",
239            "x",
240        ] {
241            assert!(is_valid_crate_name(name), "{name} should be valid");
242        }
243        assert!(is_valid_crate_name(&"a".repeat(128)));
244    }
245    #[test]
246    fn is_valid_crate_name_rejects_path_escapes() {
247        assert!(!is_valid_crate_name(""));
248        assert!(!is_valid_crate_name("../evil"));
249        assert!(!is_valid_crate_name("a/b"));
250        assert!(!is_valid_crate_name("a\\b"));
251        assert!(!is_valid_crate_name("a..b")); // traversal substring
252        assert!(!is_valid_crate_name("nul\0byte"));
253        assert!(!is_valid_crate_name("tab\there"));
254        assert!(!is_valid_crate_name(&"a".repeat(129))); // too long
255    }
256    /// #211: the trust-boundary hash validator accepts only a 64-char blake3
257    /// hex digest and rejects everything a hostile/corrupt meta.json might
258    /// carry (empty, short, wrong length, non-hex, traversal-shaped).
259    #[test]
260    fn is_blob_hash_accepts_only_blake3_hex() {
261        assert!(is_blob_hash(&"a".repeat(64)));
262        assert!(is_blob_hash(&"0123456789abcdef".repeat(4)));
263        assert!(!is_blob_hash(""));
264        assert!(!is_blob_hash("ab"));
265        assert!(!is_blob_hash(&"a".repeat(63)));
266        assert!(!is_blob_hash(&"a".repeat(65)));
267        assert!(!is_blob_hash(&"g".repeat(64))); // non-hex
268        assert!(!is_blob_hash("../../etc/passwd"));
269    }
270    /// #211: a cached artifact name must be a single normal component — reject
271    /// absolute, rooted, parent-dir, separator-bearing, and empty names.
272    #[test]
273    fn is_safe_artifact_name_requires_single_normal_component() {
274        assert!(is_safe_artifact_name("libfoo-abc123.rlib"));
275        assert!(is_safe_artifact_name("foo.d"));
276        assert!(!is_safe_artifact_name(""));
277        assert!(!is_safe_artifact_name("/etc/passwd"));
278        assert!(!is_safe_artifact_name("../escape"));
279        assert!(!is_safe_artifact_name("a/b"));
280        assert!(!is_safe_artifact_name("./a"));
281        assert!(!is_safe_artifact_name(".."));
282    }
283    /// The local writers store one normal component (`rustc`/`cc`), or a
284    /// build-script tree `out/<relative>` or `outside/<relative>`. Both are
285    /// valid committed metadata; everything `Path::join` could escape with
286    /// is not.
287    #[test]
288    fn is_safe_stored_artifact_name_allows_only_build_script_trees_beyond_one_component() {
289        assert!(is_safe_stored_artifact_name("libfoo-abc123.rlib"));
290        assert!(is_safe_stored_artifact_name("foo.d"));
291        assert!(is_safe_stored_artifact_name("out/asm.s"));
292        assert!(is_safe_stored_artifact_name("out/nested/asm.o"));
293        assert!(is_safe_stored_artifact_name(
294            "outside/debug/gn_out/obj/lib.a"
295        ));
296        assert!(!is_safe_stored_artifact_name(""));
297        assert!(!is_safe_stored_artifact_name("/etc/passwd"));
298        assert!(!is_safe_stored_artifact_name("../escape"));
299        assert!(!is_safe_stored_artifact_name(".."));
300        assert!(!is_safe_stored_artifact_name("./a"));
301        assert!(!is_safe_stored_artifact_name("a/b"));
302        // A directory the writer never names: a tree prefix always carries a file.
303        assert!(!is_safe_stored_artifact_name("out"));
304        assert!(!is_safe_stored_artifact_name("out/"));
305        assert!(!is_safe_stored_artifact_name("outside"));
306        assert!(!is_safe_stored_artifact_name("outside/"));
307        // The prefix does not license the traversal the strict predicate bans.
308        assert!(!is_safe_stored_artifact_name("out/../escape"));
309        assert!(!is_safe_stored_artifact_name("outside/../escape"));
310    }
311    /// kunobi-ninja/kache#325: the lookup gate is superset-tolerant, skips empty
312    /// (pre-gate) entries, and rejects genuinely-missing kinds.
313    #[test]
314    fn covers_requested_emit_semantics() {
315        let mk = |kinds: &[&str]| EntryMeta {
316            cache_key: "k".into(),
317            key_schema: CACHE_KEY_VERSION,
318            crate_name: "c".into(),
319            crate_types: vec![],
320            files: vec![],
321            stdout: String::new(),
322            stderr: String::new(),
323            features: vec![],
324            target: String::new(),
325            profile: String::new(),
326            compile_time_ms: 0,
327            emit_kinds: kinds.iter().map(|s| s.to_string()).collect(),
328        };
329        let req = |kinds: &[&str]| -> Vec<String> { kinds.iter().map(|s| s.to_string()).collect() };
330
331        // Superset: entry has link+metadata+dep-info, request just link.
332        assert!(mk(&["dep-info", "link", "metadata"]).covers_requested_emit(&req(&["link"])));
333        // Exact.
334        assert!(
335            mk(&["dep-info", "metadata"]).covers_requested_emit(&req(&["dep-info", "metadata"]))
336        );
337        // Missing the requested obj → not covered.
338        assert!(!mk(&["link"]).covers_requested_emit(&req(&["link", "obj"])));
339        // Pre-gate entry (no recorded kinds) → skip the check.
340        assert!(mk(&[]).covers_requested_emit(&req(&["link", "obj"])));
341        // A requested kind the gate can't map to a file is ignored, not rejected.
342        assert!(mk(&["link"]).covers_requested_emit(&req(&["link", "future-exotic"])));
343    }
344}