Skip to main content

denoize/
atomic_output.rs

1//! Atomic creation and replacement of filesystem outputs.
2
3use std::fs::File;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6
7use tempfile::{Builder, NamedTempFile};
8
9#[cfg(unix)]
10pub(crate) fn validate_unix_acl(path: &Path, destination: &Path) -> Result<(), String> {
11    let unsafe_acl = unix_acl_is_unsafe(path).map_err(|error| {
12        format!(
13            "failed to inspect output directory ACL security for {} at {}: {error}",
14            destination.display(),
15            path.display()
16        )
17    })?;
18    if unsafe_acl {
19        return Err(format!(
20            "refusing to stage output {} through insecure directory {}: extended ACLs must not grant access beyond the owner and mode bits",
21            destination.display(),
22            path.display()
23        ));
24    }
25    Ok(())
26}
27
28#[cfg(target_os = "linux")]
29fn unix_acl_is_unsafe(path: &Path) -> io::Result<bool> {
30    use std::ffi::CString;
31    use std::os::unix::ffi::OsStrExt;
32    use std::ptr::null_mut;
33
34    let path = CString::new(path.as_os_str().as_bytes())
35        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
36
37    let mut stat = std::mem::MaybeUninit::<libc::statfs>::uninit();
38    if unsafe { libc::statfs(path.as_ptr(), stat.as_mut_ptr()) } != 0 {
39        return Err(io::Error::last_os_error());
40    }
41    // Linux does not expose NFSv4 and server-side SMB/CIFS ACLs through the
42    // POSIX ACL xattrs below. Reject those filesystems instead of treating an
43    // unverifiable ACL as safe.
44    let filesystem = (unsafe { stat.assume_init() }.f_type as u64) & 0xffff_ffff;
45    const CIFS_MAGIC_NUMBER: u64 = 0xff53_4d42;
46    const SMB2_MAGIC_NUMBER: u64 = 0xfe53_4d42;
47    const CEPH_SUPER_MAGIC: u64 = 0x00c3_6400;
48    const V9FS_MAGIC: u64 = 0x0102_1997;
49    const OPENAFS_FS_MAGIC: u64 = 0x6b41_4653;
50    let unverifiable_network_acl = [
51        libc::NFS_SUPER_MAGIC as u64,
52        libc::AFS_SUPER_MAGIC as u64,
53        libc::CODA_SUPER_MAGIC as u64,
54        libc::NCP_SUPER_MAGIC as u64,
55        libc::SMB_SUPER_MAGIC as u64,
56        CIFS_MAGIC_NUMBER,
57        SMB2_MAGIC_NUMBER,
58        CEPH_SUPER_MAGIC,
59        V9FS_MAGIC,
60        OPENAFS_FS_MAGIC,
61        libc::FUSE_SUPER_MAGIC as u64,
62    ]
63    .contains(&filesystem);
64    if unverifiable_network_acl {
65        return Err(io::Error::new(
66            io::ErrorKind::Unsupported,
67            "network or userspace filesystems with unverifiable ACLs are not supported for atomic output",
68        ));
69    }
70
71    for name in [
72        b"system.posix_acl_access\0".as_slice(),
73        b"system.posix_acl_default\0".as_slice(),
74    ] {
75        let size = unsafe { libc::getxattr(path.as_ptr(), name.as_ptr().cast(), null_mut(), 0) };
76        if size >= 0 {
77            return Ok(true);
78        }
79        let error = io::Error::last_os_error();
80        if error.raw_os_error() != Some(libc::ENODATA) {
81            // ENOTSUP is deliberately an error: on a filesystem without
82            // verifiable ACLs, mode 0600 may not provide the promised privacy.
83            return Err(error);
84        }
85    }
86
87    Ok(false)
88}
89
90#[cfg(target_os = "linux")]
91fn unix_path_has_extended_acl(path: &Path) -> io::Result<bool> {
92    unix_acl_is_unsafe(path)
93}
94
95#[cfg(target_os = "macos")]
96fn macos_acl_entries_are_unsafe(entries: &[exacl::AclEntry]) -> bool {
97    entries
98        .iter()
99        .any(|entry| entry.allow || entry.kind == exacl::AclEntryKind::Unknown)
100}
101
102#[cfg(target_os = "macos")]
103fn macos_acl_entries(path: &Path) -> io::Result<Vec<exacl::AclEntry>> {
104    let mut stat = std::mem::MaybeUninit::<libc::statfs>::uninit();
105    use std::ffi::CString;
106    use std::os::unix::ffi::OsStrExt;
107
108    let c_path = CString::new(path.as_os_str().as_bytes())
109        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
110    if unsafe { libc::statfs(c_path.as_ptr(), stat.as_mut_ptr()) } != 0 {
111        return Err(io::Error::last_os_error());
112    }
113    let mount_flags = unsafe { stat.assume_init() }.f_flags;
114    if mount_flags & libc::MNT_LOCAL as u32 == 0 {
115        return Err(io::Error::new(
116            io::ErrorKind::Unsupported,
117            "remote filesystems with server-side ACLs are not supported for atomic output",
118        ));
119    }
120    if mount_flags & libc::MNT_IGNORE_OWNERSHIP as u32 != 0 {
121        return Err(io::Error::new(
122            io::ErrorKind::Unsupported,
123            "filesystems that ignore Unix ownership are not supported for atomic output",
124        ));
125    }
126
127    exacl::getfacl(path, None)
128}
129
130#[cfg(target_os = "macos")]
131fn unix_acl_is_unsafe(path: &Path) -> io::Result<bool> {
132    Ok(macos_acl_entries_are_unsafe(&macos_acl_entries(path)?))
133}
134
135#[cfg(target_os = "macos")]
136fn unix_path_has_extended_acl(path: &Path) -> io::Result<bool> {
137    Ok(!macos_acl_entries(path)?.is_empty())
138}
139
140#[cfg(target_os = "freebsd")]
141fn unix_acl_is_unsafe(path: &Path) -> io::Result<bool> {
142    use std::ffi::{c_void, CString};
143    use std::os::unix::ffi::OsStrExt;
144    use std::ptr::null_mut;
145
146    unsafe extern "C" {
147        fn acl_get_entry(
148            acl: *mut c_void,
149            entry_id: libc::c_int,
150            entry: *mut *mut c_void,
151        ) -> libc::c_int;
152        fn acl_get_file(path: *const libc::c_char, acl_type: libc::c_int) -> *mut c_void;
153        fn acl_is_trivial_np(acl: *mut c_void, trivial: *mut libc::c_int) -> libc::c_int;
154        fn acl_free(acl: *mut c_void) -> libc::c_int;
155    }
156
157    const ACL_TYPE_ACCESS: libc::c_int = 2;
158    const ACL_TYPE_DEFAULT: libc::c_int = 3;
159    const ACL_TYPE_NFS4: libc::c_int = 4;
160    const ACL_FIRST_ENTRY: libc::c_int = 0;
161
162    fn access_acl_is_nontrivial(
163        path: *const libc::c_char,
164        acl_type: libc::c_int,
165    ) -> io::Result<bool> {
166        let acl = unsafe { acl_get_file(path, acl_type) };
167        if acl.is_null() {
168            return Err(io::Error::last_os_error());
169        }
170        let mut trivial = 0;
171        let result = unsafe { acl_is_trivial_np(acl, &mut trivial) };
172        let operation_error = (result != 0).then(io::Error::last_os_error);
173        let free_result = unsafe { acl_free(acl) };
174        if let Some(error) = operation_error {
175            return Err(error);
176        }
177        if free_result != 0 {
178            return Err(io::Error::last_os_error());
179        }
180        Ok(trivial == 0)
181    }
182
183    fn directory_has_default_acl(path: *const libc::c_char) -> io::Result<bool> {
184        let acl = unsafe { acl_get_file(path, ACL_TYPE_DEFAULT) };
185        if acl.is_null() {
186            return Err(io::Error::last_os_error());
187        }
188        let mut entry = null_mut();
189        let result = unsafe { acl_get_entry(acl, ACL_FIRST_ENTRY, &mut entry) };
190        let operation_error = (result < 0).then(io::Error::last_os_error);
191        let free_result = unsafe { acl_free(acl) };
192        if let Some(error) = operation_error {
193            return Err(error);
194        }
195        if free_result != 0 {
196            return Err(io::Error::last_os_error());
197        }
198        match result {
199            0 => Ok(false),
200            1 => Ok(true),
201            _ => Err(io::Error::new(
202                io::ErrorKind::InvalidData,
203                "FreeBSD returned an invalid ACL entry status",
204            )),
205        }
206    }
207
208    let is_directory = std::fs::metadata(path)?.is_dir();
209    let mut stat = std::mem::MaybeUninit::<libc::statfs>::uninit();
210    let path = CString::new(path.as_os_str().as_bytes())
211        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
212    if unsafe { libc::statfs(path.as_ptr(), stat.as_mut_ptr()) } != 0 {
213        return Err(io::Error::last_os_error());
214    }
215    if unsafe { stat.assume_init() }.f_flags & libc::MNT_LOCAL == 0 {
216        return Err(io::Error::new(
217            io::ErrorKind::Unsupported,
218            "remote filesystems with server-side ACLs are not supported for atomic output",
219        ));
220    }
221
222    let nfs4 = unsafe { libc::pathconf(path.as_ptr(), libc::_PC_ACL_NFS4) } > 0;
223    let acl_type = if nfs4 { ACL_TYPE_NFS4 } else { ACL_TYPE_ACCESS };
224    if access_acl_is_nontrivial(path.as_ptr(), acl_type)? {
225        return Ok(true);
226    }
227    if !nfs4 && is_directory {
228        return directory_has_default_acl(path.as_ptr());
229    }
230    Ok(false)
231}
232
233#[cfg(target_os = "freebsd")]
234fn unix_path_has_extended_acl(path: &Path) -> io::Result<bool> {
235    unix_acl_is_unsafe(path)
236}
237
238#[cfg(all(
239    unix,
240    not(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))
241))]
242fn unix_acl_is_unsafe(_path: &Path) -> io::Result<bool> {
243    Err(io::Error::new(
244        io::ErrorKind::Unsupported,
245        "ACL security validation is not supported on this Unix platform",
246    ))
247}
248
249#[cfg(all(
250    unix,
251    not(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))
252))]
253fn unix_path_has_extended_acl(path: &Path) -> io::Result<bool> {
254    unix_acl_is_unsafe(path)
255}
256
257#[cfg(unix)]
258pub(crate) fn validate_unix_staging_path(parent: &Path, destination: &Path) -> Result<(), String> {
259    use std::os::unix::fs::{MetadataExt, PermissionsExt};
260
261    let effective_uid = unsafe { libc::geteuid() };
262    let mut directory = Some(parent);
263    while let Some(path) = directory {
264        let metadata = std::fs::metadata(path).map_err(|error| {
265            format!(
266                "failed to inspect output directory security for {}: {error}",
267                destination.display()
268            )
269        })?;
270        if !metadata.is_dir() {
271            return Err(format!(
272                "refusing to stage output {} through non-directory path {}",
273                destination.display(),
274                path.display()
275            ));
276        }
277        let mode = metadata.permissions().mode();
278        let trusted_owner = metadata.uid() == effective_uid || metadata.uid() == 0;
279        let shared_writable = mode & 0o022 != 0;
280        let sticky = mode & libc::S_ISVTX as u32 != 0;
281        if !trusted_owner || (shared_writable && !sticky) {
282            return Err(format!(
283                "refusing to stage output {} through insecure directory {}: shared-writable directories must use the sticky bit and every directory must be owned by the current user or root",
284                destination.display(),
285                path.display()
286            ));
287        }
288        validate_unix_acl(path, destination)?;
289        directory = path.parent();
290    }
291    Ok(())
292}
293
294#[cfg(unix)]
295fn validate_unix_stage_file(temporary: &NamedTempFile, destination: &Path) -> Result<(), String> {
296    use std::os::unix::fs::{MetadataExt, PermissionsExt};
297
298    let held = temporary.as_file().metadata().map_err(|error| {
299        format!(
300            "failed to inspect temporary output security for {}: {error}",
301            destination.display()
302        )
303    })?;
304    let named = std::fs::symlink_metadata(temporary.path()).map_err(|error| {
305        format!(
306            "failed to inspect temporary output path security for {}: {error}",
307            destination.display()
308        )
309    })?;
310    if !named.file_type().is_file()
311        || held.dev() != named.dev()
312        || held.ino() != named.ino()
313        || held.uid() != unsafe { libc::geteuid() }
314        || held.permissions().mode() & 0o077 != 0
315    {
316        return Err(format!(
317            "refusing insecure temporary output for {}: the stage must remain an owner-only regular file at its original path",
318            destination.display()
319        ));
320    }
321    validate_unix_acl(temporary.path(), destination)
322}
323
324#[cfg(unix)]
325fn preserve_unix_group(file: &File, gid: libc::gid_t) -> io::Result<()> {
326    use std::os::fd::AsRawFd;
327    use std::os::unix::fs::MetadataExt;
328
329    let current = file.metadata()?;
330    if current.gid() == gid {
331        return Ok(());
332    }
333    // Never hand ownership of the named stage to another user before rename.
334    // Passing uid_t::MAX preserves the current owner while changing only the
335    // group; mode 0600 keeps that group from accessing the stage.
336    if unsafe { libc::fchown(file.as_raw_fd(), libc::uid_t::MAX, gid) } != 0 {
337        return Err(io::Error::last_os_error());
338    }
339    let updated = file.metadata()?;
340    if updated.uid() != current.uid() || updated.gid() != gid {
341        return Err(io::Error::new(
342            io::ErrorKind::PermissionDenied,
343            "output group did not match the existing destination after fchown",
344        ));
345    }
346    Ok(())
347}
348
349#[cfg(windows)]
350mod windows_security {
351    use std::fs::File;
352    use std::io;
353    use std::os::windows::ffi::OsStrExt;
354    use std::os::windows::io::{AsRawHandle, FromRawHandle};
355    use std::path::Path;
356    use std::ptr::{copy_nonoverlapping, null, null_mut};
357
358    use windows_sys::Win32::Foundation::{
359        LocalFree, ERROR_SUCCESS, GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE,
360    };
361    use windows_sys::Win32::Security::Authorization::{
362        ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SetSecurityInfo,
363        SDDL_REVISION_1, SE_FILE_OBJECT,
364    };
365    use windows_sys::Win32::Security::{
366        GetSecurityDescriptorControl, GetSecurityDescriptorDacl, GetSecurityDescriptorLength, ACL,
367        DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, SECURITY_ATTRIBUTES,
368        SE_DACL_PROTECTED, UNPROTECTED_DACL_SECURITY_INFORMATION,
369    };
370    use windows_sys::Win32::Storage::FileSystem::{
371        CreateFileW, FileDispositionInfo, FileRenameInfo, SetFileInformationByHandle, CREATE_NEW,
372        DELETE, FILE_ATTRIBUTE_NORMAL, FILE_DISPOSITION_INFO, FILE_FLAG_OPEN_REPARSE_POINT,
373        FILE_RENAME_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
374        READ_CONTROL, WRITE_DAC,
375    };
376
377    struct LocalMemory(*mut core::ffi::c_void);
378
379    impl Drop for LocalMemory {
380        fn drop(&mut self) {
381            if !self.0.is_null() {
382                unsafe {
383                    let _ = LocalFree(self.0);
384                }
385            }
386        }
387    }
388
389    /// An aligned copy of a self-relative Windows security descriptor.
390    pub(super) struct DaclSnapshot {
391        descriptor: Box<[usize]>,
392    }
393
394    impl DaclSnapshot {
395        pub(super) fn capture(file: &File) -> io::Result<Self> {
396            let mut dacl: *mut ACL = null_mut();
397            let mut descriptor = null_mut();
398            let status = unsafe {
399                GetSecurityInfo(
400                    file.as_raw_handle(),
401                    SE_FILE_OBJECT,
402                    DACL_SECURITY_INFORMATION,
403                    null_mut(),
404                    null_mut(),
405                    &mut dacl,
406                    null_mut(),
407                    &mut descriptor,
408                )
409            };
410            if status != ERROR_SUCCESS {
411                return Err(io::Error::from_raw_os_error(status as i32));
412            }
413            let descriptor_guard = LocalMemory(descriptor);
414            if descriptor.is_null() {
415                return Err(io::Error::new(
416                    io::ErrorKind::InvalidData,
417                    "Windows returned an empty security descriptor",
418                ));
419            }
420            let length = unsafe { GetSecurityDescriptorLength(descriptor) } as usize;
421            if length == 0 {
422                return Err(io::Error::new(
423                    io::ErrorKind::InvalidData,
424                    "Windows returned a zero-length security descriptor",
425                ));
426            }
427            let words = length.div_ceil(std::mem::size_of::<usize>());
428            let mut snapshot = vec![0usize; words].into_boxed_slice();
429            unsafe {
430                copy_nonoverlapping(
431                    descriptor.cast::<u8>(),
432                    snapshot.as_mut_ptr().cast::<u8>(),
433                    length,
434                );
435            }
436            drop(descriptor_guard);
437            Ok(Self {
438                descriptor: snapshot,
439            })
440        }
441
442        pub(super) fn apply(&self, file: &File) -> io::Result<()> {
443            let descriptor = self.descriptor.as_ptr().cast_mut().cast();
444            let mut present = 0;
445            let mut defaulted = 0;
446            let mut dacl: *mut ACL = null_mut();
447            if unsafe {
448                GetSecurityDescriptorDacl(descriptor, &mut present, &mut dacl, &mut defaulted)
449            } == 0
450            {
451                return Err(io::Error::last_os_error());
452            }
453            if present == 0 || dacl.is_null() {
454                return Err(io::Error::new(
455                    io::ErrorKind::InvalidData,
456                    "refusing to apply a missing or null Windows DACL",
457                ));
458            }
459
460            let mut control = 0;
461            let mut revision = 0;
462            if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0
463            {
464                return Err(io::Error::last_os_error());
465            }
466            let inheritance = if control & SE_DACL_PROTECTED != 0 {
467                PROTECTED_DACL_SECURITY_INFORMATION
468            } else {
469                UNPROTECTED_DACL_SECURITY_INFORMATION
470            };
471            let status = unsafe {
472                SetSecurityInfo(
473                    file.as_raw_handle(),
474                    SE_FILE_OBJECT,
475                    DACL_SECURITY_INFORMATION | inheritance,
476                    null_mut(),
477                    null_mut(),
478                    dacl,
479                    null(),
480                )
481            };
482            if status == ERROR_SUCCESS {
483                Ok(())
484            } else {
485                Err(io::Error::from_raw_os_error(status as i32))
486            }
487        }
488
489        pub(super) fn identity(&self) -> io::Result<(bool, Vec<u8>)> {
490            let descriptor = self.descriptor.as_ptr().cast_mut().cast();
491            let mut present = 0;
492            let mut defaulted = 0;
493            let mut dacl: *mut ACL = null_mut();
494            if unsafe {
495                GetSecurityDescriptorDacl(descriptor, &mut present, &mut dacl, &mut defaulted)
496            } == 0
497            {
498                return Err(io::Error::last_os_error());
499            }
500            if present == 0 || dacl.is_null() {
501                return Err(io::Error::new(
502                    io::ErrorKind::InvalidData,
503                    "Windows DACL is missing or null",
504                ));
505            }
506            let mut control = 0;
507            let mut revision = 0;
508            if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0
509            {
510                return Err(io::Error::last_os_error());
511            }
512            let length = unsafe { (*dacl).AclSize as usize };
513            let mut bytes = vec![0u8; length];
514            unsafe {
515                copy_nonoverlapping(dacl.cast::<u8>(), bytes.as_mut_ptr(), length);
516            }
517            Ok((control & SE_DACL_PROTECTED != 0, bytes))
518        }
519    }
520
521    fn create_private_with_access(
522        path: &Path,
523        desired_access: u32,
524        share_mode: u32,
525    ) -> io::Result<File> {
526        // Owner, LocalSystem, and built-in administrators receive full access;
527        // inheritance is disabled so a shared parent cannot expose the stage.
528        let sddl: Vec<u16> = "D:P(A;;GA;;;OW)(A;;GA;;;SY)(A;;GA;;;BA)\0"
529            .encode_utf16()
530            .collect();
531        let mut descriptor = null_mut();
532        if unsafe {
533            ConvertStringSecurityDescriptorToSecurityDescriptorW(
534                sddl.as_ptr(),
535                SDDL_REVISION_1,
536                &mut descriptor,
537                null_mut(),
538            )
539        } == 0
540        {
541            return Err(io::Error::last_os_error());
542        }
543        let _descriptor_guard = LocalMemory(descriptor);
544        let attributes = SECURITY_ATTRIBUTES {
545            nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
546            lpSecurityDescriptor: descriptor,
547            bInheritHandle: 0,
548        };
549        let path: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
550        let handle = unsafe {
551            CreateFileW(
552                path.as_ptr(),
553                desired_access,
554                share_mode,
555                &attributes,
556                CREATE_NEW,
557                FILE_ATTRIBUTE_NORMAL,
558                null_mut(),
559            )
560        };
561        if handle == INVALID_HANDLE_VALUE {
562            Err(io::Error::last_os_error())
563        } else {
564            Ok(unsafe { File::from_raw_handle(handle) })
565        }
566    }
567
568    pub(super) fn create_private(path: &Path) -> io::Result<File> {
569        create_private_with_access(
570            path,
571            GENERIC_READ | GENERIC_WRITE | WRITE_DAC | DELETE,
572            FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
573        )
574    }
575
576    pub(super) fn create_private_control(path: &Path) -> io::Result<File> {
577        create_private_with_access(
578            path,
579            GENERIC_READ | GENERIC_WRITE | WRITE_DAC,
580            FILE_SHARE_READ | FILE_SHARE_WRITE,
581        )
582    }
583
584    /// Open a destination entry without requesting access to its contents.
585    pub(super) fn open_for_security(path: &Path) -> io::Result<File> {
586        let path: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
587        let handle = unsafe {
588            CreateFileW(
589                path.as_ptr(),
590                READ_CONTROL,
591                FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
592                null(),
593                OPEN_EXISTING,
594                FILE_FLAG_OPEN_REPARSE_POINT,
595                null_mut(),
596            )
597        };
598        if handle == INVALID_HANDLE_VALUE {
599            Err(io::Error::last_os_error())
600        } else {
601            Ok(unsafe { File::from_raw_handle(handle) })
602        }
603    }
604
605    /// Atomically rename the staged file using its already-authorized handle.
606    pub(super) fn rename(file: &File, destination: &Path, replace: bool) -> io::Result<()> {
607        let destination: Vec<u16> = destination.as_os_str().encode_wide().collect();
608        let name_bytes = destination
609            .len()
610            .checked_mul(std::mem::size_of::<u16>())
611            .ok_or_else(|| {
612                io::Error::new(io::ErrorKind::InvalidInput, "output path is too long")
613            })?;
614        let buffer_bytes = std::mem::size_of::<FILE_RENAME_INFO>()
615            .checked_add(name_bytes)
616            .ok_or_else(|| {
617                io::Error::new(io::ErrorKind::InvalidInput, "output path is too long")
618            })?;
619        let buffer_size = u32::try_from(buffer_bytes)
620            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "output path is too long"))?;
621        let words = buffer_bytes.div_ceil(std::mem::size_of::<usize>());
622        let mut buffer = vec![0usize; words].into_boxed_slice();
623        let info = buffer.as_mut_ptr().cast::<FILE_RENAME_INFO>();
624
625        unsafe {
626            (*info).Anonymous.ReplaceIfExists = replace;
627            (*info).RootDirectory = null_mut();
628            (*info).FileNameLength = name_bytes as u32;
629            copy_nonoverlapping(
630                destination.as_ptr(),
631                (*info).FileName.as_mut_ptr(),
632                destination.len(),
633            );
634        }
635
636        if unsafe {
637            SetFileInformationByHandle(
638                file.as_raw_handle(),
639                FileRenameInfo,
640                buffer.as_ptr().cast(),
641                buffer_size,
642            )
643        } == 0
644        {
645            Err(io::Error::last_os_error())
646        } else {
647            Ok(())
648        }
649    }
650
651    /// Mark the stage for deletion using the DELETE right already granted to
652    /// its handle, independent of any DACL applied before a failed rename.
653    pub(super) fn delete_on_close(file: &File) -> io::Result<()> {
654        let disposition = FILE_DISPOSITION_INFO { DeleteFile: true };
655        if unsafe {
656            SetFileInformationByHandle(
657                file.as_raw_handle(),
658                FileDispositionInfo,
659                (&raw const disposition).cast(),
660                std::mem::size_of::<FILE_DISPOSITION_INFO>() as u32,
661            )
662        } == 0
663        {
664            Err(io::Error::last_os_error())
665        } else {
666            Ok(())
667        }
668    }
669}
670
671#[cfg(windows)]
672pub(crate) fn create_private_windows_control_file(path: &Path) -> io::Result<File> {
673    windows_security::create_private_control(path)
674}
675
676#[cfg(windows)]
677pub(crate) fn require_windows_acl_capability(file: &File) -> io::Result<()> {
678    windows_security::DaclSnapshot::capture(file)?
679        .identity()
680        .map(|_| ())
681}
682
683#[cfg(windows)]
684fn windows_metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool {
685    use std::os::windows::fs::MetadataExt;
686
687    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
688    metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
689}
690
691/// How an [`AtomicOutput`] is committed to its destination.
692#[derive(Clone, Copy, Debug, Eq, PartialEq)]
693pub enum CommitMode {
694    /// Atomically replace an existing destination entry.
695    Replace,
696    /// Commit only when no destination entry exists.
697    NoClobber,
698}
699
700/// A temporary output staged beside its final destination.
701///
702/// Dropping this value before a successful [`commit`](Self::commit) removes
703/// the staged file.
704pub struct AtomicOutput {
705    temporary: NamedTempFile,
706    destination: PathBuf,
707    display_destination: PathBuf,
708    #[cfg(unix)]
709    new_destination_permissions: std::fs::Permissions,
710    #[cfg(windows)]
711    new_destination_dacl: windows_security::DaclSnapshot,
712    #[cfg(windows)]
713    private_stage_dacl: windows_security::DaclSnapshot,
714}
715
716impl AtomicOutput {
717    /// Create a new staged output in the destination's parent directory.
718    pub fn new(destination: impl AsRef<Path>) -> Result<Self, String> {
719        let requested_destination = destination.as_ref();
720        let display_destination = requested_destination.to_path_buf();
721        let parent = requested_destination
722            .parent()
723            .filter(|path| !path.as_os_str().is_empty())
724            .unwrap_or_else(|| Path::new("."));
725        let parent = std::fs::canonicalize(parent).map_err(|error| {
726            format!(
727                "failed to resolve output directory for {}: {error}",
728                requested_destination.display()
729            )
730        })?;
731        let file_name = requested_destination.file_name().ok_or_else(|| {
732            format!(
733                "output destination must name a file: {}",
734                requested_destination.display()
735            )
736        })?;
737        let destination = parent.join(file_name);
738
739        #[cfg(unix)]
740        validate_unix_staging_path(&parent, &display_destination)?;
741
742        let mut builder = Builder::new();
743        builder.prefix(".denoize-").suffix(".part").rand_bytes(16);
744
745        #[cfg(unix)]
746        let new_destination_permissions = {
747            use std::os::unix::fs::PermissionsExt;
748
749            // Probe the process umask with an empty, independent inode. The
750            // real stage remains 0600 throughout encoding, while a newly
751            // published output keeps File::create-compatible permissions.
752            let mut probe_builder = Builder::new();
753            probe_builder
754                .prefix(".denoize-mode-")
755                .suffix(".probe")
756                .rand_bytes(16)
757                .permissions(std::fs::Permissions::from_mode(0o666));
758            let probe = probe_builder.tempfile_in(&parent).map_err(|error| {
759                format!(
760                    "failed to determine output permissions for {}: {error}",
761                    display_destination.display()
762                )
763            })?;
764            probe
765                .as_file()
766                .metadata()
767                .map_err(|error| {
768                    format!(
769                        "failed to inspect output permissions for {}: {error}",
770                        display_destination.display()
771                    )
772                })?
773                .permissions()
774        };
775
776        #[cfg(windows)]
777        let new_destination_dacl = {
778            // An empty probe safely records the DACL a normal file inherits
779            // from this directory. Sensitive bytes are only written to the
780            // separately-created, protected stage below.
781            let mut probe_builder = Builder::new();
782            probe_builder
783                .prefix(".denoize-acl-")
784                .suffix(".probe")
785                .rand_bytes(16);
786            let probe = probe_builder.tempfile_in(&parent).map_err(|error| {
787                format!(
788                    "failed to determine output security for {}: {error}",
789                    display_destination.display()
790                )
791            })?;
792            windows_security::DaclSnapshot::capture(probe.as_file()).map_err(|error| {
793                format!(
794                    "failed to inspect output security for {}: {error} (Windows atomic output requires an ACL-capable filesystem such as NTFS)",
795                    display_destination.display()
796                )
797            })?
798        };
799
800        // tempfile's default Unix mode is 0600. Keep the stage private through
801        // the atomic rename; the destination mode is restored afterward via
802        // the still-open handle.
803        #[cfg(windows)]
804        let temporary_result = builder.make_in(&parent, windows_security::create_private);
805        #[cfg(not(windows))]
806        let temporary_result = builder.tempfile_in(&parent);
807        let temporary = temporary_result.map_err(|error| {
808            format!(
809                "failed to create temporary output for {}: {error}",
810                display_destination.display()
811            )
812        })?;
813        #[cfg(unix)]
814        validate_unix_stage_file(&temporary, &display_destination)?;
815        #[cfg(windows)]
816        let private_stage_dacl = windows_security::DaclSnapshot::capture(temporary.as_file())
817            .map_err(|error| {
818                format!(
819                    "failed to inspect temporary output security for {}: {error}",
820                    display_destination.display()
821                )
822            })?;
823
824        Ok(Self {
825            temporary,
826            destination,
827            display_destination,
828            #[cfg(unix)]
829            new_destination_permissions,
830            #[cfg(windows)]
831            new_destination_dacl,
832            #[cfg(windows)]
833            private_stage_dacl,
834        })
835    }
836
837    /// Return the open staged file.
838    pub fn file_mut(&mut self) -> &mut File {
839        self.temporary.as_file_mut()
840    }
841
842    /// Return the destination path fixed when this transaction was created.
843    pub(crate) fn destination_path(&self) -> &Path {
844        &self.destination
845    }
846
847    /// Flush and atomically commit the staged file.
848    pub fn commit(mut self, mode: CommitMode) -> Result<(), String> {
849        self.temporary.as_file_mut().flush().map_err(|error| {
850            format!(
851                "failed to flush temporary output for {}: {error}",
852                self.display_destination.display()
853            )
854        })?;
855
856        #[cfg(unix)]
857        let (destination_permissions, destination_ownership) = {
858            use std::os::unix::fs::MetadataExt;
859
860            let parent = self.destination.parent().ok_or_else(|| {
861                format!(
862                    "output destination has no parent directory: {}",
863                    self.display_destination.display()
864                )
865            })?;
866            // Revalidate immediately before publishing so an ACL introduced
867            // while encoding cannot expose or replace the staged pathname.
868            validate_unix_staging_path(parent, &self.display_destination)?;
869            validate_unix_stage_file(&self.temporary, &self.display_destination)?;
870
871            if mode == CommitMode::Replace {
872                match std::fs::symlink_metadata(&self.destination) {
873                    Ok(metadata) if metadata.file_type().is_file() => {
874                        let has_acl =
875                            unix_path_has_extended_acl(&self.destination).map_err(|error| {
876                                format!(
877                                    "failed to inspect existing output ACL for {}: {error}",
878                                    self.display_destination.display()
879                                )
880                            })?;
881                        if has_acl {
882                            return Err(format!(
883                                "refusing to replace ACL-protected output {} because its extended ACL cannot be preserved safely",
884                                self.display_destination.display()
885                            ));
886                        }
887                        (
888                            metadata.permissions(),
889                            Some((metadata.uid(), metadata.gid())),
890                        )
891                    }
892                    Ok(metadata) if metadata.file_type().is_symlink() => {
893                        (self.new_destination_permissions.clone(), None)
894                    }
895                    Ok(_) => {
896                        return Err(format!(
897                            "refusing to replace output {} because the destination is a directory or special file",
898                            self.display_destination.display()
899                        ));
900                    }
901                    Err(error) if error.kind() == io::ErrorKind::NotFound => {
902                        (self.new_destination_permissions.clone(), None)
903                    }
904                    Err(error) => {
905                        return Err(format!(
906                            "failed to inspect permissions for {}: {error}",
907                            self.display_destination.display()
908                        ));
909                    }
910                }
911            } else {
912                (self.new_destination_permissions.clone(), None)
913            }
914        };
915
916        #[cfg(unix)]
917        if let Some((uid, gid)) = destination_ownership {
918            if uid != unsafe { libc::geteuid() } {
919                return Err(format!(
920                    "refusing to replace output {} because it is owned by a different Unix user",
921                    self.display_destination.display()
922                ));
923            }
924            preserve_unix_group(self.temporary.as_file(), gid).map_err(|error| {
925                format!(
926                    "failed to preserve output group for {}: {error}",
927                    self.display_destination.display()
928                )
929            })?;
930        }
931
932        #[cfg(windows)]
933        {
934            return self.commit_windows(mode);
935        }
936
937        #[cfg(not(windows))]
938        {
939            #[cfg(unix)]
940            return self.commit_with_tempfile(mode, destination_permissions);
941            #[cfg(not(unix))]
942            return self.commit_with_tempfile(mode);
943        }
944    }
945
946    #[cfg(unix)]
947    fn commit_with_tempfile(
948        self,
949        mode: CommitMode,
950        destination_permissions: std::fs::Permissions,
951    ) -> Result<(), String> {
952        let destination = self.destination;
953        let result = match mode {
954            CommitMode::Replace => self.temporary.persist(&destination),
955            CommitMode::NoClobber => self.temporary.persist_noclobber(&destination),
956        };
957
958        match result {
959            Ok(file) => {
960                // Publish while the inode is still 0600, then restore the
961                // destination mode through the held handle. A chmod failure
962                // leaves the committed output safely owner-only.
963                let _ = file.set_permissions(destination_permissions);
964                Ok(())
965            }
966            Err(error)
967                if mode == CommitMode::NoClobber
968                    && error.error.kind() == io::ErrorKind::AlreadyExists =>
969            {
970                Err(format!(
971                    "output already exists: {} (use --force to replace it)",
972                    self.display_destination.display()
973                ))
974            }
975            Err(error) => Err(format!(
976                "failed to commit output {}: {}",
977                self.display_destination.display(),
978                error.error
979            )),
980        }
981    }
982
983    #[cfg(all(not(unix), not(windows)))]
984    fn commit_with_tempfile(self, mode: CommitMode) -> Result<(), String> {
985        let destination = self.destination;
986        let result = match mode {
987            CommitMode::Replace => self.temporary.persist(&destination),
988            CommitMode::NoClobber => self.temporary.persist_noclobber(&destination),
989        };
990
991        match result {
992            Ok(file) => {
993                drop(file);
994                Ok(())
995            }
996            Err(error)
997                if mode == CommitMode::NoClobber
998                    && error.error.kind() == io::ErrorKind::AlreadyExists =>
999            {
1000                Err(format!(
1001                    "output already exists: {} (use --force to replace it)",
1002                    self.display_destination.display()
1003                ))
1004            }
1005            Err(error) => Err(format!(
1006                "failed to commit output {}: {}",
1007                self.display_destination.display(),
1008                error.error
1009            )),
1010        }
1011    }
1012
1013    #[cfg(windows)]
1014    fn commit_windows(mut self, mode: CommitMode) -> Result<(), String> {
1015        if mode == CommitMode::Replace {
1016            let existing_dacl = match std::fs::symlink_metadata(&self.destination) {
1017                Ok(metadata) if windows_metadata_is_reparse_point(&metadata) => None,
1018                Ok(metadata) if metadata.file_type().is_file() => {
1019                    let destination = windows_security::open_for_security(&self.destination)
1020                        .map_err(|error| {
1021                            format!(
1022                                "failed to open output security for {}: {error}",
1023                                self.display_destination.display()
1024                            )
1025                        })?;
1026                    Some(
1027                        windows_security::DaclSnapshot::capture(&destination).map_err(|error| {
1028                            format!(
1029                                "failed to inspect output security for {}: {error}",
1030                                self.display_destination.display()
1031                            )
1032                        })?,
1033                    )
1034                }
1035                Ok(_) => {
1036                    return Err(format!(
1037                        "refusing to replace output {} because the destination is a directory or special file",
1038                        self.display_destination.display()
1039                    ));
1040                }
1041                Err(error) if error.kind() == io::ErrorKind::NotFound => None,
1042                Err(error) => {
1043                    return Err(format!(
1044                        "failed to inspect output security for {}: {error}",
1045                        self.display_destination.display()
1046                    ));
1047                }
1048            };
1049            existing_dacl
1050                .as_ref()
1051                .unwrap_or(&self.new_destination_dacl)
1052                .apply(self.temporary.as_file())
1053                .map_err(|error| {
1054                    format!(
1055                        "failed to preserve output security for {}: {error}",
1056                        self.display_destination.display()
1057                    )
1058                })?;
1059        }
1060
1061        // The handle-based rename below consumes the old name. Disable the
1062        // path cleanup first so it cannot remove a file racing to reuse that
1063        // now-vacant randomized name after a successful rename.
1064        self.temporary.disable_cleanup(true);
1065        if let Err(error) = windows_security::rename(
1066            self.temporary.as_file(),
1067            &self.destination,
1068            mode == CommitMode::Replace,
1069        ) {
1070            if windows_security::delete_on_close(self.temporary.as_file()).is_err() {
1071                if mode == CommitMode::Replace {
1072                    // Restore an owner-accessible DACL before falling back to
1073                    // NamedTempFile's path-based cleanup.
1074                    let _ = self.private_stage_dacl.apply(self.temporary.as_file());
1075                }
1076                self.temporary.disable_cleanup(false);
1077            }
1078            if mode == CommitMode::NoClobber && error.kind() == io::ErrorKind::AlreadyExists {
1079                return Err(format!(
1080                    "output already exists: {} (use --force to replace it)",
1081                    self.display_destination.display()
1082                ));
1083            }
1084            return Err(format!(
1085                "failed to commit output {}: {error}",
1086                self.display_destination.display()
1087            ));
1088        }
1089
1090        if mode == CommitMode::NoClobber {
1091            // A failure leaves the newly-published output protected instead
1092            // of reporting an error after the atomic commit already happened.
1093            let _ = self.new_destination_dacl.apply(self.temporary.as_file());
1094        }
1095        Ok(())
1096    }
1097
1098    #[cfg(test)]
1099    pub(crate) fn temporary_path(&self) -> &Path {
1100        self.temporary.path()
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::{AtomicOutput, CommitMode};
1107    use std::collections::HashSet;
1108    use std::fs;
1109    use std::io::Write;
1110    use std::sync::{Arc, Barrier};
1111    use std::thread;
1112
1113    #[test]
1114    fn no_clobber_rejects_destination_created_before_commit() {
1115        let directory = tempfile::tempdir().unwrap();
1116        let destination = directory.path().join("output.wav");
1117        let mut output = AtomicOutput::new(&destination).unwrap();
1118        let temporary = output.temporary_path().to_path_buf();
1119        output.file_mut().write_all(b"candidate").unwrap();
1120
1121        fs::write(&destination, b"racer").unwrap();
1122
1123        let error = output.commit(CommitMode::NoClobber).unwrap_err();
1124        assert_eq!(
1125            error,
1126            format!(
1127                "output already exists: {} (use --force to replace it)",
1128                destination.display()
1129            )
1130        );
1131        assert_eq!(fs::read(&destination).unwrap(), b"racer");
1132        assert!(!temporary.exists());
1133    }
1134
1135    #[test]
1136    fn simultaneous_no_clobber_has_exactly_one_winner() {
1137        const WRITERS: usize = 8;
1138
1139        let directory = tempfile::tempdir().unwrap();
1140        let destination = directory.path().join("output.wav");
1141        let barrier = Arc::new(Barrier::new(WRITERS));
1142        let expected_error = format!(
1143            "output already exists: {} (use --force to replace it)",
1144            destination.display()
1145        );
1146
1147        let handles: Vec<_> = (0..WRITERS)
1148            .map(|writer| {
1149                let barrier = Arc::clone(&barrier);
1150                let destination = destination.clone();
1151                thread::spawn(move || {
1152                    let contents = format!("writer-{writer}").into_bytes();
1153                    let mut output = AtomicOutput::new(&destination).unwrap();
1154                    output.file_mut().write_all(&contents).unwrap();
1155                    barrier.wait();
1156                    (contents, output.commit(CommitMode::NoClobber))
1157                })
1158            })
1159            .collect();
1160
1161        let results: Vec<_> = handles
1162            .into_iter()
1163            .map(|handle| handle.join().unwrap())
1164            .collect();
1165        let winners: Vec<_> = results
1166            .iter()
1167            .filter(|(_, result)| result.is_ok())
1168            .collect();
1169
1170        assert_eq!(winners.len(), 1);
1171        assert_eq!(fs::read(&destination).unwrap(), winners[0].0);
1172        for (_, result) in results.iter().filter(|(_, result)| result.is_err()) {
1173            assert_eq!(result.as_ref().unwrap_err(), &expected_error);
1174        }
1175    }
1176
1177    #[test]
1178    fn replace_overwrites_existing_destination() {
1179        let directory = tempfile::tempdir().unwrap();
1180        let destination = directory.path().join("output.wav");
1181        fs::write(&destination, b"old").unwrap();
1182
1183        let mut output = AtomicOutput::new(&destination).unwrap();
1184        output.file_mut().write_all(b"new").unwrap();
1185        output.commit(CommitMode::Replace).unwrap();
1186
1187        assert_eq!(fs::read(destination).unwrap(), b"new");
1188    }
1189
1190    #[cfg(unix)]
1191    #[test]
1192    fn replace_preserves_existing_file_permissions() {
1193        use std::os::unix::fs::PermissionsExt;
1194
1195        let directory = tempfile::tempdir().unwrap();
1196        let destination = directory.path().join("output.wav");
1197        fs::write(&destination, b"old").unwrap();
1198        fs::set_permissions(&destination, fs::Permissions::from_mode(0o640)).unwrap();
1199
1200        let mut output = AtomicOutput::new(&destination).unwrap();
1201        output.file_mut().write_all(b"new").unwrap();
1202        output.commit(CommitMode::Replace).unwrap();
1203
1204        assert_eq!(
1205            fs::metadata(destination).unwrap().permissions().mode() & 0o777,
1206            0o640
1207        );
1208    }
1209
1210    #[cfg(unix)]
1211    #[test]
1212    fn replace_preserves_existing_file_group() {
1213        use std::os::fd::AsRawFd;
1214        use std::os::unix::fs::MetadataExt;
1215        use std::ptr::null_mut;
1216
1217        let effective_gid = unsafe { libc::getegid() };
1218        let group_count = unsafe { libc::getgroups(0, null_mut()) };
1219        assert!(group_count >= 0);
1220        let mut groups = vec![0 as libc::gid_t; group_count as usize];
1221        if group_count > 0 {
1222            assert_eq!(
1223                unsafe { libc::getgroups(group_count, groups.as_mut_ptr()) },
1224                group_count
1225            );
1226        }
1227        let alternate_gid = groups
1228            .into_iter()
1229            .find(|group| *group != effective_gid)
1230            .or_else(|| {
1231                (unsafe { libc::geteuid() } == 0).then_some(if effective_gid == 1 { 2 } else { 1 })
1232            });
1233        let Some(alternate_gid) = alternate_gid else {
1234            // Some minimal non-root environments have no second permitted
1235            // group. The ownership path is still compiled and exercised by
1236            // every replacement; only this differing-group assertion skips.
1237            return;
1238        };
1239
1240        let directory = tempfile::tempdir().unwrap();
1241        let destination = directory.path().join("output.wav");
1242        let existing = fs::File::create(&destination).unwrap();
1243        assert_eq!(
1244            unsafe { libc::fchown(existing.as_raw_fd(), libc::uid_t::MAX, alternate_gid,) },
1245            0,
1246            "failed to prepare alternate test group: {}",
1247            std::io::Error::last_os_error()
1248        );
1249        drop(existing);
1250
1251        let mut output = AtomicOutput::new(&destination).unwrap();
1252        output.file_mut().write_all(b"new").unwrap();
1253        output.commit(CommitMode::Replace).unwrap();
1254
1255        assert_eq!(fs::metadata(&destination).unwrap().gid(), alternate_gid);
1256        assert_eq!(fs::read(&destination).unwrap(), b"new");
1257    }
1258
1259    #[cfg(unix)]
1260    #[test]
1261    fn replace_rejects_existing_file_owned_by_another_user() {
1262        use std::os::fd::AsRawFd;
1263
1264        if unsafe { libc::geteuid() } != 0 {
1265            return;
1266        }
1267        let directory = tempfile::tempdir().unwrap();
1268        let destination = directory.path().join("output.wav");
1269        let mut existing = fs::File::create(&destination).unwrap();
1270        existing.write_all(b"old").unwrap();
1271        let other_uid = if unsafe { libc::geteuid() } == 1 {
1272            2
1273        } else {
1274            1
1275        };
1276        assert_eq!(
1277            unsafe { libc::fchown(existing.as_raw_fd(), other_uid, libc::gid_t::MAX,) },
1278            0
1279        );
1280        drop(existing);
1281
1282        let mut output = AtomicOutput::new(&destination).unwrap();
1283        let temporary = output.temporary_path().to_path_buf();
1284        output.file_mut().write_all(b"new").unwrap();
1285
1286        let error = output.commit(CommitMode::Replace).unwrap_err();
1287
1288        assert!(error.contains("different Unix user"));
1289        assert_eq!(fs::read(&destination).unwrap(), b"old");
1290        assert!(!temporary.exists());
1291    }
1292
1293    #[cfg(windows)]
1294    #[test]
1295    fn replace_preserves_existing_windows_dacl() {
1296        use super::windows_security::{self, DaclSnapshot};
1297
1298        let directory = tempfile::tempdir().unwrap();
1299        let destination = directory.path().join("output.wav");
1300        let mut existing = windows_security::create_private(&destination).unwrap();
1301        existing.write_all(b"old").unwrap();
1302        let expected = DaclSnapshot::capture(&existing)
1303            .unwrap()
1304            .identity()
1305            .unwrap();
1306        drop(existing);
1307
1308        let mut output = AtomicOutput::new(&destination).unwrap();
1309        output.file_mut().write_all(b"new").unwrap();
1310        output.commit(CommitMode::Replace).unwrap();
1311
1312        let committed = windows_security::open_for_security(&destination).unwrap();
1313        let actual = DaclSnapshot::capture(&committed)
1314            .unwrap()
1315            .identity()
1316            .unwrap();
1317        assert_eq!(actual, expected);
1318        assert_eq!(fs::read(destination).unwrap(), b"new");
1319    }
1320
1321    #[test]
1322    fn replace_rejects_directory_created_before_commit_and_cleans_up_temporary_file() {
1323        let directory = tempfile::tempdir().unwrap();
1324        let destination = directory.path().join("existing-directory");
1325        let mut output = AtomicOutput::new(&destination).unwrap();
1326        let temporary = output.temporary_path().to_path_buf();
1327        output.file_mut().write_all(b"candidate").unwrap();
1328        fs::create_dir(&destination).unwrap();
1329
1330        let error = output.commit(CommitMode::Replace).unwrap_err();
1331        assert!(error.contains(&destination.display().to_string()));
1332        assert!(error.contains("directory or special file"));
1333        assert!(destination.is_dir());
1334        assert!(!temporary.exists());
1335    }
1336
1337    #[cfg(unix)]
1338    #[test]
1339    fn replace_rejects_socket_created_before_commit_and_preserves_it() {
1340        use std::os::unix::fs::FileTypeExt;
1341        use std::os::unix::net::UnixListener;
1342
1343        let directory = tempfile::tempdir().unwrap();
1344        let destination = directory.path().join("output.wav");
1345        let mut output = AtomicOutput::new(&destination).unwrap();
1346        let temporary = output.temporary_path().to_path_buf();
1347        output.file_mut().write_all(b"candidate").unwrap();
1348        let listener = UnixListener::bind(&destination).unwrap();
1349
1350        let error = output.commit(CommitMode::Replace).unwrap_err();
1351
1352        assert!(error.contains("directory or special file"));
1353        assert!(fs::symlink_metadata(&destination)
1354            .unwrap()
1355            .file_type()
1356            .is_socket());
1357        assert_eq!(
1358            listener.local_addr().unwrap().as_pathname(),
1359            Some(destination.as_path())
1360        );
1361        assert!(!temporary.exists());
1362    }
1363
1364    #[cfg(unix)]
1365    #[test]
1366    fn no_clobber_rejects_dangling_symlink() {
1367        use std::os::unix::fs::symlink;
1368
1369        let directory = tempfile::tempdir().unwrap();
1370        let missing = directory.path().join("missing-target");
1371        let destination = directory.path().join("output.wav");
1372        symlink(&missing, &destination).unwrap();
1373
1374        let mut output = AtomicOutput::new(&destination).unwrap();
1375        let temporary = output.temporary_path().to_path_buf();
1376        output.file_mut().write_all(b"candidate").unwrap();
1377
1378        let error = output.commit(CommitMode::NoClobber).unwrap_err();
1379        assert_eq!(
1380            error,
1381            format!(
1382                "output already exists: {} (use --force to replace it)",
1383                destination.display()
1384            )
1385        );
1386        assert_eq!(fs::read_link(&destination).unwrap(), missing);
1387        assert!(!temporary.exists());
1388    }
1389
1390    #[cfg(unix)]
1391    #[test]
1392    fn replace_replaces_symlink_entry_without_touching_target() {
1393        use std::os::unix::fs::symlink;
1394
1395        let directory = tempfile::tempdir().unwrap();
1396        let victim = directory.path().join("victim.wav");
1397        let destination = directory.path().join("output.wav");
1398        fs::write(&victim, b"victim").unwrap();
1399        let mut output = AtomicOutput::new(&destination).unwrap();
1400        output.file_mut().write_all(b"replacement").unwrap();
1401        symlink(&victim, &destination).unwrap();
1402        output.commit(CommitMode::Replace).unwrap();
1403
1404        assert_eq!(fs::read(&victim).unwrap(), b"victim");
1405        assert_eq!(fs::read(&destination).unwrap(), b"replacement");
1406        assert!(!fs::symlink_metadata(destination)
1407            .unwrap()
1408            .file_type()
1409            .is_symlink());
1410    }
1411
1412    #[test]
1413    fn stage_names_are_unique_and_have_the_expected_shape() {
1414        const STAGES: usize = 32;
1415
1416        let directory = tempfile::tempdir().unwrap();
1417        let destination = directory.path().join("output.wav");
1418        let outputs: Vec<_> = (0..STAGES)
1419            .map(|_| AtomicOutput::new(&destination).unwrap())
1420            .collect();
1421        let mut paths = HashSet::new();
1422        let expected_parent = fs::canonicalize(directory.path()).unwrap();
1423
1424        for output in &outputs {
1425            let path = output.temporary_path();
1426            assert_eq!(path.parent(), Some(expected_parent.as_path()));
1427            let name = path.file_name().unwrap().to_str().unwrap();
1428            assert!(name.starts_with(".denoize-"));
1429            assert!(name.ends_with(".part"));
1430            assert_eq!(name.len(), ".denoize-".len() + 16 + ".part".len());
1431            assert!(paths.insert(path.to_path_buf()));
1432        }
1433
1434        let staged_paths: Vec<_> = paths.into_iter().collect();
1435        drop(outputs);
1436        assert!(staged_paths.iter().all(|path| !path.exists()));
1437    }
1438
1439    #[cfg(unix)]
1440    #[test]
1441    fn staged_output_is_private_until_commit() {
1442        use std::os::unix::fs::PermissionsExt;
1443
1444        let directory = tempfile::tempdir().unwrap();
1445        let destination = directory.path().join("output.wav");
1446        fs::write(&destination, b"old").unwrap();
1447        fs::set_permissions(&destination, fs::Permissions::from_mode(0o644)).unwrap();
1448
1449        let output = AtomicOutput::new(&destination).unwrap();
1450        let stage_mode = output
1451            .temporary
1452            .as_file()
1453            .metadata()
1454            .unwrap()
1455            .permissions()
1456            .mode()
1457            & 0o777;
1458
1459        assert_eq!(stage_mode & 0o077, 0);
1460    }
1461
1462    #[cfg(unix)]
1463    #[test]
1464    fn rejects_non_sticky_shared_writable_ancestor() {
1465        use std::os::unix::fs::PermissionsExt;
1466
1467        let directory = tempfile::tempdir().unwrap();
1468        let shared = directory.path().join("shared");
1469        let private = shared.join("private");
1470        fs::create_dir(&shared).unwrap();
1471        fs::set_permissions(&shared, fs::Permissions::from_mode(0o777)).unwrap();
1472        fs::create_dir(&private).unwrap();
1473        fs::set_permissions(&private, fs::Permissions::from_mode(0o700)).unwrap();
1474        let destination = private.join("output.wav");
1475
1476        let error = AtomicOutput::new(&destination).err().unwrap();
1477
1478        assert!(error.contains("insecure directory"));
1479        assert!(error.contains(&shared.display().to_string()));
1480        assert!(fs::read_dir(&private).unwrap().next().is_none());
1481    }
1482
1483    #[cfg(unix)]
1484    #[test]
1485    fn accepts_sticky_shared_writable_ancestor() {
1486        use std::os::unix::fs::PermissionsExt;
1487
1488        let directory = tempfile::tempdir().unwrap();
1489        let shared = directory.path().join("shared");
1490        fs::create_dir(&shared).unwrap();
1491        fs::set_permissions(&shared, fs::Permissions::from_mode(0o1777)).unwrap();
1492        let destination = shared.join("output.wav");
1493
1494        let output = AtomicOutput::new(&destination).unwrap();
1495        let canonical_shared = fs::canonicalize(&shared).unwrap();
1496
1497        assert_eq!(
1498            output.temporary_path().parent(),
1499            Some(canonical_shared.as_path())
1500        );
1501    }
1502
1503    #[cfg(target_os = "linux")]
1504    fn set_linux_posix_acl(path: &std::path::Path, xattr_name: &[u8]) {
1505        use std::ffi::CString;
1506        use std::os::unix::ffi::OsStrExt;
1507
1508        fn push_entry(bytes: &mut Vec<u8>, tag: u16, permissions: u16, id: u32) {
1509            bytes.extend_from_slice(&tag.to_le_bytes());
1510            bytes.extend_from_slice(&permissions.to_le_bytes());
1511            bytes.extend_from_slice(&id.to_le_bytes());
1512        }
1513
1514        // Linux's stable POSIX ACL xattr ABI: version 2 followed by owner,
1515        // named-user, group, mask, and other entries. The named principal has
1516        // read/search but no write, so the mode check alone would accept it.
1517        let mut acl = 2u32.to_le_bytes().to_vec();
1518        push_entry(&mut acl, 0x01, 0x07, u32::MAX); // ACL_USER_OBJ
1519        let effective_uid = unsafe { libc::geteuid() };
1520        let other_uid = if effective_uid == u32::MAX {
1521            effective_uid - 1
1522        } else {
1523            effective_uid + 1
1524        };
1525        push_entry(&mut acl, 0x02, 0x05, other_uid); // ACL_USER
1526        push_entry(&mut acl, 0x04, 0x00, u32::MAX); // ACL_GROUP_OBJ
1527        push_entry(&mut acl, 0x10, 0x05, u32::MAX); // ACL_MASK
1528        push_entry(&mut acl, 0x20, 0x00, u32::MAX); // ACL_OTHER
1529
1530        let path = CString::new(path.as_os_str().as_bytes()).unwrap();
1531        let result = unsafe {
1532            libc::setxattr(
1533                path.as_ptr(),
1534                xattr_name.as_ptr().cast(),
1535                acl.as_ptr().cast(),
1536                acl.len(),
1537                0,
1538            )
1539        };
1540        assert_eq!(
1541            result,
1542            0,
1543            "failed to install test ACL: {}",
1544            std::io::Error::last_os_error()
1545        );
1546    }
1547
1548    #[cfg(target_os = "linux")]
1549    #[test]
1550    fn rejects_linux_access_acl_ancestor() {
1551        let directory = tempfile::tempdir().unwrap();
1552        let guarded = directory.path().join("guarded");
1553        fs::create_dir(&guarded).unwrap();
1554        set_linux_posix_acl(&guarded, b"system.posix_acl_access\0");
1555        let destination = guarded.join("output.wav");
1556
1557        let error = AtomicOutput::new(&destination).err().unwrap();
1558
1559        assert!(error.contains("extended ACLs"));
1560        assert!(error.contains(&guarded.display().to_string()));
1561        assert!(fs::read_dir(&guarded).unwrap().next().is_none());
1562    }
1563
1564    #[cfg(target_os = "linux")]
1565    #[test]
1566    fn rejects_linux_default_acl_ancestor() {
1567        let directory = tempfile::tempdir().unwrap();
1568        let guarded = directory.path().join("guarded");
1569        fs::create_dir(&guarded).unwrap();
1570        set_linux_posix_acl(&guarded, b"system.posix_acl_default\0");
1571        let destination = guarded.join("output.wav");
1572
1573        let error = AtomicOutput::new(&destination).err().unwrap();
1574
1575        assert!(error.contains("extended ACLs"));
1576        assert!(error.contains(&guarded.display().to_string()));
1577        assert!(fs::read_dir(&guarded).unwrap().next().is_none());
1578    }
1579
1580    #[cfg(target_os = "linux")]
1581    #[test]
1582    fn replace_rejects_existing_linux_acl_without_changing_output() {
1583        let directory = tempfile::tempdir().unwrap();
1584        let destination = directory.path().join("output.wav");
1585        fs::write(&destination, b"old").unwrap();
1586        set_linux_posix_acl(&destination, b"system.posix_acl_access\0");
1587        let mut output = AtomicOutput::new(&destination).unwrap();
1588        let temporary = output.temporary_path().to_path_buf();
1589        output.file_mut().write_all(b"new").unwrap();
1590
1591        let error = output.commit(CommitMode::Replace).unwrap_err();
1592
1593        assert!(error.contains("ACL-protected output"));
1594        assert_eq!(fs::read(&destination).unwrap(), b"old");
1595        assert!(!temporary.exists());
1596    }
1597
1598    #[cfg(target_os = "macos")]
1599    #[test]
1600    fn macos_deny_only_acl_is_safe_but_allow_acl_is_rejected() {
1601        use super::macos_acl_entries_are_unsafe;
1602        use exacl::{AclEntry, Perm};
1603
1604        let uid = unsafe { libc::geteuid() }.to_string();
1605        let deny = AclEntry::deny_user(&uid, Perm::DELETE, None);
1606        let allow = AclEntry::allow_user(&uid, Perm::READ, None);
1607
1608        assert!(!macos_acl_entries_are_unsafe(&[deny]));
1609        assert!(macos_acl_entries_are_unsafe(&[allow]));
1610    }
1611
1612    #[cfg(target_os = "macos")]
1613    #[test]
1614    fn rejects_macos_allow_acl_ancestor() {
1615        use exacl::{AclEntry, Perm};
1616
1617        let directory = tempfile::tempdir().unwrap();
1618        let guarded = directory.path().join("guarded");
1619        fs::create_dir(&guarded).unwrap();
1620        let uid = unsafe { libc::geteuid() }.to_string();
1621        exacl::setfacl(
1622            &[guarded.as_path()],
1623            &[AclEntry::allow_user(&uid, Perm::READ, None)],
1624            None,
1625        )
1626        .unwrap();
1627        let destination = guarded.join("output.wav");
1628
1629        let error = AtomicOutput::new(&destination).err().unwrap();
1630
1631        assert!(error.contains("extended ACLs"));
1632        assert!(error.contains(&guarded.display().to_string()));
1633        assert!(fs::read_dir(&guarded).unwrap().next().is_none());
1634    }
1635
1636    #[cfg(target_os = "macos")]
1637    #[test]
1638    fn replace_rejects_existing_macos_acl_without_changing_output() {
1639        use exacl::{AclEntry, Perm};
1640
1641        let directory = tempfile::tempdir().unwrap();
1642        let destination = directory.path().join("output.wav");
1643        fs::write(&destination, b"old").unwrap();
1644        let uid = unsafe { libc::geteuid() }.to_string();
1645        exacl::setfacl(
1646            &[destination.as_path()],
1647            &[AclEntry::allow_user(&uid, Perm::READ, None)],
1648            None,
1649        )
1650        .unwrap();
1651        let mut output = AtomicOutput::new(&destination).unwrap();
1652        let temporary = output.temporary_path().to_path_buf();
1653        output.file_mut().write_all(b"new").unwrap();
1654
1655        let error = output.commit(CommitMode::Replace).unwrap_err();
1656
1657        assert!(error.contains("ACL-protected output"));
1658        assert_eq!(fs::read(&destination).unwrap(), b"old");
1659        assert!(!temporary.exists());
1660    }
1661
1662    #[cfg(windows)]
1663    #[test]
1664    fn staged_output_has_a_protected_windows_dacl() {
1665        use super::windows_security::DaclSnapshot;
1666
1667        let directory = tempfile::tempdir().unwrap();
1668        let destination = directory.path().join("output.wav");
1669        let output = AtomicOutput::new(&destination).unwrap();
1670        let (protected, _) = DaclSnapshot::capture(output.temporary.as_file())
1671            .unwrap()
1672            .identity()
1673            .unwrap();
1674
1675        assert!(protected);
1676    }
1677
1678    #[cfg(unix)]
1679    #[test]
1680    fn new_output_uses_normal_creation_permissions() {
1681        use std::os::unix::fs::PermissionsExt;
1682
1683        let directory = tempfile::tempdir().unwrap();
1684        let reference = directory.path().join("reference.wav");
1685        fs::File::create(&reference).unwrap();
1686        let expected_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777;
1687        let destination = directory.path().join("output.wav");
1688
1689        let mut output = AtomicOutput::new(&destination).unwrap();
1690        output.file_mut().write_all(b"new").unwrap();
1691        output.commit(CommitMode::NoClobber).unwrap();
1692
1693        assert_eq!(
1694            fs::metadata(destination).unwrap().permissions().mode() & 0o777,
1695            expected_mode
1696        );
1697    }
1698
1699    #[cfg(windows)]
1700    #[test]
1701    fn new_output_uses_normal_windows_dacl() {
1702        use super::windows_security::{self, DaclSnapshot};
1703
1704        let directory = tempfile::tempdir().unwrap();
1705        let reference = directory.path().join("reference.wav");
1706        let reference = fs::File::create(&reference).unwrap();
1707        let expected = DaclSnapshot::capture(&reference)
1708            .unwrap()
1709            .identity()
1710            .unwrap();
1711        let destination = directory.path().join("output.wav");
1712
1713        let mut output = AtomicOutput::new(&destination).unwrap();
1714        output.file_mut().write_all(b"new").unwrap();
1715        output.commit(CommitMode::NoClobber).unwrap();
1716
1717        let committed = windows_security::open_for_security(&destination).unwrap();
1718        let actual = DaclSnapshot::capture(&committed)
1719            .unwrap()
1720            .identity()
1721            .unwrap();
1722        assert_eq!(actual, expected);
1723    }
1724
1725    #[test]
1726    fn relative_destination_is_fixed_at_creation_time() {
1727        #[cfg(unix)]
1728        let (_directory, destination, expected) = {
1729            let directory = tempfile::tempdir().unwrap();
1730            let current = fs::canonicalize(".").unwrap();
1731            let parent = fs::canonicalize(directory.path()).unwrap();
1732            let mut destination = std::path::PathBuf::new();
1733            for component in current.components() {
1734                if matches!(component, std::path::Component::Normal(_)) {
1735                    destination.push("..");
1736                }
1737            }
1738            destination.push(parent.strip_prefix("/").unwrap());
1739            destination.push("relative-output.wav");
1740            let expected = parent.join("relative-output.wav");
1741            (directory, destination, expected)
1742        };
1743
1744        #[cfg(not(unix))]
1745        let (destination, expected) = {
1746            let destination = std::path::PathBuf::from("relative-output.wav");
1747            let expected = fs::canonicalize(".").unwrap().join(&destination);
1748            (destination, expected)
1749        };
1750
1751        assert!(!destination.is_absolute());
1752        let output = AtomicOutput::new(&destination).unwrap();
1753
1754        assert_eq!(output.destination, expected);
1755        assert!(output.destination.is_absolute());
1756    }
1757}