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() && 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) -> bool {
413 self.action.algorithm == DigestAlgorithm::Blake3.as_str()
414 && self.action.validate().is_ok()
415 && self.invocation.algorithm == DigestAlgorithm::Blake3.as_str()
416 && self.invocation.validate().is_ok()
417 && !self.adapter.is_empty()
418 && self
419 .adapter
420 .bytes()
421 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
422 && self.payload.len() <= MAX_ACTION_PREDICTION_PAYLOAD
423 && serde_json::from_str::<serde_json::Value>(&self.payload).is_ok()
424 }
425}
426
427fn valid_task_identity(value: &str) -> bool {
428 value.len() == 64
429 && value
430 .bytes()
431 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
440#[non_exhaustive]
441pub struct CapabilityProtocol {
442 pub major: u8,
444 #[serde(default)]
446 pub minor: u8,
447}
448
449impl CapabilityProtocol {
450 pub fn new(major: u8, minor: u8) -> Self {
452 Self { major, minor }
453 }
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
458#[non_exhaustive]
459pub struct ActionKindCapability {
460 pub action_schema: u8,
462 pub metadata_schema: u8,
464}
465
466impl ActionKindCapability {
467 pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
469 Self {
470 action_schema,
471 metadata_schema,
472 }
473 }
474}
475
476#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
478#[non_exhaustive]
479pub struct CapabilityFeatures {
480 #[serde(default)]
482 pub action_manifests: bool,
483 #[serde(default)]
485 pub batch: bool,
486 #[serde(default)]
492 pub action_batch: bool,
493 #[serde(default)]
495 pub blob_packs: bool,
496 #[serde(default)]
498 pub blob_pack_uploads: bool,
499 #[serde(default)]
501 pub resumable_uploads: bool,
502 #[serde(default)]
504 pub delegated_transfers: bool,
505}
506
507#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
509#[non_exhaustive]
510pub struct CapabilityLimits {
511 #[serde(default)]
513 pub max_batch_items: u64,
514 #[serde(default)]
516 pub max_inline_blob_bytes: u64,
517 #[serde(default)]
519 pub max_blob_bytes: u64,
520 #[serde(default)]
522 pub max_pack_bytes: u64,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527#[non_exhaustive]
528pub struct Capabilities {
529 pub protocol: CapabilityProtocol,
531 #[serde(default)]
533 pub digest_algorithms: Vec<String>,
534 #[serde(default)]
536 pub compressors: Vec<String>,
537 #[serde(default)]
539 pub action_kinds: BTreeMap<String, ActionKindCapability>,
540 #[serde(default)]
542 pub features: CapabilityFeatures,
543 #[serde(default)]
545 pub limits: CapabilityLimits,
546}
547
548impl Capabilities {
549 pub fn new(protocol: CapabilityProtocol) -> Self {
554 Self {
555 protocol,
556 digest_algorithms: Vec::new(),
557 compressors: Vec::new(),
558 action_kinds: BTreeMap::new(),
559 features: CapabilityFeatures::default(),
560 limits: CapabilityLimits::default(),
561 }
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn digest_validation_is_exact() {
571 let valid = Digest {
572 algorithm: DigestAlgorithm::Blake3.into(),
573 hash: "a".repeat(64),
574 size: 42,
575 };
576 assert!(valid.validate().is_ok());
577 assert!(
578 Digest {
579 hash: "A".repeat(64),
580 ..valid.clone()
581 }
582 .validate()
583 .is_err()
584 );
585 assert!(
586 Digest {
587 algorithm: "md5".into(),
588 ..valid
589 }
590 .validate()
591 .is_err()
592 );
593 }
594
595 #[test]
596 fn canonical_json_is_independent_of_map_insertion_order() {
597 #[derive(Serialize)]
598 struct ZThenA {
599 z: u8,
600 a: bool,
601 }
602
603 #[derive(Serialize)]
604 struct AThenZ {
605 a: bool,
606 z: u8,
607 }
608
609 assert_eq!(
610 canonical_json(&ZThenA { z: 1, a: true }).unwrap(),
611 canonical_json(&AThenZ { a: true, z: 1 }).unwrap()
612 );
613 }
614}