@@ -11,7 +11,7 @@ use std::os::unix::fs::DirBuilderExt as _;
1111use std:: os:: unix:: fs:: MetadataExt as _;
1212use std:: os:: unix:: fs:: OpenOptionsExt as _;
1313use std:: path:: Path ;
14- use std:: sync:: atomic:: { AtomicU64 , Ordering } ;
14+ use std:: sync:: atomic:: { AtomicBool , AtomicU64 , Ordering } ;
1515
1616use anyhow:: Context as _;
1717use serde:: { Deserialize , Serialize } ;
@@ -672,7 +672,14 @@ fn archive_validated_file(
672672 } ;
673673 if result < 0 {
674674 let error = std:: io:: Error :: last_os_error ( ) ;
675- if error. kind ( ) != std:: io:: ErrorKind :: AlreadyExists {
675+ if error. kind ( ) == std:: io:: ErrorKind :: AlreadyExists {
676+ // A receipt for this predecessor already exists; the readback below proves it
677+ // carries exactly the validated bytes.
678+ } else if capability_link_unsupported ( & error) {
679+ // The platform cannot hardlink through the open-file descriptor path at all;
680+ // degrade to a byte-copy receipt instead of failing publication.
681+ write_archive_receipt_copy ( file, & archive_dir, archive, filename) ?;
682+ } else {
676683 return Err ( error) . context ( "archive the validated predecessor capability" ) ;
677684 }
678685 }
@@ -690,6 +697,77 @@ fn archive_validated_file(
690697 Ok ( ( ) )
691698}
692699
700+ /// Whether linkat through the open-file capability path is unsupported by the platform rather
701+ /// than a real failure. macOS fdescfs answers linkat(AT_SYMLINK_FOLLOW) on /dev/fd/N with
702+ /// EPERM; ENOSYS/EOPNOTSUPP cover kernels lacking the syscall or its symlink-follow semantics.
703+ /// Everything else stays a hard error so genuine failures (permissions, cross-device, ...)
704+ /// surface instead of being silently degraded to a copy.
705+ fn capability_link_unsupported ( error : & std:: io:: Error ) -> bool {
706+ #[ cfg( debug_assertions) ]
707+ if TEST_FORCE_ARCHIVE_RECEIPT_COPY . load ( Ordering :: Relaxed ) {
708+ return true ;
709+ }
710+ matches ! (
711+ error. raw_os_error( ) ,
712+ Some ( libc:: EPERM ) | Some ( libc:: ENOSYS ) | Some ( libc:: EOPNOTSUPP )
713+ )
714+ }
715+
716+ /// Debug-only switch letting tests exercise the byte-copy fallback on platforms where the real
717+ /// capability linkat would succeed. Not a supported configuration knob. Flipping this mid-run
718+ /// is safe: the fallback receipt is verified against the validated bytes exactly like the
719+ /// hardlink path, and the same-inode unlink treats a copy receipt as "archived" regardless.
720+ #[ cfg( debug_assertions) ]
721+ pub ( crate ) static TEST_FORCE_ARCHIVE_RECEIPT_COPY : AtomicBool = AtomicBool :: new ( false ) ;
722+
723+ /// Materialize the archive receipt as a byte copy of the validated file, for platforms that
724+ /// cannot hardlink through the open-file descriptor path.
725+ ///
726+ /// The staged temp keeps a concurrent archiver from observing a partial receipt, and
727+ /// rename_noreplace turns an install race into a no-op: whichever receipt wins, the caller's
728+ /// readback proves it carries exactly the validated bytes. The tradeoff against the hardlink
729+ /// fast path is inode identity -- a crash between the copy and the conditional unlink leaves
730+ /// the retained inbox entry in place until revalidation, which the same-inode checks read as
731+ /// "still present", never as data loss.
732+ fn write_archive_receipt_copy (
733+ file : & File ,
734+ archive_dir : & File ,
735+ archive : & Path ,
736+ filename : & str ,
737+ ) -> anyhow:: Result < ( ) > {
738+ let mut source = file. try_clone ( ) ?;
739+ source. rewind ( ) ?;
740+ let mut bytes = Vec :: new ( ) ;
741+ source. read_to_end ( & mut bytes) ?;
742+ drop ( source) ;
743+
744+ let staged = archive. join ( format ! (
745+ ".st2-archive-{}-{}" ,
746+ std:: process:: id( ) ,
747+ TMP_COUNTER . fetch_add( 1 , Ordering :: Relaxed )
748+ ) ) ;
749+ let mut staged_file = OpenOptions :: new ( )
750+ . read ( true )
751+ . write ( true )
752+ . create_new ( true )
753+ . mode ( 0o600 )
754+ . custom_flags ( libc:: O_NOFOLLOW | libc:: O_CLOEXEC )
755+ . open ( & staged) ?;
756+ staged_file. write_all ( & bytes) ?;
757+ staged_file. sync_all ( ) ?;
758+ drop ( staged_file) ;
759+
760+ let target = archive. join ( filename) ;
761+ if let Err ( error) = crate :: catalog_transaction:: rename_noreplace ( & staged, & target) {
762+ fs:: remove_file ( & staged) . context ( "remove staging copy after failed receipt install" ) ?;
763+ if error. kind ( ) != std:: io:: ErrorKind :: AlreadyExists {
764+ return Err ( error) . context ( "install archived predecessor receipt" ) ;
765+ }
766+ }
767+ archive_dir. sync_all ( ) ?;
768+ Ok ( ( ) )
769+ }
770+
693771fn conditional_unlink_same_inode (
694772 inbox_file : & File ,
695773 expected_file : & File ,
0 commit comments