Skip to main content

ferrum_native_ops/
build_cache.rs

1//! Content-addressed cache for native build outputs.
2//!
3//! Cargo assigns a different `OUT_DIR` to each profile and target identity.
4//! Native CUDA outputs are substantially more expensive than the Rust leaf
5//! that caused the profile change, so the build script uses this cache to move
6//! only signature-identical artifacts across those boundaries.
7
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Read, Seek, SeekFrom, Write};
10#[cfg(unix)]
11use std::os::fd::AsRawFd;
12#[cfg(windows)]
13use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::thread;
17use std::time::{Duration, Instant};
18
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use thiserror::Error;
22
23pub const NATIVE_BUILD_ARTIFACT_CACHE_SCHEMA_VERSION: u32 = 1;
24const ENTRY_LOCK_WAIT: Duration = Duration::from_secs(30);
25const ENTRY_LOCK_POLL: Duration = Duration::from_millis(25);
26static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(1);
27
28/// Accept a legacy signature only when removing one obsolete numeric field
29/// makes every remaining line exactly equal to the canonical signature.
30pub fn legacy_signature_matches_without_numeric_line(
31    legacy: &str,
32    canonical: &str,
33    line_prefix: &str,
34) -> bool {
35    if line_prefix.is_empty()
36        || line_prefix.contains('\n')
37        || canonical
38            .split('\n')
39            .any(|line| line.starts_with(line_prefix))
40    {
41        return false;
42    }
43
44    let mut removed = 0_u8;
45    let mut retained = Vec::new();
46    for line in legacy.split('\n') {
47        let Some(value) = line.strip_prefix(line_prefix) else {
48            retained.push(line);
49            continue;
50        };
51        if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
52            return false;
53        }
54        removed = removed.saturating_add(1);
55        if removed != 1 {
56            return false;
57        }
58    }
59    removed == 1 && retained == canonical.split('\n').collect::<Vec<_>>()
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct NativeBuildArtifactSpec {
64    artifact_id: String,
65    file_name: String,
66    input_signature: String,
67    input_signature_sha256: String,
68}
69
70impl NativeBuildArtifactSpec {
71    pub fn new(
72        artifact_id: impl Into<String>,
73        file_name: impl Into<String>,
74        input_signature: impl Into<String>,
75    ) -> Result<Self, NativeBuildArtifactCacheError> {
76        let artifact_id = artifact_id.into();
77        let file_name = file_name.into();
78        let input_signature = input_signature.into();
79        validate_artifact_id(&artifact_id)?;
80        validate_file_name(&file_name)?;
81        if input_signature.is_empty() {
82            return Err(NativeBuildArtifactCacheError::InvalidInputSignature);
83        }
84        let input_signature_sha256 = sha256_bytes(input_signature.as_bytes());
85        Ok(Self {
86            artifact_id,
87            file_name,
88            input_signature,
89            input_signature_sha256,
90        })
91    }
92
93    pub fn artifact_id(&self) -> &str {
94        &self.artifact_id
95    }
96
97    pub fn file_name(&self) -> &str {
98        &self.file_name
99    }
100
101    pub fn input_signature(&self) -> &str {
102        &self.input_signature
103    }
104
105    pub fn input_signature_sha256(&self) -> &str {
106        &self.input_signature_sha256
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct NativeBuildArtifactCacheManifest {
112    pub schema_version: u32,
113    pub artifact_id: String,
114    pub file_name: String,
115    pub input_signature: String,
116    pub input_signature_sha256: String,
117    pub artifact_sha256: String,
118    pub artifact_size_bytes: u64,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct NativeBuildArtifactCacheReceipt {
123    pub cache_entry: PathBuf,
124    pub artifact_path: PathBuf,
125    pub manifest_path: PathBuf,
126    pub artifact_sha256: String,
127    pub artifact_size_bytes: u64,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum NativeBuildArtifactLookup {
132    Hit(NativeBuildArtifactCacheReceipt),
133    Miss { reason: &'static str },
134}
135
136#[derive(Debug, Clone)]
137pub struct NativeBuildArtifactCache {
138    root: PathBuf,
139}
140
141impl NativeBuildArtifactCache {
142    pub fn new(root: impl Into<PathBuf>) -> Result<Self, NativeBuildArtifactCacheError> {
143        let root = root.into();
144        if !root.is_absolute() {
145            return Err(NativeBuildArtifactCacheError::CacheRootNotAbsolute(root));
146        }
147        fs::create_dir_all(&root).map_err(|source| {
148            NativeBuildArtifactCacheError::CreateDirectory {
149                path: root.clone(),
150                source,
151            }
152        })?;
153        let metadata = fs::symlink_metadata(&root).map_err(|source| {
154            NativeBuildArtifactCacheError::Metadata {
155                path: root.clone(),
156                source,
157            }
158        })?;
159        if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
160            return Err(NativeBuildArtifactCacheError::CacheRootNotDirectory(root));
161        }
162        Ok(Self { root })
163    }
164
165    pub fn root(&self) -> &Path {
166        &self.root
167    }
168
169    pub fn restore(
170        &self,
171        spec: &NativeBuildArtifactSpec,
172        destination: impl AsRef<Path>,
173    ) -> Result<NativeBuildArtifactLookup, NativeBuildArtifactCacheError> {
174        let entry = self.entry_dir(spec);
175        match self.validate_entry(spec, &entry)? {
176            Some(receipt) => {
177                let mut staged = stage_verified_copy(&receipt.artifact_path, destination.as_ref())?;
178                if staged.sha256 != receipt.artifact_sha256 {
179                    return Err(NativeBuildArtifactCacheError::ArtifactSha256Mismatch {
180                        path: receipt.artifact_path,
181                        expected: receipt.artifact_sha256,
182                        actual: staged.sha256.clone(),
183                    });
184                }
185                if staged.size_bytes != receipt.artifact_size_bytes {
186                    return Err(NativeBuildArtifactCacheError::ArtifactSizeMismatch {
187                        path: receipt.artifact_path,
188                        expected: receipt.artifact_size_bytes,
189                        actual: staged.size_bytes,
190                    });
191                }
192                staged.commit(destination.as_ref())?;
193                Ok(NativeBuildArtifactLookup::Hit(receipt))
194            }
195            None => Ok(NativeBuildArtifactLookup::Miss {
196                reason: "entry-absent",
197            }),
198        }
199    }
200
201    pub fn publish(
202        &self,
203        spec: &NativeBuildArtifactSpec,
204        source: impl AsRef<Path>,
205    ) -> Result<NativeBuildArtifactCacheReceipt, NativeBuildArtifactCacheError> {
206        let source = source.as_ref();
207        let entry = self.entry_dir(spec);
208        fs::create_dir_all(&entry).map_err(|source| {
209            NativeBuildArtifactCacheError::CreateDirectory {
210                path: entry.clone(),
211                source,
212            }
213        })?;
214        let _lock = EntryLock::acquire(&entry)?;
215        let artifact_path = entry.join(&spec.file_name);
216        let manifest_path = entry.join("manifest.json");
217        let mut staged = stage_verified_copy(source, &artifact_path)?;
218        let source_size = staged.size_bytes;
219        let source_sha256 = staged.sha256.clone();
220
221        if let Some(existing) = self.validate_entry(spec, &entry)? {
222            if existing.artifact_sha256 != source_sha256
223                || existing.artifact_size_bytes != source_size
224            {
225                return Err(NativeBuildArtifactCacheError::NondeterministicArtifact {
226                    artifact_id: spec.artifact_id.clone(),
227                    input_signature_sha256: spec.input_signature_sha256.clone(),
228                    existing_sha256: existing.artifact_sha256,
229                    candidate_sha256: source_sha256,
230                });
231            }
232            return Ok(existing);
233        }
234
235        if artifact_path.exists() && !manifest_path.exists() {
236            fs::remove_file(&artifact_path).map_err(|source| {
237                NativeBuildArtifactCacheError::RemoveIncompleteEntry {
238                    path: artifact_path.clone(),
239                    source,
240                }
241            })?;
242        }
243
244        let manifest = NativeBuildArtifactCacheManifest {
245            schema_version: NATIVE_BUILD_ARTIFACT_CACHE_SCHEMA_VERSION,
246            artifact_id: spec.artifact_id.clone(),
247            file_name: spec.file_name.clone(),
248            input_signature: spec.input_signature.clone(),
249            input_signature_sha256: spec.input_signature_sha256.clone(),
250            artifact_sha256: source_sha256.clone(),
251            artifact_size_bytes: source_size,
252        };
253        staged.commit(&artifact_path)?;
254        atomic_write_json(&manifest_path, &manifest)?;
255
256        self.validate_entry(spec, &entry)?.ok_or_else(|| {
257            NativeBuildArtifactCacheError::PublishedEntryMissing {
258                path: entry.clone(),
259            }
260        })
261    }
262
263    fn entry_dir(&self, spec: &NativeBuildArtifactSpec) -> PathBuf {
264        self.root
265            .join(&spec.artifact_id)
266            .join(&spec.input_signature_sha256)
267    }
268
269    fn validate_entry(
270        &self,
271        spec: &NativeBuildArtifactSpec,
272        entry: &Path,
273    ) -> Result<Option<NativeBuildArtifactCacheReceipt>, NativeBuildArtifactCacheError> {
274        let manifest_path = entry.join("manifest.json");
275        let artifact_path = entry.join(&spec.file_name);
276        let manifest_exists = manifest_path.exists();
277        let artifact_exists = artifact_path.exists();
278        if !manifest_exists && !artifact_exists {
279            return Ok(None);
280        }
281        if !manifest_exists {
282            return Ok(None);
283        }
284        if !artifact_exists {
285            return Err(NativeBuildArtifactCacheError::EntryArtifactMissing {
286                path: artifact_path,
287            });
288        }
289        validate_regular_file(&manifest_path)?;
290        validate_regular_file(&artifact_path)?;
291        let raw = fs::read_to_string(&manifest_path).map_err(|source| {
292            NativeBuildArtifactCacheError::Read {
293                path: manifest_path.clone(),
294                source,
295            }
296        })?;
297        let manifest: NativeBuildArtifactCacheManifest =
298            serde_json::from_str(&raw).map_err(|source| {
299                NativeBuildArtifactCacheError::ManifestJson {
300                    path: manifest_path.clone(),
301                    source,
302                }
303            })?;
304        validate_manifest(spec, &manifest, &manifest_path)?;
305        let actual_size = fs::metadata(&artifact_path)
306            .map_err(|source| NativeBuildArtifactCacheError::Metadata {
307                path: artifact_path.clone(),
308                source,
309            })?
310            .len();
311        if actual_size != manifest.artifact_size_bytes {
312            return Err(NativeBuildArtifactCacheError::ArtifactSizeMismatch {
313                path: artifact_path,
314                expected: manifest.artifact_size_bytes,
315                actual: actual_size,
316            });
317        }
318        let actual_sha256 = sha256_file(&artifact_path)?;
319        if actual_sha256 != manifest.artifact_sha256 {
320            return Err(NativeBuildArtifactCacheError::ArtifactSha256Mismatch {
321                path: artifact_path,
322                expected: manifest.artifact_sha256,
323                actual: actual_sha256,
324            });
325        }
326        Ok(Some(NativeBuildArtifactCacheReceipt {
327            cache_entry: entry.to_path_buf(),
328            artifact_path,
329            manifest_path,
330            artifact_sha256: actual_sha256,
331            artifact_size_bytes: actual_size,
332        }))
333    }
334}
335
336#[derive(Debug, Error)]
337pub enum NativeBuildArtifactCacheError {
338    #[error("native build artifact id is invalid: {0:?}")]
339    InvalidArtifactId(String),
340    #[error("native build artifact file name is invalid: {0:?}")]
341    InvalidFileName(String),
342    #[error("native build artifact input signature must not be empty")]
343    InvalidInputSignature,
344    #[error("native build artifact cache root must be absolute: {0}")]
345    CacheRootNotAbsolute(PathBuf),
346    #[error("native build artifact cache root must be a real directory: {0}")]
347    CacheRootNotDirectory(PathBuf),
348    #[error("failed to create native build artifact directory {path}: {source}")]
349    CreateDirectory { path: PathBuf, source: io::Error },
350    #[error("failed to stat native build artifact {path}: {source}")]
351    Metadata { path: PathBuf, source: io::Error },
352    #[error("native build artifact must be a regular, non-symlink file: {0}")]
353    NotRegularFile(PathBuf),
354    #[error("failed to read native build artifact {path}: {source}")]
355    Read { path: PathBuf, source: io::Error },
356    #[error("failed to write native build artifact {path}: {source}")]
357    Write { path: PathBuf, source: io::Error },
358    #[error("failed to parse native build artifact manifest {path}: {source}")]
359    ManifestJson {
360        path: PathBuf,
361        source: serde_json::Error,
362    },
363    #[error("native build artifact manifest mismatch at {path}: {detail}")]
364    ManifestMismatch { path: PathBuf, detail: String },
365    #[error("native build artifact entry is missing its payload: {path}")]
366    EntryArtifactMissing { path: PathBuf },
367    #[error("native build artifact size mismatch at {path}: expected {expected}, got {actual}")]
368    ArtifactSizeMismatch {
369        path: PathBuf,
370        expected: u64,
371        actual: u64,
372    },
373    #[error("native build artifact sha256 mismatch at {path}: expected {expected}, got {actual}")]
374    ArtifactSha256Mismatch {
375        path: PathBuf,
376        expected: String,
377        actual: String,
378    },
379    #[error(
380        "native build artifact source changed while it was copied from {path}: copied {copied_sha256}, reread {reread_sha256}"
381    )]
382    SourceChangedDuringCopy {
383        path: PathBuf,
384        copied_sha256: String,
385        reread_sha256: String,
386    },
387    #[error(
388        "native build output is nondeterministic for {artifact_id}/{input_signature_sha256}: existing {existing_sha256}, candidate {candidate_sha256}"
389    )]
390    NondeterministicArtifact {
391        artifact_id: String,
392        input_signature_sha256: String,
393        existing_sha256: String,
394        candidate_sha256: String,
395    },
396    #[error("failed to acquire native build artifact entry lock {path}: {source}")]
397    LockCreate { path: PathBuf, source: io::Error },
398    #[error("timed out acquiring native build artifact entry lock: {0}")]
399    LockTimeout(PathBuf),
400    #[error("failed to remove native build artifact entry lock {path}: {source}")]
401    LockRemove { path: PathBuf, source: io::Error },
402    #[error("failed to remove incomplete native build artifact {path}: {source}")]
403    RemoveIncompleteEntry { path: PathBuf, source: io::Error },
404    #[error("published native build artifact entry is missing: {path}")]
405    PublishedEntryMissing { path: PathBuf },
406}
407
408fn validate_artifact_id(value: &str) -> Result<(), NativeBuildArtifactCacheError> {
409    let valid = !value.is_empty()
410        && value.len() <= 128
411        && value != "."
412        && value != ".."
413        && value
414            .bytes()
415            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
416    if valid {
417        Ok(())
418    } else {
419        Err(NativeBuildArtifactCacheError::InvalidArtifactId(
420            value.to_string(),
421        ))
422    }
423}
424
425fn validate_file_name(value: &str) -> Result<(), NativeBuildArtifactCacheError> {
426    let path = Path::new(value);
427    let valid = !value.is_empty()
428        && value.len() <= 255
429        && path.file_name().and_then(|name| name.to_str()) == Some(value)
430        && value != "."
431        && value != "..";
432    if valid {
433        Ok(())
434    } else {
435        Err(NativeBuildArtifactCacheError::InvalidFileName(
436            value.to_string(),
437        ))
438    }
439}
440
441fn validate_manifest(
442    spec: &NativeBuildArtifactSpec,
443    manifest: &NativeBuildArtifactCacheManifest,
444    path: &Path,
445) -> Result<(), NativeBuildArtifactCacheError> {
446    let mut mismatches = Vec::new();
447    if manifest.schema_version != NATIVE_BUILD_ARTIFACT_CACHE_SCHEMA_VERSION {
448        mismatches.push(format!(
449            "schema_version expected {}, got {}",
450            NATIVE_BUILD_ARTIFACT_CACHE_SCHEMA_VERSION, manifest.schema_version
451        ));
452    }
453    if manifest.artifact_id != spec.artifact_id {
454        mismatches.push(format!(
455            "artifact_id expected {:?}, got {:?}",
456            spec.artifact_id, manifest.artifact_id
457        ));
458    }
459    if manifest.file_name != spec.file_name {
460        mismatches.push(format!(
461            "file_name expected {:?}, got {:?}",
462            spec.file_name, manifest.file_name
463        ));
464    }
465    if manifest.input_signature != spec.input_signature {
466        mismatches.push("input_signature differs".to_string());
467    }
468    if manifest.input_signature_sha256 != spec.input_signature_sha256 {
469        mismatches.push(format!(
470            "input_signature_sha256 expected {}, got {}",
471            spec.input_signature_sha256, manifest.input_signature_sha256
472        ));
473    }
474    if sha256_bytes(manifest.input_signature.as_bytes()) != manifest.input_signature_sha256 {
475        mismatches.push("manifest input_signature sha256 is invalid".to_string());
476    }
477    if manifest.artifact_sha256.len() != 64
478        || !manifest
479            .artifact_sha256
480            .bytes()
481            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
482    {
483        mismatches.push("artifact_sha256 is not lowercase SHA256".to_string());
484    }
485    if mismatches.is_empty() {
486        Ok(())
487    } else {
488        Err(NativeBuildArtifactCacheError::ManifestMismatch {
489            path: path.to_path_buf(),
490            detail: mismatches.join("; "),
491        })
492    }
493}
494
495fn validate_regular_file(path: &Path) -> Result<(), NativeBuildArtifactCacheError> {
496    let metadata =
497        fs::symlink_metadata(path).map_err(|source| NativeBuildArtifactCacheError::Metadata {
498            path: path.to_path_buf(),
499            source,
500        })?;
501    if metadata.file_type().is_file() && !metadata.file_type().is_symlink() {
502        Ok(())
503    } else {
504        Err(NativeBuildArtifactCacheError::NotRegularFile(
505            path.to_path_buf(),
506        ))
507    }
508}
509
510fn sha256_bytes(bytes: &[u8]) -> String {
511    format!("{:x}", Sha256::digest(bytes))
512}
513
514fn sha256_file(path: &Path) -> Result<String, NativeBuildArtifactCacheError> {
515    let mut file = File::open(path).map_err(|source| NativeBuildArtifactCacheError::Read {
516        path: path.to_path_buf(),
517        source,
518    })?;
519    let mut digest = Sha256::new();
520    let mut buffer = vec![0_u8; 1024 * 1024];
521    loop {
522        let count =
523            file.read(&mut buffer)
524                .map_err(|source| NativeBuildArtifactCacheError::Read {
525                    path: path.to_path_buf(),
526                    source,
527                })?;
528        if count == 0 {
529            break;
530        }
531        digest.update(&buffer[..count]);
532    }
533    Ok(format!("{:x}", digest.finalize()))
534}
535
536fn temporary_path(path: &Path) -> PathBuf {
537    let file_name = path
538        .file_name()
539        .and_then(|name| name.to_str())
540        .unwrap_or("artifact");
541    let sequence = NEXT_TEMPORARY_FILE.fetch_add(1, Ordering::Relaxed);
542    path.with_file_name(format!(
543        ".{file_name}.{}.{sequence}.tmp",
544        std::process::id()
545    ))
546}
547
548struct StagedCopy {
549    path: PathBuf,
550    sha256: String,
551    size_bytes: u64,
552    committed: bool,
553}
554
555impl StagedCopy {
556    fn commit(&mut self, destination: &Path) -> Result<(), NativeBuildArtifactCacheError> {
557        if destination.exists() {
558            fs::remove_file(destination).map_err(|source| {
559                NativeBuildArtifactCacheError::Write {
560                    path: destination.to_path_buf(),
561                    source,
562                }
563            })?;
564        }
565        fs::rename(&self.path, destination).map_err(|source| {
566            NativeBuildArtifactCacheError::Write {
567                path: destination.to_path_buf(),
568                source,
569            }
570        })?;
571        self.committed = true;
572        Ok(())
573    }
574}
575
576impl Drop for StagedCopy {
577    fn drop(&mut self) {
578        if !self.committed {
579            let _ = fs::remove_file(&self.path);
580        }
581    }
582}
583
584fn stage_verified_copy(
585    source: &Path,
586    destination: &Path,
587) -> Result<StagedCopy, NativeBuildArtifactCacheError> {
588    validate_regular_file(source)?;
589    let parent = destination
590        .parent()
591        .ok_or_else(|| NativeBuildArtifactCacheError::Write {
592            path: destination.to_path_buf(),
593            source: io::Error::new(io::ErrorKind::InvalidInput, "destination has no parent"),
594        })?;
595    fs::create_dir_all(parent).map_err(|source| {
596        NativeBuildArtifactCacheError::CreateDirectory {
597            path: parent.to_path_buf(),
598            source,
599        }
600    })?;
601    let temporary = temporary_path(destination);
602    let mut input =
603        File::open(source).map_err(|source_error| NativeBuildArtifactCacheError::Read {
604            path: source.to_path_buf(),
605            source: source_error,
606        })?;
607    let mut output = OpenOptions::new()
608        .write(true)
609        .create_new(true)
610        .open(&temporary)
611        .map_err(|source| NativeBuildArtifactCacheError::Write {
612            path: temporary.clone(),
613            source,
614        })?;
615    let mut copied_digest = Sha256::new();
616    let mut size_bytes = 0_u64;
617    let mut buffer = vec![0_u8; 1024 * 1024];
618    loop {
619        let count = input.read(&mut buffer).map_err(|source_error| {
620            NativeBuildArtifactCacheError::Read {
621                path: source.to_path_buf(),
622                source: source_error,
623            }
624        })?;
625        if count == 0 {
626            break;
627        }
628        output.write_all(&buffer[..count]).map_err(|source_error| {
629            NativeBuildArtifactCacheError::Write {
630                path: temporary.clone(),
631                source: source_error,
632            }
633        })?;
634        copied_digest.update(&buffer[..count]);
635        size_bytes = size_bytes
636            .checked_add(count as u64)
637            .expect("native artifact size overflow");
638    }
639    output
640        .sync_all()
641        .map_err(|source| NativeBuildArtifactCacheError::Write {
642            path: temporary.clone(),
643            source,
644        })?;
645    drop(output);
646
647    input
648        .seek(SeekFrom::Start(0))
649        .map_err(|source_error| NativeBuildArtifactCacheError::Read {
650            path: source.to_path_buf(),
651            source: source_error,
652        })?;
653    let mut reread_digest = Sha256::new();
654    loop {
655        let count = input.read(&mut buffer).map_err(|source_error| {
656            NativeBuildArtifactCacheError::Read {
657                path: source.to_path_buf(),
658                source: source_error,
659            }
660        })?;
661        if count == 0 {
662            break;
663        }
664        reread_digest.update(&buffer[..count]);
665    }
666    let copied_sha256 = format!("{:x}", copied_digest.finalize());
667    let reread_sha256 = format!("{:x}", reread_digest.finalize());
668    if copied_sha256 != reread_sha256 {
669        let _ = fs::remove_file(&temporary);
670        return Err(NativeBuildArtifactCacheError::SourceChangedDuringCopy {
671            path: source.to_path_buf(),
672            copied_sha256,
673            reread_sha256,
674        });
675    }
676
677    Ok(StagedCopy {
678        path: temporary,
679        sha256: copied_sha256,
680        size_bytes,
681        committed: false,
682    })
683}
684
685fn atomic_write_json(
686    path: &Path,
687    manifest: &NativeBuildArtifactCacheManifest,
688) -> Result<(), NativeBuildArtifactCacheError> {
689    let bytes = serde_json::to_vec_pretty(manifest).map_err(|source| {
690        NativeBuildArtifactCacheError::ManifestJson {
691            path: path.to_path_buf(),
692            source,
693        }
694    })?;
695    let temporary = temporary_path(path);
696    let mut cleanup = StagedCopy {
697        path: temporary.clone(),
698        sha256: String::new(),
699        size_bytes: 0,
700        committed: false,
701    };
702    let mut file = OpenOptions::new()
703        .write(true)
704        .create_new(true)
705        .open(&temporary)
706        .map_err(|source| NativeBuildArtifactCacheError::Write {
707            path: temporary.clone(),
708            source,
709        })?;
710    file.write_all(&bytes)
711        .and_then(|_| file.write_all(b"\n"))
712        .and_then(|_| file.sync_all())
713        .map_err(|source| NativeBuildArtifactCacheError::Write {
714            path: temporary.clone(),
715            source,
716        })?;
717    cleanup.commit(path)
718}
719
720struct EntryLock {
721    file: File,
722    #[cfg(not(windows))]
723    path: PathBuf,
724}
725
726#[cfg(windows)]
727fn open_windows_lock_file(path: &Path) -> io::Result<File> {
728    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
729    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
730    let file = OpenOptions::new()
731        .write(true)
732        .create(true)
733        .share_mode(0)
734        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
735        .open(path)?;
736    let metadata = file.metadata()?;
737    if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
738        return Err(io::Error::new(
739            io::ErrorKind::InvalidData,
740            "native cache lock must be a regular file, not a reparse point",
741        ));
742    }
743    Ok(file)
744}
745
746impl EntryLock {
747    #[cfg(unix)]
748    fn acquire(entry: &Path) -> Result<Self, NativeBuildArtifactCacheError> {
749        let path = entry.join("publish.lock");
750        let file = OpenOptions::new()
751            .read(true)
752            .write(true)
753            .create(true)
754            .open(&path)
755            .map_err(|source| NativeBuildArtifactCacheError::LockCreate {
756                path: path.clone(),
757                source,
758            })?;
759        let started = Instant::now();
760        loop {
761            let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
762            if rc == 0 {
763                let mut guard = Self {
764                    file,
765                    path: path.clone(),
766                };
767                guard
768                    .file
769                    .set_len(0)
770                    .and_then(|_| {
771                        writeln!(guard.file, "pid={}", std::process::id())?;
772                        guard.file.sync_all()
773                    })
774                    .map_err(|source| NativeBuildArtifactCacheError::LockCreate {
775                        path: path.clone(),
776                        source,
777                    })?;
778                return Ok(guard);
779            }
780            let source = io::Error::last_os_error();
781            if source.kind() != io::ErrorKind::WouldBlock {
782                return Err(NativeBuildArtifactCacheError::LockCreate { path, source });
783            }
784            if started.elapsed() >= ENTRY_LOCK_WAIT {
785                return Err(NativeBuildArtifactCacheError::LockTimeout(path));
786            }
787            thread::sleep(ENTRY_LOCK_POLL);
788        }
789    }
790
791    #[cfg(windows)]
792    fn acquire(entry: &Path) -> Result<Self, NativeBuildArtifactCacheError> {
793        let path = entry.join("publish.lock");
794        let started = Instant::now();
795        loop {
796            match open_windows_lock_file(&path) {
797                Ok(file) => {
798                    let mut guard = Self { file };
799                    guard
800                        .file
801                        .set_len(0)
802                        .and_then(|_| {
803                            writeln!(guard.file, "pid={}", std::process::id())?;
804                            guard.file.sync_all()
805                        })
806                        .map_err(|source| NativeBuildArtifactCacheError::LockCreate {
807                            path: path.clone(),
808                            source,
809                        })?;
810                    return Ok(guard);
811                }
812                Err(source) if source.raw_os_error() == Some(32) => {
813                    if started.elapsed() >= ENTRY_LOCK_WAIT {
814                        return Err(NativeBuildArtifactCacheError::LockTimeout(path));
815                    }
816                    thread::sleep(ENTRY_LOCK_POLL);
817                }
818                Err(source) => {
819                    return Err(NativeBuildArtifactCacheError::LockCreate { path, source });
820                }
821            }
822        }
823    }
824
825    #[cfg(not(any(unix, windows)))]
826    fn acquire(entry: &Path) -> Result<Self, NativeBuildArtifactCacheError> {
827        let path = entry.join("publish.lock");
828        let started = Instant::now();
829        loop {
830            match OpenOptions::new().write(true).create_new(true).open(&path) {
831                Ok(file) => {
832                    let mut guard = Self {
833                        file,
834                        path: path.clone(),
835                    };
836                    writeln!(guard.file, "pid={}", std::process::id())
837                        .and_then(|_| guard.file.sync_all())
838                        .map_err(|source| NativeBuildArtifactCacheError::LockCreate {
839                            path: path.clone(),
840                            source,
841                        })?;
842                    return Ok(guard);
843                }
844                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
845                    if started.elapsed() >= ENTRY_LOCK_WAIT {
846                        return Err(NativeBuildArtifactCacheError::LockTimeout(path));
847                    }
848                    thread::sleep(ENTRY_LOCK_POLL);
849                }
850                Err(source) => {
851                    return Err(NativeBuildArtifactCacheError::LockCreate { path, source });
852                }
853            }
854        }
855    }
856}
857
858impl Drop for EntryLock {
859    fn drop(&mut self) {
860        #[cfg(unix)]
861        {
862            if unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) } != 0 {
863                eprintln!(
864                    "failed to release native build artifact entry lock {}: {}",
865                    self.path.display(),
866                    io::Error::last_os_error()
867                );
868            }
869        }
870        // On Windows, closing `file` releases the exclusive sharing mode.
871        // Keep the pathname so a crashed publisher leaves no stale ownership.
872        #[cfg(not(any(unix, windows)))]
873        if let Err(source) = fs::remove_file(&self.path) {
874            if source.kind() != io::ErrorKind::NotFound {
875                eprintln!(
876                    "{}",
877                    NativeBuildArtifactCacheError::LockRemove {
878                        path: self.path.clone(),
879                        source,
880                    }
881                );
882            }
883        }
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use std::sync::atomic::{AtomicU64, Ordering};
890
891    use super::*;
892
893    static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
894
895    struct TestDir(PathBuf);
896
897    impl TestDir {
898        fn new(label: &str) -> Self {
899            let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
900            let path = std::env::temp_dir().join(format!(
901                "ferrum-native-build-cache-{label}-{}-{sequence}",
902                std::process::id()
903            ));
904            if path.exists() {
905                fs::remove_dir_all(&path).unwrap();
906            }
907            fs::create_dir_all(&path).unwrap();
908            Self(path)
909        }
910    }
911
912    impl Drop for TestDir {
913        fn drop(&mut self) {
914            let _ = fs::remove_dir_all(&self.0);
915        }
916    }
917
918    #[test]
919    fn publish_and_restore_fit_a_windows_sized_stack() {
920        // MSVC executables can run with a 1 MiB main stack. The cache must
921        // stream larger artifacts without putting its I/O buffers there.
922        thread::Builder::new()
923            .name("native-cache-small-stack".to_string())
924            .stack_size(1024 * 1024)
925            .spawn(|| {
926                let temp = TestDir::new("small-stack");
927                let cache_root = temp.0.join("cache");
928                let cache = NativeBuildArtifactCache::new(&cache_root).unwrap();
929                let source = temp.0.join("unit.obj");
930                let mut bytes = (0..2 * 1024 * 1024 + 37)
931                    .map(|index| (index % 251) as u8)
932                    .collect::<Vec<_>>();
933                fs::write(&source, &bytes).unwrap();
934                let expected_sha256 = format!("{:x}", Sha256::digest(&bytes));
935                let spec = NativeBuildArtifactSpec::new(
936                    "object.small-stack",
937                    "unit.obj",
938                    "source=small-stack-fixture",
939                )
940                .unwrap();
941
942                let published = cache.publish(&spec, &source).unwrap();
943                assert_eq!(published.artifact_sha256, expected_sha256);
944                assert_eq!(published.artifact_size_bytes, bytes.len() as u64);
945                drop(cache);
946
947                let reopened = NativeBuildArtifactCache::new(&cache_root).unwrap();
948                let destination = temp.0.join("out/unit.obj");
949                assert_eq!(
950                    reopened.restore(&spec, &destination).unwrap(),
951                    NativeBuildArtifactLookup::Hit(published.clone())
952                );
953                assert_eq!(fs::read(&destination).unwrap(), bytes);
954
955                let middle = bytes.len() / 2;
956                bytes[middle] ^= 1;
957                fs::write(&published.artifact_path, &bytes).unwrap();
958                fs::write(&destination, b"existing output").unwrap();
959                assert!(matches!(
960                    reopened.restore(&spec, &destination),
961                    Err(NativeBuildArtifactCacheError::ArtifactSha256Mismatch { .. })
962                ));
963                assert_eq!(fs::read(&destination).unwrap(), b"existing output");
964            })
965            .unwrap()
966            .join()
967            .unwrap();
968    }
969
970    #[test]
971    fn publish_and_restore_are_content_addressed() {
972        let temp = TestDir::new("roundtrip");
973        let cache = NativeBuildArtifactCache::new(temp.0.join("cache")).unwrap();
974        let source = temp.0.join("libdemo.a");
975        fs::write(&source, b"native-archive-v1").unwrap();
976        let spec =
977            NativeBuildArtifactSpec::new("static.demo", "libdemo.a", "flags=sm_89\ninput=abc")
978                .unwrap();
979
980        let published = cache.publish(&spec, &source).unwrap();
981        let restored = temp.0.join("out/libdemo.a");
982        let lookup = cache.restore(&spec, &restored).unwrap();
983
984        assert_eq!(fs::read(&restored).unwrap(), b"native-archive-v1");
985        assert_eq!(lookup, NativeBuildArtifactLookup::Hit(published.clone()));
986        let manifest: NativeBuildArtifactCacheManifest =
987            serde_json::from_str(&fs::read_to_string(published.manifest_path).unwrap()).unwrap();
988        assert_eq!(manifest.input_signature, spec.input_signature());
989        assert_eq!(manifest.artifact_sha256, published.artifact_sha256);
990    }
991
992    #[test]
993    fn a_different_signature_is_a_cache_miss() {
994        let temp = TestDir::new("signature-miss");
995        let cache = NativeBuildArtifactCache::new(temp.0.join("cache")).unwrap();
996        let source = temp.0.join("kernel.ptx");
997        fs::write(&source, b"ptx-v1").unwrap();
998        let first =
999            NativeBuildArtifactSpec::new("core_ptx.kernel", "kernel.ptx", "source=one").unwrap();
1000        let second =
1001            NativeBuildArtifactSpec::new("core_ptx.kernel", "kernel.ptx", "source=two").unwrap();
1002        cache.publish(&first, &source).unwrap();
1003
1004        assert_eq!(
1005            cache
1006                .restore(&second, temp.0.join("out/kernel.ptx"))
1007                .unwrap(),
1008            NativeBuildArtifactLookup::Miss {
1009                reason: "entry-absent"
1010            }
1011        );
1012    }
1013
1014    #[test]
1015    fn legacy_numeric_signature_field_can_be_removed_exactly_once() {
1016        let canonical = "label=marlin\nflag=arch=compute_80\nsource=abc\ntoolchain=def";
1017        for compute_capability in ["80", "89", "120"] {
1018            let legacy = format!(
1019                "label=marlin\nflag=arch=compute_80\n\
1020                 flag=reported_compute_cap={compute_capability}\n\
1021                 source=abc\ntoolchain=def"
1022            );
1023            assert!(legacy_signature_matches_without_numeric_line(
1024                &legacy,
1025                canonical,
1026                "flag=reported_compute_cap=",
1027            ));
1028        }
1029    }
1030
1031    #[test]
1032    fn legacy_numeric_signature_migration_rejects_other_drift() {
1033        let canonical = "label=marlin\nflag=arch=compute_80\nsource=abc\ntoolchain=def";
1034        let hostile = [
1035            "label=marlin\nflag=arch=compute_80\nsource=abc\ntoolchain=def",
1036            "label=marlin\nflag=arch=compute_80\nflag=reported_compute_cap=\nsource=abc\ntoolchain=def",
1037            "label=marlin\nflag=arch=compute_80\nflag=reported_compute_cap=sm_89\nsource=abc\ntoolchain=def",
1038            "label=marlin\nflag=arch=compute_80\nflag=reported_compute_cap=80\nflag=reported_compute_cap=89\nsource=abc\ntoolchain=def",
1039            "label=marlin\nflag=arch=compute_80\nflag=reported_compute_cap=89\nsource=tampered\ntoolchain=def",
1040        ];
1041        for legacy in hostile {
1042            assert!(!legacy_signature_matches_without_numeric_line(
1043                legacy,
1044                canonical,
1045                "flag=reported_compute_cap=",
1046            ));
1047        }
1048        assert!(!legacy_signature_matches_without_numeric_line(
1049            "label=marlin\nflag=reported_compute_cap=89\nsource=abc",
1050            "label=marlin\nflag=reported_compute_cap=89\nsource=abc",
1051            "flag=reported_compute_cap=",
1052        ));
1053    }
1054
1055    #[test]
1056    fn corrupted_cache_entries_fail_closed() {
1057        let temp = TestDir::new("corrupt");
1058        let cache = NativeBuildArtifactCache::new(temp.0.join("cache")).unwrap();
1059        let source = temp.0.join("libdemo.a");
1060        fs::write(&source, b"native-archive-v1").unwrap();
1061        let spec = NativeBuildArtifactSpec::new("static.demo", "libdemo.a", "flags=sm_89").unwrap();
1062        let published = cache.publish(&spec, &source).unwrap();
1063        fs::write(&published.artifact_path, b"tampered").unwrap();
1064
1065        let error = cache
1066            .restore(&spec, temp.0.join("out/libdemo.a"))
1067            .unwrap_err();
1068        assert!(matches!(
1069            error,
1070            NativeBuildArtifactCacheError::ArtifactSizeMismatch { .. }
1071                | NativeBuildArtifactCacheError::ArtifactSha256Mismatch { .. }
1072        ));
1073    }
1074
1075    #[test]
1076    fn one_signature_cannot_publish_two_native_outputs() {
1077        let temp = TestDir::new("nondeterministic");
1078        let cache = NativeBuildArtifactCache::new(temp.0.join("cache")).unwrap();
1079        let first = temp.0.join("first.a");
1080        let second = temp.0.join("second.a");
1081        fs::write(&first, b"native-output-one").unwrap();
1082        fs::write(&second, b"native-output-two").unwrap();
1083        let spec = NativeBuildArtifactSpec::new("static.demo", "libdemo.a", "flags=sm_89").unwrap();
1084        cache.publish(&spec, &first).unwrap();
1085
1086        assert!(matches!(
1087            cache.publish(&spec, &second),
1088            Err(NativeBuildArtifactCacheError::NondeterministicArtifact { .. })
1089        ));
1090    }
1091
1092    #[test]
1093    fn artifact_identifiers_cannot_escape_the_cache_root() {
1094        assert!(matches!(
1095            NativeBuildArtifactSpec::new("../escape", "libdemo.a", "signature"),
1096            Err(NativeBuildArtifactCacheError::InvalidArtifactId(_))
1097        ));
1098        assert!(matches!(
1099            NativeBuildArtifactSpec::new("..", "libdemo.a", "signature"),
1100            Err(NativeBuildArtifactCacheError::InvalidArtifactId(_))
1101        ));
1102        assert!(matches!(
1103            NativeBuildArtifactSpec::new("static.demo", "../libdemo.a", "signature"),
1104            Err(NativeBuildArtifactCacheError::InvalidFileName(_))
1105        ));
1106    }
1107
1108    #[cfg(any(unix, windows))]
1109    #[test]
1110    fn stale_lock_files_do_not_poison_the_cache() {
1111        let temp = TestDir::new("stale-lock");
1112        let cache = NativeBuildArtifactCache::new(temp.0.join("cache")).unwrap();
1113        let source = temp.0.join("libdemo.a");
1114        fs::write(&source, b"native-archive-v1").unwrap();
1115        let spec = NativeBuildArtifactSpec::new("static.demo", "libdemo.a", "flags=sm_89").unwrap();
1116        let entry = cache.entry_dir(&spec);
1117        fs::create_dir_all(&entry).unwrap();
1118        fs::write(entry.join("publish.lock"), b"pid=999999999\n").unwrap();
1119
1120        let receipt = cache.publish(&spec, &source).unwrap();
1121
1122        assert_eq!(receipt.artifact_sha256, sha256_file(&source).unwrap());
1123    }
1124
1125    #[cfg(windows)]
1126    #[test]
1127    #[ignore = "subprocess fixture invoked only by windows_lock_recovers_after_process_exit"]
1128    fn windows_lock_exit_without_drop_child() {
1129        let entry = std::env::var_os("FERRUM_NATIVE_CACHE_LOCK_TEST_ENTRY")
1130            .expect("parent test must supply its isolated lock directory");
1131        let _guard = EntryLock::acquire(Path::new(&entry)).unwrap();
1132        // Exit closes OS handles without running EntryLock::drop.
1133        std::process::exit(0);
1134    }
1135
1136    #[cfg(windows)]
1137    #[test]
1138    fn windows_lock_recovers_after_process_exit() {
1139        let temp = TestDir::new("lock-process-exit");
1140        let path = temp.0.join("publish.lock");
1141        let output = std::process::Command::new(std::env::current_exe().unwrap())
1142            .args([
1143                "--exact",
1144                "build_cache::tests::windows_lock_exit_without_drop_child",
1145                "--ignored",
1146            ])
1147            .env("FERRUM_NATIVE_CACHE_LOCK_TEST_ENTRY", &temp.0)
1148            .output()
1149            .unwrap();
1150        assert!(output.status.success(), "{output:?}");
1151        assert!(path.is_file(), "the exited child must leave its lock file");
1152
1153        let guard = EntryLock::acquire(&temp.0).unwrap();
1154        assert_eq!(
1155            open_windows_lock_file(&path).unwrap_err().raw_os_error(),
1156            Some(32)
1157        );
1158        drop(guard);
1159        assert!(
1160            path.is_file(),
1161            "ownership must not depend on deleting the file"
1162        );
1163        drop(EntryLock::acquire(&temp.0).unwrap());
1164        assert_eq!(
1165            fs::read_to_string(path).unwrap(),
1166            format!("pid={}\n", std::process::id())
1167        );
1168    }
1169
1170    #[cfg(windows)]
1171    #[test]
1172    fn windows_lock_respects_legacy_live_handles() {
1173        let temp = TestDir::new("lock-legacy-handle");
1174        let path = temp.0.join("publish.lock");
1175        let mut legacy = OpenOptions::new()
1176            .write(true)
1177            .create_new(true)
1178            .open(&path)
1179            .unwrap();
1180        legacy
1181            .write_all(b"pid=stale-legacy-marker-with-extra-bytes\n")
1182            .unwrap();
1183        assert_eq!(
1184            open_windows_lock_file(&path).unwrap_err().raw_os_error(),
1185            Some(32)
1186        );
1187        drop(legacy);
1188        drop(EntryLock::acquire(&temp.0).unwrap());
1189        assert_eq!(
1190            fs::read_to_string(path).unwrap(),
1191            format!("pid={}\n", std::process::id())
1192        );
1193    }
1194
1195    #[cfg(windows)]
1196    #[test]
1197    fn windows_lock_rejects_a_directory_without_contention_retry() {
1198        let temp = TestDir::new("lock-directory");
1199        fs::create_dir(temp.0.join("publish.lock")).unwrap();
1200        assert!(matches!(
1201            EntryLock::acquire(&temp.0),
1202            Err(NativeBuildArtifactCacheError::LockCreate { .. })
1203        ));
1204    }
1205}