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 BLOB_PACK_RECEIPT_MEDIA_TYPE: &str =
45 "application/vnd.mbx.cache-blob-pack-receipt.v1+json";
46pub const BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
48pub const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
50pub const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
52pub const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
54pub const MAX_BATCH_ITEMS: usize = 10_000;
56pub const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
58pub const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
60
61pub fn canonical_json(value: &impl Serialize) -> serde_json::Result<Vec<u8>> {
63 serde_json_canonicalizer::to_vec(value)
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct Digest {
70 pub algorithm: String,
72 pub hash: String,
74 pub size: u64,
76}
77
78impl Digest {
79 pub fn blake3(bytes: &[u8]) -> Self {
81 Self {
82 algorithm: DigestAlgorithm::Blake3.into(),
83 hash: blake3::hash(bytes).to_hex().to_string(),
84 size: bytes.len() as u64,
85 }
86 }
87
88 pub fn blake3_file(path: &Path) -> eyre::Result<Self> {
90 let (hash, size) = hash_file(path, DigestAlgorithm::Blake3)?;
91 Ok(Self {
92 algorithm: DigestAlgorithm::Blake3.into(),
93 hash,
94 size,
95 })
96 }
97
98 pub fn validate(&self) -> eyre::Result<()> {
100 self.algorithm_kind()?;
101 if self.hash.len() != 64
102 || !self
103 .hash
104 .bytes()
105 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
106 {
107 eyre::bail!("invalid remote cache digest");
108 }
109 Ok(())
110 }
111
112 pub fn matches_bytes(&self, bytes: &[u8]) -> eyre::Result<bool> {
114 self.validate()?;
115 if self.size != bytes.len() as u64 {
116 return Ok(false);
117 }
118 let hash = match self.algorithm_kind()? {
119 DigestAlgorithm::Blake3 => blake3::hash(bytes).to_hex().to_string(),
120 DigestAlgorithm::Sha256 => hex::encode(sha2::Sha256::digest(bytes)),
121 };
122 Ok(self.hash == hash)
123 }
124
125 pub fn matches_file(&self, path: &Path) -> eyre::Result<bool> {
127 self.validate()?;
128 let (hash, size) = hash_file(path, self.algorithm_kind()?)?;
129 Ok(self.size == size && self.hash == hash)
130 }
131
132 pub fn key(&self) -> String {
134 format!("{}/{}/{}", self.algorithm, self.hash, self.size)
135 }
136
137 pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
139 Ok(self.algorithm.parse()?)
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum DigestAlgorithm {
147 Blake3,
149 Sha256,
151}
152
153impl DigestAlgorithm {
154 pub const fn as_str(self) -> &'static str {
156 match self {
157 Self::Blake3 => "blake3",
158 Self::Sha256 => "sha256",
159 }
160 }
161}
162
163impl fmt::Display for DigestAlgorithm {
164 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165 formatter.write_str(self.as_str())
166 }
167}
168
169impl std::str::FromStr for DigestAlgorithm {
170 type Err = ParseDigestAlgorithmError;
171
172 fn from_str(value: &str) -> Result<Self, Self::Err> {
173 match value {
174 "blake3" => Ok(Self::Blake3),
175 "sha256" => Ok(Self::Sha256),
176 _ => Err(ParseDigestAlgorithmError),
177 }
178 }
179}
180
181impl From<DigestAlgorithm> for String {
182 fn from(algorithm: DigestAlgorithm) -> Self {
183 algorithm.as_str().into()
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct ParseDigestAlgorithmError;
190
191impl fmt::Display for ParseDigestAlgorithmError {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 formatter.write_str("unsupported remote cache digest algorithm")
194 }
195}
196
197impl std::error::Error for ParseDigestAlgorithmError {}
198
199fn hash_file(path: &Path, algorithm: DigestAlgorithm) -> eyre::Result<(String, u64)> {
200 let mut file = File::open(path)?;
201 let mut buffer = [0; 64 * 1024];
202 let mut size = 0;
203 let mut blake3 = blake3::Hasher::new();
204 let mut sha256 = sha2::Sha256::new();
205 loop {
206 let count = file.read(&mut buffer)?;
207 if count == 0 {
208 break;
209 }
210 match algorithm {
211 DigestAlgorithm::Blake3 => {
212 blake3.update(&buffer[..count]);
213 }
214 DigestAlgorithm::Sha256 => {
215 sha256.update(&buffer[..count]);
216 }
217 }
218 size += count as u64;
219 }
220 let hash = match algorithm {
221 DigestAlgorithm::Blake3 => blake3.finalize().to_hex().to_string(),
222 DigestAlgorithm::Sha256 => hex::encode(sha256.finalize()),
223 };
224 Ok((hash, size))
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct ActionResult {
231 pub action: Digest,
233 #[serde(default)]
235 pub metadata: Option<Digest>,
236 #[serde(default)]
238 pub output_root: Option<Digest>,
239 pub version: u8,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct Directory {
247 pub directories: Vec<DirectoryNode>,
249 pub files: Vec<FileNode>,
251 pub symlinks: Vec<SymlinkNode>,
253 pub version: u8,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(deny_unknown_fields)]
260pub struct DirectoryNode {
261 pub digest: Digest,
263 pub mode: u32,
265 pub name: String,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct FileNode {
273 pub digest: Digest,
275 pub executable: bool,
277 pub mode: u32,
279 pub name: String,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct SymlinkNode {
287 pub mode: u32,
289 pub name: String,
291 pub target: String,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(deny_unknown_fields)]
298pub struct RustcMetadata {
299 pub version: u8,
301 pub kind: String,
303 pub stdout: Digest,
305 pub stderr: Digest,
307}
308
309impl RustcMetadata {
310 pub fn validate(&self) -> bool {
312 self.version == 1
313 && self.kind == "rustc"
314 && self.stdout.validate().is_ok()
315 && self.stderr.validate().is_ok()
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct CcMetadata {
323 pub version: u8,
325 pub kind: String,
327 pub stdout: Digest,
329 pub stderr: Digest,
331}
332
333impl CcMetadata {
334 pub fn validate(&self) -> bool {
336 self.version == 1
337 && self.kind == "cc"
338 && self.stdout.validate().is_ok()
339 && self.stderr.validate().is_ok()
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(deny_unknown_fields)]
346pub struct ActionPrediction {
347 pub invocation: Digest,
349 pub action: Digest,
351 pub adapter: String,
353 pub payload: String,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(deny_unknown_fields)]
360pub struct TaskActionManifest {
361 pub version: u8,
363 pub task: String,
365 pub predictions: Vec<ActionPrediction>,
367}
368
369#[derive(Serialize)]
370struct TaskActionManifestSelector<'a> {
371 kind: &'static str,
372 task: &'a str,
373 version: u8,
374}
375
376impl TaskActionManifest {
377 pub fn validate(&self) -> bool {
379 let mut invocations = std::collections::BTreeSet::new();
380 self.version == 1
381 && valid_task_identity(&self.task)
382 && self.predictions.len() <= MAX_TASK_ACTION_PREDICTIONS
383 && self.predictions.iter().all(|prediction| {
384 prediction.validate().is_ok() && invocations.insert(&prediction.invocation)
385 })
386 }
387
388 pub fn selector_digest(&self) -> Digest {
390 Self::selector(&self.task)
391 .expect("manifest task identity must be valid")
392 .1
393 }
394
395 pub fn selector(task: &str) -> eyre::Result<(Vec<u8>, Digest)> {
397 if !valid_task_identity(task) {
398 eyre::bail!("invalid task action manifest identity");
399 }
400 let selector = canonical_json(&TaskActionManifestSelector {
401 kind: "task_action_manifest",
402 task,
403 version: 1,
404 })?;
405 let digest = Digest::blake3(&selector);
406 Ok((selector, digest))
407 }
408}
409
410impl ActionPrediction {
411 pub fn validate(&self) -> eyre::Result<()> {
418 match self.constraint_violation() {
419 Some(reason) => eyre::bail!("invalid action prediction: {reason}"),
420 None => Ok(()),
421 }
422 }
423
424 fn constraint_violation(&self) -> Option<String> {
425 if self.action.algorithm != DigestAlgorithm::Blake3.as_str()
426 || self.action.validate().is_err()
427 {
428 return Some("action digest is not a valid blake3 digest".into());
429 }
430 if self.invocation.algorithm != DigestAlgorithm::Blake3.as_str()
431 || self.invocation.validate().is_err()
432 {
433 return Some("invocation digest is not a valid blake3 digest".into());
434 }
435 if self.adapter.is_empty() {
436 return Some("adapter name is empty".into());
437 }
438 if !self
439 .adapter
440 .bytes()
441 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
442 {
443 return Some(format!(
444 "adapter name {:?} is not alphanumeric, '-', or '_'",
445 self.adapter
446 ));
447 }
448 if self.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
449 return Some(format!(
450 "payload is {} bytes, over the {MAX_ACTION_PREDICTION_PAYLOAD} byte limit",
451 self.payload.len()
452 ));
453 }
454 if serde_json::from_str::<serde_json::Value>(&self.payload).is_err() {
455 return Some("payload is not valid JSON".into());
456 }
457 None
458 }
459}
460
461fn valid_task_identity(value: &str) -> bool {
462 value.len() == 64
463 && value
464 .bytes()
465 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
474#[non_exhaustive]
475pub struct CapabilityProtocol {
476 pub major: u8,
478 #[serde(default)]
480 pub minor: u8,
481}
482
483impl CapabilityProtocol {
484 pub fn new(major: u8, minor: u8) -> Self {
486 Self { major, minor }
487 }
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
492#[non_exhaustive]
493pub struct ActionKindCapability {
494 pub action_schema: u8,
496 pub metadata_schema: u8,
498}
499
500impl ActionKindCapability {
501 pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
503 Self {
504 action_schema,
505 metadata_schema,
506 }
507 }
508}
509
510#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
512#[non_exhaustive]
513pub struct CapabilityFeatures {
514 #[serde(default)]
516 pub action_manifests: bool,
517 #[serde(default)]
519 pub batch: bool,
520 #[serde(default)]
526 pub action_batch: bool,
527 #[serde(default)]
529 pub blob_packs: bool,
530 #[serde(default)]
532 pub blob_pack_uploads: bool,
533 #[serde(default)]
535 pub resumable_uploads: bool,
536 #[serde(default)]
538 pub delegated_transfers: bool,
539}
540
541#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
543#[non_exhaustive]
544pub struct CapabilityLimits {
545 #[serde(default)]
547 pub max_batch_items: u64,
548 #[serde(default)]
550 pub max_inline_blob_bytes: u64,
551 #[serde(default)]
553 pub max_blob_bytes: u64,
554 #[serde(default)]
556 pub max_pack_bytes: u64,
557}
558
559#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561#[non_exhaustive]
562pub struct Capabilities {
563 pub protocol: CapabilityProtocol,
565 #[serde(default)]
567 pub digest_algorithms: Vec<String>,
568 #[serde(default)]
570 pub compressors: Vec<String>,
571 #[serde(default)]
573 pub action_kinds: BTreeMap<String, ActionKindCapability>,
574 #[serde(default)]
576 pub features: CapabilityFeatures,
577 #[serde(default)]
579 pub limits: CapabilityLimits,
580}
581
582impl Capabilities {
583 pub fn new(protocol: CapabilityProtocol) -> Self {
588 Self {
589 protocol,
590 digest_algorithms: Vec::new(),
591 compressors: Vec::new(),
592 action_kinds: BTreeMap::new(),
593 features: CapabilityFeatures::default(),
594 limits: CapabilityLimits::default(),
595 }
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 #[test]
604 fn digest_validation_is_exact() {
605 let valid = Digest {
606 algorithm: DigestAlgorithm::Blake3.into(),
607 hash: "a".repeat(64),
608 size: 42,
609 };
610 assert!(valid.validate().is_ok());
611 assert!(
612 Digest {
613 hash: "A".repeat(64),
614 ..valid.clone()
615 }
616 .validate()
617 .is_err()
618 );
619 assert!(
620 Digest {
621 algorithm: "md5".into(),
622 ..valid
623 }
624 .validate()
625 .is_err()
626 );
627 }
628
629 #[test]
630 fn a_rejected_prediction_names_the_constraint_it_violated() {
631 let digest = Digest::blake3(b"action");
632 let prediction = ActionPrediction {
633 invocation: digest.clone(),
634 action: digest,
635 adapter: "rustc".into(),
636 payload: "{}".into(),
637 };
638 assert!(prediction.validate().is_ok());
639
640 let reason = |prediction: ActionPrediction| {
641 prediction
642 .validate()
643 .expect_err("the prediction violates a constraint")
644 .to_string()
645 };
646 let oversized = ActionPrediction {
647 payload: format!("\"{}\"", "p".repeat(MAX_ACTION_PREDICTION_PAYLOAD)),
648 ..prediction.clone()
649 };
650 let oversized_len = oversized.payload.len();
651 let message = reason(oversized);
652 assert!(
653 message.contains(&oversized_len.to_string())
654 && message.contains(&MAX_ACTION_PREDICTION_PAYLOAD.to_string()),
655 "the message must carry both sizes so a build log says how far over it went: {message}"
656 );
657
658 assert_eq!(
661 reason(ActionPrediction {
662 adapter: "rust c".into(),
663 ..prediction.clone()
664 }),
665 r#"invalid action prediction: adapter name "rust c" is not alphanumeric, '-', or '_'"#
666 );
667 assert_eq!(
668 reason(ActionPrediction {
669 payload: "not json".into(),
670 ..prediction.clone()
671 }),
672 "invalid action prediction: payload is not valid JSON"
673 );
674 assert_eq!(
675 reason(ActionPrediction {
676 action: Digest {
677 algorithm: DigestAlgorithm::Sha256.into(),
678 ..prediction.action.clone()
679 },
680 ..prediction
681 }),
682 "invalid action prediction: action digest is not a valid blake3 digest"
683 );
684 }
685
686 #[test]
687 fn canonical_json_is_independent_of_map_insertion_order() {
688 #[derive(Serialize)]
689 struct ZThenA {
690 z: u8,
691 a: bool,
692 }
693
694 #[derive(Serialize)]
695 struct AThenZ {
696 a: bool,
697 z: u8,
698 }
699
700 assert_eq!(
701 canonical_json(&ZThenA { z: 1, a: true }).unwrap(),
702 canonical_json(&AThenZ { a: true, z: 1 }).unwrap()
703 );
704 }
705}