zccache 1.12.16

Local-first compiler cache for C/C++/Rust/Emscripten
Documentation
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
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! Rust artifact plan schema, validation, and wire-facing types.

use std::collections::BTreeSet;
use std::path::Path;

use prost::Message;
use serde::{Deserialize, Serialize};
use crate::core::NormalizedPath;

use super::proto::{plan_from_proto, plan_to_proto, rust_plan_proto};

/// Supported Rust artifact plan schema version.
pub const RUST_ARTIFACT_PLAN_SCHEMA_VERSION: u32 = 1;
/// Supported cache bundle schema versions soldr may send. v1 is the legacy
/// shape (thin-v1 / full). v2 is the `thin-v2` opt-in described in soldr#461:
/// it adds the `cache_profile` and `dropped_artifact_classes` fields and
/// splits the legacy `cargo_fingerprint` class into `cargo_fingerprint_meta`
/// (kept) and `cargo_fingerprint_outputs` (dropped). zccache accepts both so
/// older soldr builds keep working unchanged.
pub const SUPPORTED_RUST_ARTIFACT_CACHE_SCHEMA_VERSIONS: &[u32] = &[1, 2, 3];
/// Stable policy identifier for the thin-v3 ownership partition.
pub const THIN_V3_OWNERSHIP_POLICY_ID: &str = "thin-v3-lifetime-partition-v1";
/// Cache schema version zccache writes into bundle manifests it creates.
/// Pinned at 1 so the on-disk manifest format stays stable across the
/// thin-v2 opt-in — the v2 wire fields are inputs to the save walker, not
/// outputs in the manifest itself.
pub const RUST_ARTIFACT_CACHE_SCHEMA_VERSION: u32 = 1;

/// Errors returned by plan loading and execution.
#[derive(Debug, thiserror::Error)]
pub enum RustPlanError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("protobuf encode error: {0}")]
    ProtobufEncode(#[from] prost::EncodeError),
    #[error("protobuf decode error: {0}")]
    ProtobufDecode(#[from] prost::DecodeError),
    #[error(
        "unsupported Rust artifact plan schema version {found}; supported version is {supported}"
    )]
    UnsupportedSchemaVersion { found: u32, supported: u32 },
    #[error(
        "unsupported Rust artifact cache schema version {found}; supported version is {supported}"
    )]
    UnsupportedCacheSchemaVersion { found: u32, supported: u32 },
    #[error("invalid Rust artifact plan: {0}")]
    InvalidPlan(String),
    #[error("Rust artifact bundle is missing: {0}")]
    BundleMissing(NormalizedPath),
    #[error("invalid Rust artifact bundle manifest: {0}")]
    InvalidManifest(String),
    #[error("unsafe relative artifact path in bundle: {0}")]
    UnsafeRelativePath(String),
}

/// Rust artifact plan mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RustPlanMode {
    /// Restore/save bounded dependency artifacts and Cargo freshness metadata.
    Thin,
    /// Restore/save the target tree explicitly, except transient state.
    Full,
}

impl std::fmt::Display for RustPlanMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Thin => write!(f, "thin"),
            Self::Full => write!(f, "full"),
        }
    }
}

/// Artifact classes that a plan may allow.
///
/// New variants added for the soldr `thin-v2` profile (soldr#461):
/// `CargoFingerprintMeta` / `CargoFingerprintOutputs` split the legacy
/// `CargoFingerprint` umbrella into freshness inputs vs. outputs;
/// `Incremental` / `BuildScriptBuild` / `Dwo` / `Pdb` / `Dsym` enumerate
/// categories that thin-v2 explicitly drops so the wire-format
/// `dropped_artifact_classes` list can name them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RustArtifactClass {
    Rlib,
    Rmeta,
    DepInfo,
    ProcMacro,
    SharedLib,
    /// Legacy thin-v1 umbrella over the `.fingerprint/<crate>/` directory.
    /// Kept for backwards compatibility; thin-v2 splits this into
    /// `CargoFingerprintMeta` (freshness inputs cargo reads) and
    /// `CargoFingerprintOutputs` (everything else in that directory).
    CargoFingerprint,
    /// Freshness-input files inside `<profile>/.fingerprint/<crate>-<hash>/`:
    /// `invoked.timestamp`, `dep-*`, `output-*`, `lib-*`, `bin-*`. Cargo
    /// reads these to decide skip-vs-rebuild, so thin-v2 keeps them.
    CargoFingerprintMeta,
    /// Non-meta files inside `<profile>/.fingerprint/<crate>-<hash>/`. Dropped
    /// by thin-v2 — they are outputs of past compilations, not inputs to the
    /// next freshness decision.
    CargoFingerprintOutputs,
    BuildScriptMetadata,
    BuildScriptOutput,
    /// Compiled build-script binary at `target/<profile>/build/*/build-script-build*`.
    /// thin-v2 drops these — they are cheap to regenerate from cached deps.
    BuildScriptBuild,
    /// Anything under `target/<profile>/incremental/`. Always transient state;
    /// thin-v2 names it explicitly so the drop list is exhaustive.
    Incremental,
    /// `.dwo` split-DWARF files under `deps/`. Dropped by thin-v2.
    Dwo,
    /// `.pdb` Windows debug files under `deps/`. Dropped by thin-v2.
    Pdb,
    /// `.dSYM/` macOS debug bundles under `deps/` (directory bundles — every
    /// file inside the bundle is classified as `Dsym`). Dropped by thin-v2.
    Dsym,
    FullTarget,
}

/// Toolchain identity supplied by soldr.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustToolchainIdentity {
    pub rustc: String,
    pub cargo: String,
    pub channel: String,
    pub host: String,
}

/// Input hashes that affect Cargo build outputs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustPlanInputs {
    pub features_hash: String,
    pub rustflags_hash: String,
    pub env_hash: String,
    pub lockfile_hash: String,
    pub cargo_config_hash: String,
    #[serde(default)]
    pub manifest_hashes: Vec<String>,
}

/// Package IDs selected or excluded by the planner.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustPlanPackages {
    #[serde(default)]
    pub selected_package_ids: Vec<String>,
    #[serde(default)]
    pub workspace_package_ids: Vec<String>,
    #[serde(default)]
    pub excluded_path_package_ids: Vec<String>,
    /// Policy selected by the producer for ownership-aware durable exports.
    /// Absent for legacy thin-v1/v2 plans.
    #[serde(default)]
    pub ownership_policy: Option<String>,
    /// Durable ownership mode. `None` preserves the legacy selection rules.
    #[serde(default)]
    pub ownership_mode: Option<RustPlanOwnershipMode>,
    /// Resolved owner for every path the producer can classify. Paths are
    /// target-directory-relative and are never interpreted as storage paths.
    #[serde(default)]
    pub artifact_owners: Vec<RustPlanArtifactOwner>,
    /// True only when the producer has complete ownership coverage. A
    /// cook-partitioned export fails closed when this is false.
    #[serde(default)]
    pub ownership_complete: bool,
}

/// Lifetime partition selected for a thin-v3 durable export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RustPlanOwnershipMode {
    CookPartitionedV1,
    ZccacheAllV1,
}

/// Durable owner supplied by soldr's resolved package classifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RustPlanArtifactOwnerKind {
    Cook,
    Zccache,
    ThinV3,
    None,
}

/// Ownership record for a target-directory-relative artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustPlanArtifactOwner {
    pub relative_path: String,
    pub package_id: String,
    pub owner: RustPlanArtifactOwnerKind,
}

/// Versioned v1 Rust artifact cache plan.
///
/// Wire-compat note (soldr#461): the previous version used
/// `#[serde(deny_unknown_fields)]`, which made every future soldr addition
/// a coordinated breaking change. The `thin-v2` rollout added
/// `cache_profile` and `dropped_artifact_classes` as new top-level fields,
/// so we now accept (and ignore) unknown fields here. The explicit fields
/// below cover everything zccache acts on; anything else soldr ships in a
/// future plan is silently dropped during deserialization rather than
/// crashing the save/restore.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RustArtifactPlanV1 {
    pub schema_version: u32,
    pub mode: RustPlanMode,
    pub workspace_root: NormalizedPath,
    pub target_dir: NormalizedPath,
    pub toolchain: RustToolchainIdentity,
    pub target_triple: String,
    pub profile: String,
    pub inputs: RustPlanInputs,
    pub packages: RustPlanPackages,
    #[serde(default)]
    pub allowed_artifact_classes: Vec<RustArtifactClass>,
    pub cache_schema_version: u32,
    #[serde(default)]
    pub journal_log_path: Option<NormalizedPath>,
    /// Thin-slice pruning profile selected by soldr. `None` for legacy plans
    /// (thin-v1 / full) that predate soldr#461; `Some("thin-v2")` opts in to
    /// the fingerprint-aware prune.
    #[serde(default)]
    pub cache_profile: Option<String>,
    /// Artifact classes soldr explicitly wants dropped from the saved bundle.
    /// Honored regardless of what `allowed_artifact_classes` says — a file
    /// whose class appears here is skipped even if its class is also in the
    /// allow-list. Empty for legacy plans.
    #[serde(default)]
    pub dropped_artifact_classes: Vec<RustArtifactClass>,
    /// Relative paths reported by Cargo's JSON artifact stream.
    #[serde(default)]
    pub cargo_artifact_paths: Vec<String>,
    #[serde(default)]
    pub cargo_artifacts_complete: bool,
}

impl RustArtifactPlanV1 {
    /// Load, version-check, and validate a plan from a protobuf or legacy JSON file.
    pub fn load(path: &Path) -> Result<Self, RustPlanError> {
        let raw = std::fs::read(path)?;
        if is_probably_json_plan(&raw) {
            let raw = std::str::from_utf8(&raw).map_err(|err| {
                RustPlanError::InvalidPlan(format!("JSON plan is not valid UTF-8: {err}"))
            })?;
            return Self::from_json_str(raw);
        }
        Self::from_proto_bytes(&raw)
    }

    /// Load, version-check, and validate a plan from a JSON string.
    pub fn from_json_str(raw: &str) -> Result<Self, RustPlanError> {
        let value: serde_json::Value = serde_json::from_str(raw.trim_start_matches('\u{feff}'))?;
        Self::from_json_value(value)
    }

    /// Load, version-check, and validate a plan from protobuf bytes.
    pub fn from_proto_bytes(raw: &[u8]) -> Result<Self, RustPlanError> {
        let proto = rust_plan_proto::RustArtifactPlanV1::decode(raw)?;
        plan_from_proto(proto)
    }

    /// Encode this plan as compact protobuf bytes.
    pub fn to_proto_bytes(&self) -> Result<Vec<u8>, RustPlanError> {
        let mut bytes = Vec::new();
        plan_to_proto(self).encode(&mut bytes)?;
        Ok(bytes)
    }

    /// Load, version-check, and validate a plan from a JSON value.
    pub fn from_json_value(value: serde_json::Value) -> Result<Self, RustPlanError> {
        let schema_version = json_u32_field(&value, "schema_version")?;
        if schema_version != RUST_ARTIFACT_PLAN_SCHEMA_VERSION {
            return Err(RustPlanError::UnsupportedSchemaVersion {
                found: schema_version,
                supported: RUST_ARTIFACT_PLAN_SCHEMA_VERSION,
            });
        }

        let cache_schema_version = json_u32_field(&value, "cache_schema_version")?;
        ensure_supported_cache_schema_version(cache_schema_version)?;

        let plan: Self = serde_json::from_value(value)?;
        plan.validate()?;
        Ok(plan)
    }

    /// Validate fields whose constraints are outside serde's type checks.
    pub fn validate(&self) -> Result<(), RustPlanError> {
        let mut errors = Vec::new();
        if self.profile.trim().is_empty() {
            errors.push("profile must not be empty");
        }
        if self.target_triple.trim().is_empty() {
            errors.push("target_triple must not be empty");
        }
        if self.toolchain.rustc.trim().is_empty() {
            errors.push("toolchain.rustc must not be empty");
        }
        if self.toolchain.cargo.trim().is_empty() {
            errors.push("toolchain.cargo must not be empty");
        }
        if self.toolchain.channel.trim().is_empty() {
            errors.push("toolchain.channel must not be empty");
        }
        if self.toolchain.host.trim().is_empty() {
            errors.push("toolchain.host must not be empty");
        }
        if self.workspace_root.as_os_str().is_empty() {
            errors.push("workspace_root must not be empty");
        }
        if self.target_dir.as_os_str().is_empty() {
            errors.push("target_dir must not be empty");
        }
        match (
            &self.packages.ownership_policy,
            self.packages.ownership_mode,
        ) {
            (None, None) => {}
            (Some(policy), Some(RustPlanOwnershipMode::CookPartitionedV1)) => {
                if policy != THIN_V3_OWNERSHIP_POLICY_ID {
                    errors.push("unsupported thin-v3 ownership policy");
                }
                if !self.packages.ownership_complete {
                    errors.push("cook-partitioned ownership must be complete");
                }
            }
            (Some(policy), Some(RustPlanOwnershipMode::ZccacheAllV1)) => {
                if policy != THIN_V3_OWNERSHIP_POLICY_ID {
                    errors.push("unsupported thin-v3 ownership policy");
                }
            }
            _ => errors.push("ownership policy and mode must be supplied together"),
        }
        if errors.is_empty() {
            Ok(())
        } else {
            Err(RustPlanError::InvalidPlan(errors.join("; ")))
        }
    }

    /// Effective allowed classes for thin mode.
    #[must_use]
    pub fn effective_allowed_classes(&self) -> BTreeSet<RustArtifactClass> {
        if self.allowed_artifact_classes.is_empty() {
            default_thin_classes()
        } else {
            self.allowed_artifact_classes.iter().copied().collect()
        }
    }
}

pub(super) fn is_probably_json_plan(raw: &[u8]) -> bool {
    let without_bom = raw.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(raw);
    without_bom
        .iter()
        .copied()
        .find(|byte| !byte.is_ascii_whitespace())
        .is_some_and(|byte| byte == b'{')
}

pub(super) fn default_thin_classes() -> BTreeSet<RustArtifactClass> {
    [
        RustArtifactClass::Rlib,
        RustArtifactClass::Rmeta,
        RustArtifactClass::DepInfo,
        RustArtifactClass::ProcMacro,
        RustArtifactClass::SharedLib,
        RustArtifactClass::CargoFingerprint,
        RustArtifactClass::BuildScriptMetadata,
        RustArtifactClass::BuildScriptOutput,
    ]
    .into_iter()
    .collect()
}

pub(super) fn json_u32_field(
    value: &serde_json::Value,
    field: &'static str,
) -> Result<u32, RustPlanError> {
    let Some(raw) = value.get(field) else {
        return Err(RustPlanError::InvalidPlan(format!("{field} is required")));
    };
    let Some(n) = raw.as_u64() else {
        return Err(RustPlanError::InvalidPlan(format!(
            "{field} must be an unsigned integer"
        )));
    };
    u32::try_from(n).map_err(|_| RustPlanError::InvalidPlan(format!("{field} is too large")))
}

pub(super) fn ensure_supported_cache_schema_version(
    cache_schema_version: u32,
) -> Result<(), RustPlanError> {
    if SUPPORTED_RUST_ARTIFACT_CACHE_SCHEMA_VERSIONS.contains(&cache_schema_version) {
        Ok(())
    } else {
        // Report the most recent supported version in the error message so
        // operators see "expected 2" once thin-v2 lands instead of always
        // seeing the legacy "expected 1". The error type still carries the
        // single canonical version field for compat with existing
        // pattern-matching consumers.
        let supported = SUPPORTED_RUST_ARTIFACT_CACHE_SCHEMA_VERSIONS
            .iter()
            .copied()
            .max()
            .unwrap_or(RUST_ARTIFACT_CACHE_SCHEMA_VERSION);
        Err(RustPlanError::UnsupportedCacheSchemaVersion {
            found: cache_schema_version,
            supported,
        })
    }
}