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/// Media type for a JSON batch of [`ActionResult`] records.
38///
39/// Each record names the action it belongs to, so the batch is unordered and
40/// carries only the results a service actually holds.
41pub const ACTION_RESULT_BATCH_MEDIA_TYPE: &str =
42    "application/vnd.mbx.cache-action-result-batch.v1+json";
43/// Media type for ephemeral, invocation-keyed compilation promises.
44pub const ACTION_PROMISE_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-promise.v1+json";
45/// Media type for the receipt describing an accepted blob-pack upload.
46pub const BLOB_PACK_RECEIPT_MEDIA_TYPE: &str =
47    "application/vnd.mbx.cache-blob-pack-receipt.v1+json";
48/// Header declaring the number of blobs in a blob pack.
49pub const BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
50/// Header declaring the total payload bytes in a blob pack.
51pub const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
52/// Magic prefix identifying a version-one blob pack.
53pub const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
54/// Bytes before each blob pack payload: algorithm, hash, and length.
55pub const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
56/// Maximum number of digests in a batch request supported by the protocol.
57pub const MAX_BATCH_ITEMS: usize = 10_000;
58/// Maximum predictions carried by one task action manifest.
59pub const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
60/// Maximum serialized adapter payload in one action prediction.
61pub const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
62/// Maximum opaque lease-token length accepted for an action promise.
63pub const MAX_ACTION_PROMISE_CLAIM_BYTES: usize = 256;
64
65/// Serialize a protocol record using the JSON Canonicalization Scheme.
66pub fn canonical_json(value: &impl Serialize) -> serde_json::Result<Vec<u8>> {
67    serde_json_canonicalizer::to_vec(value)
68}
69
70/// Algorithm-tagged digest and exact byte length of a cache object.
71#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct Digest {
74    /// Hash algorithm name (`blake3` or `sha256`).
75    pub algorithm: String,
76    /// Lowercase hexadecimal hash value.
77    pub hash: String,
78    /// Exact uncompressed object length in bytes.
79    pub size: u64,
80}
81
82impl Digest {
83    /// Compute a BLAKE3 digest for in-memory bytes.
84    pub fn blake3(bytes: &[u8]) -> Self {
85        Self {
86            algorithm: DigestAlgorithm::Blake3.into(),
87            hash: blake3::hash(bytes).to_hex().to_string(),
88            size: bytes.len() as u64,
89        }
90    }
91
92    /// Hash a file with BLAKE3 while counting bytes in the same pass.
93    pub fn blake3_file(path: &Path) -> eyre::Result<Self> {
94        let (hash, size) = hash_file(path, DigestAlgorithm::Blake3)?;
95        Ok(Self {
96            algorithm: DigestAlgorithm::Blake3.into(),
97            hash,
98            size,
99        })
100    }
101
102    /// Validate the algorithm and lowercase hexadecimal representation.
103    pub fn validate(&self) -> eyre::Result<()> {
104        self.algorithm_kind()?;
105        if self.hash.len() != 64
106            || !self
107                .hash
108                .bytes()
109                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
110        {
111            eyre::bail!("invalid remote cache digest");
112        }
113        Ok(())
114    }
115
116    /// Return whether `bytes` have this digest and declared length.
117    pub fn matches_bytes(&self, bytes: &[u8]) -> eyre::Result<bool> {
118        self.validate()?;
119        if self.size != bytes.len() as u64 {
120            return Ok(false);
121        }
122        let hash = match self.algorithm_kind()? {
123            DigestAlgorithm::Blake3 => blake3::hash(bytes).to_hex().to_string(),
124            DigestAlgorithm::Sha256 => hex::encode(sha2::Sha256::digest(bytes)),
125        };
126        Ok(self.hash == hash)
127    }
128
129    /// Stream a file and return whether it has this digest and length.
130    pub fn matches_file(&self, path: &Path) -> eyre::Result<bool> {
131        self.validate()?;
132        let (hash, size) = hash_file(path, self.algorithm_kind()?)?;
133        Ok(self.size == size && self.hash == hash)
134    }
135
136    /// Stable storage key containing the algorithm, hash, and byte length.
137    pub fn key(&self) -> String {
138        format!("{}/{}/{}", self.algorithm, self.hash, self.size)
139    }
140
141    /// Parse the algorithm tag into its closed version-one enum.
142    pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
143        Ok(self.algorithm.parse()?)
144    }
145}
146
147/// Hash algorithms supported by protocol version one.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum DigestAlgorithm {
151    /// BLAKE3.
152    Blake3,
153    /// SHA-256.
154    Sha256,
155}
156
157impl DigestAlgorithm {
158    /// Lowercase name serialized on the wire and used in endpoint paths.
159    pub const fn as_str(self) -> &'static str {
160        match self {
161            Self::Blake3 => "blake3",
162            Self::Sha256 => "sha256",
163        }
164    }
165}
166
167impl fmt::Display for DigestAlgorithm {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        formatter.write_str(self.as_str())
170    }
171}
172
173impl std::str::FromStr for DigestAlgorithm {
174    type Err = ParseDigestAlgorithmError;
175
176    fn from_str(value: &str) -> Result<Self, Self::Err> {
177        match value {
178            "blake3" => Ok(Self::Blake3),
179            "sha256" => Ok(Self::Sha256),
180            _ => Err(ParseDigestAlgorithmError),
181        }
182    }
183}
184
185impl From<DigestAlgorithm> for String {
186    fn from(algorithm: DigestAlgorithm) -> Self {
187        algorithm.as_str().into()
188    }
189}
190
191/// A digest algorithm name was outside the version-one contract.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub struct ParseDigestAlgorithmError;
194
195impl fmt::Display for ParseDigestAlgorithmError {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        formatter.write_str("unsupported remote cache digest algorithm")
198    }
199}
200
201impl std::error::Error for ParseDigestAlgorithmError {}
202
203fn hash_file(path: &Path, algorithm: DigestAlgorithm) -> eyre::Result<(String, u64)> {
204    let mut file = File::open(path)?;
205    let mut buffer = [0; 64 * 1024];
206    let mut size = 0;
207    let mut blake3 = blake3::Hasher::new();
208    let mut sha256 = sha2::Sha256::new();
209    loop {
210        let count = file.read(&mut buffer)?;
211        if count == 0 {
212            break;
213        }
214        match algorithm {
215            DigestAlgorithm::Blake3 => {
216                blake3.update(&buffer[..count]);
217            }
218            DigestAlgorithm::Sha256 => {
219                sha256.update(&buffer[..count]);
220            }
221        }
222        size += count as u64;
223    }
224    let hash = match algorithm {
225        DigestAlgorithm::Blake3 => blake3.finalize().to_hex().to_string(),
226        DigestAlgorithm::Sha256 => hex::encode(sha256.finalize()),
227    };
228    Ok((hash, size))
229}
230
231/// A canonical action-result record referencing objects in the CAS.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct ActionResult {
235    /// Digest of the canonical action descriptor this record satisfies.
236    pub action: Digest,
237    /// Optional adapter metadata blob.
238    #[serde(default)]
239    pub metadata: Option<Digest>,
240    /// Optional digest of the root [`Directory`] containing outputs.
241    #[serde(default)]
242    pub output_root: Option<Digest>,
243    /// Action-result schema version.
244    pub version: u8,
245}
246
247/// A canonical directory object stored in the CAS.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Directory {
251    /// Child directory entries, sorted canonically by name.
252    pub directories: Vec<DirectoryNode>,
253    /// Child file entries, sorted canonically by name.
254    pub files: Vec<FileNode>,
255    /// Child symbolic-link entries, sorted canonically by name.
256    pub symlinks: Vec<SymlinkNode>,
257    /// Directory-object schema version.
258    pub version: u8,
259}
260
261/// A child directory entry in a canonical cache directory.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct DirectoryNode {
265    /// Digest of the child [`Directory`].
266    pub digest: Digest,
267    /// Platform mode bits recorded for the directory.
268    pub mode: u32,
269    /// Single path-component name within the parent directory.
270    pub name: String,
271}
272
273/// A file entry in a canonical cache directory.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct FileNode {
277    /// Digest of the file contents.
278    pub digest: Digest,
279    /// Whether the file should be restored as executable.
280    pub executable: bool,
281    /// Platform mode bits recorded for the file.
282    pub mode: u32,
283    /// Single path-component name within the parent directory.
284    pub name: String,
285}
286
287/// A symbolic-link entry in a canonical cache directory.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct SymlinkNode {
291    /// Platform mode bits recorded for the symbolic link.
292    pub mode: u32,
293    /// Single path-component name within the parent directory.
294    pub name: String,
295    /// Link target text exactly as recorded by the producer.
296    pub target: String,
297}
298
299/// Rust-specific action metadata stored alongside compiled outputs.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct RustcMetadata {
303    /// Metadata schema version.
304    pub version: u8,
305    /// Adapter-defined output kind.
306    pub kind: String,
307    /// Digest of captured compiler standard output.
308    pub stdout: Digest,
309    /// Digest of captured compiler standard error.
310    pub stderr: Digest,
311}
312
313impl RustcMetadata {
314    /// Whether the metadata satisfies the version-one rustc schema invariants.
315    pub fn validate(&self) -> bool {
316        self.version == 1
317            && self.kind == "rustc"
318            && self.stdout.validate().is_ok()
319            && self.stderr.validate().is_ok()
320    }
321}
322
323/// C and C++ action metadata stored alongside compiled objects.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(deny_unknown_fields)]
326pub struct CcMetadata {
327    /// Metadata schema version.
328    pub version: u8,
329    /// Adapter-defined output kind.
330    pub kind: String,
331    /// Digest of captured compiler standard output.
332    pub stdout: Digest,
333    /// Digest of captured compiler standard error.
334    pub stderr: Digest,
335}
336
337impl CcMetadata {
338    /// Whether the metadata satisfies the version-one cc schema invariants.
339    pub fn validate(&self) -> bool {
340        self.version == 1
341            && self.kind == "cc"
342            && self.stdout.validate().is_ok()
343            && self.stderr.validate().is_ok()
344    }
345}
346
347/// Adapter-owned data needed to reconstruct an action from a prior task run.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349#[serde(deny_unknown_fields)]
350pub struct ActionPrediction {
351    /// Invocation digest used to locate this prediction.
352    pub invocation: Digest,
353    /// Full action digest produced when the prediction was recorded.
354    pub action: Digest,
355    /// Adapter name that owns and understands `payload`.
356    pub adapter: String,
357    /// Adapter-defined serialized input prediction.
358    pub payload: String,
359}
360
361/// Request to join or claim one invocation-wide compilation promise.
362///
363/// The invocation digest lives in the endpoint URL. The adapter remains in
364/// the body so a server can reject a client trying to join a promise created
365/// by a different action adapter under the same digest.
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct ActionPromiseJoin {
369    /// Action adapter that will interpret the completed prediction.
370    pub adapter: String,
371}
372
373/// State returned when a client joins an invocation-wide promise.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
376#[non_exhaustive]
377pub enum ActionPromiseState {
378    /// This client owns the lease and should perform the compilation.
379    Claimed {
380        /// Opaque bearer token required to complete this claim.
381        claim: String,
382    },
383    /// Another client owns the lease; retry after the stated delay.
384    Pending {
385        /// Minimum delay before asking again.
386        retry_after_ms: u64,
387    },
388    /// The owner published a prediction through which this client can restore.
389    Complete {
390        /// Prediction for reconstructing and looking up the final action key.
391        prediction: ActionPrediction,
392    },
393}
394
395/// Immutable completion of an invocation-wide compilation promise.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(deny_unknown_fields)]
398pub struct ActionPromiseCompletion {
399    /// Opaque token returned to the client that claimed the promise.
400    pub claim: String,
401    /// Prediction naming the action result that was published first.
402    pub prediction: ActionPrediction,
403}
404
405impl ActionPromiseJoin {
406    /// Whether the join names a valid action adapter.
407    pub fn validate(&self) -> eyre::Result<()> {
408        if !valid_adapter_name(&self.adapter) {
409            eyre::bail!("invalid action promise adapter name");
410        }
411        Ok(())
412    }
413}
414
415impl ActionPromiseState {
416    /// Check the bounds and prediction invariants of a server response.
417    pub fn validate(&self) -> eyre::Result<()> {
418        match self {
419            Self::Claimed { claim }
420                if claim.is_empty() || claim.len() > MAX_ACTION_PROMISE_CLAIM_BYTES =>
421            {
422                eyre::bail!("invalid action promise claim token")
423            }
424            Self::Complete { prediction } => prediction.validate(),
425            _ => Ok(()),
426        }
427    }
428}
429
430impl ActionPromiseCompletion {
431    /// Check the claim-token bound and completed prediction.
432    pub fn validate(&self) -> eyre::Result<()> {
433        if self.claim.is_empty() || self.claim.len() > MAX_ACTION_PROMISE_CLAIM_BYTES {
434            eyre::bail!("invalid action promise claim token");
435        }
436        self.prediction.validate()
437    }
438}
439
440/// Predictions associated with one stable task identity.
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443pub struct TaskActionManifest {
444    /// Manifest schema version.
445    pub version: u8,
446    /// Stable task identity.
447    pub task: String,
448    /// Predicted actions, uniquely keyed by invocation digest.
449    pub predictions: Vec<ActionPrediction>,
450}
451
452#[derive(Serialize)]
453struct TaskActionManifestSelector<'a> {
454    kind: &'static str,
455    task: &'a str,
456    version: u8,
457}
458
459impl TaskActionManifest {
460    /// Whether the manifest satisfies the version-one wire invariants.
461    pub fn validate(&self) -> bool {
462        let mut invocations = std::collections::BTreeSet::new();
463        self.version == 1
464            && valid_task_identity(&self.task)
465            && self.predictions.len() <= MAX_TASK_ACTION_PREDICTIONS
466            && self.predictions.iter().all(|prediction| {
467                prediction.validate().is_ok() && invocations.insert(&prediction.invocation)
468            })
469    }
470
471    /// Digest selecting this task identity's action manifest.
472    pub fn selector_digest(&self) -> Digest {
473        Self::selector(&self.task)
474            .expect("manifest task identity must be valid")
475            .1
476    }
477
478    /// Canonical selector bytes and digest for a task identity.
479    pub fn selector(task: &str) -> eyre::Result<(Vec<u8>, Digest)> {
480        if !valid_task_identity(task) {
481            eyre::bail!("invalid task action manifest identity");
482        }
483        let selector = canonical_json(&TaskActionManifestSelector {
484            kind: "task_action_manifest",
485            task,
486            version: 1,
487        })?;
488        let digest = Digest::blake3(&selector);
489        Ok((selector, digest))
490    }
491}
492
493impl ActionPrediction {
494    /// Check the version-one wire invariants, naming the one that fails.
495    ///
496    /// A rejected prediction is only ever reported to someone working out why
497    /// a build stopped predicting, and "invalid" on its own tells them nothing
498    /// about which of these constraints to go looking at. The message stands on
499    /// its own because the agent sends callers `Display`, not the error chain.
500    pub fn validate(&self) -> eyre::Result<()> {
501        match self.constraint_violation() {
502            Some(reason) => eyre::bail!("invalid action prediction: {reason}"),
503            None => Ok(()),
504        }
505    }
506
507    fn constraint_violation(&self) -> Option<String> {
508        if self.action.algorithm != DigestAlgorithm::Blake3.as_str()
509            || self.action.validate().is_err()
510        {
511            return Some("action digest is not a valid blake3 digest".into());
512        }
513        if self.invocation.algorithm != DigestAlgorithm::Blake3.as_str()
514            || self.invocation.validate().is_err()
515        {
516            return Some("invocation digest is not a valid blake3 digest".into());
517        }
518        if self.adapter.is_empty() {
519            return Some("adapter name is empty".into());
520        }
521        if !valid_adapter_name(&self.adapter) {
522            return Some(format!(
523                "adapter name {:?} is not alphanumeric, '-', or '_'",
524                self.adapter
525            ));
526        }
527        if self.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
528            return Some(format!(
529                "payload is {} bytes, over the {MAX_ACTION_PREDICTION_PAYLOAD} byte limit",
530                self.payload.len()
531            ));
532        }
533        if serde_json::from_str::<serde_json::Value>(&self.payload).is_err() {
534            return Some("payload is not valid JSON".into());
535        }
536        None
537    }
538}
539
540fn valid_adapter_name(value: &str) -> bool {
541    !value.is_empty()
542        && value
543            .bytes()
544            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
545}
546
547fn valid_task_identity(value: &str) -> bool {
548    value.len() == 64
549        && value
550            .bytes()
551            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
552}
553
554/// Version advertised by a cache service.
555///
556/// Capability records are the protocol's additive surface: a server may
557/// advertise fields a client does not know, so every type below stays open to
558/// extension rather than requiring a major release per advertised field.
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
560#[non_exhaustive]
561pub struct CapabilityProtocol {
562    /// Protocol major version.
563    pub major: u8,
564    /// Backward-compatible protocol revision.
565    #[serde(default)]
566    pub minor: u8,
567}
568
569impl CapabilityProtocol {
570    /// A protocol version advertisement.
571    pub fn new(major: u8, minor: u8) -> Self {
572        Self { major, minor }
573    }
574}
575
576/// Schemas supported for one action adapter.
577#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
578#[non_exhaustive]
579pub struct ActionKindCapability {
580    /// Action descriptor schema version.
581    pub action_schema: u8,
582    /// Adapter metadata schema version.
583    pub metadata_schema: u8,
584}
585
586impl ActionKindCapability {
587    /// The schema pair one adapter accepts.
588    pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
589        Self {
590            action_schema,
591            metadata_schema,
592        }
593    }
594}
595
596/// Optional server protocol features.
597#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
598#[non_exhaustive]
599pub struct CapabilityFeatures {
600    /// Conditional action-manifest endpoints are available.
601    #[serde(default)]
602    pub action_manifests: bool,
603    /// Missing-blob batch queries are available.
604    #[serde(default)]
605    pub batch: bool,
606    /// Batched action-result lookups are available.
607    ///
608    /// Distinct from [`Self::batch`], which covers missing-blob queries only. A
609    /// service that answered those before this feature existed advertises
610    /// `batch` without implementing the action-result endpoint.
611    #[serde(default)]
612    pub action_batch: bool,
613    /// Framed blob-pack downloads are available.
614    #[serde(default)]
615    pub blob_packs: bool,
616    /// Framed blob-pack uploads are available.
617    #[serde(default)]
618    pub blob_pack_uploads: bool,
619    /// Resumable uploads are available.
620    #[serde(default)]
621    pub resumable_uploads: bool,
622    /// Delegated transfers are available.
623    #[serde(default)]
624    pub delegated_transfers: bool,
625    /// Invocation-wide claim and promise endpoints are available.
626    #[serde(default)]
627    pub action_promises: bool,
628}
629
630/// Server-advertised request and object limits.
631#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
632#[non_exhaustive]
633pub struct CapabilityLimits {
634    /// Maximum digests accepted by one batch request.
635    #[serde(default)]
636    pub max_batch_items: u64,
637    /// Maximum blob size eligible for inline transfer.
638    #[serde(default)]
639    pub max_inline_blob_bytes: u64,
640    /// Maximum size of an individual blob.
641    #[serde(default)]
642    pub max_blob_bytes: u64,
643    /// Maximum declared payload bytes in one blob pack.
644    #[serde(default)]
645    pub max_pack_bytes: u64,
646}
647
648/// Cache service capabilities negotiated before optional protocol features.
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[non_exhaustive]
651pub struct Capabilities {
652    /// Protocol version implemented by the server.
653    pub protocol: CapabilityProtocol,
654    /// Digest algorithms accepted by the service.
655    #[serde(default)]
656    pub digest_algorithms: Vec<String>,
657    /// Content codings accepted and produced by the service.
658    #[serde(default)]
659    pub compressors: Vec<String>,
660    /// Adapter schemas accepted by the service.
661    #[serde(default)]
662    pub action_kinds: BTreeMap<String, ActionKindCapability>,
663    /// Optional endpoint features.
664    #[serde(default)]
665    pub features: CapabilityFeatures,
666    /// Server-enforced request limits.
667    #[serde(default)]
668    pub limits: CapabilityLimits,
669}
670
671impl Capabilities {
672    /// A baseline advertisement for `protocol`, claiming no optional features.
673    ///
674    /// The remaining fields are public and assignable, so a service adds only
675    /// what it actually supports.
676    pub fn new(protocol: CapabilityProtocol) -> Self {
677        Self {
678            protocol,
679            digest_algorithms: Vec::new(),
680            compressors: Vec::new(),
681            action_kinds: BTreeMap::new(),
682            features: CapabilityFeatures::default(),
683            limits: CapabilityLimits::default(),
684        }
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn digest_validation_is_exact() {
694        let valid = Digest {
695            algorithm: DigestAlgorithm::Blake3.into(),
696            hash: "a".repeat(64),
697            size: 42,
698        };
699        assert!(valid.validate().is_ok());
700        assert!(
701            Digest {
702                hash: "A".repeat(64),
703                ..valid.clone()
704            }
705            .validate()
706            .is_err()
707        );
708        assert!(
709            Digest {
710                algorithm: "md5".into(),
711                ..valid
712            }
713            .validate()
714            .is_err()
715        );
716    }
717
718    #[test]
719    fn a_rejected_prediction_names_the_constraint_it_violated() {
720        let digest = Digest::blake3(b"action");
721        let prediction = ActionPrediction {
722            invocation: digest.clone(),
723            action: digest,
724            adapter: "rustc".into(),
725            payload: "{}".into(),
726        };
727        assert!(prediction.validate().is_ok());
728
729        let reason = |prediction: ActionPrediction| {
730            prediction
731                .validate()
732                .expect_err("the prediction violates a constraint")
733                .to_string()
734        };
735        let oversized = ActionPrediction {
736            payload: format!("\"{}\"", "p".repeat(MAX_ACTION_PREDICTION_PAYLOAD)),
737            ..prediction.clone()
738        };
739        let oversized_len = oversized.payload.len();
740        let message = reason(oversized);
741        assert!(
742            message.contains(&oversized_len.to_string())
743                && message.contains(&MAX_ACTION_PREDICTION_PAYLOAD.to_string()),
744            "the message must carry both sizes so a build log says how far over it went: {message}"
745        );
746
747        // Every message stands alone, because the agent sends callers the
748        // outermost `Display` rather than the error chain.
749        assert_eq!(
750            reason(ActionPrediction {
751                adapter: "rust c".into(),
752                ..prediction.clone()
753            }),
754            r#"invalid action prediction: adapter name "rust c" is not alphanumeric, '-', or '_'"#
755        );
756        assert_eq!(
757            reason(ActionPrediction {
758                payload: "not json".into(),
759                ..prediction.clone()
760            }),
761            "invalid action prediction: payload is not valid JSON"
762        );
763        assert_eq!(
764            reason(ActionPrediction {
765                action: Digest {
766                    algorithm: DigestAlgorithm::Sha256.into(),
767                    ..prediction.action.clone()
768                },
769                ..prediction
770            }),
771            "invalid action prediction: action digest is not a valid blake3 digest"
772        );
773    }
774
775    #[test]
776    fn canonical_json_is_independent_of_map_insertion_order() {
777        #[derive(Serialize)]
778        struct ZThenA {
779            z: u8,
780            a: bool,
781        }
782
783        #[derive(Serialize)]
784        struct AThenZ {
785            a: bool,
786            z: u8,
787        }
788
789        assert_eq!(
790            canonical_json(&ZThenA { z: 1, a: true }).unwrap(),
791            canonical_json(&AThenZ { a: true, z: 1 }).unwrap()
792        );
793    }
794}