Skip to main content

running_process/
daemon_registration_compat.rs

1//! Frozen v1 daemon-registration semantics for application-owned policy.
2//!
3//! The private substrate retains the established v1 record bytes, manifest
4//! seal, and owner-private persistence behavior. This facade deliberately
5//! does not select an endpoint, construct a broker client, or define product
6//! cache/service policy.
7
8use std::error::Error;
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12use crate::daemon_registration as backend;
13
14/// The v1 cache-root categories used by real registration consumers.
15///
16/// [`Self::Unknown`] preserves a decoded additive wire value without exposing
17/// a generated protocol enum.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum CacheRootKind {
20    /// Durable cache artifact data.
21    CacheData,
22    /// Dependency or artifact index data.
23    CacheIndex,
24    /// Cache log files.
25    CacheLogs,
26    /// Cache coordination/lock files.
27    CacheLocks,
28    /// Temporary cache files.
29    CacheTmp,
30    /// A frozen v1 root-kind value not yet assigned facade semantics.
31    Unknown(i32),
32}
33
34impl CacheRootKind {
35    fn from_backend(value: i32) -> Self {
36        match backend::protocol::CacheRootKind::try_from(value) {
37            Ok(backend::protocol::CacheRootKind::CacheData) => Self::CacheData,
38            Ok(backend::protocol::CacheRootKind::CacheIndex) => Self::CacheIndex,
39            Ok(backend::protocol::CacheRootKind::CacheLogs) => Self::CacheLogs,
40            Ok(backend::protocol::CacheRootKind::CacheLocks) => Self::CacheLocks,
41            Ok(backend::protocol::CacheRootKind::CacheTmp) => Self::CacheTmp,
42            Ok(_) | Err(_) => Self::Unknown(value),
43        }
44    }
45
46    fn into_backend(self) -> i32 {
47        match self {
48            Self::CacheData => backend::protocol::CacheRootKind::CacheData as i32,
49            Self::CacheIndex => backend::protocol::CacheRootKind::CacheIndex as i32,
50            Self::CacheLogs => backend::protocol::CacheRootKind::CacheLogs as i32,
51            Self::CacheLocks => backend::protocol::CacheRootKind::CacheLocks as i32,
52            Self::CacheTmp => backend::protocol::CacheRootKind::CacheTmp as i32,
53            Self::Unknown(value) => value,
54        }
55    }
56}
57
58/// A borrowed, ordered cache-root entry in a decoded manifest.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct CacheRoot<'a> {
61    kind: CacheRootKind,
62    path: &'a str,
63}
64
65impl<'a> CacheRoot<'a> {
66    /// Semantic root category.
67    #[must_use]
68    pub fn kind(self) -> CacheRootKind {
69        self.kind
70    }
71
72    /// Root path as it was recorded in the frozen v1 record.
73    #[must_use]
74    pub fn path(self) -> &'a str {
75        self.path
76    }
77}
78
79/// Failure while validating, persisting, or reading a frozen v1 registration.
80#[derive(Debug)]
81pub enum DaemonRegistrationError {
82    /// A filesystem operation failed.
83    Io(std::io::Error),
84    /// A persisted v1 record could not be decoded.
85    MalformedRecord,
86    /// A persisted manifest's required SHA-256 seal did not verify.
87    ManifestIntegrityFailure,
88    /// A manifest uses a schema newer than this facade supports.
89    UnsupportedManifestSchema {
90        /// Schema found in the persisted manifest.
91        got: u32,
92        /// Maximum schema this facade supports.
93        supported: u32,
94    },
95    /// A service name or version violates frozen v1 validation.
96    InvalidNameOrVersion {
97        /// Stable diagnostic supplied by the underlying validator.
98        detail: String,
99    },
100    /// A required registration path had no parent directory.
101    MissingParent {
102        /// Path rejected by the persistence layer.
103        path: PathBuf,
104    },
105    /// A registration directory was not private to its current user.
106    InsecureDirectory {
107        /// Directory rejected by the persistence layer.
108        path: PathBuf,
109    },
110    /// A loaded service-definition did not name the requested service.
111    ServiceNameMismatch {
112        /// Service name requested by the caller.
113        requested: String,
114        /// Service name stored in the record.
115        actual: String,
116    },
117    /// A service-definition binary or per-version directory was invalid.
118    InvalidServicePath {
119        /// Field rejected by frozen v1 validation.
120        field: &'static str,
121        /// Recorded path string.
122        path: String,
123        /// Stable validation reason.
124        reason: &'static str,
125    },
126    /// A service-definition isolation combination was invalid.
127    InvalidServiceIsolation {
128        /// Stable validation reason.
129        reason: &'static str,
130    },
131    /// Serializing a v1 record failed.
132    Serialization,
133}
134
135impl fmt::Display for DaemonRegistrationError {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Self::Io(error) => write!(formatter, "daemon registration I/O failed: {error}"),
139            Self::MalformedRecord => formatter.write_str("malformed frozen v1 registration record"),
140            Self::ManifestIntegrityFailure => {
141                formatter.write_str("frozen v1 manifest SHA-256 seal did not verify")
142            }
143            Self::UnsupportedManifestSchema { got, supported } => {
144                write!(
145                    formatter,
146                    "unsupported manifest schema {got}; supported through {supported}"
147                )
148            }
149            Self::InvalidNameOrVersion { detail } => {
150                write!(
151                    formatter,
152                    "invalid frozen v1 service name or version: {detail}"
153                )
154            }
155            Self::MissingParent { path } => {
156                write!(
157                    formatter,
158                    "registration path has no parent: {}",
159                    path.display()
160                )
161            }
162            Self::InsecureDirectory { path } => {
163                write!(
164                    formatter,
165                    "registration directory is not owner-private: {}",
166                    path.display()
167                )
168            }
169            Self::ServiceNameMismatch { requested, actual } => {
170                write!(
171                    formatter,
172                    "requested service {requested:?}, found {actual:?}"
173                )
174            }
175            Self::InvalidServicePath {
176                field,
177                path,
178                reason,
179            } => write!(
180                formatter,
181                "invalid service-definition {field} {path:?}: {reason}"
182            ),
183            Self::InvalidServiceIsolation { reason } => {
184                write!(formatter, "invalid service-definition isolation: {reason}")
185            }
186            Self::Serialization => {
187                formatter.write_str("frozen v1 registration serialization failed")
188            }
189        }
190    }
191}
192
193impl Error for DaemonRegistrationError {
194    fn source(&self) -> Option<&(dyn Error + 'static)> {
195        match self {
196            Self::Io(error) => Some(error),
197            _ => None,
198        }
199    }
200}
201
202fn manifest_error(error: backend::manifest::ManifestError) -> DaemonRegistrationError {
203    match error {
204        backend::manifest::ManifestError::Io(error) => DaemonRegistrationError::Io(error),
205        backend::manifest::ManifestError::Decode(_) => DaemonRegistrationError::MalformedRecord,
206        backend::manifest::ManifestError::Encode(_) => DaemonRegistrationError::Serialization,
207        backend::manifest::ManifestError::Corruption => {
208            DaemonRegistrationError::ManifestIntegrityFailure
209        }
210        backend::manifest::ManifestError::SchemaTooNew { got, supported } => {
211            DaemonRegistrationError::UnsupportedManifestSchema { got, supported }
212        }
213        backend::manifest::ManifestError::InvalidName(error) => {
214            DaemonRegistrationError::InvalidNameOrVersion {
215                detail: error.to_string(),
216            }
217        }
218        backend::manifest::ManifestError::MissingParent(path) => {
219            DaemonRegistrationError::MissingParent { path }
220        }
221        backend::manifest::ManifestError::InsecureRegistry(path) => {
222            DaemonRegistrationError::InsecureDirectory { path }
223        }
224    }
225}
226
227fn service_error(
228    error: backend::service_def_loader::ServiceDefinitionError,
229) -> DaemonRegistrationError {
230    match error {
231        backend::service_def_loader::ServiceDefinitionError::Io(error) => {
232            DaemonRegistrationError::Io(error)
233        }
234        backend::service_def_loader::ServiceDefinitionError::Decode(_) => {
235            DaemonRegistrationError::MalformedRecord
236        }
237        backend::service_def_loader::ServiceDefinitionError::InvalidName(error) => {
238            DaemonRegistrationError::InvalidNameOrVersion {
239                detail: error.to_string(),
240            }
241        }
242        backend::service_def_loader::ServiceDefinitionError::InsecureDirectory(path) => {
243            DaemonRegistrationError::InsecureDirectory { path }
244        }
245        backend::service_def_loader::ServiceDefinitionError::ServiceNameMismatch {
246            requested,
247            actual,
248        } => DaemonRegistrationError::ServiceNameMismatch { requested, actual },
249        backend::service_def_loader::ServiceDefinitionError::InvalidPath {
250            field,
251            path,
252            reason,
253        } => DaemonRegistrationError::InvalidServicePath {
254            field,
255            path,
256            reason,
257        },
258        backend::service_def_loader::ServiceDefinitionError::InvalidIsolation { reason } => {
259            DaemonRegistrationError::InvalidServiceIsolation { reason }
260        }
261    }
262}
263
264/// Builder for a frozen v1 cache manifest.
265#[derive(Clone, Debug)]
266pub struct CacheManifestBuilder {
267    inner: backend::builders::CacheManifestBuilder,
268    roots: Vec<(i32, String)>,
269}
270
271impl CacheManifestBuilder {
272    /// Begin a manifest for this application-selected service and version.
273    #[must_use]
274    pub fn new(service_name: impl Into<String>, service_version: impl Into<String>) -> Self {
275        Self {
276            inner: backend::builders::CacheManifestBuilder::new(service_name, service_version),
277            roots: Vec::new(),
278        }
279    }
280
281    /// Record the application-selected broker-instance label.
282    #[must_use]
283    pub fn broker_instance(mut self, instance: impl Into<String>) -> Self {
284        self.inner = self.inner.broker_instance(instance);
285        self
286    }
287
288    /// Append one root, retaining caller order in the v1 record.
289    #[must_use]
290    pub fn root(mut self, kind: CacheRootKind, path: impl Into<String>) -> Self {
291        self.roots.push((kind.into_backend(), path.into()));
292        self
293    }
294
295    /// Build and SHA-256 seal this manifest without persisting it.
296    pub fn build(self) -> Result<CacheManifest, DaemonRegistrationError> {
297        let mut manifest = self.inner.build().map_err(manifest_error)?;
298        manifest.roots = self
299            .roots
300            .into_iter()
301            .map(|(kind, path)| backend::protocol::CacheRoot {
302                kind,
303                path,
304                ..Default::default()
305            })
306            .collect();
307        backend::manifest::manifest_with_self_sha256(&manifest)
308            .map(CacheManifest::from_backend)
309            .map_err(manifest_error)
310    }
311
312    /// Build, seal, and atomically publish this manifest in the default registry.
313    pub fn publish(self) -> Result<PathBuf, DaemonRegistrationError> {
314        let manifest = self.build()?;
315        backend::manifest::write_to_central(
316            manifest.service_name(),
317            manifest.service_version(),
318            &manifest.inner,
319        )
320        .map_err(manifest_error)
321    }
322
323    /// Build, seal, and atomically publish this manifest in `registry_dir`.
324    pub fn publish_in(
325        self,
326        registry_dir: impl AsRef<Path>,
327    ) -> Result<PathBuf, DaemonRegistrationError> {
328        let manifest = self.build()?;
329        backend::manifest::write_to_central_in_dir(
330            registry_dir.as_ref(),
331            manifest.service_name(),
332            manifest.service_version(),
333            &manifest.inner,
334        )
335        .map_err(manifest_error)
336    }
337}
338
339/// A frozen v1 cache manifest with its generated record kept private.
340#[derive(Clone, Debug, PartialEq)]
341pub struct CacheManifest {
342    inner: backend::protocol::CacheManifest,
343}
344
345impl CacheManifest {
346    fn from_backend(inner: backend::protocol::CacheManifest) -> Self {
347        Self { inner }
348    }
349
350    /// Read, schema-check, and verify a sealed frozen v1 manifest.
351    pub fn read(path: impl AsRef<Path>) -> Result<Self, DaemonRegistrationError> {
352        backend::manifest::read_manifest(path.as_ref())
353            .map(Self::from_backend)
354            .map_err(manifest_error)
355    }
356
357    /// Service name retained in the v1 record.
358    #[must_use]
359    pub fn service_name(&self) -> &str {
360        &self.inner.service_name
361    }
362
363    /// Service version retained in the v1 record.
364    #[must_use]
365    pub fn service_version(&self) -> &str {
366        &self.inner.service_version
367    }
368
369    /// Broker envelope version retained in the v1 record.
370    #[must_use]
371    pub fn broker_envelope_version(&self) -> &str {
372        &self.inner.broker_envelope_version
373    }
374
375    /// Application-selected broker-instance label.
376    #[must_use]
377    pub fn broker_instance(&self) -> &str {
378        &self.inner.broker_instance
379    }
380
381    /// Frozen v1 schema number.
382    #[must_use]
383    pub fn schema_version(&self) -> u32 {
384        self.inner.manifest_schema_version
385    }
386
387    /// Frozen v1 media type.
388    #[must_use]
389    pub fn media_type(&self) -> &str {
390        &self.inner.media_type
391    }
392
393    /// Unix-millisecond creation time stamped by the v1 builder.
394    #[must_use]
395    pub fn created_at_unix_ms(&self) -> u64 {
396        self.inner.created_at_unix_ms
397    }
398
399    /// Unix-millisecond last-active time stamped by the v1 builder.
400    #[must_use]
401    pub fn last_active_unix_ms(&self) -> u64 {
402        self.inner.last_active_unix_ms
403    }
404
405    /// Whether the v1 builder stamped host identity metadata.
406    #[must_use]
407    pub fn has_host_identity(&self) -> bool {
408        self.inner.host.is_some()
409    }
410
411    /// Whether the manifest carries a correctly sized v1 seal.
412    #[must_use]
413    pub fn has_sha256_seal(&self) -> bool {
414        self.inner.self_sha256.len() == 32
415    }
416
417    /// Ordered cache roots retained by the manifest.
418    pub fn roots(&self) -> impl ExactSizeIterator<Item = CacheRoot<'_>> {
419        self.inner.roots.iter().map(|root| CacheRoot {
420            kind: CacheRootKind::from_backend(root.kind),
421            path: &root.path,
422        })
423    }
424}
425
426/// Builder for the shared-broker frozen v1 service definition used by current consumers.
427#[derive(Clone, Debug)]
428pub struct ServiceDefinitionBuilder {
429    inner: backend::builders::ServiceDefinitionBuilder,
430}
431
432impl ServiceDefinitionBuilder {
433    /// Begin a shared-broker definition for an application-selected binary.
434    #[must_use]
435    pub fn shared_broker(service_name: impl Into<String>, binary_path: impl Into<String>) -> Self {
436        Self {
437            inner: backend::builders::ServiceDefinitionBuilder::shared_broker(
438                service_name,
439                binary_path,
440            ),
441        }
442    }
443
444    /// Set the absolute directory containing per-version binaries.
445    #[must_use]
446    pub fn per_version_binary_dir(mut self, directory: impl Into<String>) -> Self {
447        self.inner = self.inner.per_version_binary_dir(directory);
448        self
449    }
450
451    /// Set the minimum compatible service version.
452    #[must_use]
453    pub fn min_version(mut self, version: impl Into<String>) -> Self {
454        self.inner = self.inner.min_version(version);
455        self
456    }
457
458    /// Append one allowed service version, retaining caller order.
459    #[must_use]
460    pub fn allow_version(mut self, version: impl Into<String>) -> Self {
461        self.inner = self.inner.allow_version(version);
462        self
463    }
464
465    /// Attach one application-selected diagnostic label.
466    #[must_use]
467    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
468        self.inner = self.inner.label(key, value);
469        self
470    }
471
472    /// Validate and return a facade-owned v1 definition without persisting it.
473    pub fn build(self) -> Result<ServiceDefinition, DaemonRegistrationError> {
474        self.inner
475            .build()
476            .map(ServiceDefinition::from_backend)
477            .map_err(service_error)
478    }
479
480    /// Validate and write the v1 definition into the default owner-private directory.
481    pub fn install(self) -> Result<PathBuf, DaemonRegistrationError> {
482        self.inner.install().map_err(service_error)
483    }
484
485    /// Validate and write the v1 definition into an explicit owner-private directory.
486    pub fn install_in(self, root: impl AsRef<Path>) -> Result<PathBuf, DaemonRegistrationError> {
487        self.inner.install_in(root.as_ref()).map_err(service_error)
488    }
489}
490
491/// A frozen v1 service definition with its generated record kept private.
492#[derive(Clone, Debug, PartialEq)]
493pub struct ServiceDefinition {
494    inner: backend::protocol::ServiceDefinition,
495}
496
497impl ServiceDefinition {
498    fn from_backend(inner: backend::protocol::ServiceDefinition) -> Self {
499        Self { inner }
500    }
501
502    /// Read and validate a v1 definition for `service_name` from `root`.
503    pub fn read(
504        root: impl AsRef<Path>,
505        service_name: impl AsRef<str>,
506    ) -> Result<Self, DaemonRegistrationError> {
507        backend::service_def_loader::ServiceDefinitionLoader::new(root.as_ref())
508            .load(service_name.as_ref())
509            .map(Self::from_backend)
510            .map_err(service_error)
511    }
512
513    /// Service name retained in the v1 definition.
514    #[must_use]
515    pub fn service_name(&self) -> &str {
516        &self.inner.service_name
517    }
518
519    /// Absolute binary path string retained in the v1 definition.
520    #[must_use]
521    pub fn binary_path(&self) -> &str {
522        &self.inner.binary_path
523    }
524
525    /// Whether this is the shared-broker form used by current consumers.
526    #[must_use]
527    pub fn is_shared_broker(&self) -> bool {
528        self.inner.isolation == backend::protocol::BrokerIsolation::SharedBroker as i32
529    }
530
531    /// Absolute directory holding per-version binaries, when set.
532    #[must_use]
533    pub fn per_version_binary_dir(&self) -> &str {
534        &self.inner.per_version_binary_dir
535    }
536
537    /// Minimum compatible service version, when set.
538    #[must_use]
539    pub fn min_version(&self) -> &str {
540        &self.inner.min_version
541    }
542
543    /// Ordered allowed service versions.
544    pub fn allowed_versions(&self) -> impl ExactSizeIterator<Item = &str> {
545        self.inner.version_allow_list.iter().map(String::as_str)
546    }
547
548    /// Value for one diagnostic label, if present.
549    #[must_use]
550    pub fn label(&self, key: &str) -> Option<&str> {
551        self.inner.labels.get(key).map(String::as_str)
552    }
553}
554
555/// The default owner-private directory for frozen v1 cache manifests.
556#[must_use]
557pub fn manifest_directory() -> PathBuf {
558    backend::manifest::central_registry_dir()
559}
560
561/// The default owner-private directory for frozen v1 service definitions.
562#[must_use]
563pub fn service_definition_directory() -> PathBuf {
564    backend::service_def_loader::service_definition_dir()
565}
566
567#[cfg(test)]
568mod compatibility_tests {
569    use super::*;
570
571    const FROZEN_MANIFEST_V1: &[u8] = &[
572        0x0a, 0x07, b'z', b'c', b'c', b'a', b'c', b'h', b'e', 0x12, 0x05, b'1', b'.', b'2', b'.',
573        b'3', 0x1a, 0x02, b'v', b'1', 0x20, 0x01, 0x28, 0x02, 0xc2, 0x02, 0x06, b's', b'h', b'a',
574        b'r', b'e', b'd', 0xb2, 0x04, 0x06, b'b', b'u', b'n', b'd', b'l', b'e', 0xa0, 0x06, 0x01,
575        0xaa, 0x06, 0x31, b'a', b'p', b'p', b'l', b'i', b'c', b'a', b't', b'i', b'o', b'n', b'/',
576        b'v', b'n', b'd', b'.', b'r', b'u', b'n', b'n', b'i', b'n', b'g', b'-', b'p', b'r', b'o',
577        b'c', b'e', b's', b's', b'.', b'c', b'a', b'c', b'h', b'e', b'-', b'm', b'a', b'n', b'i',
578        b'f', b'e', b's', b't', b'.', b'v', b'1', 0xb2, 0x06, 0x20, 0x01, 0x12, 0x0d, 0x59, 0xff,
579        0xa9, 0x45, 0xe3, 0xff, 0xa4, 0x6a, 0xaa, 0xaf, 0xee, 0xc8, 0x6f, 0xef, 0xfe, 0x55, 0xc2,
580        0x5f, 0x0a, 0x04, 0x0c, 0x9d, 0xe3, 0xdb, 0x67, 0x4b, 0xe3, 0xa0, 0x51,
581    ];
582
583    #[test]
584    fn reads_the_frozen_v1_manifest_golden_without_protocol_types() {
585        let directory = tempfile::tempdir().expect("manifest tempdir");
586        let path = directory.path().join("zccache-1.2.3.pb");
587        std::fs::write(&path, FROZEN_MANIFEST_V1).expect("write frozen manifest");
588
589        let manifest = CacheManifest::read(&path).expect("read frozen manifest");
590        assert_eq!(manifest.service_name(), "zccache");
591        assert_eq!(manifest.service_version(), "1.2.3");
592        assert_eq!(manifest.broker_envelope_version(), "v1");
593        assert_eq!(manifest.broker_instance(), "shared");
594        assert_eq!(manifest.schema_version(), 1);
595        assert_eq!(
596            manifest.media_type(),
597            "application/vnd.running-process.cache-manifest.v1"
598        );
599        assert_eq!(manifest.created_at_unix_ms(), 1);
600        assert_eq!(manifest.last_active_unix_ms(), 2);
601        assert!(manifest.has_sha256_seal());
602    }
603
604    #[test]
605    fn frozen_manifest_rejects_payload_changes_under_the_original_seal() {
606        let directory = tempfile::tempdir().expect("manifest tempdir");
607        let path = directory.path().join("tampered.pb");
608        let mut bytes = FROZEN_MANIFEST_V1.to_vec();
609        // Change a valid service-name byte without changing the protobuf shape
610        // or stored seal. This must be integrity failure, not decode failure.
611        assert_eq!(bytes[2], b'z');
612        bytes[2] = b'x';
613        std::fs::write(&path, bytes).expect("write tampered manifest");
614        assert!(matches!(
615            CacheManifest::read(&path),
616            Err(DaemonRegistrationError::ManifestIntegrityFailure)
617        ));
618    }
619
620    #[test]
621    fn unknown_cache_root_discriminants_and_borrowed_paths_survive() {
622        for raw in [-7, 713, i32::MAX] {
623            assert_eq!(
624                CacheRootKind::from_backend(raw),
625                CacheRootKind::Unknown(raw)
626            );
627            assert_eq!(CacheRootKind::from_backend(raw).into_backend(), raw);
628        }
629        let path = String::from("/cache/retained");
630        let root = CacheRoot {
631            kind: CacheRootKind::Unknown(713),
632            path: &path,
633        };
634        assert_eq!(root.kind(), CacheRootKind::Unknown(713));
635        assert_eq!(root.path().as_ptr(), path.as_ptr());
636    }
637
638    #[test]
639    fn integrity_schema_and_io_failures_keep_distinct_classifications() {
640        assert!(matches!(
641            manifest_error(backend::manifest::ManifestError::Corruption),
642            DaemonRegistrationError::ManifestIntegrityFailure
643        ));
644        assert!(matches!(
645            manifest_error(backend::manifest::ManifestError::SchemaTooNew {
646                got: 99,
647                supported: 1,
648            }),
649            DaemonRegistrationError::UnsupportedManifestSchema {
650                got: 99,
651                supported: 1
652            }
653        ));
654        let error = manifest_error(backend::manifest::ManifestError::Io(
655            std::io::Error::from_raw_os_error(13),
656        ));
657        assert!(error.source().is_some());
658        let DaemonRegistrationError::Io(source) = error else {
659            panic!("I/O error expected")
660        };
661        assert_eq!(source.raw_os_error(), Some(13));
662    }
663}