1use 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
26pub 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}