Skip to main content

gsym_cache/
manage.rs

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/// Longest accepted negative-cache lifetime.
20#[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    /// Tries to become the sole population owner for a build identifier.
26    ///
27    /// This never waits for another process. After acquiring the advisory
28    /// lock it rechecks the cache, closing the race between lookup and lock. An
29    /// existing entry is fully verified before it is returned as
30    /// [`PopulationOutcome::Present`]. Confirmed corruption is removed only
31    /// while holding the population lock; transient I/O failures never cause
32    /// deletion.
33    ///
34    /// See [`docs::operations`](crate::docs::operations) for the complete state
35    /// machine and [`docs::cookbook`](crate::docs::cookbook) for recipes.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error when the cache directories or lock file cannot be
40    /// created, inspected, or locked.
41    ///
42    /// # Example
43    ///
44    /// ```no_run
45    /// use std::fs::File;
46    /// use std::io;
47    /// use gsym_cache::{BuildId, Cache, CacheEpoch, PopulationOutcome};
48    ///
49    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
50    /// let cache = Cache::open("/var/cache/my-profiler/gsym", CacheEpoch::new(1))?;
51    /// let build_id = BuildId::new([0x12; 20])?;
52    ///
53    /// match cache.try_begin_population(&build_id)? {
54    ///     PopulationOutcome::Present(entry) => drop(entry),
55    ///     PopulationOutcome::Acquired(population) => {
56    ///         let mut source = File::open("artifact.gsym")?;
57    ///         let mut writer = population.into_writer()?;
58    ///         io::copy(&mut source, &mut writer)?;
59    ///         let entry = writer.publish()?.into_entry();
60    ///         drop(entry);
61    ///     }
62    ///     PopulationOutcome::Suppressed(failure) => {
63    ///         eprintln!("retry after {:?}", failure.expires_at());
64    ///     }
65    ///     PopulationOutcome::Busy => eprintln!("another population is active"),
66    /// }
67    /// # Ok(())
68    /// # }
69    /// ```
70    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    /// Returns an unexpired cached failure.
185    ///
186    /// Expired or malformed records are ignored. Population or a scrub pass
187    /// removes them while holding the entry lock.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error for filesystem failures or an untrusted cache entry.
192    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/// Outcome of trying to acquire population ownership.
331#[derive(Debug)]
332#[must_use]
333#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
334pub enum PopulationOutcome<'cache> {
335    /// The caller owns population for this build identifier.
336    Acquired(Population<'cache>),
337    /// Another process currently owns the same bounded lock slot.
338    ///
339    /// Lock-slot collisions may delay an unrelated build ID.
340    Busy,
341    /// A cached failure suppresses population until its expiration.
342    Suppressed(CachedFailure),
343    /// Population completed before the lock was needed or acquired.
344    Present(CacheEntry),
345}
346
347/// Exclusive population capability for one build identifier.
348///
349/// Dropping the guard releases its process lock. A staging file is not created
350/// until [`Population::into_writer`] consumes this value.
351#[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    /// Creates a staged GSYM file and transitions to its write-only owner.
365    ///
366    /// The returned writer owns this population capability. Writers can stream
367    /// directly into it without a second full-size allocation or copy.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if the temporary file cannot be created.
372    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    /// Atomically records an expiring population failure.
390    ///
391    /// Consuming the population guard ensures no successful publisher for the
392    /// same build identifier races this record. Applications should use a
393    /// short lifetime for missing inputs and transient resource failures.
394    /// The lifetime is rounded up to whole Unix seconds and must be nonzero and
395    /// at most [`MAX_FAILURE_TTL`].
396    ///
397    /// Returns the cached failure, including its rounded expiration time.
398    ///
399    /// # Errors
400    ///
401    /// Returns an error if `lifetime` is zero, exceeds [`MAX_FAILURE_TTL`],
402    /// cannot be represented, or the record cannot be published.
403    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/// Exclusive write-only owner of a staged GSYM file.
424///
425/// Dropping the writer removes the unpublished temporary file and releases the
426/// population lock.
427#[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    /// Atomically records an expiring population failure and discards staged output.
445    ///
446    /// # Errors
447    ///
448    /// Returns an error if `lifetime` is zero, exceeds [`MAX_FAILURE_TTL`],
449    /// cannot be represented, or the record cannot be published.
450    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    /// Verifies and atomically publishes the staged GSYM file.
468    ///
469    /// Publication consumes the writer. A racing winner is validated and
470    /// returned instead of being replaced.
471    ///
472    /// # Errors
473    ///
474    /// Returns an error if verification fails, the GSYM build identifier
475    /// differs from the cache key, or durable publication fails.
476    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/// Result of publishing a verified staged file.
519#[derive(Debug)]
520#[must_use]
521#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
522pub enum PublishOutcome {
523    /// This process published the entry.
524    Published(CacheEntry),
525    /// Another process had already published an equivalent valid entry.
526    Existing(CacheEntry),
527}
528
529impl PublishOutcome {
530    /// Borrows the published or concurrently existing cache entry.
531    #[must_use]
532    pub const fn entry(&self) -> &CacheEntry {
533        match self {
534            Self::Published(entry) | Self::Existing(entry) => entry,
535        }
536    }
537
538    /// Consumes the outcome and returns its cache entry.
539    #[must_use]
540    pub fn into_entry(self) -> CacheEntry {
541        match self {
542            Self::Published(entry) | Self::Existing(entry) => entry,
543        }
544    }
545
546    /// Returns whether this process published the entry.
547    #[must_use]
548    pub const fn is_published(&self) -> bool {
549        matches!(self, Self::Published(_))
550    }
551}
552
553/// Stable class of a population failure.
554#[derive(Clone, Copy, Debug, Eq, PartialEq)]
555#[non_exhaustive]
556#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
557pub enum FailureKind {
558    /// Required executable or debug information is not currently available.
559    MissingInput,
560    /// The input format or conversion mode is unsupported.
561    UnsupportedInput,
562    /// The input is malformed.
563    MalformedInput,
564    /// A temporary I/O failure occurred.
565    TransientIo,
566    /// Conversion exceeded a resource limit.
567    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/// An unexpired persistent population failure.
594#[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    /// Returns the stable failure class.
609    #[must_use]
610    pub const fn kind(self) -> FailureKind {
611        self.kind
612    }
613
614    /// Returns when this failure should be retried.
615    #[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    // SAFETY: population exposes only a Write facade, publish consumes it, and
628    // cache entries are immutable after publication.
629    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}