1use 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 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 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 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 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 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
693pub enum CommitMode {
694 Replace,
696 NoClobber,
698}
699
700pub 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 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 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 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 #[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 pub fn file_mut(&mut self) -> &mut File {
839 self.temporary.as_file_mut()
840 }
841
842 pub(crate) fn destination_path(&self) -> &Path {
844 &self.destination
845 }
846
847 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 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 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 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 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 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 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 let mut acl = 2u32.to_le_bytes().to_vec();
1518 push_entry(&mut acl, 0x01, 0x07, u32::MAX); 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); push_entry(&mut acl, 0x04, 0x00, u32::MAX); push_entry(&mut acl, 0x10, 0x05, u32::MAX); push_entry(&mut acl, 0x20, 0x00, u32::MAX); 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}