1#![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
16pub const PROTOCOL_VERSION: u8 = 1;
18pub const PROTOCOL_HEADER: &str = "mbx-cache-protocol";
20pub const NAMESPACE_HEADER: &str = "mbx-cache-namespace";
22pub const ACTION_RESULT_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-result.v1+json";
24pub const DIRECTORY_MEDIA_TYPE: &str = "application/vnd.mbx.cache-directory.v1+json";
26pub const CLIENT_METADATA_MEDIA_TYPE: &str = "application/vnd.mbx.cache-client-metadata.v1+json";
28pub const TASK_ACTION_MANIFEST_MEDIA_TYPE: &str =
30 "application/vnd.mbx.cache-task-action-manifest.v1+json";
31pub const BLOB_MEDIA_TYPE: &str = "application/octet-stream";
33pub const BLOB_PACK_MEDIA_TYPE: &str = "application/vnd.mbx.cache-blob-pack.v1";
35pub const DIGEST_LIST_MEDIA_TYPE: &str = "application/vnd.mbx.cache-digests.v1+json";
37pub const ACTION_RESULT_BATCH_MEDIA_TYPE: &str =
42 "application/vnd.mbx.cache-action-result-batch.v1+json";
43pub const ACTION_PROMISE_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-promise.v1+json";
45pub const BLOB_PACK_RECEIPT_MEDIA_TYPE: &str =
47 "application/vnd.mbx.cache-blob-pack-receipt.v1+json";
48pub const BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
50pub const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
52pub const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
54pub const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
56pub const MAX_BATCH_ITEMS: usize = 10_000;
58pub const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
60pub const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
62pub const MAX_ACTION_PROMISE_CLAIM_BYTES: usize = 256;
64
65pub fn canonical_json(value: &impl Serialize) -> serde_json::Result<Vec<u8>> {
67 serde_json_canonicalizer::to_vec(value)
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct Digest {
74 pub algorithm: String,
76 pub hash: String,
78 pub size: u64,
80}
81
82impl Digest {
83 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 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 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 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 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 pub fn key(&self) -> String {
138 format!("{}/{}/{}", self.algorithm, self.hash, self.size)
139 }
140
141 pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
143 Ok(self.algorithm.parse()?)
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum DigestAlgorithm {
151 Blake3,
153 Sha256,
155}
156
157impl DigestAlgorithm {
158 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct ActionResult {
235 pub action: Digest,
237 #[serde(default)]
239 pub metadata: Option<Digest>,
240 #[serde(default)]
242 pub output_root: Option<Digest>,
243 pub version: u8,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Directory {
251 pub directories: Vec<DirectoryNode>,
253 pub files: Vec<FileNode>,
255 pub symlinks: Vec<SymlinkNode>,
257 pub version: u8,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct DirectoryNode {
265 pub digest: Digest,
267 pub mode: u32,
269 pub name: String,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct FileNode {
277 pub digest: Digest,
279 pub executable: bool,
281 pub mode: u32,
283 pub name: String,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct SymlinkNode {
291 pub mode: u32,
293 pub name: String,
295 pub target: String,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct RustcMetadata {
303 pub version: u8,
305 pub kind: String,
307 pub stdout: Digest,
309 pub stderr: Digest,
311}
312
313impl RustcMetadata {
314 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(deny_unknown_fields)]
326pub struct CcMetadata {
327 pub version: u8,
329 pub kind: String,
331 pub stdout: Digest,
333 pub stderr: Digest,
335}
336
337impl CcMetadata {
338 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349#[serde(deny_unknown_fields)]
350pub struct ActionPrediction {
351 pub invocation: Digest,
353 pub action: Digest,
355 pub adapter: String,
357 pub payload: String,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct ActionPromiseJoin {
369 pub adapter: String,
371}
372
373#[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 Claimed {
380 claim: String,
382 },
383 Pending {
385 retry_after_ms: u64,
387 },
388 Complete {
390 prediction: ActionPrediction,
392 },
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(deny_unknown_fields)]
398pub struct ActionPromiseCompletion {
399 pub claim: String,
401 pub prediction: ActionPrediction,
403}
404
405impl ActionPromiseJoin {
406 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443pub struct TaskActionManifest {
444 pub version: u8,
446 pub task: String,
448 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 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 pub fn selector_digest(&self) -> Digest {
473 Self::selector(&self.task)
474 .expect("manifest task identity must be valid")
475 .1
476 }
477
478 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
560#[non_exhaustive]
561pub struct CapabilityProtocol {
562 pub major: u8,
564 #[serde(default)]
566 pub minor: u8,
567}
568
569impl CapabilityProtocol {
570 pub fn new(major: u8, minor: u8) -> Self {
572 Self { major, minor }
573 }
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
578#[non_exhaustive]
579pub struct ActionKindCapability {
580 pub action_schema: u8,
582 pub metadata_schema: u8,
584}
585
586impl ActionKindCapability {
587 pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
589 Self {
590 action_schema,
591 metadata_schema,
592 }
593 }
594}
595
596#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
598#[non_exhaustive]
599pub struct CapabilityFeatures {
600 #[serde(default)]
602 pub action_manifests: bool,
603 #[serde(default)]
605 pub batch: bool,
606 #[serde(default)]
612 pub action_batch: bool,
613 #[serde(default)]
615 pub blob_packs: bool,
616 #[serde(default)]
618 pub blob_pack_uploads: bool,
619 #[serde(default)]
621 pub resumable_uploads: bool,
622 #[serde(default)]
624 pub delegated_transfers: bool,
625 #[serde(default)]
627 pub action_promises: bool,
628}
629
630#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
632#[non_exhaustive]
633pub struct CapabilityLimits {
634 #[serde(default)]
636 pub max_batch_items: u64,
637 #[serde(default)]
639 pub max_inline_blob_bytes: u64,
640 #[serde(default)]
642 pub max_blob_bytes: u64,
643 #[serde(default)]
645 pub max_pack_bytes: u64,
646}
647
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[non_exhaustive]
651pub struct Capabilities {
652 pub protocol: CapabilityProtocol,
654 #[serde(default)]
656 pub digest_algorithms: Vec<String>,
657 #[serde(default)]
659 pub compressors: Vec<String>,
660 #[serde(default)]
662 pub action_kinds: BTreeMap<String, ActionKindCapability>,
663 #[serde(default)]
665 pub features: CapabilityFeatures,
666 #[serde(default)]
668 pub limits: CapabilityLimits,
669}
670
671impl Capabilities {
672 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 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}