1use 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
28pub 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 #[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 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 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}