Skip to main content

mbx_cache_protocol/
lib.rs

1//! Wire types and constants shared by mbx remote cache clients and servers.
2//!
3//! These records are protocol-owned: changing their serialized shape or a
4//! framing constant is a wire-format change. Transport, authentication, local
5//! storage, and adapter behavior deliberately live outside this crate.
6#![deny(missing_docs)]
7
8use serde::{Deserialize, Serialize};
9use sha2::Digest as _;
10use std::collections::BTreeMap;
11use std::fmt;
12use std::fs::File;
13use std::io::Read;
14use std::path::Path;
15
16/// Major version of the HTTP cache protocol.
17pub const PROTOCOL_VERSION: u8 = 1;
18/// Header carrying the negotiated cache protocol version.
19pub const PROTOCOL_HEADER: &str = "mbx-cache-protocol";
20/// Header carrying the caller's isolated cache namespace.
21pub const NAMESPACE_HEADER: &str = "mbx-cache-namespace";
22/// Media type for canonical [`ActionResult`] JSON records.
23pub const ACTION_RESULT_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-result.v1+json";
24/// Media type for canonical [`Directory`] JSON records.
25pub const DIRECTORY_MEDIA_TYPE: &str = "application/vnd.mbx.cache-directory.v1+json";
26/// Media type for adapter-specific action metadata blobs.
27pub const CLIENT_METADATA_MEDIA_TYPE: &str = "application/vnd.mbx.cache-client-metadata.v1+json";
28/// Media type for task-to-action prediction manifests.
29pub const TASK_ACTION_MANIFEST_MEDIA_TYPE: &str =
30    "application/vnd.mbx.cache-task-action-manifest.v1+json";
31/// Media type for opaque content-addressed blobs.
32pub const BLOB_MEDIA_TYPE: &str = "application/octet-stream";
33/// Media type for framed batches of content-addressed blobs.
34pub const BLOB_PACK_MEDIA_TYPE: &str = "application/vnd.mbx.cache-blob-pack.v1";
35/// Media type for a JSON list of digests.
36pub const DIGEST_LIST_MEDIA_TYPE: &str = "application/vnd.mbx.cache-digests.v1+json";
37/// Header declaring the number of blobs in a blob pack.
38pub const BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
39/// Header declaring the total payload bytes in a blob pack.
40pub const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
41/// Magic prefix identifying a version-one blob pack.
42pub const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
43/// Bytes before each blob pack payload: algorithm, hash, and length.
44pub const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
45/// Maximum number of digests in a batch request supported by the protocol.
46pub const MAX_BATCH_ITEMS: usize = 10_000;
47/// Maximum predictions carried by one task action manifest.
48pub const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
49/// Maximum serialized adapter payload in one action prediction.
50pub const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
51
52/// Serialize a protocol record using the JSON Canonicalization Scheme.
53pub fn canonical_json(value: &impl Serialize) -> serde_json::Result<Vec<u8>> {
54    serde_json_canonicalizer::to_vec(value)
55}
56
57/// Algorithm-tagged digest and exact byte length of a cache object.
58#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct Digest {
61    /// Hash algorithm name (`blake3` or `sha256`).
62    pub algorithm: String,
63    /// Lowercase hexadecimal hash value.
64    pub hash: String,
65    /// Exact uncompressed object length in bytes.
66    pub size: u64,
67}
68
69impl Digest {
70    /// Compute a BLAKE3 digest for in-memory bytes.
71    pub fn blake3(bytes: &[u8]) -> Self {
72        Self {
73            algorithm: DigestAlgorithm::Blake3.into(),
74            hash: blake3::hash(bytes).to_hex().to_string(),
75            size: bytes.len() as u64,
76        }
77    }
78
79    /// Hash a file with BLAKE3 while counting bytes in the same pass.
80    pub fn blake3_file(path: &Path) -> eyre::Result<Self> {
81        let (hash, size) = hash_file(path, DigestAlgorithm::Blake3)?;
82        Ok(Self {
83            algorithm: DigestAlgorithm::Blake3.into(),
84            hash,
85            size,
86        })
87    }
88
89    /// Validate the algorithm and lowercase hexadecimal representation.
90    pub fn validate(&self) -> eyre::Result<()> {
91        self.algorithm_kind()?;
92        if self.hash.len() != 64
93            || !self
94                .hash
95                .bytes()
96                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
97        {
98            eyre::bail!("invalid remote cache digest");
99        }
100        Ok(())
101    }
102
103    /// Return whether `bytes` have this digest and declared length.
104    pub fn matches_bytes(&self, bytes: &[u8]) -> eyre::Result<bool> {
105        self.validate()?;
106        if self.size != bytes.len() as u64 {
107            return Ok(false);
108        }
109        let hash = match self.algorithm_kind()? {
110            DigestAlgorithm::Blake3 => blake3::hash(bytes).to_hex().to_string(),
111            DigestAlgorithm::Sha256 => hex::encode(sha2::Sha256::digest(bytes)),
112        };
113        Ok(self.hash == hash)
114    }
115
116    /// Stream a file and return whether it has this digest and length.
117    pub fn matches_file(&self, path: &Path) -> eyre::Result<bool> {
118        self.validate()?;
119        let (hash, size) = hash_file(path, self.algorithm_kind()?)?;
120        Ok(self.size == size && self.hash == hash)
121    }
122
123    /// Stable storage key containing the algorithm, hash, and byte length.
124    pub fn key(&self) -> String {
125        format!("{}/{}/{}", self.algorithm, self.hash, self.size)
126    }
127
128    /// Parse the algorithm tag into its closed version-one enum.
129    pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
130        Ok(self.algorithm.parse()?)
131    }
132}
133
134/// Hash algorithms supported by protocol version one.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum DigestAlgorithm {
138    /// BLAKE3.
139    Blake3,
140    /// SHA-256.
141    Sha256,
142}
143
144impl DigestAlgorithm {
145    /// Lowercase name serialized on the wire and used in endpoint paths.
146    pub const fn as_str(self) -> &'static str {
147        match self {
148            Self::Blake3 => "blake3",
149            Self::Sha256 => "sha256",
150        }
151    }
152}
153
154impl fmt::Display for DigestAlgorithm {
155    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156        formatter.write_str(self.as_str())
157    }
158}
159
160impl std::str::FromStr for DigestAlgorithm {
161    type Err = ParseDigestAlgorithmError;
162
163    fn from_str(value: &str) -> Result<Self, Self::Err> {
164        match value {
165            "blake3" => Ok(Self::Blake3),
166            "sha256" => Ok(Self::Sha256),
167            _ => Err(ParseDigestAlgorithmError),
168        }
169    }
170}
171
172impl From<DigestAlgorithm> for String {
173    fn from(algorithm: DigestAlgorithm) -> Self {
174        algorithm.as_str().into()
175    }
176}
177
178/// A digest algorithm name was outside the version-one contract.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct ParseDigestAlgorithmError;
181
182impl fmt::Display for ParseDigestAlgorithmError {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter.write_str("unsupported remote cache digest algorithm")
185    }
186}
187
188impl std::error::Error for ParseDigestAlgorithmError {}
189
190fn hash_file(path: &Path, algorithm: DigestAlgorithm) -> eyre::Result<(String, u64)> {
191    let mut file = File::open(path)?;
192    let mut buffer = [0; 64 * 1024];
193    let mut size = 0;
194    let mut blake3 = blake3::Hasher::new();
195    let mut sha256 = sha2::Sha256::new();
196    loop {
197        let count = file.read(&mut buffer)?;
198        if count == 0 {
199            break;
200        }
201        match algorithm {
202            DigestAlgorithm::Blake3 => {
203                blake3.update(&buffer[..count]);
204            }
205            DigestAlgorithm::Sha256 => {
206                sha256.update(&buffer[..count]);
207            }
208        }
209        size += count as u64;
210    }
211    let hash = match algorithm {
212        DigestAlgorithm::Blake3 => blake3.finalize().to_hex().to_string(),
213        DigestAlgorithm::Sha256 => hex::encode(sha256.finalize()),
214    };
215    Ok((hash, size))
216}
217
218/// A canonical action-result record referencing objects in the CAS.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct ActionResult {
222    /// Digest of the canonical action descriptor this record satisfies.
223    pub action: Digest,
224    /// Optional adapter metadata blob.
225    #[serde(default)]
226    pub metadata: Option<Digest>,
227    /// Optional digest of the root [`Directory`] containing outputs.
228    #[serde(default)]
229    pub output_root: Option<Digest>,
230    /// Action-result schema version.
231    pub version: u8,
232}
233
234/// A canonical directory object stored in the CAS.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct Directory {
238    /// Child directory entries, sorted canonically by name.
239    pub directories: Vec<DirectoryNode>,
240    /// Child file entries, sorted canonically by name.
241    pub files: Vec<FileNode>,
242    /// Child symbolic-link entries, sorted canonically by name.
243    pub symlinks: Vec<SymlinkNode>,
244    /// Directory-object schema version.
245    pub version: u8,
246}
247
248/// A child directory entry in a canonical cache directory.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct DirectoryNode {
252    /// Digest of the child [`Directory`].
253    pub digest: Digest,
254    /// Platform mode bits recorded for the directory.
255    pub mode: u32,
256    /// Single path-component name within the parent directory.
257    pub name: String,
258}
259
260/// A file entry in a canonical cache directory.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct FileNode {
264    /// Digest of the file contents.
265    pub digest: Digest,
266    /// Whether the file should be restored as executable.
267    pub executable: bool,
268    /// Platform mode bits recorded for the file.
269    pub mode: u32,
270    /// Single path-component name within the parent directory.
271    pub name: String,
272}
273
274/// A symbolic-link entry in a canonical cache directory.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(deny_unknown_fields)]
277pub struct SymlinkNode {
278    /// Platform mode bits recorded for the symbolic link.
279    pub mode: u32,
280    /// Single path-component name within the parent directory.
281    pub name: String,
282    /// Link target text exactly as recorded by the producer.
283    pub target: String,
284}
285
286/// Rust-specific action metadata stored alongside compiled outputs.
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct RustcMetadata {
290    /// Metadata schema version.
291    pub version: u8,
292    /// Adapter-defined output kind.
293    pub kind: String,
294    /// Digest of captured compiler standard output.
295    pub stdout: Digest,
296    /// Digest of captured compiler standard error.
297    pub stderr: Digest,
298}
299
300impl RustcMetadata {
301    /// Whether the metadata satisfies the version-one rustc schema invariants.
302    pub fn validate(&self) -> bool {
303        self.version == 1
304            && self.kind == "rustc"
305            && self.stdout.validate().is_ok()
306            && self.stderr.validate().is_ok()
307    }
308}
309
310/// Adapter-owned data needed to reconstruct an action from a prior task run.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct ActionPrediction {
314    /// Invocation digest used to locate this prediction.
315    pub invocation: Digest,
316    /// Full action digest produced when the prediction was recorded.
317    pub action: Digest,
318    /// Adapter name that owns and understands `payload`.
319    pub adapter: String,
320    /// Adapter-defined serialized input prediction.
321    pub payload: String,
322}
323
324/// Predictions associated with one stable task identity.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct TaskActionManifest {
328    /// Manifest schema version.
329    pub version: u8,
330    /// Stable task identity.
331    pub task: String,
332    /// Predicted actions, uniquely keyed by invocation digest.
333    pub predictions: Vec<ActionPrediction>,
334}
335
336#[derive(Serialize)]
337struct TaskActionManifestSelector<'a> {
338    kind: &'static str,
339    task: &'a str,
340    version: u8,
341}
342
343impl TaskActionManifest {
344    /// Whether the manifest satisfies the version-one wire invariants.
345    pub fn validate(&self) -> bool {
346        let mut invocations = std::collections::BTreeSet::new();
347        self.version == 1
348            && valid_task_identity(&self.task)
349            && self.predictions.len() <= MAX_TASK_ACTION_PREDICTIONS
350            && self.predictions.iter().all(|prediction| {
351                prediction.validate() && invocations.insert(&prediction.invocation)
352            })
353    }
354
355    /// Digest selecting this task identity's action manifest.
356    pub fn selector_digest(&self) -> Digest {
357        Self::selector(&self.task)
358            .expect("manifest task identity must be valid")
359            .1
360    }
361
362    /// Canonical selector bytes and digest for a task identity.
363    pub fn selector(task: &str) -> eyre::Result<(Vec<u8>, Digest)> {
364        if !valid_task_identity(task) {
365            eyre::bail!("invalid task action manifest identity");
366        }
367        let selector = canonical_json(&TaskActionManifestSelector {
368            kind: "task_action_manifest",
369            task,
370            version: 1,
371        })?;
372        let digest = Digest::blake3(&selector);
373        Ok((selector, digest))
374    }
375}
376
377impl ActionPrediction {
378    /// Whether the prediction satisfies the version-one wire invariants.
379    pub fn validate(&self) -> bool {
380        self.action.algorithm == DigestAlgorithm::Blake3.as_str()
381            && self.action.validate().is_ok()
382            && self.invocation.algorithm == DigestAlgorithm::Blake3.as_str()
383            && self.invocation.validate().is_ok()
384            && !self.adapter.is_empty()
385            && self
386                .adapter
387                .bytes()
388                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
389            && self.payload.len() <= MAX_ACTION_PREDICTION_PAYLOAD
390            && serde_json::from_str::<serde_json::Value>(&self.payload).is_ok()
391    }
392}
393
394fn valid_task_identity(value: &str) -> bool {
395    value.len() == 64
396        && value
397            .bytes()
398            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
399}
400
401/// Version advertised by a cache service.
402///
403/// Capability records are the protocol's additive surface: a server may
404/// advertise fields a client does not know, so every type below stays open to
405/// extension rather than requiring a major release per advertised field.
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[non_exhaustive]
408pub struct CapabilityProtocol {
409    /// Protocol major version.
410    pub major: u8,
411    /// Backward-compatible protocol revision.
412    #[serde(default)]
413    pub minor: u8,
414}
415
416impl CapabilityProtocol {
417    /// A protocol version advertisement.
418    pub fn new(major: u8, minor: u8) -> Self {
419        Self { major, minor }
420    }
421}
422
423/// Schemas supported for one action adapter.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[non_exhaustive]
426pub struct ActionKindCapability {
427    /// Action descriptor schema version.
428    pub action_schema: u8,
429    /// Adapter metadata schema version.
430    pub metadata_schema: u8,
431}
432
433impl ActionKindCapability {
434    /// The schema pair one adapter accepts.
435    pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
436        Self {
437            action_schema,
438            metadata_schema,
439        }
440    }
441}
442
443/// Optional server protocol features.
444#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
445#[non_exhaustive]
446pub struct CapabilityFeatures {
447    /// Conditional action-manifest endpoints are available.
448    #[serde(default)]
449    pub action_manifests: bool,
450    /// Missing-blob batch queries are available.
451    #[serde(default)]
452    pub batch: bool,
453    /// Framed blob-pack downloads are available.
454    #[serde(default)]
455    pub blob_packs: bool,
456    /// Resumable uploads are available.
457    #[serde(default)]
458    pub resumable_uploads: bool,
459    /// Delegated transfers are available.
460    #[serde(default)]
461    pub delegated_transfers: bool,
462}
463
464/// Server-advertised request and object limits.
465#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
466#[non_exhaustive]
467pub struct CapabilityLimits {
468    /// Maximum digests accepted by one batch request.
469    #[serde(default)]
470    pub max_batch_items: u64,
471    /// Maximum blob size eligible for inline transfer.
472    #[serde(default)]
473    pub max_inline_blob_bytes: u64,
474    /// Maximum size of an individual blob.
475    #[serde(default)]
476    pub max_blob_bytes: u64,
477    /// Maximum declared payload bytes in one blob pack.
478    #[serde(default)]
479    pub max_pack_bytes: u64,
480}
481
482/// Cache service capabilities negotiated before optional protocol features.
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484#[non_exhaustive]
485pub struct Capabilities {
486    /// Protocol version implemented by the server.
487    pub protocol: CapabilityProtocol,
488    /// Digest algorithms accepted by the service.
489    #[serde(default)]
490    pub digest_algorithms: Vec<String>,
491    /// Content codings accepted and produced by the service.
492    #[serde(default)]
493    pub compressors: Vec<String>,
494    /// Adapter schemas accepted by the service.
495    #[serde(default)]
496    pub action_kinds: BTreeMap<String, ActionKindCapability>,
497    /// Optional endpoint features.
498    #[serde(default)]
499    pub features: CapabilityFeatures,
500    /// Server-enforced request limits.
501    #[serde(default)]
502    pub limits: CapabilityLimits,
503}
504
505impl Capabilities {
506    /// A baseline advertisement for `protocol`, claiming no optional features.
507    ///
508    /// The remaining fields are public and assignable, so a service adds only
509    /// what it actually supports.
510    pub fn new(protocol: CapabilityProtocol) -> Self {
511        Self {
512            protocol,
513            digest_algorithms: Vec::new(),
514            compressors: Vec::new(),
515            action_kinds: BTreeMap::new(),
516            features: CapabilityFeatures::default(),
517            limits: CapabilityLimits::default(),
518        }
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn digest_validation_is_exact() {
528        let valid = Digest {
529            algorithm: DigestAlgorithm::Blake3.into(),
530            hash: "a".repeat(64),
531            size: 42,
532        };
533        assert!(valid.validate().is_ok());
534        assert!(
535            Digest {
536                hash: "A".repeat(64),
537                ..valid.clone()
538            }
539            .validate()
540            .is_err()
541        );
542        assert!(
543            Digest {
544                algorithm: "md5".into(),
545                ..valid
546            }
547            .validate()
548            .is_err()
549        );
550    }
551
552    #[test]
553    fn canonical_json_is_independent_of_map_insertion_order() {
554        #[derive(Serialize)]
555        struct ZThenA {
556            z: u8,
557            a: bool,
558        }
559
560        #[derive(Serialize)]
561        struct AThenZ {
562            a: bool,
563            z: u8,
564        }
565
566        assert_eq!(
567            canonical_json(&ZThenA { z: 1, a: true }).unwrap(),
568            canonical_json(&AThenZ { a: true, z: 1 }).unwrap()
569        );
570    }
571}