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 BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
39pub const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
41pub const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
43pub const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
45pub const MAX_BATCH_ITEMS: usize = 10_000;
47pub const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
49pub const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
51
52pub fn canonical_json(value: &impl Serialize) -> serde_json::Result<Vec<u8>> {
54 serde_json_canonicalizer::to_vec(value)
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct Digest {
61 pub algorithm: String,
63 pub hash: String,
65 pub size: u64,
67}
68
69impl Digest {
70 pub fn blake3(bytes: &[u8]) -> Self {
72 Self {
73 algorithm: DigestAlgorithm::Blake3.into(),
74 hash: blake3::hash(bytes).to_hex().to_string(),
75 size: bytes.len() as u64,
76 }
77 }
78
79 pub fn blake3_file(path: &Path) -> eyre::Result<Self> {
81 let (hash, size) = hash_file(path, DigestAlgorithm::Blake3)?;
82 Ok(Self {
83 algorithm: DigestAlgorithm::Blake3.into(),
84 hash,
85 size,
86 })
87 }
88
89 pub fn validate(&self) -> eyre::Result<()> {
91 self.algorithm_kind()?;
92 if self.hash.len() != 64
93 || !self
94 .hash
95 .bytes()
96 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
97 {
98 eyre::bail!("invalid remote cache digest");
99 }
100 Ok(())
101 }
102
103 pub fn matches_bytes(&self, bytes: &[u8]) -> eyre::Result<bool> {
105 self.validate()?;
106 if self.size != bytes.len() as u64 {
107 return Ok(false);
108 }
109 let hash = match self.algorithm_kind()? {
110 DigestAlgorithm::Blake3 => blake3::hash(bytes).to_hex().to_string(),
111 DigestAlgorithm::Sha256 => hex::encode(sha2::Sha256::digest(bytes)),
112 };
113 Ok(self.hash == hash)
114 }
115
116 pub fn matches_file(&self, path: &Path) -> eyre::Result<bool> {
118 self.validate()?;
119 let (hash, size) = hash_file(path, self.algorithm_kind()?)?;
120 Ok(self.size == size && self.hash == hash)
121 }
122
123 pub fn key(&self) -> String {
125 format!("{}/{}/{}", self.algorithm, self.hash, self.size)
126 }
127
128 pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
130 Ok(self.algorithm.parse()?)
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum DigestAlgorithm {
138 Blake3,
140 Sha256,
142}
143
144impl DigestAlgorithm {
145 pub const fn as_str(self) -> &'static str {
147 match self {
148 Self::Blake3 => "blake3",
149 Self::Sha256 => "sha256",
150 }
151 }
152}
153
154impl fmt::Display for DigestAlgorithm {
155 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156 formatter.write_str(self.as_str())
157 }
158}
159
160impl std::str::FromStr for DigestAlgorithm {
161 type Err = ParseDigestAlgorithmError;
162
163 fn from_str(value: &str) -> Result<Self, Self::Err> {
164 match value {
165 "blake3" => Ok(Self::Blake3),
166 "sha256" => Ok(Self::Sha256),
167 _ => Err(ParseDigestAlgorithmError),
168 }
169 }
170}
171
172impl From<DigestAlgorithm> for String {
173 fn from(algorithm: DigestAlgorithm) -> Self {
174 algorithm.as_str().into()
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct ParseDigestAlgorithmError;
181
182impl fmt::Display for ParseDigestAlgorithmError {
183 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184 formatter.write_str("unsupported remote cache digest algorithm")
185 }
186}
187
188impl std::error::Error for ParseDigestAlgorithmError {}
189
190fn hash_file(path: &Path, algorithm: DigestAlgorithm) -> eyre::Result<(String, u64)> {
191 let mut file = File::open(path)?;
192 let mut buffer = [0; 64 * 1024];
193 let mut size = 0;
194 let mut blake3 = blake3::Hasher::new();
195 let mut sha256 = sha2::Sha256::new();
196 loop {
197 let count = file.read(&mut buffer)?;
198 if count == 0 {
199 break;
200 }
201 match algorithm {
202 DigestAlgorithm::Blake3 => {
203 blake3.update(&buffer[..count]);
204 }
205 DigestAlgorithm::Sha256 => {
206 sha256.update(&buffer[..count]);
207 }
208 }
209 size += count as u64;
210 }
211 let hash = match algorithm {
212 DigestAlgorithm::Blake3 => blake3.finalize().to_hex().to_string(),
213 DigestAlgorithm::Sha256 => hex::encode(sha256.finalize()),
214 };
215 Ok((hash, size))
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct ActionResult {
222 pub action: Digest,
224 #[serde(default)]
226 pub metadata: Option<Digest>,
227 #[serde(default)]
229 pub output_root: Option<Digest>,
230 pub version: u8,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct Directory {
238 pub directories: Vec<DirectoryNode>,
240 pub files: Vec<FileNode>,
242 pub symlinks: Vec<SymlinkNode>,
244 pub version: u8,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct DirectoryNode {
252 pub digest: Digest,
254 pub mode: u32,
256 pub name: String,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct FileNode {
264 pub digest: Digest,
266 pub executable: bool,
268 pub mode: u32,
270 pub name: String,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(deny_unknown_fields)]
277pub struct SymlinkNode {
278 pub mode: u32,
280 pub name: String,
282 pub target: String,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct RustcMetadata {
290 pub version: u8,
292 pub kind: String,
294 pub stdout: Digest,
296 pub stderr: Digest,
298}
299
300impl RustcMetadata {
301 pub fn validate(&self) -> bool {
303 self.version == 1
304 && self.kind == "rustc"
305 && self.stdout.validate().is_ok()
306 && self.stderr.validate().is_ok()
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct ActionPrediction {
314 pub invocation: Digest,
316 pub action: Digest,
318 pub adapter: String,
320 pub payload: String,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct TaskActionManifest {
328 pub version: u8,
330 pub task: String,
332 pub predictions: Vec<ActionPrediction>,
334}
335
336#[derive(Serialize)]
337struct TaskActionManifestSelector<'a> {
338 kind: &'static str,
339 task: &'a str,
340 version: u8,
341}
342
343impl TaskActionManifest {
344 pub fn validate(&self) -> bool {
346 let mut invocations = std::collections::BTreeSet::new();
347 self.version == 1
348 && valid_task_identity(&self.task)
349 && self.predictions.len() <= MAX_TASK_ACTION_PREDICTIONS
350 && self.predictions.iter().all(|prediction| {
351 prediction.validate() && invocations.insert(&prediction.invocation)
352 })
353 }
354
355 pub fn selector_digest(&self) -> Digest {
357 Self::selector(&self.task)
358 .expect("manifest task identity must be valid")
359 .1
360 }
361
362 pub fn selector(task: &str) -> eyre::Result<(Vec<u8>, Digest)> {
364 if !valid_task_identity(task) {
365 eyre::bail!("invalid task action manifest identity");
366 }
367 let selector = canonical_json(&TaskActionManifestSelector {
368 kind: "task_action_manifest",
369 task,
370 version: 1,
371 })?;
372 let digest = Digest::blake3(&selector);
373 Ok((selector, digest))
374 }
375}
376
377impl ActionPrediction {
378 pub fn validate(&self) -> bool {
380 self.action.algorithm == DigestAlgorithm::Blake3.as_str()
381 && self.action.validate().is_ok()
382 && self.invocation.algorithm == DigestAlgorithm::Blake3.as_str()
383 && self.invocation.validate().is_ok()
384 && !self.adapter.is_empty()
385 && self
386 .adapter
387 .bytes()
388 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
389 && self.payload.len() <= MAX_ACTION_PREDICTION_PAYLOAD
390 && serde_json::from_str::<serde_json::Value>(&self.payload).is_ok()
391 }
392}
393
394fn valid_task_identity(value: &str) -> bool {
395 value.len() == 64
396 && value
397 .bytes()
398 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[non_exhaustive]
408pub struct CapabilityProtocol {
409 pub major: u8,
411 #[serde(default)]
413 pub minor: u8,
414}
415
416impl CapabilityProtocol {
417 pub fn new(major: u8, minor: u8) -> Self {
419 Self { major, minor }
420 }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[non_exhaustive]
426pub struct ActionKindCapability {
427 pub action_schema: u8,
429 pub metadata_schema: u8,
431}
432
433impl ActionKindCapability {
434 pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
436 Self {
437 action_schema,
438 metadata_schema,
439 }
440 }
441}
442
443#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
445#[non_exhaustive]
446pub struct CapabilityFeatures {
447 #[serde(default)]
449 pub action_manifests: bool,
450 #[serde(default)]
452 pub batch: bool,
453 #[serde(default)]
455 pub blob_packs: bool,
456 #[serde(default)]
458 pub resumable_uploads: bool,
459 #[serde(default)]
461 pub delegated_transfers: bool,
462}
463
464#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
466#[non_exhaustive]
467pub struct CapabilityLimits {
468 #[serde(default)]
470 pub max_batch_items: u64,
471 #[serde(default)]
473 pub max_inline_blob_bytes: u64,
474 #[serde(default)]
476 pub max_blob_bytes: u64,
477 #[serde(default)]
479 pub max_pack_bytes: u64,
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484#[non_exhaustive]
485pub struct Capabilities {
486 pub protocol: CapabilityProtocol,
488 #[serde(default)]
490 pub digest_algorithms: Vec<String>,
491 #[serde(default)]
493 pub compressors: Vec<String>,
494 #[serde(default)]
496 pub action_kinds: BTreeMap<String, ActionKindCapability>,
497 #[serde(default)]
499 pub features: CapabilityFeatures,
500 #[serde(default)]
502 pub limits: CapabilityLimits,
503}
504
505impl Capabilities {
506 pub fn new(protocol: CapabilityProtocol) -> Self {
511 Self {
512 protocol,
513 digest_algorithms: Vec::new(),
514 compressors: Vec::new(),
515 action_kinds: BTreeMap::new(),
516 features: CapabilityFeatures::default(),
517 limits: CapabilityLimits::default(),
518 }
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn digest_validation_is_exact() {
528 let valid = Digest {
529 algorithm: DigestAlgorithm::Blake3.into(),
530 hash: "a".repeat(64),
531 size: 42,
532 };
533 assert!(valid.validate().is_ok());
534 assert!(
535 Digest {
536 hash: "A".repeat(64),
537 ..valid.clone()
538 }
539 .validate()
540 .is_err()
541 );
542 assert!(
543 Digest {
544 algorithm: "md5".into(),
545 ..valid
546 }
547 .validate()
548 .is_err()
549 );
550 }
551
552 #[test]
553 fn canonical_json_is_independent_of_map_insertion_order() {
554 #[derive(Serialize)]
555 struct ZThenA {
556 z: u8,
557 a: bool,
558 }
559
560 #[derive(Serialize)]
561 struct AThenZ {
562 a: bool,
563 z: u8,
564 }
565
566 assert_eq!(
567 canonical_json(&ZThenA { z: 1, a: true }).unwrap(),
568 canonical_json(&AThenZ { a: true, z: 1 }).unwrap()
569 );
570 }
571}