Skip to main content

running_process/broker/protocol_v2/
manifest_io.rs

1//! v2 cache-manifest I/O helpers (slice 23-A of zccache#782).
2//!
3//! Mirrors the v1 [`super::super::manifest`] surface that consumers
4//! (zccache, fbuild, soldr) use today — `CacheManifestBuilder`,
5//! `write_to_root`, `write_to_central[_in_dir]`, `central_registry_dir`
6//! — against the v2 [`CacheManifest`] / [`CacheRoot`] / [`CacheRootKind`]
7//! types added in [`super::super::protocol_v2`].
8//!
9//! Per the v1↔v2 coexistence design (#470), the two write paths use
10//! distinct file extensions so a single registry directory can carry
11//! both formats:
12//!
13//! | format | per-cache-root file | central registry file |
14//! |---|---|---|
15//! | v1 | `.running-process-manifest.pb` | `<svc>-<ver>.pb` |
16//! | v2 | `.running-process-manifest.v2.pb` | `<svc>-<ver>.v2.pb` |
17//!
18//! Slice 23-A ships only the WRITE side + extension constants. A
19//! verifying loader (signature check, host-identity match,
20//! self_sha256) lands when a v2 broker actually consumes these files
21//! — separate slice in [`zackees/running-process#523`].
22
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use prost::Message as _;
28
29use super::super::manifest::ManifestError;
30use super::super::secure_dir;
31use super::{CacheManifest, CacheRoot, CacheRootKind};
32
33/// v2 file name written inside `<cache_root>/`. Distinct from
34/// v1's `.running-process-manifest.pb` so a v1 broker never decodes
35/// a v2 file by accident (and vice versa).
36pub const ROOT_MANIFEST_FILE_V2: &str = ".running-process-manifest.v2.pb";
37
38/// v2 file extension used for entries in the central manifest registry.
39/// Mirrors v1's `pb` extension with a `v2.pb` distinguisher.
40pub const CENTRAL_MANIFEST_EXTENSION_V2: &str = "v2.pb";
41
42/// Constant carried in every v2 manifest's
43/// [`CacheManifest::broker_envelope_version`] field. Pins the schema
44/// generation from the proto side independently of the file name.
45pub const BROKER_ENVELOPE_VERSION_V2: &str = "v2";
46
47/// Return the platform central-registry directory (same path as v1).
48///
49/// `RUNNING_PROCESS_MANIFEST_DIR` is honored as a test override —
50/// callers MUST NOT set this in production. Production callers leave
51/// it unset and rely on the per-OS default.
52///
53/// Per v1↔v2 coexistence, v2 manifest files coexist with v1's in the
54/// same directory; the file extension (`.v2.pb` vs `.pb`) is what
55/// keeps them distinct.
56#[must_use]
57pub fn central_registry_dir_v2() -> PathBuf {
58    super::super::manifest::central_registry_dir()
59}
60
61/// Builder for [`CacheManifest`]. Mirrors v1's
62/// [`super::super::builders::CacheManifestBuilder`] API verbatim so
63/// the consumer-side migration is a literal s/v1::/v2::/ swap.
64#[derive(Debug, Clone)]
65pub struct CacheManifestBuilder {
66    manifest: CacheManifest,
67}
68
69impl CacheManifestBuilder {
70    /// Begin a v2 manifest for `service_name` at `service_version`.
71    ///
72    /// Pre-populates [`CacheManifest::broker_envelope_version`] = `"v2"`
73    /// plus the unix-ms `created_at` / `last_active` timestamps so the
74    /// callee only has to chain `.root(...)` calls.
75    #[must_use]
76    pub fn new(service_name: impl Into<String>, service_version: impl Into<String>) -> Self {
77        let now = now_unix_ms();
78        Self {
79            manifest: CacheManifest {
80                service_name: service_name.into(),
81                service_version: service_version.into(),
82                broker_envelope_version: BROKER_ENVELOPE_VERSION_V2.to_owned(),
83                created_at_unix_ms: now,
84                last_active_unix_ms: now,
85                ..Default::default()
86            },
87        }
88    }
89
90    /// Append one cache root of the given kind at `path`.
91    #[must_use]
92    pub fn root(mut self, kind: CacheRootKind, path: impl Into<String>) -> Self {
93        self.manifest.roots.push(CacheRoot {
94            kind: kind as i32,
95            path: path.into(),
96        });
97        self
98    }
99
100    /// Set the broker instance label (e.g. `"shared"` or an
101    /// explicit-instance trust group).
102    #[must_use]
103    pub fn broker_instance(mut self, instance: impl Into<String>) -> Self {
104        self.manifest.broker_instance = instance.into();
105        self
106    }
107
108    /// Set the manifest bundle id (deploy hint for multi-service bundles).
109    #[must_use]
110    pub fn bundle_id(mut self, bundle_id: impl Into<String>) -> Self {
111        self.manifest.bundle_id = bundle_id.into();
112        self
113    }
114
115    /// Finalize into a [`CacheManifest`] without writing anywhere.
116    #[must_use]
117    pub fn build(self) -> CacheManifest {
118        self.manifest
119    }
120
121    /// Build + write into the platform's central registry, returning
122    /// the written path.
123    ///
124    /// # Errors
125    ///
126    /// See [`write_to_central_v2`].
127    pub fn publish(self) -> Result<PathBuf, ManifestError> {
128        let manifest = self.build();
129        write_to_central_v2(&manifest.service_name, &manifest.service_version, &manifest)
130    }
131
132    /// Testable variant of [`Self::publish`] with an explicit registry
133    /// directory.
134    ///
135    /// # Errors
136    ///
137    /// See [`write_to_central_in_dir_v2`].
138    pub fn publish_in(self, registry_dir: &Path) -> Result<PathBuf, ManifestError> {
139        let manifest = self.build();
140        write_to_central_in_dir_v2(
141            registry_dir,
142            &manifest.service_name,
143            &manifest.service_version,
144            &manifest,
145        )
146    }
147}
148
149/// Write `<cache_root>/.running-process-manifest.v2.pb` atomically.
150///
151/// # Errors
152///
153/// - [`ManifestError::Io`] on filesystem failures.
154/// - [`ManifestError::InsecureRegistry`] (re-used for the cache root)
155///   when the directory exists but has insecure permissions.
156pub fn write_to_root_v2(cache_root: &Path, manifest: &CacheManifest) -> Result<(), ManifestError> {
157    fs::create_dir_all(cache_root)?;
158    secure_dir::ensure_private_dir(cache_root)?;
159    let target = cache_root.join(ROOT_MANIFEST_FILE_V2);
160    write_manifest_file_v2(&target, manifest)
161}
162
163/// Write `<central_registry>/{service}-{version}.v2.pb` atomically.
164///
165/// # Errors
166///
167/// See [`write_to_root_v2`] (filesystem + permissions) plus
168/// [`ManifestError::InvalidName`] when `service_name` / `version`
169/// fail validation.
170pub fn write_to_central_v2(
171    service_name: &str,
172    version: &str,
173    manifest: &CacheManifest,
174) -> Result<PathBuf, ManifestError> {
175    let dir = central_registry_dir_v2();
176    write_to_central_in_dir_v2(&dir, service_name, version, manifest)
177}
178
179/// Testable variant of [`write_to_central_v2`] with an explicit
180/// registry directory (tests, custom layouts).
181///
182/// # Errors
183///
184/// See [`write_to_central_v2`].
185pub fn write_to_central_in_dir_v2(
186    registry_dir: &Path,
187    service_name: &str,
188    version: &str,
189    manifest: &CacheManifest,
190) -> Result<PathBuf, ManifestError> {
191    super::super::manifest::ensure_central_registry_dir(registry_dir)?;
192    let target = central_manifest_path_v2(registry_dir, service_name, version)?;
193    write_manifest_file_v2(&target, manifest)?;
194    Ok(target)
195}
196
197/// Compute the v2 central-registry file path for one (service, version)
198/// pair. Mirrors v1's `central_manifest_path` with the `.v2.pb` suffix.
199///
200/// # Errors
201///
202/// Surfaces [`ManifestError::InvalidName`] when the service name
203/// fails validation (delegates to the shared v1 validator since the
204/// name rules are cross-version-stable per #228).
205pub fn central_manifest_path_v2(
206    registry_dir: &Path,
207    service_name: &str,
208    version: &str,
209) -> Result<PathBuf, ManifestError> {
210    // Delegate to the shared validator to keep the rules identical to v1.
211    super::super::manifest::central_manifest_path(registry_dir, service_name, version).map(
212        |v1_path| {
213            // v1 returns `<registry>/<svc>-<ver>.pb`. Re-stem to `.v2.pb`
214            // by stripping the `.pb` and appending `.v2.pb`.
215            let stem = v1_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
216            registry_dir.join(format!("{stem}.{CENTRAL_MANIFEST_EXTENSION_V2}"))
217        },
218    )
219}
220
221fn write_manifest_file_v2(target: &Path, manifest: &CacheManifest) -> Result<(), ManifestError> {
222    let bytes = manifest.encode_to_vec();
223    super::super::manifest::write_atomic(target, &bytes)
224}
225
226fn now_unix_ms() -> u64 {
227    SystemTime::now()
228        .duration_since(UNIX_EPOCH)
229        .map(|d| d.as_millis() as u64)
230        .unwrap_or(0)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use tempfile::tempdir;
237
238    #[test]
239    fn root_manifest_filename_is_v2() {
240        assert_eq!(ROOT_MANIFEST_FILE_V2, ".running-process-manifest.v2.pb");
241    }
242
243    #[test]
244    fn central_extension_is_v2_pb() {
245        assert_eq!(CENTRAL_MANIFEST_EXTENSION_V2, "v2.pb");
246    }
247
248    #[test]
249    fn envelope_version_is_v2() {
250        assert_eq!(BROKER_ENVELOPE_VERSION_V2, "v2");
251    }
252
253    #[test]
254    fn builder_new_populates_required_fields() {
255        let manifest = CacheManifestBuilder::new("svc", "1.0.0").build();
256        assert_eq!(manifest.service_name, "svc");
257        assert_eq!(manifest.service_version, "1.0.0");
258        assert_eq!(manifest.broker_envelope_version, "v2");
259        assert!(manifest.created_at_unix_ms > 0);
260        assert_eq!(manifest.created_at_unix_ms, manifest.last_active_unix_ms);
261        assert!(manifest.roots.is_empty());
262    }
263
264    #[test]
265    fn builder_root_appends_in_order() {
266        let manifest = CacheManifestBuilder::new("svc", "1.0.0")
267            .root(CacheRootKind::CacheData, "/var/cache/svc")
268            .root(CacheRootKind::CacheIndex, "/var/cache/svc/index")
269            .root(CacheRootKind::CacheLogs, "/var/log/svc")
270            .root(CacheRootKind::CacheLocks, "/var/cache/svc/locks")
271            .build();
272        assert_eq!(manifest.roots.len(), 4);
273        assert_eq!(manifest.roots[0].kind, CacheRootKind::CacheData as i32);
274        assert_eq!(manifest.roots[0].path, "/var/cache/svc");
275        assert_eq!(manifest.roots[1].kind, CacheRootKind::CacheIndex as i32);
276        assert_eq!(manifest.roots[2].kind, CacheRootKind::CacheLogs as i32);
277        assert_eq!(manifest.roots[3].kind, CacheRootKind::CacheLocks as i32);
278    }
279
280    /// v2 wire values mirror v1's exactly so consumers that bridge the
281    /// two generations (zccache, fbuild) can `as i32`-cast across
282    /// without translation. Pins every variant so a future renumber
283    /// forces an explicit migration of every consumer instead of
284    /// silently misclassifying.
285    #[test]
286    fn cache_root_kind_wire_values_mirror_v1() {
287        assert_eq!(CacheRootKind::Unspecified as i32, 0);
288        assert_eq!(CacheRootKind::CacheData as i32, 1);
289        assert_eq!(CacheRootKind::CacheLogs as i32, 2);
290        assert_eq!(CacheRootKind::CacheLocks as i32, 3);
291        assert_eq!(CacheRootKind::CacheRuntime as i32, 4);
292        assert_eq!(CacheRootKind::CacheTmp as i32, 5);
293        assert_eq!(CacheRootKind::CacheConfig as i32, 6);
294        assert_eq!(CacheRootKind::CacheIndex as i32, 7);
295        assert_eq!(CacheRootKind::CacheJournal as i32, 8);
296        assert_eq!(CacheRootKind::CacheSecrets as i32, 9);
297    }
298
299    #[test]
300    fn builder_broker_instance_and_bundle_id_round_trip() {
301        let manifest = CacheManifestBuilder::new("svc", "1.0.0")
302            .broker_instance("ci-trusted")
303            .bundle_id("bundle-42")
304            .build();
305        assert_eq!(manifest.broker_instance, "ci-trusted");
306        assert_eq!(manifest.bundle_id, "bundle-42");
307    }
308
309    #[test]
310    fn write_to_root_v2_writes_to_canonical_filename() {
311        let dir = tempdir().expect("tempdir");
312        let manifest = CacheManifestBuilder::new("svc", "1.0.0")
313            .root(CacheRootKind::CacheData, "/path/to/data")
314            .build();
315        write_to_root_v2(dir.path(), &manifest).expect("write_to_root_v2");
316
317        let written = dir.path().join(ROOT_MANIFEST_FILE_V2);
318        assert!(written.exists(), "v2 manifest file must exist");
319
320        let bytes = fs::read(&written).expect("read");
321        let decoded = CacheManifest::decode(bytes.as_slice()).expect("decode");
322        assert_eq!(decoded.service_name, "svc");
323        assert_eq!(decoded.roots.len(), 1);
324        assert_eq!(decoded.roots[0].path, "/path/to/data");
325    }
326
327    #[test]
328    fn publish_in_writes_to_central_with_v2_extension() {
329        let dir = tempdir().expect("tempdir");
330        let path = CacheManifestBuilder::new("svc", "1.2.3")
331            .root(CacheRootKind::CacheData, "/path")
332            .publish_in(dir.path())
333            .expect("publish_in");
334
335        assert_eq!(
336            path.file_name().and_then(|s| s.to_str()),
337            Some("svc-1.2.3.v2.pb")
338        );
339        let bytes = fs::read(&path).expect("read");
340        let decoded = CacheManifest::decode(bytes.as_slice()).expect("decode");
341        assert_eq!(decoded.service_name, "svc");
342        assert_eq!(decoded.service_version, "1.2.3");
343    }
344
345    #[test]
346    fn publish_in_rejects_invalid_service_name() {
347        let dir = tempdir().expect("tempdir");
348        let manifest = CacheManifest {
349            service_name: "BAD-Caps".to_owned(),
350            service_version: "1.0.0".to_owned(),
351            ..Default::default()
352        };
353        let err = write_to_central_in_dir_v2(dir.path(), "BAD-Caps", "1.0.0", &manifest)
354            .expect_err("must reject");
355        let _ = err;
356    }
357
358    /// Round-trip: write via builder, read raw bytes, assert every
359    /// builder-set field survives. Pins the contract from a different
360    /// angle than the standalone proto round-trip tests in `mod.rs`.
361    #[test]
362    fn builder_publish_round_trip_preserves_every_field() {
363        let dir = tempdir().expect("tempdir");
364        let path = CacheManifestBuilder::new("zccache", "1.12.9")
365            .root(CacheRootKind::CacheData, "/var/cache/zccache/data")
366            .root(CacheRootKind::CacheIndex, "/var/cache/zccache/index")
367            .root(CacheRootKind::CacheLogs, "/var/log/zccache")
368            .broker_instance("shared")
369            .bundle_id("zccache-bundle-v1")
370            .publish_in(dir.path())
371            .expect("publish_in");
372
373        let bytes = fs::read(&path).expect("read");
374        let decoded = CacheManifest::decode(bytes.as_slice()).expect("decode");
375        assert_eq!(decoded.service_name, "zccache");
376        assert_eq!(decoded.service_version, "1.12.9");
377        assert_eq!(decoded.broker_envelope_version, "v2");
378        assert_eq!(decoded.roots.len(), 3);
379        assert_eq!(decoded.broker_instance, "shared");
380        assert_eq!(decoded.bundle_id, "zccache-bundle-v1");
381        assert!(decoded.created_at_unix_ms > 0);
382    }
383
384    /// Coexistence: a v1 file (`.pb`) and a v2 file (`.v2.pb`) for the
385    /// same (service, version) can live in the same registry dir
386    /// without colliding.
387    #[test]
388    fn v2_central_filename_does_not_collide_with_v1() {
389        let dir = tempdir().expect("tempdir");
390        let v1_path = dir.path().join("zccache-1.12.9.pb");
391        let v2_path = central_manifest_path_v2(dir.path(), "zccache", "1.12.9").unwrap();
392        assert_ne!(v1_path, v2_path);
393        assert_eq!(
394            v2_path.file_name().and_then(|s| s.to_str()),
395            Some("zccache-1.12.9.v2.pb")
396        );
397    }
398}