1use std::fs;
8use std::io::Write;
9#[cfg(feature = "mmap")]
10use std::marker::PhantomData;
11use std::path::{Path, PathBuf};
12use std::time::UNIX_EPOCH;
13
14use rkyv::Serialize as RkyvSerialize;
15use rkyv::rancor::Error as RkyvError;
16use thiserror::Error;
17
18use super::api::serialize;
19
20pub const MACHINE_CACHE_HEADER_LEN: usize = 256;
22
23type MachineSerializer<'a> = rkyv::rancor::Strategy<
24 rkyv::ser::Serializer<
25 rkyv::util::AlignedVec,
26 rkyv::ser::allocator::ArenaHandle<'a>,
27 rkyv::ser::sharing::Share,
28 >,
29 RkyvError,
30>;
31type MachineValidator<'a> = rkyv::api::high::HighValidator<'a, RkyvError>;
32
33const MACHINE_CACHE_MAGIC: [u8; 8] = *b"DXMCACH1";
34const MACHINE_CACHE_VERSION: u32 = 1;
35const MACHINE_CACHE_FLAG_NONE: u32 = 0;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum MachineCacheKind {
40 Json,
42 Config,
44 Receipt,
46 Index,
48 Custom(u32),
50}
51
52impl MachineCacheKind {
53 fn as_u32(self) -> u32 {
54 match self {
55 Self::Json => 1,
56 Self::Config => 2,
57 Self::Receipt => 3,
58 Self::Index => 4,
59 Self::Custom(value) => value,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum MachineCacheCodec {
67 None,
69 Lz4,
71 Zstd,
73}
74
75impl MachineCacheCodec {
76 fn as_u32(self) -> u32 {
77 match self {
78 Self::None => 0,
79 Self::Lz4 => 1,
80 Self::Zstd => 2,
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct MachineCacheSchema {
88 pub name: &'static str,
90 pub version: u32,
92 pub kind: MachineCacheKind,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct MachineCacheSource {
99 pub path: PathBuf,
101 pub bytes: u64,
103 pub modified_unix_ms: Option<u64>,
105 pub blake3: [u8; 32],
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct MachineCachePaths {
112 pub source: PathBuf,
114 pub machine: PathBuf,
116 pub metadata: PathBuf,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct MachineCacheWriteOptions {
123 pub codec: MachineCacheCodec,
125}
126
127impl Default for MachineCacheWriteOptions {
128 fn default() -> Self {
129 Self {
130 codec: MachineCacheCodec::None,
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct MachineCacheReceipt {
138 pub machine: PathBuf,
140 pub metadata: PathBuf,
142 pub archive_bytes: u64,
144 pub machine_bytes: u64,
146 pub archive_blake3: [u8; 32],
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct MachineCacheEnvelopeV1 {
153 pub magic: [u8; 8],
155 pub version: u32,
157 pub header_bytes: u32,
159 pub kind_id: u32,
161 pub schema_version: u32,
163 pub codec: u32,
165 pub flags: u32,
167 pub payload_bytes: u64,
169 pub archive_bytes: u64,
171 pub source_bytes: u64,
173 pub source_blake3: [u8; 32],
175 pub payload_blake3: [u8; 32],
177 pub archive_blake3: [u8; 32],
179 pub schema_blake3: [u8; 32],
181 pub reserved: [u8; 64],
183}
184
185#[derive(Debug, Error)]
187pub enum MachineCacheError {
188 #[error("machine cache IO error: {0}")]
190 Io(#[from] std::io::Error),
191
192 #[error("invalid machine cache path: {0}")]
194 InvalidCachePath(String),
195
196 #[error("invalid machine cache name: {0}")]
198 InvalidCacheName(String),
199
200 #[error("unsupported machine cache codec: {0:?}")]
202 UnsupportedCodec(MachineCacheCodec),
203
204 #[error("unsupported machine cache codec id: {0}")]
206 UnsupportedCodecId(u32),
207
208 #[error("machine cache serialization failed: {0}")]
210 Serialization(String),
211
212 #[error("invalid machine cache magic")]
214 InvalidMagic,
215
216 #[error("unsupported machine cache version: {0}")]
218 UnsupportedVersion(u32),
219
220 #[error("invalid machine cache header length: {0}")]
222 InvalidHeaderLength(u32),
223
224 #[error("machine cache reserved bytes or flags were set")]
226 ReservedBytesSet,
227
228 #[error("machine cache length mismatch")]
230 LengthMismatch,
231
232 #[error("machine cache source fingerprint mismatch")]
234 SourceMismatch,
235
236 #[error("machine cache schema mismatch")]
238 SchemaMismatch,
239
240 #[error("machine cache payload checksum mismatch")]
242 PayloadChecksumMismatch,
243
244 #[error("machine cache archive checksum mismatch")]
246 ArchiveChecksumMismatch,
247
248 #[error("machine cache bytecheck failed: {0}")]
250 BytecheckFailed(String),
251
252 #[cfg(feature = "converters")]
254 #[error("machine cache JSON parse failed: {0}")]
255 Json(String),
256
257 #[cfg(feature = "converters")]
259 #[error("machine cache TOML parse failed: {0}")]
260 Toml(String),
261}
262
263#[derive(Debug, Error)]
265pub enum MachineCacheMiss {
266 #[error("machine cache missing: {0}")]
268 Missing(PathBuf),
269
270 #[error(transparent)]
272 Invalid(#[from] MachineCacheError),
273}
274
275#[cfg(feature = "mmap")]
277#[derive(Debug)]
278pub struct MappedMachineCache<T>
279where
280 T: rkyv::Archive,
281{
282 mmap: memmap2::Mmap,
283 payload_offset: usize,
284 payload_len: usize,
285 _marker: PhantomData<T>,
286}
287
288#[cfg(feature = "mmap")]
289impl<T> MappedMachineCache<T>
290where
291 T: rkyv::Archive,
292{
293 #[allow(unsafe_code)]
295 pub fn archived(&self) -> &T::Archived {
296 let payload = &self.mmap[self.payload_offset..self.payload_offset + self.payload_len];
297 unsafe { rkyv::access_unchecked::<T::Archived>(payload) }
300 }
301}
302
303pub fn source_fingerprint(path: &Path) -> Result<MachineCacheSource, MachineCacheError> {
305 let data = fs::read(path)?;
306 let metadata = fs::metadata(path)?;
307 let modified_unix_ms = metadata
308 .modified()
309 .ok()
310 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
311 .and_then(|duration| u64::try_from(duration.as_millis()).ok());
312
313 Ok(MachineCacheSource {
314 path: path.to_path_buf(),
315 bytes: metadata.len(),
316 modified_unix_ms,
317 blake3: *blake3::hash(&data).as_bytes(),
318 })
319}
320
321pub fn paths_for_project_cache(
323 project_root: &Path,
324 project_name: &str,
325 cache_name: &str,
326 source_path: &Path,
327) -> Result<MachineCachePaths, MachineCacheError> {
328 validate_cache_name(project_name)?;
329 validate_cache_name(cache_name)?;
330
331 let cache_dir = project_root.join(".dx").join(project_name);
332 if !path_stays_under(project_root, &cache_dir) {
333 return Err(MachineCacheError::InvalidCachePath(
334 cache_dir.display().to_string(),
335 ));
336 }
337
338 Ok(MachineCachePaths {
339 source: source_path.to_path_buf(),
340 machine: cache_dir.join(format!("{cache_name}.machine")),
341 metadata: cache_dir.join(format!("{cache_name}.machine.meta.json")),
342 })
343}
344
345pub fn write_typed_machine_cache<T>(
347 payload: &T,
348 source: &MachineCacheSource,
349 paths: &MachineCachePaths,
350 schema: MachineCacheSchema,
351 options: MachineCacheWriteOptions,
352) -> Result<MachineCacheReceipt, MachineCacheError>
353where
354 T: for<'a> RkyvSerialize<MachineSerializer<'a>>,
355{
356 if options.codec != MachineCacheCodec::None {
357 return Err(MachineCacheError::UnsupportedCodec(options.codec));
358 }
359
360 let archive = serialize(payload).map_err(|error| {
361 MachineCacheError::Serialization(format!("RKYV serialization failed: {error}"))
362 })?;
363 let archive_bytes = archive.as_ref();
364 let archive_blake3 = *blake3::hash(archive_bytes).as_bytes();
365 let schema_blake3 = schema_hash(schema);
366 let header = MachineCacheEnvelopeV1 {
367 magic: MACHINE_CACHE_MAGIC,
368 version: MACHINE_CACHE_VERSION,
369 header_bytes: MACHINE_CACHE_HEADER_LEN as u32,
370 kind_id: schema.kind.as_u32(),
371 schema_version: schema.version,
372 codec: options.codec.as_u32(),
373 flags: MACHINE_CACHE_FLAG_NONE,
374 payload_bytes: archive_bytes.len() as u64,
375 archive_bytes: archive_bytes.len() as u64,
376 source_bytes: source.bytes,
377 source_blake3: source.blake3,
378 payload_blake3: archive_blake3,
379 archive_blake3,
380 schema_blake3,
381 reserved: [0; 64],
382 };
383
384 if let Some(parent) = paths.machine.parent() {
385 fs::create_dir_all(parent)?;
386 }
387
388 let mut machine_bytes = Vec::with_capacity(MACHINE_CACHE_HEADER_LEN + archive_bytes.len());
389 machine_bytes.extend_from_slice(&encode_header(&header));
390 machine_bytes.extend_from_slice(archive_bytes);
391 write_atomic(&paths.machine, &machine_bytes)?;
392
393 write_metadata_if_enabled(source, paths, schema, &header)?;
394
395 Ok(MachineCacheReceipt {
396 machine: paths.machine.clone(),
397 metadata: paths.metadata.clone(),
398 archive_bytes: archive_bytes.len() as u64,
399 machine_bytes: machine_bytes.len() as u64,
400 archive_blake3,
401 })
402}
403
404pub fn access_typed_machine_cache<'a, T>(
406 data: &'a [u8],
407 current_source: &MachineCacheSource,
408 expected_schema: MachineCacheSchema,
409) -> Result<&'a T::Archived, MachineCacheError>
410where
411 T: rkyv::Archive,
412 T::Archived: for<'check> rkyv::bytecheck::CheckBytes<MachineValidator<'check>>,
413{
414 let payload = validate_machine_cache_bytes(data, current_source, expected_schema)?;
415 rkyv::access::<T::Archived, RkyvError>(payload)
416 .map_err(|error| MachineCacheError::BytecheckFailed(error.to_string()))
417}
418
419#[cfg(feature = "mmap")]
421#[allow(unsafe_code)]
422pub fn open_typed_machine_cache<T>(
423 paths: &MachineCachePaths,
424 current_source: &MachineCacheSource,
425 expected_schema: MachineCacheSchema,
426) -> Result<MappedMachineCache<T>, MachineCacheMiss>
427where
428 T: rkyv::Archive,
429 T::Archived: for<'check> rkyv::bytecheck::CheckBytes<MachineValidator<'check>>,
430{
431 if !paths.machine.exists() {
432 return Err(MachineCacheMiss::Missing(paths.machine.clone()));
433 }
434
435 let file = fs::File::open(&paths.machine).map_err(MachineCacheError::Io)?;
436 let mmap = unsafe { memmap2::MmapOptions::new().map(&file) }.map_err(MachineCacheError::Io)?;
439 let payload = validate_machine_cache_bytes(&mmap, current_source, expected_schema)?;
440 rkyv::access::<T::Archived, RkyvError>(payload)
441 .map_err(|error| MachineCacheError::BytecheckFailed(error.to_string()))?;
442
443 let payload_offset = MACHINE_CACHE_HEADER_LEN;
444 let payload_len = payload.len();
445 Ok(MappedMachineCache {
446 mmap,
447 payload_offset,
448 payload_len,
449 _marker: PhantomData,
450 })
451}
452
453#[cfg(feature = "converters")]
455pub fn json_source_to_typed_machine_cache<T>(
456 source_path: &Path,
457 paths: &MachineCachePaths,
458 schema: MachineCacheSchema,
459 options: MachineCacheWriteOptions,
460) -> Result<MachineCacheReceipt, MachineCacheError>
461where
462 T: serde::de::DeserializeOwned + for<'a> RkyvSerialize<MachineSerializer<'a>>,
463{
464 let source = source_fingerprint(source_path)?;
465 let text = fs::read_to_string(source_path)?;
466 let payload = serde_json::from_str::<T>(&text)
467 .map_err(|error| MachineCacheError::Json(error.to_string()))?;
468 write_typed_machine_cache(&payload, &source, paths, schema, options)
469}
470
471#[cfg(feature = "converters")]
473pub fn toml_source_to_typed_machine_cache<T>(
474 source_path: &Path,
475 paths: &MachineCachePaths,
476 schema: MachineCacheSchema,
477 options: MachineCacheWriteOptions,
478) -> Result<MachineCacheReceipt, MachineCacheError>
479where
480 T: serde::de::DeserializeOwned + for<'a> RkyvSerialize<MachineSerializer<'a>>,
481{
482 let source = source_fingerprint(source_path)?;
483 let text = fs::read_to_string(source_path)?;
484 let payload =
485 toml::from_str::<T>(&text).map_err(|error| MachineCacheError::Toml(error.to_string()))?;
486 write_typed_machine_cache(&payload, &source, paths, schema, options)
487}
488
489fn validate_machine_cache_bytes<'a>(
490 data: &'a [u8],
491 current_source: &MachineCacheSource,
492 expected_schema: MachineCacheSchema,
493) -> Result<&'a [u8], MachineCacheError> {
494 if data.len() < MACHINE_CACHE_HEADER_LEN {
495 return Err(MachineCacheError::LengthMismatch);
496 }
497
498 let header = decode_header(&data[..MACHINE_CACHE_HEADER_LEN])?;
499 if header.source_bytes != current_source.bytes || header.source_blake3 != current_source.blake3
500 {
501 return Err(MachineCacheError::SourceMismatch);
502 }
503 if header.kind_id != expected_schema.kind.as_u32()
504 || header.schema_version != expected_schema.version
505 || header.schema_blake3 != schema_hash(expected_schema)
506 {
507 return Err(MachineCacheError::SchemaMismatch);
508 }
509 if header.codec != MachineCacheCodec::None.as_u32() {
510 return match header.codec {
511 1 => Err(MachineCacheError::UnsupportedCodec(MachineCacheCodec::Lz4)),
512 2 => Err(MachineCacheError::UnsupportedCodec(MachineCacheCodec::Zstd)),
513 other => Err(MachineCacheError::UnsupportedCodecId(other)),
514 };
515 }
516
517 let payload_len =
518 usize::try_from(header.payload_bytes).map_err(|_| MachineCacheError::LengthMismatch)?;
519 let archive_len =
520 usize::try_from(header.archive_bytes).map_err(|_| MachineCacheError::LengthMismatch)?;
521 if payload_len != archive_len || data.len() != MACHINE_CACHE_HEADER_LEN + payload_len {
522 return Err(MachineCacheError::LengthMismatch);
523 }
524
525 let payload = &data[MACHINE_CACHE_HEADER_LEN..];
526 let payload_blake3 = *blake3::hash(payload).as_bytes();
527 if payload_blake3 != header.payload_blake3 {
528 return Err(MachineCacheError::PayloadChecksumMismatch);
529 }
530 if payload_blake3 != header.archive_blake3 {
531 return Err(MachineCacheError::ArchiveChecksumMismatch);
532 }
533
534 Ok(payload)
535}
536
537fn encode_header(header: &MachineCacheEnvelopeV1) -> [u8; MACHINE_CACHE_HEADER_LEN] {
538 let mut bytes = [0u8; MACHINE_CACHE_HEADER_LEN];
539 bytes[0..8].copy_from_slice(&header.magic);
540 write_u32(&mut bytes, 8, header.version);
541 write_u32(&mut bytes, 12, header.header_bytes);
542 write_u32(&mut bytes, 16, header.kind_id);
543 write_u32(&mut bytes, 20, header.schema_version);
544 write_u32(&mut bytes, 24, header.codec);
545 write_u32(&mut bytes, 28, header.flags);
546 write_u64(&mut bytes, 32, header.payload_bytes);
547 write_u64(&mut bytes, 40, header.archive_bytes);
548 write_u64(&mut bytes, 48, header.source_bytes);
549 bytes[56..88].copy_from_slice(&header.source_blake3);
550 bytes[88..120].copy_from_slice(&header.payload_blake3);
551 bytes[120..152].copy_from_slice(&header.archive_blake3);
552 bytes[152..184].copy_from_slice(&header.schema_blake3);
553 bytes[184..248].copy_from_slice(&header.reserved);
554 bytes
555}
556
557fn decode_header(bytes: &[u8]) -> Result<MachineCacheEnvelopeV1, MachineCacheError> {
558 if bytes.len() != MACHINE_CACHE_HEADER_LEN {
559 return Err(MachineCacheError::InvalidHeaderLength(bytes.len() as u32));
560 }
561
562 let mut magic = [0; 8];
563 magic.copy_from_slice(&bytes[0..8]);
564 if magic != MACHINE_CACHE_MAGIC {
565 return Err(MachineCacheError::InvalidMagic);
566 }
567
568 let version = read_u32(bytes, 8);
569 if version != MACHINE_CACHE_VERSION {
570 return Err(MachineCacheError::UnsupportedVersion(version));
571 }
572
573 let header_bytes = read_u32(bytes, 12);
574 if header_bytes != MACHINE_CACHE_HEADER_LEN as u32 {
575 return Err(MachineCacheError::InvalidHeaderLength(header_bytes));
576 }
577
578 let flags = read_u32(bytes, 28);
579 let mut reserved = [0; 64];
580 reserved.copy_from_slice(&bytes[184..248]);
581 if flags != MACHINE_CACHE_FLAG_NONE || reserved.iter().any(|byte| *byte != 0) {
582 return Err(MachineCacheError::ReservedBytesSet);
583 }
584 if bytes[248..MACHINE_CACHE_HEADER_LEN]
585 .iter()
586 .any(|byte| *byte != 0)
587 {
588 return Err(MachineCacheError::ReservedBytesSet);
589 }
590
591 let mut source_blake3 = [0; 32];
592 source_blake3.copy_from_slice(&bytes[56..88]);
593 let mut payload_blake3 = [0; 32];
594 payload_blake3.copy_from_slice(&bytes[88..120]);
595 let mut archive_blake3 = [0; 32];
596 archive_blake3.copy_from_slice(&bytes[120..152]);
597 let mut schema_blake3 = [0; 32];
598 schema_blake3.copy_from_slice(&bytes[152..184]);
599
600 Ok(MachineCacheEnvelopeV1 {
601 magic,
602 version,
603 header_bytes,
604 kind_id: read_u32(bytes, 16),
605 schema_version: read_u32(bytes, 20),
606 codec: read_u32(bytes, 24),
607 flags,
608 payload_bytes: read_u64(bytes, 32),
609 archive_bytes: read_u64(bytes, 40),
610 source_bytes: read_u64(bytes, 48),
611 source_blake3,
612 payload_blake3,
613 archive_blake3,
614 schema_blake3,
615 reserved,
616 })
617}
618
619fn schema_hash(schema: MachineCacheSchema) -> [u8; 32] {
620 let mut hasher = blake3::Hasher::new();
621 hasher.update(schema.name.as_bytes());
622 hasher.update(&schema.version.to_le_bytes());
623 hasher.update(&schema.kind.as_u32().to_le_bytes());
624 *hasher.finalize().as_bytes()
625}
626
627fn validate_cache_name(name: &str) -> Result<(), MachineCacheError> {
628 let valid = !name.is_empty()
629 && name != "."
630 && name != ".."
631 && !name.contains("..")
632 && name
633 .bytes()
634 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
635 if valid {
636 Ok(())
637 } else {
638 Err(MachineCacheError::InvalidCacheName(name.to_string()))
639 }
640}
641
642fn path_stays_under(project_root: &Path, path: &Path) -> bool {
643 path.starts_with(project_root.join(".dx"))
644}
645
646fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), MachineCacheError> {
647 let tmp = path.with_extension(format!(
648 "{}.tmp.{}",
649 path.extension()
650 .and_then(|extension| extension.to_str())
651 .unwrap_or("machine"),
652 std::process::id()
653 ));
654
655 {
656 let mut file = fs::File::create(&tmp)?;
657 file.write_all(bytes)?;
658 file.sync_all()?;
659 }
660
661 if path.exists() {
662 fs::remove_file(path)?;
663 }
664 fs::rename(&tmp, path)?;
665 Ok(())
666}
667
668#[cfg(feature = "converters")]
669fn write_metadata_if_enabled(
670 source: &MachineCacheSource,
671 paths: &MachineCachePaths,
672 schema: MachineCacheSchema,
673 header: &MachineCacheEnvelopeV1,
674) -> Result<(), MachineCacheError> {
675 use serde::Serialize;
676
677 #[derive(Serialize)]
678 struct Metadata<'a> {
679 cache_schema: &'a str,
680 cache_version: u32,
681 cache_kind: u32,
682 source_path: String,
683 source_len: u64,
684 source_modified_unix_ms: Option<u64>,
685 source_blake3: String,
686 machine_path: String,
687 machine_payload_len: u64,
688 machine_archive_len: u64,
689 machine_payload_blake3: String,
690 }
691
692 let metadata = Metadata {
693 cache_schema: schema.name,
694 cache_version: schema.version,
695 cache_kind: schema.kind.as_u32(),
696 source_path: source.path.display().to_string(),
697 source_len: source.bytes,
698 source_modified_unix_ms: source.modified_unix_ms,
699 source_blake3: hex32(source.blake3),
700 machine_path: paths.machine.display().to_string(),
701 machine_payload_len: header.payload_bytes,
702 machine_archive_len: header.archive_bytes,
703 machine_payload_blake3: hex32(header.payload_blake3),
704 };
705
706 let bytes = serde_json::to_vec_pretty(&metadata)
707 .map_err(|error| MachineCacheError::Json(error.to_string()))?;
708 write_atomic(&paths.metadata, &bytes)
709}
710
711#[cfg(not(feature = "converters"))]
712fn write_metadata_if_enabled(
713 _source: &MachineCacheSource,
714 _paths: &MachineCachePaths,
715 _schema: MachineCacheSchema,
716 _header: &MachineCacheEnvelopeV1,
717) -> Result<(), MachineCacheError> {
718 Ok(())
719}
720
721#[cfg(feature = "converters")]
722fn hex32(bytes: [u8; 32]) -> String {
723 const HEX: &[u8; 16] = b"0123456789abcdef";
724 let mut output = String::with_capacity(64);
725 for byte in bytes {
726 output.push(HEX[(byte >> 4) as usize] as char);
727 output.push(HEX[(byte & 0x0f) as usize] as char);
728 }
729 output
730}
731
732fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
733 bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
734}
735
736fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
737 bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
738}
739
740fn read_u32(bytes: &[u8], offset: usize) -> u32 {
741 u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0; 4]))
742}
743
744fn read_u64(bytes: &[u8], offset: usize) -> u64 {
745 u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap_or([0; 8]))
746}
747
748#[cfg(all(test, feature = "converters"))]
749mod tests {
750 use std::fs;
751
752 use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
753 use serde::Deserialize;
754 use tempfile::tempdir;
755
756 use super::{
757 MachineCacheError, MachineCacheKind, MachineCacheSchema, MachineCacheWriteOptions,
758 access_typed_machine_cache, json_source_to_typed_machine_cache, paths_for_project_cache,
759 source_fingerprint, write_typed_machine_cache,
760 };
761
762 #[derive(Archive, RkyvSerialize, RkyvDeserialize, Deserialize, Debug, PartialEq)]
763 #[rkyv(compare(PartialEq), derive(Debug))]
764 struct TestPayload {
765 id: u64,
766 flags: u32,
767 active: bool,
768 }
769
770 fn schema() -> MachineCacheSchema {
771 MachineCacheSchema {
772 name: "dx.test.payload",
773 version: 1,
774 kind: MachineCacheKind::Json,
775 }
776 }
777
778 #[test]
779 fn source_fingerprint_changes_for_same_length_content_change() {
780 let temp = tempdir().unwrap();
781 let source_path = temp.path().join("source.json");
782
783 fs::write(&source_path, b"{\"id\":1}").unwrap();
784 let first = source_fingerprint(&source_path).unwrap();
785
786 fs::write(&source_path, b"{\"id\":2}").unwrap();
787 let second = source_fingerprint(&source_path).unwrap();
788
789 assert_eq!(first.bytes, second.bytes);
790 assert_ne!(first.blake3, second.blake3);
791 }
792
793 #[test]
794 fn paths_for_project_cache_uses_project_cache_root() {
795 let temp = tempdir().unwrap();
796 let source_path = temp.path().join("nested").join("source.json");
797
798 let paths =
799 paths_for_project_cache(temp.path(), "check", "check-report-latest", &source_path)
800 .unwrap();
801
802 assert_eq!(paths.source, source_path);
803 assert_eq!(
804 paths.machine,
805 temp.path()
806 .join(".dx")
807 .join("check")
808 .join("check-report-latest.machine")
809 );
810 assert_eq!(
811 paths.metadata,
812 temp.path()
813 .join(".dx")
814 .join("check")
815 .join("check-report-latest.machine.meta.json")
816 );
817 }
818
819 #[test]
820 fn paths_for_project_cache_rejects_parent_traversal_names() {
821 let temp = tempdir().unwrap();
822 let source_path = temp.path().join("source.json");
823
824 let error = paths_for_project_cache(temp.path(), "..", "cache", &source_path).unwrap_err();
825 assert!(matches!(error, MachineCacheError::InvalidCacheName(_)));
826
827 let error =
828 paths_for_project_cache(temp.path(), "check", "../cache", &source_path).unwrap_err();
829 assert!(matches!(error, MachineCacheError::InvalidCacheName(_)));
830 }
831
832 #[test]
833 fn write_typed_machine_cache_writes_readable_machine_and_metadata() {
834 let temp = tempdir().unwrap();
835 let source_path = temp.path().join("source.json");
836 fs::write(&source_path, b"{\"id\":1,\"flags\":7,\"active\":true}").unwrap();
837
838 let source = source_fingerprint(&source_path).unwrap();
839 let paths = paths_for_project_cache(temp.path(), "check", "payload", &source_path).unwrap();
840 let payload = TestPayload {
841 id: 1,
842 flags: 7,
843 active: true,
844 };
845
846 let receipt = write_typed_machine_cache(
847 &payload,
848 &source,
849 &paths,
850 schema(),
851 MachineCacheWriteOptions::default(),
852 )
853 .unwrap();
854
855 assert_eq!(receipt.machine, paths.machine);
856 assert!(paths.machine.exists());
857 assert!(paths.metadata.exists());
858 assert_eq!(
859 fs::read_dir(paths.machine.parent().unwrap())
860 .unwrap()
861 .filter_map(Result::ok)
862 .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp."))
863 .count(),
864 0
865 );
866
867 let bytes = fs::read(&paths.machine).unwrap();
868 let archived =
869 access_typed_machine_cache::<TestPayload>(&bytes, &source, schema()).unwrap();
870 assert_eq!(archived.id, 1);
871 assert_eq!(archived.flags, 7);
872 assert!(archived.active);
873 }
874
875 #[test]
876 fn json_source_to_typed_machine_cache_parses_source_once_into_machine() {
877 let temp = tempdir().unwrap();
878 let source_path = temp.path().join("source.json");
879 fs::write(&source_path, b"{\"id\":9,\"flags\":3,\"active\":false}").unwrap();
880
881 let paths =
882 paths_for_project_cache(temp.path(), "www", "forge-status", &source_path).unwrap();
883 json_source_to_typed_machine_cache::<TestPayload>(
884 &source_path,
885 &paths,
886 schema(),
887 MachineCacheWriteOptions::default(),
888 )
889 .unwrap();
890
891 let source = source_fingerprint(&source_path).unwrap();
892 let bytes = fs::read(&paths.machine).unwrap();
893 let archived =
894 access_typed_machine_cache::<TestPayload>(&bytes, &source, schema()).unwrap();
895 assert_eq!(archived.id, 9);
896 assert_eq!(archived.flags, 3);
897 assert!(!archived.active);
898 }
899
900 #[test]
901 fn corrupt_machine_header_is_rejected_as_cache_miss_material() {
902 let temp = tempdir().unwrap();
903 let source_path = temp.path().join("source.json");
904 fs::write(&source_path, b"{\"id\":1,\"flags\":7,\"active\":true}").unwrap();
905
906 let source = source_fingerprint(&source_path).unwrap();
907 let paths = paths_for_project_cache(temp.path(), "check", "payload", &source_path).unwrap();
908 let payload = TestPayload {
909 id: 1,
910 flags: 7,
911 active: true,
912 };
913 write_typed_machine_cache(
914 &payload,
915 &source,
916 &paths,
917 schema(),
918 MachineCacheWriteOptions::default(),
919 )
920 .unwrap();
921
922 let mut bytes = fs::read(&paths.machine).unwrap();
923 bytes[0] = b'X';
924 let error =
925 access_typed_machine_cache::<TestPayload>(&bytes, &source, schema()).unwrap_err();
926 assert!(matches!(error, MachineCacheError::InvalidMagic));
927 }
928
929 #[test]
930 fn source_fingerprint_mismatch_is_rejected() {
931 let temp = tempdir().unwrap();
932 let source_path = temp.path().join("source.json");
933 fs::write(&source_path, b"{\"id\":1,\"flags\":7,\"active\":true}").unwrap();
934
935 let source = source_fingerprint(&source_path).unwrap();
936 let paths = paths_for_project_cache(temp.path(), "check", "payload", &source_path).unwrap();
937 let payload = TestPayload {
938 id: 1,
939 flags: 7,
940 active: true,
941 };
942 write_typed_machine_cache(
943 &payload,
944 &source,
945 &paths,
946 schema(),
947 MachineCacheWriteOptions::default(),
948 )
949 .unwrap();
950
951 fs::write(&source_path, b"{\"id\":2,\"flags\":7,\"active\":true}").unwrap();
952 let changed_source = source_fingerprint(&source_path).unwrap();
953 let bytes = fs::read(&paths.machine).unwrap();
954 let error = access_typed_machine_cache::<TestPayload>(&bytes, &changed_source, schema())
955 .unwrap_err();
956 assert!(matches!(error, MachineCacheError::SourceMismatch));
957 }
958}