Skip to main content

rch_common/
pooled_target_key.rs

1//! Pooled target-dir cache key (bd-session-history-remediation-ocv9i.11.2).
2//!
3//! Pooled Rust target dirs let independent builds share a warm cache instead of
4//! each carving a fresh `target/` — but only when sharing is *safe*. Two builds
5//! may reuse the same pooled dir iff every dimension that can change the
6//! compiled artifacts matches: the originating repo, the toolchain, the target
7//! triple, the cargo profile, and the enabled feature/runtime set. Mixing any
8//! of these in one directory corrupts the cache (stale rebuilds, wrong-feature
9//! artifacts, cross-project contamination) — the exact failure session history
10//! attributed to ad-hoc shared target dirs.
11//!
12//! [`PooledTargetDimensions`] captures those dimensions and
13//! [`PooledTargetKey`] derives a stable, filesystem-safe key from them. The
14//! derivation is **pure** (no clock, no env) so it is unit-testable and
15//! reproducible: identical dimensions always yield the same key, and any
16//! difference yields a different one. The hash is domain-separated per field so
17//! adjacent fields can never run together into a colliding key (e.g. repo `ab`
18//! + toolchain `c` must not equal repo `a` + toolchain `bc`).
19//!
20//! The reaper that *evicts* stale pooled dirs, the active-build protection that
21//! pins in-use keys, and the opt-out config knob are the daemon-side half of
22//! this bead; this module is the shared key contract both the pooled-dir layout
23//! and the reaper key off.
24
25use blake3::Hasher;
26
27/// Root directory name under which pooled target dirs live, mirroring the
28/// `.rch-target` convention RCH already rewrites `CARGO_TARGET_DIR` to.
29pub const POOLED_TARGET_ROOT: &str = ".rch-pool";
30
31/// The dimensions that must match for two builds to safely share a pooled
32/// target dir. Any field differing means a different (non-shared) pooled dir.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PooledTargetDimensions {
35    /// Stable repo identity (e.g. the canonical project root or its hash).
36    /// Prevents cross-project contamination of a shared pool.
37    pub repo_identity: String,
38    /// Toolchain channel or pinned version (e.g. `nightly-2025-11-01`).
39    pub toolchain: String,
40    /// Target triple (e.g. `x86_64-unknown-linux-gnu`, `wasm32-unknown-unknown`).
41    pub target_triple: String,
42    /// Cargo profile (e.g. `dev`, `release`).
43    pub profile: String,
44    /// Enabled cargo features. Order- and duplicate-insensitive (canonicalized
45    /// before hashing), so `["a","b"]` and `["b","a","a"]` share a pool.
46    pub features: Vec<String>,
47    /// Extra runtime dimension that changes artifacts (e.g. `bun`, `node`,
48    /// `wasm`), when relevant. `None` for a plain Rust build.
49    pub runtime: Option<String>,
50}
51
52impl PooledTargetDimensions {
53    /// Construct the minimal Rust build dimensions (no features/runtime).
54    #[must_use]
55    pub fn new(
56        repo_identity: impl Into<String>,
57        toolchain: impl Into<String>,
58        target_triple: impl Into<String>,
59        profile: impl Into<String>,
60    ) -> Self {
61        Self {
62            repo_identity: repo_identity.into(),
63            toolchain: toolchain.into(),
64            target_triple: target_triple.into(),
65            profile: profile.into(),
66            features: Vec::new(),
67            runtime: None,
68        }
69    }
70
71    /// Set the feature set (builder style); canonicalized at hash time.
72    #[must_use]
73    pub fn with_features<I, S>(mut self, features: I) -> Self
74    where
75        I: IntoIterator<Item = S>,
76        S: Into<String>,
77    {
78        self.features = features.into_iter().map(Into::into).collect();
79        self
80    }
81
82    /// Set the runtime dimension (builder style).
83    #[must_use]
84    pub fn with_runtime(mut self, runtime: impl Into<String>) -> Self {
85        self.runtime = Some(runtime.into());
86        self
87    }
88
89    /// Features sorted and de-duplicated — the canonical form used for hashing
90    /// and for an auditable view of what a pool key covers.
91    #[must_use]
92    pub fn canonical_features(&self) -> Vec<String> {
93        let mut f = self.features.clone();
94        f.sort();
95        f.dedup();
96        f
97    }
98}
99
100/// A derived pooled target-dir key. Stable across processes and hosts for the
101/// same dimensions.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct PooledTargetKey {
104    hex: String,
105}
106
107/// Feed one domain-separated, length-prefixed field into the hasher so adjacent
108/// fields can never concatenate into a colliding pre-image.
109fn absorb(hasher: &mut Hasher, tag: &str, value: &str) {
110    // tag\0 <len-as-decimal>\0 value\0 — the length prefix plus the NUL
111    // terminators make the framing unambiguous regardless of field contents.
112    hasher.update(tag.as_bytes());
113    hasher.update(b"\0");
114    hasher.update(value.len().to_string().as_bytes());
115    hasher.update(b"\0");
116    hasher.update(value.as_bytes());
117    hasher.update(b"\0");
118}
119
120impl PooledTargetKey {
121    /// Derive the key from dimensions. Pure and total.
122    #[must_use]
123    pub fn derive(dims: &PooledTargetDimensions) -> Self {
124        let mut hasher = Hasher::new();
125        absorb(&mut hasher, "v", "1"); // key-schema version, so we can rotate.
126        absorb(&mut hasher, "repo", &dims.repo_identity);
127        absorb(&mut hasher, "toolchain", &dims.toolchain);
128        absorb(&mut hasher, "triple", &dims.target_triple);
129        absorb(&mut hasher, "profile", &dims.profile);
130        absorb(
131            &mut hasher,
132            "runtime",
133            dims.runtime.as_deref().unwrap_or(""),
134        );
135        // Canonicalized features, count-prefixed then each length-framed, so the
136        // feature *set* (not its order or multiplicity) determines the key.
137        let features = dims.canonical_features();
138        absorb(&mut hasher, "features.n", &features.len().to_string());
139        for feature in &features {
140            absorb(&mut hasher, "feature", feature);
141        }
142        // 128-bit truncation: ample collision resistance for a cache key while
143        // keeping the directory name short.
144        let hex = hasher.finalize().to_hex();
145        Self {
146            hex: hex[..32].to_string(),
147        }
148    }
149
150    /// The stable hex key (filesystem-safe: lowercase hex only).
151    #[must_use]
152    pub fn as_str(&self) -> &str {
153        &self.hex
154    }
155
156    /// The pooled target directory name for this key, e.g.
157    /// `.rch-pool/<key>`. Always filesystem-safe.
158    #[must_use]
159    pub fn pooled_dir_name(&self) -> String {
160        format!("{POOLED_TARGET_ROOT}/{}", self.hex)
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn base() -> PooledTargetDimensions {
169        PooledTargetDimensions::new(
170            "/data/projects/acme",
171            "nightly-2025-11-01",
172            "x86_64-unknown-linux-gnu",
173            "dev",
174        )
175    }
176
177    fn key_of(dims: &PooledTargetDimensions) -> String {
178        PooledTargetKey::derive(dims).as_str().to_string()
179    }
180
181    #[test]
182    fn cache_reuse_identical_dimensions_share_a_key() {
183        // The whole point of pooling: same dimensions => same warm cache.
184        assert_eq!(key_of(&base()), key_of(&base()));
185    }
186
187    #[test]
188    fn feature_order_and_duplicates_do_not_change_the_key() {
189        let a = base().with_features(["serde", "tokio"]);
190        let b = base().with_features(["tokio", "serde", "serde"]);
191        assert_eq!(key_of(&a), key_of(&b), "feature SET must determine the key");
192    }
193
194    #[test]
195    fn incompatible_features_get_separate_pools() {
196        // Different feature sets must NOT share a pool (wrong-feature artifacts).
197        let none = base();
198        let with = base().with_features(["tokio"]);
199        let other = base().with_features(["async-std"]);
200        assert_ne!(key_of(&none), key_of(&with));
201        assert_ne!(key_of(&with), key_of(&other));
202    }
203
204    #[test]
205    fn distinct_repos_never_share_a_pool() {
206        let acme = base();
207        let other = PooledTargetDimensions::new(
208            "/data/projects/other",
209            "nightly-2025-11-01",
210            "x86_64-unknown-linux-gnu",
211            "dev",
212        );
213        assert_ne!(
214            key_of(&acme),
215            key_of(&other),
216            "cross-project contamination guard"
217        );
218    }
219
220    #[test]
221    fn field_boundaries_are_unambiguous_no_contamination() {
222        // repo `ab` + toolchain `c` must not collide with repo `a` + toolchain `bc`.
223        let left = PooledTargetDimensions::new("ab", "c", "t", "dev");
224        let right = PooledTargetDimensions::new("a", "bc", "t", "dev");
225        assert_ne!(key_of(&left), key_of(&right));
226        // Likewise across the triple/profile boundary.
227        let p1 = PooledTargetDimensions::new("r", "t", "abc", "");
228        let p2 = PooledTargetDimensions::new("r", "t", "ab", "c");
229        assert_ne!(key_of(&p1), key_of(&p2));
230    }
231
232    #[test]
233    fn each_dimension_change_changes_the_key() {
234        let b = base();
235        let bk = key_of(&b);
236        assert_ne!(
237            bk,
238            key_of(&PooledTargetDimensions::new(
239                b.repo_identity.clone(),
240                "nightly-2026-01-01",
241                b.target_triple.clone(),
242                b.profile.clone(),
243            )),
244            "toolchain"
245        );
246        assert_ne!(
247            bk,
248            key_of(&PooledTargetDimensions::new(
249                b.repo_identity.clone(),
250                b.toolchain.clone(),
251                "wasm32-unknown-unknown",
252                b.profile.clone(),
253            )),
254            "triple"
255        );
256        assert_ne!(
257            bk,
258            key_of(&PooledTargetDimensions::new(
259                b.repo_identity.clone(),
260                b.toolchain.clone(),
261                b.target_triple.clone(),
262                "release",
263            )),
264            "profile"
265        );
266        assert_ne!(bk, key_of(&base().with_runtime("bun")), "runtime");
267    }
268
269    #[test]
270    fn key_is_filesystem_safe_and_stable_length() {
271        let key = PooledTargetKey::derive(&base().with_features(["a", "b"]));
272        let s = key.as_str();
273        assert_eq!(s.len(), 32, "128-bit truncated hex");
274        assert!(
275            s.chars()
276                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
277            "lowercase hex only: {s}"
278        );
279        assert_eq!(key.pooled_dir_name(), format!(".rch-pool/{s}"));
280    }
281
282    #[test]
283    fn runtime_none_and_empty_string_are_equivalent() {
284        // A plain Rust build (runtime None) keys the same as runtime="" — the
285        // absence of a runtime dimension, not a distinct value.
286        let none = base();
287        let mut empty = base();
288        empty.runtime = Some(String::new());
289        assert_eq!(key_of(&none), key_of(&empty));
290    }
291}