1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
//! The single owner of cache-key composition.
//!
//! Before this module, key material was assembled in five uncoordinated
//! places — `Product::descriptor_key`, the `output_config_hash` allowlist,
//! `extend_config_hash`, `apply_tool_version_hashes` (which used a different
//! concatenation style than the others), and each processor's
//! `checksum_fields()` — all funnelling into one opaque `Option<String>`.
//! Nothing recorded *what* had gone into that string, so a component that
//! silently failed to be mixed in (tool versions without a lock file) was
//! invisible, and `--explain` could not attribute a key to its causes.
//!
//! `CacheKey` replaces the opaque string with an ordered, named component
//! list. Every contributor appends a `(Component, value)` pair; one function
//! folds them into the final digest. The components survive into
//! `rsconstruct product show`, so a changed key can always be attributed to
//! the exact component that changed.
use std::fmt;
/// What kind of state a key component captures. The discriminant is mixed
/// into the digest, so two components with equal values but different kinds
/// produce different keys — a config hash of `"abc"` is not a tool version
/// of `"abc"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Component {
/// Hash of the processor's checksum-relevant config fields
/// (`output_config_hash` over `checksum_fields()`).
Config,
/// A piece contributed by an analyzer — non-content state that still
/// affects the output, e.g. the sorted set of paths matching a glob.
Analyzer,
/// Hash of the versions of the external tools the processor invokes.
ToolVersion,
/// A variant/profile name (e.g. a compiler profile).
Variant,
}
impl Component {
/// Stable tag mixed into the digest and shown by `product show`.
/// These strings are part of the on-disk cache key: changing one
/// invalidates every cache entry that has that component.
pub const fn tag(self) -> &'static str {
match self {
Self::Config => "config",
Self::Analyzer => "analyzer",
Self::ToolVersion => "tool",
Self::Variant => "variant",
}
}
/// The build phase that contributes this kind: discovery (config,
/// variant), then analysis, then tool-version hashing. Contribution
/// order — and so the digest — follows the phases.
pub const fn phase(self) -> u8 {
match self {
Self::Config | Self::Variant => 0,
Self::Analyzer => 1,
Self::ToolVersion => 2,
}
}
}
impl fmt::Display for Component {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.tag())
}
}
/// The ordered set of non-input state that contributes to a product's cache
/// key. Input *content* is not stored here — it is passed to
/// [`CacheKey::descriptor_key`] at the point of use, because it is computed
/// lazily (and expensively) from the filesystem.
///
/// Components are kept in insertion order rather than sorted: the order in
/// which contributors run is deterministic (discovery, then analyzers, then
/// tool versions), and preserving it keeps the digest stable while making
/// the `product show` output read in causal order.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CacheKey {
components: Vec<(Component, String)>,
}
impl CacheKey {
/// An empty key — a product whose output depends on nothing but its inputs.
pub const fn new() -> Self {
Self {
components: Vec::new(),
}
}
/// Build a key from a single config hash, the common case at discovery time.
pub fn from_config_hash(hash: Option<String>) -> Self {
let mut key = Self::new();
if let Some(hash) = hash {
key.push(Component::Config, hash);
}
key
}
/// Append a component. Contributors call this instead of hand-splicing
/// strings, which is what let three different separator conventions
/// coexist previously.
pub fn push(&mut self, component: Component, value: impl Into<String>) {
self.components.push((component, value.into()));
}
/// Replace every component of `kind` with `values`, keeping the digest
/// identical to a key that had `values` pushed at the original time.
///
/// Used when analysis is redone mid-build (a generated source changed):
/// the new analyzer pieces must land where the graph-time pieces sit,
/// because the next build pushes them at that point again and the order
/// is part of the digest. They go where the old pieces were, or — when
/// there were none — before the first component contributed after this
/// kind (see [`Component::phase`]).
pub fn replace(&mut self, kind: Component, values: Vec<String>) {
let at = self
.components
.iter()
.position(|(c, _)| *c == kind)
.or_else(|| {
self.components
.iter()
.position(|(c, _)| c.phase() > kind.phase())
})
.unwrap_or(self.components.len());
let before = self.components[..at]
.iter()
.filter(|(c, _)| *c != kind)
.cloned();
let after = self.components[at..]
.iter()
.filter(|(c, _)| *c != kind)
.cloned();
let replaced: Vec<(Component, String)> = before
.chain(values.into_iter().map(|v| (kind, v)))
.chain(after)
.collect();
self.components = replaced;
}
/// Whether any component has been contributed.
pub const fn is_empty(&self) -> bool {
self.components.is_empty()
}
/// The components in contribution order, for display and testing.
pub fn components(&self) -> &[(Component, String)] {
&self.components
}
/// A stable digest of the components alone, with no input content.
/// `None` when there are no components, so that a product with no
/// non-input state produces a key identical in shape to the old
/// `config_hash: None` case.
///
/// Tags and values are hashed as length-prefixed parts (see
/// `checksum::hash_parts`): analyzer values embed arbitrary file paths,
/// so a separator-joined encoding would let a crafted path realign
/// component boundaries and collide two different keys.
pub fn digest(&self) -> Option<String> {
if self.components.is_empty() {
return None;
}
let parts: Vec<&str> = self
.components
.iter()
.flat_map(|(c, v)| [c.tag(), v.as_str()])
.collect();
Some(crate::checksum::hash_parts(&parts))
}
/// Compute the content-addressed descriptor key for a product.
///
/// This key does NOT include file paths — renaming a file with identical
/// content produces the same key. The blob in the cache is path-free; the
/// product knows where to restore it.
///
/// The key mixes in the processor's implementation `version` (from its
/// plugin registration). Bumping that version invalidates every cache
/// entry produced by this processor — see docs/src/processor-versioning.md
/// for the bump rule. For processors not in the builtin registry (e.g. Lua
/// plugins), `v0` is used.
pub fn descriptor_key(&self, processor: &str, input_checksum: &str) -> String {
let version = crate::registries::processor_version(processor)
.unwrap_or(0)
.to_string();
// Length-prefixed parts (not separator-joined): the processor half is
// a user-controlled instance name, so a name containing the separator
// could otherwise realign the version/digest boundaries and collide
// two different keys. An absent digest is the empty part — the length
// prefix keeps it distinct from every real digest.
let digest = self.digest().unwrap_or_default();
crate::checksum::hash_parts(&[processor, &version, &digest, input_checksum])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_key_has_no_digest() {
assert_eq!(CacheKey::new().digest(), None);
assert!(CacheKey::new().is_empty());
}
#[test]
fn from_config_hash_none_is_empty() {
assert!(CacheKey::from_config_hash(None).is_empty());
assert!(!CacheKey::from_config_hash(Some("x".into())).is_empty());
}
#[test]
fn component_kind_is_part_of_the_digest() {
// The bug this prevents: a config hash and a tool hash with the same
// value must not collapse to the same key.
let mut a = CacheKey::new();
a.push(Component::Config, "abc");
let mut b = CacheKey::new();
b.push(Component::ToolVersion, "abc");
assert_ne!(a.digest(), b.digest());
}
#[test]
fn order_is_significant() {
let mut a = CacheKey::new();
a.push(Component::Config, "one");
a.push(Component::Analyzer, "two");
let mut b = CacheKey::new();
b.push(Component::Analyzer, "two");
b.push(Component::Config, "one");
assert_ne!(a.digest(), b.digest());
}
#[test]
fn digest_is_stable_across_calls() {
let mut key = CacheKey::new();
key.push(Component::Config, "abc");
key.push(Component::ToolVersion, "def");
assert_eq!(key.digest(), key.digest());
}
#[test]
fn adding_a_component_changes_the_digest() {
let mut key = CacheKey::new();
key.push(Component::Config, "abc");
let before = key.digest();
key.push(Component::ToolVersion, "def");
assert_ne!(before, key.digest());
}
#[test]
fn descriptor_key_is_injection_proof() {
// Under the old separator-joined scheme both sides hashed the same
// string ("p:v0:q:v0:r"): a user-controlled instance name containing
// the separator realigned the version boundary. Length-prefixed
// parts make the boundaries structural.
assert_ne!(
CacheKey::new().descriptor_key("p:v0:q", "r"),
CacheKey::new().descriptor_key("p", "q:v0:r"),
"instance names must not be able to realign key boundaries"
);
}
#[test]
fn digest_is_injection_proof() {
// Analyzer values embed arbitrary file paths; a value containing the
// old "|tag=" encoding must not collide with two real components.
let mut spliced = CacheKey::new();
spliced.push(Component::Analyzer, "a|analyzer=b");
let mut two = CacheKey::new();
two.push(Component::Analyzer, "a");
two.push(Component::Analyzer, "b");
assert_ne!(
spliced.digest(),
two.digest(),
"analyzer values must not be able to fake component boundaries"
);
}
#[test]
fn descriptor_key_varies_with_every_input() {
let mut key = CacheKey::new();
key.push(Component::Config, "abc");
let base = key.descriptor_key("proc", "chk");
assert_ne!(base, key.descriptor_key("other", "chk"));
assert_ne!(base, key.descriptor_key("proc", "other"));
assert_ne!(base, CacheKey::new().descriptor_key("proc", "chk"));
}
/// Redoing analysis mid-build must produce the key the next build
/// computes from scratch, where analyzer pieces come before tool
/// versions.
#[test]
fn replace_matches_a_key_built_in_phase_order() {
let mut fresh = CacheKey::new();
fresh.push(Component::Config, "c");
fresh.push(Component::Analyzer, "new");
fresh.push(Component::ToolVersion, "t");
let mut had_piece = CacheKey::new();
had_piece.push(Component::Config, "c");
had_piece.push(Component::Analyzer, "old");
had_piece.push(Component::ToolVersion, "t");
had_piece.replace(Component::Analyzer, vec!["new".into()]);
assert_eq!(had_piece, fresh);
let mut had_none = CacheKey::new();
had_none.push(Component::Config, "c");
had_none.push(Component::ToolVersion, "t");
had_none.replace(Component::Analyzer, vec!["new".into()]);
assert_eq!(had_none, fresh);
let mut cleared = fresh.clone();
cleared.replace(Component::Analyzer, Vec::new());
assert_eq!(cleared, had_none_without_piece());
}
fn had_none_without_piece() -> CacheKey {
let mut key = CacheKey::new();
key.push(Component::Config, "c");
key.push(Component::ToolVersion, "t");
key
}
#[test]
fn components_are_reported_in_contribution_order() {
let mut key = CacheKey::new();
key.push(Component::Config, "c");
key.push(Component::Analyzer, "a");
key.push(Component::ToolVersion, "t");
let tags: Vec<_> = key.components().iter().map(|(c, _)| c.tag()).collect();
assert_eq!(tags, vec!["config", "analyzer", "tool"]);
}
}