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