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