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 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 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 pub fn key(&self) -> String {
142 format!("{}/{}/{}", self.algorithm, self.hash, self.size)
143 }
144
145 pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
147 Ok(self.algorithm.parse()?)
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
153#[serde(rename_all = "lowercase")]
154pub enum DigestAlgorithm {
155 Blake3,
157 Sha256,
159}
160
161impl DigestAlgorithm {
162 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct ActionResult {
239 pub action: Digest,
241 #[serde(default)]
243 pub metadata: Option<Digest>,
244 #[serde(default)]
246 pub output_root: Option<Digest>,
247 pub version: u8,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(deny_unknown_fields)]
254pub struct Directory {
255 pub directories: Vec<DirectoryNode>,
257 pub files: Vec<FileNode>,
259 pub symlinks: Vec<SymlinkNode>,
261 pub version: u8,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct DirectoryNode {
269 pub digest: Digest,
271 pub mode: u32,
273 pub name: String,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct FileNode {
281 pub digest: Digest,
283 pub executable: bool,
285 pub mode: u32,
287 pub name: String,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(deny_unknown_fields)]
294pub struct SymlinkNode {
295 pub mode: u32,
297 pub name: String,
299 pub target: String,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(deny_unknown_fields)]
306pub struct RustcMetadata {
307 pub version: u8,
309 pub kind: String,
311 pub stdout: Digest,
313 pub stderr: Digest,
315}
316
317impl RustcMetadata {
318 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct CcMetadata {
331 pub version: u8,
333 pub kind: String,
335 pub stdout: Digest,
337 pub stderr: Digest,
339}
340
341impl CcMetadata {
342 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct ActionPrediction {
355 pub invocation: Digest,
357 pub action: Digest,
359 pub adapter: String,
361 pub payload: String,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct ActionPromiseJoin {
373 pub adapter: String,
375}
376
377#[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 Claimed {
384 claim: String,
386 },
387 Pending {
389 retry_after_ms: u64,
391 },
392 Complete {
394 prediction: ActionPrediction,
396 },
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(deny_unknown_fields)]
402pub struct ActionPromiseCompletion {
403 pub claim: String,
405 pub prediction: ActionPrediction,
407}
408
409impl ActionPromiseJoin {
410 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(deny_unknown_fields)]
447pub struct TaskActionManifest {
448 pub version: u8,
450 pub task: String,
452 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 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 pub fn selector_digest(&self) -> Digest {
477 Self::selector(&self.task)
478 .expect("manifest task identity must be valid")
479 .1
480 }
481
482 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564#[non_exhaustive]
565pub struct CapabilityProtocol {
566 pub major: u8,
568 #[serde(default)]
570 pub minor: u8,
571}
572
573impl CapabilityProtocol {
574 pub fn new(major: u8, minor: u8) -> Self {
576 Self { major, minor }
577 }
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
582#[non_exhaustive]
583pub struct ActionKindCapability {
584 pub action_schema: u8,
586 pub metadata_schema: u8,
588}
589
590impl ActionKindCapability {
591 pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
593 Self {
594 action_schema,
595 metadata_schema,
596 }
597 }
598}
599
600#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
602#[non_exhaustive]
603pub struct CapabilityFeatures {
604 #[serde(default)]
606 pub action_manifests: bool,
607 #[serde(default)]
609 pub batch: bool,
610 #[serde(default)]
616 pub action_batch: bool,
617 #[serde(default)]
619 pub blob_packs: bool,
620 #[serde(default)]
622 pub blob_pack_uploads: bool,
623 #[serde(default)]
625 pub resumable_uploads: bool,
626 #[serde(default)]
628 pub delegated_transfers: bool,
629 #[serde(default)]
631 pub action_promises: bool,
632}
633
634#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
636#[non_exhaustive]
637pub struct CapabilityLimits {
638 #[serde(default)]
640 pub max_batch_items: u64,
641 #[serde(default)]
643 pub max_inline_blob_bytes: u64,
644 #[serde(default)]
646 pub max_blob_bytes: u64,
647 #[serde(default)]
649 pub max_pack_bytes: u64,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654#[non_exhaustive]
655pub struct Capabilities {
656 pub protocol: CapabilityProtocol,
658 #[serde(default)]
660 pub digest_algorithms: Vec<String>,
661 #[serde(default)]
663 pub compressors: Vec<String>,
664 #[serde(default)]
666 pub action_kinds: BTreeMap<String, ActionKindCapability>,
667 #[serde(default)]
669 pub features: CapabilityFeatures,
670 #[serde(default)]
672 pub limits: CapabilityLimits,
673}
674
675impl Capabilities {
676 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 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}