1use std::fs::File;
2use std::io::{self, Read, Write};
3use std::path::Path;
4use std::sync::atomic::Ordering;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use tempfile::NamedTempFile;
8
9use crate::access::ensure_parent;
10use crate::error::io_error;
11use crate::lookup::open_read_only;
12use crate::{
13 BuildId, BuildIdMismatchError, Cache, CacheEntry, Error, InvalidGsymError, Result, layout,
14};
15
16const FAILURE_RECORD_LEN: usize = 16;
17const FAILURE_MAGIC: [u8; 4] = *b"GSNF";
18const FAILURE_VERSION: u8 = 1;
19#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
21pub const MAX_FAILURE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
22
23#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
24impl Cache {
25 pub fn try_begin_population<'cache>(
71 &'cache self,
72 build_id: &'cache BuildId,
73 ) -> Result<PopulationOutcome<'cache>> {
74 self.prepare()?;
75 let object_path = layout::object(self.base(), build_id);
76 let corrupt = match inspect_existing(self, build_id, &object_path)? {
77 ExistingEntry::Missing => false,
78 ExistingEntry::Valid(entry) => return Ok(PopulationOutcome::Present(entry)),
79 ExistingEntry::Corrupt => true,
80 };
81 if !corrupt && let FailureRecord::Cached(failure) = self.failure_record(build_id)? {
82 return Ok(PopulationOutcome::Suppressed(failure));
83 }
84
85 let path = layout::lock(self.base(), build_id);
86 let Some(lock) = try_lock_file(self, &path, "lock cache population")? else {
87 return Ok(PopulationOutcome::Busy);
88 };
89 match inspect_existing(self, build_id, &object_path)? {
90 ExistingEntry::Missing => {}
91 ExistingEntry::Valid(entry) => return Ok(PopulationOutcome::Present(entry)),
92 ExistingEntry::Corrupt => self.remove_corrupt_entry(build_id, &object_path)?,
93 }
94 match self.failure_record(build_id)? {
95 FailureRecord::Cached(failure) => return Ok(PopulationOutcome::Suppressed(failure)),
96 FailureRecord::Stale => self.clear_failure(build_id),
97 FailureRecord::Missing => {}
98 }
99 Ok(PopulationOutcome::Acquired(Population {
100 ownership: PopulationLock {
101 cache: self,
102 build_id,
103 _lock: lock,
104 },
105 }))
106 }
107
108 fn record_failure(
109 &self,
110 build_id: &BuildId,
111 kind: FailureKind,
112 lifetime: Duration,
113 ) -> Result<CachedFailure> {
114 let (expires, expires_at) = failure_expiration(lifetime, SystemTime::now())?;
115 let path = layout::negative(self.base(), build_id);
116 let (parent, parent_directory) = ensure_parent(&path)?;
117 let mut temporary = NamedTempFile::new_in(parent)
118 .map_err(|source| io_error("create negative-cache temporary file", parent, source))?;
119 let [magic_0, magic_1, magic_2, magic_3] = FAILURE_MAGIC;
120 let [
121 expires_0,
122 expires_1,
123 expires_2,
124 expires_3,
125 expires_4,
126 expires_5,
127 expires_6,
128 expires_7,
129 ] = expires.to_le_bytes();
130 let record = [
131 magic_0,
132 magic_1,
133 magic_2,
134 magic_3,
135 FAILURE_VERSION,
136 kind.code(),
137 0,
138 0,
139 expires_0,
140 expires_1,
141 expires_2,
142 expires_3,
143 expires_4,
144 expires_5,
145 expires_6,
146 expires_7,
147 ];
148 temporary
149 .write_all(&record)
150 .map_err(|source| io_error("write negative-cache record", &path, source))?;
151 set_read_only(temporary.as_file(), &path)?;
152 temporary
153 .as_file()
154 .sync_all()
155 .map_err(|source| io_error("sync negative-cache record", &path, source))?;
156 temporary
157 .persist(&path)
158 .map_err(|error| io_error("publish negative-cache record", &path, error.error))?;
159 sync_open_directory(&parent_directory, parent)?;
160 Ok(CachedFailure { kind, expires_at })
161 }
162
163 fn clear_failure(&self, build_id: &BuildId) {
164 drop(std::fs::remove_file(layout::negative(
165 self.base(),
166 build_id,
167 )));
168 }
169
170 pub(crate) fn clear_auxiliary(&self, build_id: &BuildId) {
171 drop(std::fs::remove_file(layout::access(self.base(), build_id)));
172 self.clear_failure(build_id);
173 }
174
175 fn remove_corrupt_entry(&self, build_id: &BuildId, path: &Path) -> Result<()> {
176 let removed = remove_file_if_exists("remove corrupt cache entry", path)?;
177 self.clear_auxiliary(build_id);
178 if removed && let Some(parent) = path.parent() {
179 sync_directory(parent)?;
180 }
181 Ok(())
182 }
183
184 pub fn cached_failure(&self, build_id: &BuildId) -> Result<Option<CachedFailure>> {
193 if self.lookup(build_id)?.is_some() {
194 return Ok(None);
195 }
196 match self.failure_record(build_id)? {
197 FailureRecord::Cached(failure) => Ok(Some(failure)),
198 FailureRecord::Missing | FailureRecord::Stale => Ok(None),
199 }
200 }
201
202 pub(crate) fn failure_record(&self, build_id: &BuildId) -> Result<FailureRecord> {
203 let path = layout::negative(self.base(), build_id);
204 let mut file = match open_read_only(&path) {
205 Ok(file) => file,
206 Err(source) if source.kind() == io::ErrorKind::NotFound => {
207 return Ok(FailureRecord::Missing);
208 }
209 Err(source) => return Err(io_error("open negative-cache record", path, source)),
210 };
211 let metadata = file
212 .metadata()
213 .map_err(|source| io_error("inspect negative-cache record", &path, source))?;
214 self.validate_entry(&path, &metadata)?;
215 if metadata.len() != FAILURE_RECORD_LEN as u64 {
216 return Ok(FailureRecord::Stale);
217 }
218 let mut record = [0_u8; FAILURE_RECORD_LEN];
219 file.read_exact(&mut record)
220 .map_err(|source| io_error("read negative-cache record", &path, source))?;
221 let [
222 magic_0,
223 magic_1,
224 magic_2,
225 magic_3,
226 version,
227 kind,
228 reserved_0,
229 reserved_1,
230 expires_0,
231 expires_1,
232 expires_2,
233 expires_3,
234 expires_4,
235 expires_5,
236 expires_6,
237 expires_7,
238 ] = record;
239 let Some(kind) = FailureKind::from_code(kind) else {
240 return Ok(FailureRecord::Stale);
241 };
242 let Some(expires_at) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::from_le_bytes([
243 expires_0, expires_1, expires_2, expires_3, expires_4, expires_5, expires_6, expires_7,
244 ]))) else {
245 return Ok(FailureRecord::Stale);
246 };
247 if [magic_0, magic_1, magic_2, magic_3] != FAILURE_MAGIC
248 || version != FAILURE_VERSION
249 || reserved_0 != 0
250 || reserved_1 != 0
251 {
252 return Ok(FailureRecord::Stale);
253 }
254 let now = SystemTime::now();
255 if expires_at <= now
256 || expires_at
257 .duration_since(now)
258 .is_ok_and(|ttl| ttl >= MAX_FAILURE_TTL.saturating_add(Duration::from_secs(1)))
259 {
260 return Ok(FailureRecord::Stale);
261 }
262 Ok(FailureRecord::Cached(CachedFailure { kind, expires_at }))
263 }
264
265 pub(crate) fn prepare(&self) -> Result<()> {
266 if self.manage_prepared.load(Ordering::Acquire) {
267 return Ok(());
268 }
269 self.prepare_access()?;
270 for path in [
271 self.base().join(layout::OBJECTS).join(layout::BUILD_ID),
272 self.base().join(layout::LOCKS),
273 self.base().join(layout::NEGATIVE).join(layout::BUILD_ID),
274 self.base().join(layout::TEMPORARY).join(layout::BUILD_ID),
275 ] {
276 crate::lookup::ensure_private_directory(&path)?;
277 }
278 self.manage_prepared.store(true, Ordering::Release);
279 Ok(())
280 }
281}
282
283fn failure_expiration(lifetime: Duration, now: SystemTime) -> Result<(u64, SystemTime)> {
284 if lifetime.is_zero() || lifetime > MAX_FAILURE_TTL {
285 return Err(Error::InvalidFailureTtl {
286 lifetime,
287 maximum: MAX_FAILURE_TTL,
288 });
289 }
290 let expires_at = now
291 .checked_add(lifetime)
292 .ok_or(Error::FailureExpirationUnrepresentable { now, lifetime })?;
293 let epoch_duration = expires_at
294 .duration_since(UNIX_EPOCH)
295 .map_err(|_| Error::FailureExpirationUnrepresentable { now, lifetime })?;
296 let expires = epoch_duration
297 .as_secs()
298 .checked_add(u64::from(epoch_duration.subsec_nanos() != 0))
299 .ok_or(Error::FailureExpirationUnrepresentable { now, lifetime })?;
300 let rounded = UNIX_EPOCH
301 .checked_add(Duration::from_secs(expires))
302 .ok_or(Error::FailureExpirationUnrepresentable { now, lifetime })?;
303 Ok((expires, rounded))
304}
305
306#[cfg(test)]
307mod tests {
308 use super::{MAX_FAILURE_TTL, failure_expiration};
309 use crate::Error;
310 use std::time::{Duration, UNIX_EPOCH};
311
312 #[test]
313 fn failure_expiration_rounds_up_without_accepting_invalid_lifetimes() {
314 let now = UNIX_EPOCH + Duration::from_secs(10);
315 assert!(matches!(
316 failure_expiration(Duration::from_millis(1), now),
317 Ok((11, expiration)) if expiration == UNIX_EPOCH + Duration::from_secs(11)
318 ));
319 assert!(matches!(
320 failure_expiration(Duration::ZERO, now),
321 Err(Error::InvalidFailureTtl { .. })
322 ));
323 assert!(matches!(
324 failure_expiration(MAX_FAILURE_TTL + Duration::from_secs(1), now),
325 Err(Error::InvalidFailureTtl { .. })
326 ));
327 }
328}
329
330#[derive(Debug)]
332#[must_use]
333#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
334pub enum PopulationOutcome<'cache> {
335 Acquired(Population<'cache>),
337 Busy,
341 Suppressed(CachedFailure),
343 Present(CacheEntry),
345}
346
347#[must_use = "record a failure, create a writer, or drop it to abandon population"]
352#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
353pub struct Population<'cache> {
354 ownership: PopulationLock<'cache>,
355}
356
357struct PopulationLock<'cache> {
358 cache: &'cache Cache,
359 build_id: &'cache BuildId,
360 _lock: File,
361}
362
363impl<'cache> Population<'cache> {
364 pub fn into_writer(self) -> Result<PopulationWriter<'cache>> {
373 let directory = layout::temporary(self.ownership.cache.base(), self.ownership.build_id);
374 crate::lookup::ensure_private_directory(&directory)?;
375 let temporary = match NamedTempFile::new_in(&directory) {
376 Ok(temporary) => temporary,
377 Err(source) => {
378 drop(std::fs::remove_dir(&directory));
379 return Err(io_error("create staged GSYM file", directory, source));
380 }
381 };
382 Ok(PopulationWriter {
383 temporary,
384 _directory: StagingDirectory(directory),
385 ownership: self.ownership,
386 })
387 }
388
389 pub fn record_failure_for(
404 self,
405 kind: FailureKind,
406 lifetime: Duration,
407 ) -> Result<CachedFailure> {
408 self.ownership
409 .cache
410 .record_failure(self.ownership.build_id, kind, lifetime)
411 }
412}
413
414impl std::fmt::Debug for Population<'_> {
415 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 formatter
417 .debug_struct("Population")
418 .field("build_id", self.ownership.build_id)
419 .finish_non_exhaustive()
420 }
421}
422
423#[must_use = "publish the staged GSYM, record a failure, or drop it to abandon population"]
428#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
429pub struct PopulationWriter<'cache> {
430 temporary: NamedTempFile,
431 _directory: StagingDirectory,
432 ownership: PopulationLock<'cache>,
433}
434
435struct StagingDirectory(std::path::PathBuf);
436
437impl Drop for StagingDirectory {
438 fn drop(&mut self) {
439 drop(std::fs::remove_dir(&self.0));
440 }
441}
442
443impl PopulationWriter<'_> {
444 pub fn record_failure_for(
451 self,
452 kind: FailureKind,
453 lifetime: Duration,
454 ) -> Result<CachedFailure> {
455 let Self {
456 temporary,
457 _directory: directory,
458 ownership,
459 } = self;
460 drop(temporary);
461 drop(directory);
462 ownership
463 .cache
464 .record_failure(ownership.build_id, kind, lifetime)
465 }
466
467 pub fn publish(self) -> Result<PublishOutcome> {
477 verify_file(
478 self.temporary.as_file(),
479 self.ownership.build_id,
480 self.temporary.path(),
481 )?;
482 set_read_only(self.temporary.as_file(), self.temporary.path())?;
483 self.temporary.as_file().sync_all().map_err(|source| {
484 io_error(
485 "sync staged GSYM metadata",
486 self.temporary.path().to_path_buf(),
487 source,
488 )
489 })?;
490
491 publish_noclobber(
492 self.ownership.cache,
493 self.ownership.build_id,
494 self.temporary,
495 )
496 }
497}
498
499impl Write for PopulationWriter<'_> {
500 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
501 self.temporary.write(buffer)
502 }
503
504 fn flush(&mut self) -> io::Result<()> {
505 self.temporary.flush()
506 }
507}
508
509impl std::fmt::Debug for PopulationWriter<'_> {
510 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511 formatter
512 .debug_struct("PopulationWriter")
513 .field("build_id", self.ownership.build_id)
514 .finish_non_exhaustive()
515 }
516}
517
518#[derive(Debug)]
520#[must_use]
521#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
522pub enum PublishOutcome {
523 Published(CacheEntry),
525 Existing(CacheEntry),
527}
528
529impl PublishOutcome {
530 #[must_use]
532 pub const fn entry(&self) -> &CacheEntry {
533 match self {
534 Self::Published(entry) | Self::Existing(entry) => entry,
535 }
536 }
537
538 #[must_use]
540 pub fn into_entry(self) -> CacheEntry {
541 match self {
542 Self::Published(entry) | Self::Existing(entry) => entry,
543 }
544 }
545
546 #[must_use]
548 pub const fn is_published(&self) -> bool {
549 matches!(self, Self::Published(_))
550 }
551}
552
553#[derive(Clone, Copy, Debug, Eq, PartialEq)]
555#[non_exhaustive]
556#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
557pub enum FailureKind {
558 MissingInput,
560 UnsupportedInput,
562 MalformedInput,
564 TransientIo,
566 ResourceExhausted,
568}
569
570impl FailureKind {
571 const fn code(self) -> u8 {
572 match self {
573 Self::MissingInput => 1,
574 Self::UnsupportedInput => 2,
575 Self::MalformedInput => 3,
576 Self::TransientIo => 4,
577 Self::ResourceExhausted => 5,
578 }
579 }
580
581 const fn from_code(code: u8) -> Option<Self> {
582 match code {
583 1 => Some(Self::MissingInput),
584 2 => Some(Self::UnsupportedInput),
585 3 => Some(Self::MalformedInput),
586 4 => Some(Self::TransientIo),
587 5 => Some(Self::ResourceExhausted),
588 _ => None,
589 }
590 }
591}
592
593#[derive(Clone, Copy, Debug, Eq, PartialEq)]
595#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
596pub struct CachedFailure {
597 kind: FailureKind,
598 expires_at: SystemTime,
599}
600
601pub(crate) enum FailureRecord {
602 Missing,
603 Stale,
604 Cached(CachedFailure),
605}
606
607impl CachedFailure {
608 #[must_use]
610 pub const fn kind(self) -> FailureKind {
611 self.kind
612 }
613
614 #[must_use]
616 pub const fn expires_at(self) -> SystemTime {
617 self.expires_at
618 }
619}
620
621#[expect(
622 unsafe_code,
623 reason = "the consumed population guard owns the only writable handle while verification is mapped"
624)]
625pub(crate) fn verify_file(file: &File, build_id: &BuildId, path: &Path) -> Result<()> {
626 let invalid_gsym = |source| Error::InvalidGsym(Box::new(InvalidGsymError::new(path, source)));
627 let gsym = unsafe { gsym::MappedGsym::map_file(file) }.map_err(&invalid_gsym)?;
630 let actual = gsym.build_id();
631 if actual != build_id.as_bytes() {
632 return Err(Error::BuildIdMismatch(Box::new(BuildIdMismatchError::new(
633 path,
634 build_id.clone(),
635 actual,
636 ))));
637 }
638 gsym.verify().map_err(invalid_gsym)?;
639 Ok(())
640}
641
642enum ExistingEntry {
643 Missing,
644 Valid(CacheEntry),
645 Corrupt,
646}
647
648fn inspect_existing(cache: &Cache, build_id: &BuildId, path: &Path) -> Result<ExistingEntry> {
649 let Some(entry) = cache.lookup_path(path)? else {
650 return Ok(ExistingEntry::Missing);
651 };
652 match verify_file(entry.file(), build_id, path) {
653 Ok(()) => Ok(ExistingEntry::Valid(entry)),
654 Err(error) if is_corrupt(&error) => Ok(ExistingEntry::Corrupt),
655 Err(error) => Err(error),
656 }
657}
658
659#[expect(
660 clippy::wildcard_enum_match_arm,
661 reason = "future error classes must not cause cache deletion"
662)]
663pub(crate) const fn is_corrupt(error: &Error) -> bool {
664 match error {
665 Error::InvalidGsym(error)
666 if matches!(
667 error.gsym_error(),
668 gsym::Error::Io(_) | gsym::Error::IoAtPath { .. }
669 ) =>
670 {
671 false
672 }
673 Error::BuildIdMismatch(_) | Error::InvalidGsym(_) => true,
674 _ => false,
675 }
676}
677
678fn publish_noclobber(
679 cache: &Cache,
680 build_id: &BuildId,
681 mut temporary: NamedTempFile,
682) -> Result<PublishOutcome> {
683 let path = layout::object(cache.base(), build_id);
684 let (parent, parent_directory) = ensure_parent(&path)?;
685 let len = temporary
686 .as_file()
687 .metadata()
688 .map_err(|source| io_error("inspect staged GSYM file", temporary.path(), source))?
689 .len();
690 loop {
691 match temporary.persist_noclobber(&path) {
692 Ok(file) => {
693 sync_open_directory(&parent_directory, parent)?;
694 drop(file);
695 let entry = cache.lookup(build_id)?.ok_or_else(|| {
696 io_error(
697 "reopen published GSYM file",
698 &path,
699 io::Error::from(io::ErrorKind::NotFound),
700 )
701 })?;
702 debug_assert_eq!(entry.len(), len);
703 return Ok(PublishOutcome::Published(entry));
704 }
705 Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => {
706 temporary = error.file;
707 match inspect_existing(cache, build_id, &path)? {
708 ExistingEntry::Missing => {}
709 ExistingEntry::Valid(entry) => {
710 drop(temporary);
711 return Ok(PublishOutcome::Existing(entry));
712 }
713 ExistingEntry::Corrupt => cache.remove_corrupt_entry(build_id, &path)?,
714 }
715 }
716 Err(error) => return Err(io_error("publish GSYM file", path, error.error)),
717 }
718 }
719}
720
721fn set_read_only(file: &File, path: &Path) -> Result<()> {
722 use std::os::unix::fs::PermissionsExt as _;
723
724 file.set_permissions(std::fs::Permissions::from_mode(0o400))
725 .map_err(|source| io_error("set cache file permissions", path, source))?;
726 Ok(())
727}
728
729fn sync_directory(path: &Path) -> Result<()> {
730 sync_open_directory(&crate::lookup::open_existing_directory(path)?, path)
731}
732
733fn sync_open_directory(directory: &File, path: &Path) -> Result<()> {
734 directory
735 .sync_all()
736 .map_err(|source| io_error("sync cache directory", path, source))
737}
738
739pub(crate) fn open_lock(cache: &Cache, path: &Path) -> Result<File> {
740 use rustix::fs::{Mode, OFlags};
741
742 loop {
743 match rustix::fs::open(
744 path,
745 OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CREATE,
746 Mode::RUSR | Mode::WUSR,
747 ) {
748 Ok(file) => {
749 let file = File::from(file);
750 cache.validate_file(path, &file)?;
751 return Ok(file);
752 }
753 Err(source) if source == rustix::io::Errno::NOENT => {
754 let _parent = ensure_parent(path)?;
755 }
756 Err(source) => {
757 return Err(io_error("open cache lock", path, io::Error::from(source)));
758 }
759 }
760 }
761}
762
763pub(crate) fn remove_file_if_exists(operation: &'static str, path: &Path) -> Result<bool> {
764 match std::fs::remove_file(path) {
765 Ok(()) => Ok(true),
766 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
767 Err(source) => Err(io_error(operation, path, source)),
768 }
769}
770
771pub(crate) fn try_lock_file(
772 cache: &Cache,
773 path: &Path,
774 description: &'static str,
775) -> Result<Option<File>> {
776 let file = open_lock(cache, path)?;
777 match rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
778 Ok(()) => Ok(Some(file)),
779 Err(source) if source == rustix::io::Errno::WOULDBLOCK => Ok(None),
780 Err(source) => Err(io_error(description, path, io::Error::from(source))),
781 }
782}