Skip to main content

serializer/machine/
cache.rs

1//! Typed `.machine` cache helpers for JSON/config/receipt/index read models.
2//!
3//! These helpers are intentionally separate from the `DxDocument` machine
4//! conversion path. They archive caller-owned Rust types directly so hot paths
5//! can parse source JSON once, then validate and read an immutable machine cache.
6
7use 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
20/// Number of bytes in the typed machine cache header.
21pub 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/// Broad kind of typed cache stored in a `.machine` sidecar.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum MachineCacheKind {
40    /// Source was a JSON document or JSON-derived read model.
41    Json,
42    /// Source was configuration such as TOML, YAML, or normalized config JSON.
43    Config,
44    /// Source was a generated receipt or status report.
45    Receipt,
46    /// Source was an index/catalog/search read model.
47    Index,
48    /// Project-specific cache kind.
49    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/// Compression codec used by a typed `.machine` cache.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum MachineCacheCodec {
67    /// Store the RKYV archive uncompressed for true mmap-friendly reads.
68    None,
69    /// LZ4 payload compression for warm caches.
70    Lz4,
71    /// Zstandard payload compression for cold or distribution caches.
72    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/// Schema identity expected by a typed `.machine` cache reader.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct MachineCacheSchema {
88    /// Stable human-readable schema name, for example `dx.www.forge_status`.
89    pub name: &'static str,
90    /// Monotonic schema version for this cache payload.
91    pub version: u32,
92    /// Broad cache kind.
93    pub kind: MachineCacheKind,
94}
95
96/// Fingerprint for the authoritative source file behind a machine cache.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct MachineCacheSource {
99    /// Source file path.
100    pub path: PathBuf,
101    /// Source file length in bytes.
102    pub bytes: u64,
103    /// Source modified time as Unix milliseconds, when the platform provides it.
104    pub modified_unix_ms: Option<u64>,
105    /// BLAKE3 hash of the source file bytes.
106    pub blake3: [u8; 32],
107}
108
109/// Resolved paths for a source file and its generated machine sidecars.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct MachineCachePaths {
112    /// Authoritative source path.
113    pub source: PathBuf,
114    /// Generated `.machine` cache path.
115    pub machine: PathBuf,
116    /// Generated metadata JSON path for diagnostics and receipts.
117    pub metadata: PathBuf,
118}
119
120/// Write options for a typed `.machine` cache.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct MachineCacheWriteOptions {
123    /// Payload codec. Hot mmap caches should use [`MachineCacheCodec::None`].
124    pub codec: MachineCacheCodec,
125}
126
127impl Default for MachineCacheWriteOptions {
128    fn default() -> Self {
129        Self {
130            codec: MachineCacheCodec::None,
131        }
132    }
133}
134
135/// Summary of a typed machine cache write.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct MachineCacheReceipt {
138    /// Machine cache path written.
139    pub machine: PathBuf,
140    /// Metadata path written when metadata support is enabled.
141    pub metadata: PathBuf,
142    /// Number of archived payload bytes before the typed cache header.
143    pub archive_bytes: u64,
144    /// Number of bytes written to the `.machine` file.
145    pub machine_bytes: u64,
146    /// BLAKE3 hash of the RKYV archive bytes.
147    pub archive_blake3: [u8; 32],
148}
149
150/// Parsed typed machine cache envelope.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct MachineCacheEnvelopeV1 {
153    /// Cache magic bytes. Current value is `DXMCACH1`.
154    pub magic: [u8; 8],
155    /// Envelope format version.
156    pub version: u32,
157    /// Header byte length.
158    pub header_bytes: u32,
159    /// Broad cache kind ID.
160    pub kind_id: u32,
161    /// Payload schema version.
162    pub schema_version: u32,
163    /// Compression codec ID.
164    pub codec: u32,
165    /// Reserved flags for future use.
166    pub flags: u32,
167    /// Stored payload length in bytes.
168    pub payload_bytes: u64,
169    /// Uncompressed archive length in bytes.
170    pub archive_bytes: u64,
171    /// Authoritative source length in bytes.
172    pub source_bytes: u64,
173    /// BLAKE3 hash of the authoritative source.
174    pub source_blake3: [u8; 32],
175    /// BLAKE3 hash of the stored payload bytes.
176    pub payload_blake3: [u8; 32],
177    /// BLAKE3 hash of the uncompressed archive bytes.
178    pub archive_blake3: [u8; 32],
179    /// BLAKE3 hash of schema name, version, and kind.
180    pub schema_blake3: [u8; 32],
181    /// Reserved zero bytes.
182    pub reserved: [u8; 64],
183}
184
185/// Error returned by typed machine cache helpers.
186#[derive(Debug, Error)]
187pub enum MachineCacheError {
188    /// Underlying I/O operation failed.
189    #[error("machine cache IO error: {0}")]
190    Io(#[from] std::io::Error),
191
192    /// Source path or cache path is not valid for a project cache.
193    #[error("invalid machine cache path: {0}")]
194    InvalidCachePath(String),
195
196    /// Project name or cache name contains unsupported characters.
197    #[error("invalid machine cache name: {0}")]
198    InvalidCacheName(String),
199
200    /// Requested codec is not supported by the typed cache helper yet.
201    #[error("unsupported machine cache codec: {0:?}")]
202    UnsupportedCodec(MachineCacheCodec),
203
204    /// Cache bytes specified an unknown codec ID.
205    #[error("unsupported machine cache codec id: {0}")]
206    UnsupportedCodecId(u32),
207
208    /// RKYV serialization failed.
209    #[error("machine cache serialization failed: {0}")]
210    Serialization(String),
211
212    /// Header magic did not match typed machine cache bytes.
213    #[error("invalid machine cache magic")]
214    InvalidMagic,
215
216    /// Header version is not supported.
217    #[error("unsupported machine cache version: {0}")]
218    UnsupportedVersion(u32),
219
220    /// Header length was not the expected fixed size.
221    #[error("invalid machine cache header length: {0}")]
222    InvalidHeaderLength(u32),
223
224    /// Header reserved bytes or flags were non-zero.
225    #[error("machine cache reserved bytes or flags were set")]
226    ReservedBytesSet,
227
228    /// Payload length in the header did not match file bytes.
229    #[error("machine cache length mismatch")]
230    LengthMismatch,
231
232    /// Source fingerprint did not match the authoritative source file.
233    #[error("machine cache source fingerprint mismatch")]
234    SourceMismatch,
235
236    /// Schema identity did not match the expected typed payload.
237    #[error("machine cache schema mismatch")]
238    SchemaMismatch,
239
240    /// Payload checksum did not match.
241    #[error("machine cache payload checksum mismatch")]
242    PayloadChecksumMismatch,
243
244    /// Archive checksum did not match.
245    #[error("machine cache archive checksum mismatch")]
246    ArchiveChecksumMismatch,
247
248    /// RKYV byte validation failed.
249    #[error("machine cache bytecheck failed: {0}")]
250    BytecheckFailed(String),
251
252    /// JSON source parsing failed.
253    #[cfg(feature = "converters")]
254    #[error("machine cache JSON parse failed: {0}")]
255    Json(String),
256
257    /// TOML source parsing failed.
258    #[cfg(feature = "converters")]
259    #[error("machine cache TOML parse failed: {0}")]
260    Toml(String),
261}
262
263/// Reason a typed cache could not be opened as a valid fast-path hit.
264#[derive(Debug, Error)]
265pub enum MachineCacheMiss {
266    /// Machine cache file does not exist.
267    #[error("machine cache missing: {0}")]
268    Missing(PathBuf),
269
270    /// Machine cache existed but was invalid, stale, or unreadable.
271    #[error(transparent)]
272    Invalid(#[from] MachineCacheError),
273}
274
275/// Memory-mapped typed machine cache.
276#[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    /// Returns the validated archived payload.
294    #[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        // SAFETY: `open_typed_machine_cache` validates this byte range with
298        // rkyv::access before constructing the mapped cache.
299        unsafe { rkyv::access_unchecked::<T::Archived>(payload) }
300    }
301}
302
303/// Compute the authoritative source fingerprint for a cache input file.
304pub 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
321/// Resolve deterministic `.machine` and metadata paths for a project cache.
322pub 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
345/// Write a typed RKYV payload to a validated machine cache sidecar.
346pub 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
404/// Validate typed cache bytes and return the archived payload.
405pub 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/// Open a typed cache with mmap after validating source, envelope, checksums, and bytes.
420#[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    // SAFETY: The file is opened read-only and the returned mapping is kept
437    // alive inside `MappedMachineCache` for the full lifetime of archived refs.
438    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/// Parse a JSON source file into `T` and write it as a typed machine cache.
454#[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/// Parse a TOML source file into `T` and write it as a typed machine cache.
472#[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}