返回 CodeWhale
files.rs
根目录 / crates / tui / src / fleet / files.rs
1 //! Workspace-confined file operations shared by Fleet artifacts and its ledger.
2
3 use std::fs::File;
4 use std::io::{self, Write};
5 use std::path::{Component, Path};
6
7 pub(crate) fn path_is_confined(path: &Path) -> bool {
8 !path.as_os_str().is_empty()
9 && path.components().all(|component| match component {
10 Component::Normal(name) => !cfg!(windows) || !name.as_encoded_bytes().contains(&b':'),
11 _ => false,
12 })
13 }
14
15 /// Refuse `path` when any existing component below `root` is a link. The
16 /// walk stops at the first component that does not exist yet, so a path that
17 /// is about to be created through a real directory chain passes. This is a
18 /// lexical check made before the use; prefer [`WorkspaceFile`] where the
19 /// caller can open through it, and use this for paths a library call (a
20 /// directory listing, a removal) takes by name.
21 pub(crate) fn reject_linked_path(root: &Path, path: &Path) -> io::Result<()> {
22 let relative = path.strip_prefix(root).map_err(|_| {
23 io::Error::new(
24 io::ErrorKind::InvalidInput,
25 format!("{} must stay within {}", path.display(), root.display()),
26 )
27 })?;
28 let mut current = root.to_path_buf();
29 for component in relative.components() {
30 current.push(component);
31 match std::fs::symlink_metadata(&current) {
32 Ok(metadata) if crate::plugins::metadata_is_link_or_reparse(&metadata) => {
33 return Err(io::Error::new(
34 io::ErrorKind::InvalidInput,
35 format!("Refusing symlinked path {}", current.display()),
36 ));
37 }
38 Ok(_) => {}
39 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
40 Err(error) => return Err(error),
41 }
42 }
43 Ok(())
44 }
45
46 fn invalid_path() -> io::Error {
47 io::Error::new(
48 io::ErrorKind::InvalidInput,
49 "Fleet artifact path must stay within the workspace",
50 )
51 }
52
53 #[cfg(unix)]
54 #[derive(Debug)]
55 pub(crate) struct WorkspaceFile {
56 directory: File,
57 filename: std::ffi::CString,
58 /// A user's own workspace file rather than a private store entry: new
59 /// entries take the process umask and replacement keeps the existing
60 /// permission bits instead of narrowing them to owner-only.
61 shared: bool,
62 }
63
64 #[cfg(unix)]
65 impl WorkspaceFile {
66 /// Private store entry: created files are 0600, created parents 0700, and
67 /// every replacement is owner-only again.
68 pub(crate) fn open(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
69 Self::open_confined(workspace, relative, create, false, true)
70 }
71
72 /// A user's workspace file, edited in place: created entries follow the
73 /// umask and replacement preserves the file's permission bits (an
74 /// executable script stays executable). Confinement is unchanged.
75 pub(crate) fn open_shared(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
76 Self::open_confined(workspace, relative, create, true, true)
77 }
78
79 /// An already captured absolute root: reject later root/ancestor links.
80 pub(crate) fn open_delivery(workspace: &Path, relative: &Path) -> io::Result<Self> {
81 Self::open_confined(workspace, relative, false, false, false)
82 }
83
84 fn open_confined(
85 workspace: &Path,
86 relative: &Path,
87 create: bool,
88 shared: bool,
89 resolve_root: bool,
90 ) -> io::Result<Self> {
91 use std::os::fd::{AsRawFd, FromRawFd};
92 use std::os::unix::ffi::OsStrExt;
93 if !path_is_confined(relative) {
94 return Err(invalid_path());
95 }
96 let workspace = if resolve_root {
97 workspace.canonicalize()?
98 } else {
99 if !workspace.is_absolute() {
100 return Err(invalid_path());
101 }
102 workspace.to_path_buf()
103 };
104 // Use the established credential/artifact openat pattern, without
105 // touching credentials or creating a second filesystem store.
106 // SAFETY: static path and immediate ownership of a successful fd.
107 let fd = unsafe {
108 libc::open(
109 c"/".as_ptr(),
110 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
111 )
112 };
113 if fd < 0 {
114 return Err(io::Error::last_os_error());
115 }
116 // SAFETY: this fd was just created and has no other owner.
117 let mut directory = unsafe { File::from_raw_fd(fd) };
118 let parents = relative.parent().ok_or_else(invalid_path)?;
119 for (path, may_create) in [(workspace.as_path(), false), (parents, create)] {
120 for component in path.components() {
121 let Component::Normal(name) = component else {
122 if component == Component::RootDir {
123 continue;
124 }
125 return Err(invalid_path());
126 };
127 let name = std::ffi::CString::new(name.as_bytes())?;
128 let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
129 // SAFETY: directory pins the parent; name is one component.
130 let mut fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
131 if fd < 0
132 && may_create
133 && io::Error::last_os_error().kind() == io::ErrorKind::NotFound
134 {
135 // SAFETY: directory and relative basename remain valid.
136 let mode = if shared { 0o777 } else { 0o700 };
137 if unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), mode) } != 0
138 && io::Error::last_os_error().kind() != io::ErrorKind::AlreadyExists
139 {
140 return Err(io::Error::last_os_error());
141 }
142 // SAFETY: reject a symlink inserted after mkdirat.
143 fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
144 }
145 if fd < 0 {
146 return Err(io::Error::last_os_error());
147 }
148 // SAFETY: fd is freshly owned.
149 directory = unsafe { File::from_raw_fd(fd) };
150 }
151 }
152 Ok(Self {
153 directory,
154 filename: std::ffi::CString::new(
155 relative.file_name().ok_or_else(invalid_path)?.as_bytes(),
156 )?,
157 shared,
158 })
159 }
160
161 fn create_mode(&self) -> libc::c_uint {
162 if self.shared { 0o666 } else { 0o600 }
163 }
164
165 pub(crate) fn sibling(&self, name: &str) -> io::Result<Self> {
166 if !path_is_confined(Path::new(name)) || Path::new(name).components().count() != 1 {
167 return Err(invalid_path());
168 }
169 Ok(Self {
170 directory: self.directory.try_clone()?,
171 filename: std::ffi::CString::new(name)?,
172 shared: self.shared,
173 })
174 }
175
176 pub(crate) fn open_update(&self, create: bool, append: bool) -> io::Result<File> {
177 self.open_with_flags(
178 libc::O_RDWR
179 | if create { libc::O_CREAT } else { 0 }
180 | if append { libc::O_APPEND } else { 0 },
181 )
182 }
183
184 pub(crate) fn open_file(&self) -> io::Result<File> {
185 self.open_with_flags(libc::O_RDONLY)
186 }
187
188 /// Reads a file another process may hold open for writing. Unix opens do
189 /// not exclude writers, so this is [`Self::open_file`].
190 pub(crate) fn open_file_shared(&self) -> io::Result<File> {
191 self.open_file()
192 }
193
194 pub(crate) fn open_write(&self, append: bool) -> io::Result<File> {
195 self.open_with_flags(
196 libc::O_WRONLY | libc::O_CREAT | if append { libc::O_APPEND } else { 0 },
197 )
198 }
199
200 fn open_with_flags(&self, flags: libc::c_int) -> io::Result<File> {
201 use std::os::fd::{AsRawFd, FromRawFd};
202 use std::os::unix::fs::MetadataExt;
203 // macOS can fail an `openat(O_CREAT)` with ENOENT while another thread
204 // is creating the same name through the same pinned directory, even
205 // though the parent exists. The loser of that race only has to ask
206 // again: the file is there by then. The retry is bounded and applies
207 // only when creating, so a vanished parent still fails.
208 let mut attempts = 0;
209 let fd = loop {
210 // SAFETY: a pinned parent and validated basename; never follows links.
211 let fd = unsafe {
212 libc::openat(
213 self.directory.as_raw_fd(),
214 self.filename.as_ptr(),
215 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
216 self.create_mode(),
217 )
218 };
219 if fd >= 0 {
220 break fd;
221 }
222 let error = io::Error::last_os_error();
223 attempts += 1;
224 if flags & libc::O_CREAT != 0 && error.kind() == io::ErrorKind::NotFound && attempts < 4
225 {
226 std::thread::yield_now();
227 continue;
228 }
229 return Err(error);
230 };
231 // SAFETY: fd is freshly owned.
232 let file = unsafe { File::from_raw_fd(fd) };
233 let metadata = file.metadata()?;
234 if !metadata.is_file() || metadata.nlink() != 1 {
235 return Err(io::Error::new(
236 io::ErrorKind::InvalidData,
237 "Fleet file must be a regular, non-hard-linked file",
238 ));
239 }
240 Ok(file)
241 }
242
243 pub(crate) fn publish(&self, bytes: &[u8]) -> io::Result<()> {
244 self.atomic_write(bytes, false)
245 }
246
247 pub(crate) fn replace(&self, bytes: &[u8]) -> io::Result<()> {
248 self.atomic_write(bytes, true)
249 }
250
251 fn atomic_write(&self, bytes: &[u8], replace: bool) -> io::Result<()> {
252 self.atomic_write_retained(bytes, replace, None).map(drop)
253 }
254
255 pub(crate) fn publish_retained(&self, bytes: &[u8], executable: bool) -> io::Result<File> {
256 self.atomic_write_retained(bytes, false, Some(if executable { 0o755 } else { 0o644 }))
257 }
258
259 fn atomic_write_retained(
260 &self,
261 bytes: &[u8],
262 replace: bool,
263 mode: Option<u32>,
264 ) -> io::Result<File> {
265 use std::os::fd::{AsRawFd, FromRawFd};
266 let temporary =
267 std::ffi::CString::new(format!(".fleet-write-{}.tmp", uuid::Uuid::new_v4()))
268 .expect("generated basename");
269 // SAFETY: parent is pinned; exclusive creation cannot follow a link.
270 let fd = unsafe {
271 libc::openat(
272 self.directory.as_raw_fd(),
273 temporary.as_ptr(),
274 libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
275 self.create_mode(),
276 )
277 };
278 if fd < 0 {
279 return Err(io::Error::last_os_error());
280 }
281 // SAFETY: fd is freshly owned.
282 let mut file = unsafe { File::from_raw_fd(fd) };
283 let result = (|| {
284 if self.shared && replace {
285 self.copy_permissions_to(&file)?;
286 }
287 if let Some(mode) = mode {
288 use std::os::unix::fs::PermissionsExt;
289 file.set_permissions(std::fs::Permissions::from_mode(mode))?;
290 }
291 file.write_all(bytes)?;
292 file.sync_all()?;
293 if !replace && rename_exclusive(self.directory.as_raw_fd(), &temporary, &self.filename)?
294 {
295 return Ok(true);
296 }
297 // SAFETY: both basenames are anchored to the same open parent.
298 // Replacement changes the directory entry, never a symlink target.
299 let published = unsafe {
300 if replace {
301 libc::renameat(
302 self.directory.as_raw_fd(),
303 temporary.as_ptr(),
304 self.directory.as_raw_fd(),
305 self.filename.as_ptr(),
306 )
307 } else {
308 libc::linkat(
309 self.directory.as_raw_fd(),
310 temporary.as_ptr(),
311 self.directory.as_raw_fd(),
312 self.filename.as_ptr(),
313 0,
314 )
315 }
316 };
317 if published != 0 {
318 return Err(io::Error::last_os_error());
319 }
320 Ok(replace)
321 })();
322 // Successful rename already consumed this temporary entry. Never
323 // unlink the vacant old name, which another writer could now reuse.
324 if !matches!(result, Ok(true)) {
325 // SAFETY: unlink this call's exclusive temporary basename.
326 if unsafe { libc::unlinkat(self.directory.as_raw_fd(), temporary.as_ptr(), 0) } != 0 {
327 return Err(io::Error::last_os_error());
328 }
329 }
330 result?;
331 self.directory.sync_all()?;
332 Ok(file)
333 }
334
335 pub(crate) fn open_retained(&self) -> io::Result<File> {
336 self.open_file()
337 }
338
339 pub(crate) fn identity_probe(&self) -> io::Result<File> {
340 self.open_file()
341 }
342
343 pub(crate) fn move_opened_to_sibling(
344 &self,
345 _file: &File,
346 destination: &Self,
347 ) -> io::Result<()> {
348 use std::os::fd::AsRawFd;
349 if !same_file(&self.directory, &destination.directory)? {
350 return Err(invalid_path());
351 }
352 // Retirement must be one no-clobber rename. The link/unlink fallback
353 // used by immutable publication is not a conditional move of an old entry.
354 if !rename_exclusive(
355 self.directory.as_raw_fd(),
356 &self.filename,
357 &destination.filename,
358 )? {
359 return Err(io::Error::new(
360 io::ErrorKind::Unsupported,
361 "exclusive retirement is unavailable",
362 ));
363 }
364 Ok(())
365 }
366
367 pub(crate) fn sync_parent(&self) -> io::Result<()> {
368 self.directory.sync_all()
369 }
370 }
371
372 #[cfg(unix)]
373 impl WorkspaceFile {
374 /// Give the replacement the permission bits of the regular file it
375 /// replaces. Set-id bits are never carried over; an absent target keeps
376 /// the umask-derived creation mode.
377 fn copy_permissions_to(&self, replacement: &File) -> io::Result<()> {
378 use std::os::fd::AsRawFd;
379 // SAFETY: zeroed plain-data struct, filled by fstatat on success.
380 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
381 // SAFETY: pinned parent, validated basename, and no link following.
382 let found = unsafe {
383 libc::fstatat(
384 self.directory.as_raw_fd(),
385 self.filename.as_ptr(),
386 &mut stat,
387 libc::AT_SYMLINK_NOFOLLOW,
388 )
389 };
390 if found != 0 {
391 let error = io::Error::last_os_error();
392 return if error.kind() == io::ErrorKind::NotFound {
393 Ok(())
394 } else {
395 Err(error)
396 };
397 }
398 if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG {
399 return Ok(());
400 }
401 // SAFETY: an owned, open descriptor; fchmod never follows a path.
402 if unsafe { libc::fchmod(replacement.as_raw_fd(), stat.st_mode & 0o777) } != 0 {
403 return Err(io::Error::last_os_error());
404 }
405 Ok(())
406 }
407 }
408
409 /// Publish `from` as `to` without replacing an existing entry, as the Windows
410 /// rename below does. `linkat` then `unlinkat` leaves the published file with
411 /// two links for a moment, and `open_with_flags` rejects a link count other
412 /// than 1, so a concurrent reader failed with InvalidData. `Ok(false)` sends
413 /// the caller back to `linkat`, which keeps that window: other Unixes, and any
414 /// kernel, filesystem or sandbox that refuses the exclusive rename.
415 #[cfg(target_os = "linux")]
416 fn rename_exclusive(
417 directory: libc::c_int,
418 from: &std::ffi::CStr,
419 to: &std::ffi::CStr,
420 ) -> io::Result<bool> {
421 // SAFETY: both basenames are anchored to the same open parent.
422 let renamed = unsafe {
423 libc::syscall(
424 libc::SYS_renameat2,
425 directory,
426 from.as_ptr(),
427 directory,
428 to.as_ptr(),
429 libc::RENAME_NOREPLACE,
430 )
431 };
432 exclusive_rename_outcome(renamed == 0)
433 }
434
435 #[cfg(target_vendor = "apple")]
436 fn rename_exclusive(
437 directory: libc::c_int,
438 from: &std::ffi::CStr,
439 to: &std::ffi::CStr,
440 ) -> io::Result<bool> {
441 // SAFETY: both basenames are anchored to the same open parent.
442 let renamed = unsafe {
443 libc::renameatx_np(
444 directory,
445 from.as_ptr(),
446 directory,
447 to.as_ptr(),
448 libc::RENAME_EXCL,
449 )
450 };
451 exclusive_rename_outcome(renamed == 0)
452 }
453
454 #[cfg(all(unix, not(any(target_os = "linux", target_vendor = "apple"))))]
455 fn rename_exclusive(_: libc::c_int, _: &std::ffi::CStr, _: &std::ffi::CStr) -> io::Result<bool> {
456 Ok(false)
457 }
458
459 /// Only an existing target is final. Any other refusal falls back to `linkat`,
460 /// so publication is never worse off than before the exclusive rename.
461 #[cfg(any(target_os = "linux", target_vendor = "apple"))]
462 fn exclusive_rename_outcome(renamed: bool) -> io::Result<bool> {
463 if renamed {
464 return Ok(true);
465 }
466 let error = io::Error::last_os_error();
467 if error.kind() == io::ErrorKind::AlreadyExists {
468 return Err(error);
469 }
470 Ok(false)
471 }
472
473 /// Windows path fence shared by Fleet publication and Native sandbox ACL
474 /// admission. Every original absolute ancestor is opened without delete/write
475 /// sharing and without following reparse points before path-based calls.
476 #[cfg(windows)]
477 #[derive(Clone, Debug)]
478 pub(crate) struct WindowsDirectory {
479 ancestors: Vec<std::sync::Arc<File>>,
480 directory: std::path::PathBuf,
481 }
482
483 #[cfg(windows)]
484 impl WindowsDirectory {
485 pub(crate) fn open(path: &Path) -> io::Result<Self> {
486 Self::open_inner(path, false, false)
487 }
488
489 fn open_inner(path: &Path, create: bool, acl_target: bool) -> io::Result<Self> {
490 if !path.is_absolute() {
491 return Err(invalid_path());
492 }
493 let mut directory = std::path::PathBuf::new();
494 let mut ancestors = Vec::new();
495 let count = path.components().count();
496 for (index, component) in path.components().enumerate() {
497 if matches!(component, Component::ParentDir | Component::CurDir) {
498 return Err(invalid_path());
499 }
500 directory.push(component.as_os_str());
501 if matches!(component, Component::Prefix(_)) {
502 continue;
503 }
504 let open = || Self::open_component(&directory, acl_target && index + 1 == count);
505 let file = match open() {
506 Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
507 match std::fs::create_dir(&directory) {
508 Ok(()) => {}
509 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
510 Err(error) => return Err(error),
511 }
512 open()?
513 }
514 result => result?,
515 };
516 ancestors.push(file);
517 }
518 Ok(Self {
519 ancestors,
520 directory,
521 })
522 }
523
524 fn open_component(path: &Path, acl_target: bool) -> io::Result<std::sync::Arc<File>> {
525 use std::os::windows::fs::OpenOptionsExt;
526 let mut options = std::fs::OpenOptions::new();
527 options.read(true).share_mode(1).custom_flags(0x0220_0000);
528 if acl_target {
529 // The Native ACL writer uses SetKernelObjectSecurity, which never
530 // propagates to descendants; each child is fenced and granted
531 // explicitly. So the target needs only READ_CONTROL|WRITE_DAC plus a
532 // read right that records share access: read-only sharing still
533 // blocks rename, delete and data writes while it is edited. Unlike
534 // MAXIMUM_ALLOWED, which includes DELETE, it does not collide with a
535 // live host whose current directory is this directory.
536 use windows_sys::Win32::Storage::FileSystem::{
537 FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, READ_CONTROL, WRITE_DAC,
538 };
539 options
540 .access_mode(READ_CONTROL | WRITE_DAC | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES);
541 }
542 let file = options.open(path)?;
543 let metadata = file.metadata()?;
544 if !metadata.is_dir() || crate::plugins::metadata_is_link_or_reparse(&metadata) {
545 return Err(invalid_path());
546 }
547 Ok(std::sync::Arc::new(file))
548 }
549
550 pub(crate) fn child_path(&self, name: &std::ffi::OsStr) -> io::Result<std::path::PathBuf> {
551 let name = Path::new(name);
552 if !path_is_confined(name) || name.components().count() != 1 {
553 return Err(invalid_path());
554 }
555 Ok(self.directory.join(name))
556 }
557
558 /// Extend the actual held direct-parent chain instead of reopening pinned
559 /// ancestors by path.
560 pub(crate) fn open_acl_child(&self, name: &std::ffi::OsStr) -> io::Result<Self> {
561 let directory = self.child_path(name)?;
562 let file = Self::open_component(&directory, true)?;
563 let mut ancestors = self.ancestors.clone();
564 ancestors.push(file);
565 Ok(Self {
566 ancestors,
567 directory,
568 })
569 }
570
571 pub(crate) fn open_acl(path: &Path) -> io::Result<Self> {
572 Self::open_inner(path, false, true)
573 }
574
575 pub(crate) fn acl_handle(&self) -> io::Result<&File> {
576 self.ancestors
577 .last()
578 .map(std::sync::Arc::as_ref)
579 .ok_or_else(invalid_path)
580 }
581 }
582
583 #[cfg(windows)]
584 #[derive(Debug)]
585 pub(crate) struct WorkspaceFile {
586 // Retaining every ancestor without delete/write sharing prevents a path
587 // swap or junction replacement while path-based Windows calls are running.
588 _ancestors: Vec<std::sync::Arc<File>>,
589 directory: std::path::PathBuf,
590 filename: std::ffi::OsString,
591 }
592
593 #[cfg(windows)]
594 impl WorkspaceFile {
595 pub(crate) fn open(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
596 if !path_is_confined(relative) {
597 return Err(invalid_path());
598 }
599 // Preserve the original spelling until links/reparse points have been
600 // refused. Canonicalization must not turn a linked root into authority.
601 let workspace = if workspace.is_absolute() {
602 workspace.to_path_buf()
603 } else {
604 std::env::current_dir()?.join(workspace)
605 };
606 let root = WindowsDirectory::open(&workspace)?;
607 let target = workspace.join(relative.parent().ok_or_else(invalid_path)?);
608 let mut pinned = WindowsDirectory::open_inner(&target, create, false)?;
609 pinned.ancestors.extend(root.ancestors);
610 let ancestors = pinned.ancestors;
611 let directory = pinned.directory;
612 Ok(Self {
613 _ancestors: ancestors,
614 directory,
615 filename: relative.file_name().ok_or_else(invalid_path)?.to_owned(),
616 })
617 }
618
619 pub(crate) fn open_delivery(workspace: &Path, relative: &Path) -> io::Result<Self> {
620 Self::open(workspace, relative, false)
621 }
622
623 /// Windows has no Unix permission bits to preserve; see the Unix opener.
624 pub(crate) fn open_shared(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
625 Self::open(workspace, relative, create)
626 }
627
628 pub(crate) fn sibling(&self, name: &str) -> io::Result<Self> {
629 if !path_is_confined(Path::new(name)) || Path::new(name).components().count() != 1 {
630 return Err(invalid_path());
631 }
632 Ok(Self {
633 _ancestors: self._ancestors.clone(),
634 directory: self.directory.clone(),
635 filename: name.into(),
636 })
637 }
638
639 pub(crate) fn open_update(&self, create: bool, append: bool) -> io::Result<File> {
640 self.open_with_access(create, append, true, true)
641 }
642
643 pub(crate) fn open_write(&self, append: bool) -> io::Result<File> {
644 self.open_with_access(true, append, false, true)
645 }
646
647 /// Reads a file another process may hold open for writing (a running
648 /// worker's log). [`Self::open_file`] denies concurrent writers, so it
649 /// fails with a sharing violation while the writer is alive; this opens
650 /// read-only with full sharing and applies the same regular, unlinked
651 /// checks to the handle.
652 pub(crate) fn open_file_shared(&self) -> io::Result<File> {
653 self.open_with_access(false, false, true, false)
654 }
655
656 fn open_with_access(
657 &self,
658 create: bool,
659 append: bool,
660 read: bool,
661 write: bool,
662 ) -> io::Result<File> {
663 use std::os::windows::fs::OpenOptionsExt;
664 let file = std::fs::OpenOptions::new()
665 .read(read)
666 .write(write)
667 .append(append)
668 .create(create)
669 .truncate(false)
670 .share_mode(0x0000_0007)
671 .custom_flags(0x0020_0000)
672 .open(self.directory.join(&self.filename))?;
673 let metadata = file.metadata()?;
674 if !metadata.is_file()
675 || crate::plugins::metadata_is_link_or_reparse(&metadata)
676 || crate::plugins::windows_file_identity(&file)?.links != 1
677 {
678 return Err(io::Error::new(
679 io::ErrorKind::InvalidData,
680 "Fleet file must be regular and not linked",
681 ));
682 }
683 Ok(file)
684 }
685
686 pub(crate) fn open_file(&self) -> io::Result<File> {
687 // Existing protected reader rejects reparse points, hard links and
688 // non-regular files, and denies concurrent writes/replacement.
689 crate::plugins::manifest::open_bundle_file(&self.directory.join(&self.filename))
690 }
691
692 pub(crate) fn publish(&self, bytes: &[u8]) -> io::Result<()> {
693 self.atomic_write(bytes, false)
694 }
695
696 pub(crate) fn replace(&self, bytes: &[u8]) -> io::Result<()> {
697 self.atomic_write(bytes, true)
698 }
699
700 fn atomic_write(&self, bytes: &[u8], replace: bool) -> io::Result<()> {
701 self.atomic_write_retained(bytes, replace).map(drop)
702 }
703
704 pub(crate) fn publish_retained(&self, bytes: &[u8], _executable: bool) -> io::Result<File> {
705 self.atomic_write_retained(bytes, false)
706 }
707
708 pub(crate) fn open_retained(&self) -> io::Result<File> {
709 crate::plugins::manifest::open_bundle_file_for_retirement(
710 &self.directory.join(&self.filename),
711 )
712 }
713
714 pub(crate) fn identity_probe(&self) -> io::Result<File> {
715 crate::plugins::manifest::open_bundle_identity_probe(
716 &self.directory.join(&self.filename),
717 false,
718 )
719 }
720
721 pub(crate) fn move_opened_to_sibling(&self, file: &File, destination: &Self) -> io::Result<()> {
722 if self.directory != destination.directory {
723 return Err(invalid_path());
724 }
725 rename_windows_opened(file, &destination.filename, false)
726 }
727
728 pub(crate) fn sync_parent(&self) -> io::Result<()> {
729 Ok(())
730 }
731
732 fn atomic_write_retained(&self, bytes: &[u8], replace: bool) -> io::Result<File> {
733 use std::os::windows::fs::OpenOptionsExt;
734 use windows_sys::Win32::Storage::FileSystem::{
735 DELETE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ,
736 };
737
738 let mut temporary =
739 tempfile::Builder::new()
740 .prefix(".fleet-write-")
741 .make_in(&self.directory, |path| {
742 std::fs::OpenOptions::new()
743 .write(true)
744 .create_new(true)
745 .access_mode(FILE_GENERIC_READ | FILE_GENERIC_WRITE | DELETE)
746 .share_mode(FILE_SHARE_READ)
747 .open(path)
748 })?;
749 let result = (|| {
750 temporary.write_all(bytes)?;
751 temporary.as_file().sync_all()?;
752
753 rename_windows_opened(temporary.as_file(), &self.filename, replace)
754 })();
755 match result {
756 Ok(()) => {
757 // The old name is vacant after the rename; do not unlink an
758 // entry another process might create there afterwards.
759 temporary.disable_cleanup(true);
760 Ok(temporary.into_file())
761 }
762 Err(error) => {
763 // Close the source's delete-denying handle before cleanup.
764 temporary.into_temp_path().close()?;
765 Err(error)
766 }
767 }
768 }
769 }
770
771 #[cfg(windows)]
772 pub(crate) fn rename_windows_opened(
773 file: &File,
774 filename: &std::ffi::OsStr,
775 replace: bool,
776 ) -> io::Result<()> {
777 use std::mem::{offset_of, size_of};
778 use std::os::windows::ffi::OsStrExt;
779 use std::os::windows::io::AsRawHandle;
780 use windows_sys::Wdk::Storage::FileSystem::{
781 FILE_RENAME_INFORMATION, FileRenameInformation, NtSetInformationFile,
782 };
783 use windows_sys::Win32::Foundation::RtlNtStatusToDosError;
784 use windows_sys::Win32::System::IO::IO_STATUS_BLOCK;
785 // MoveFileExW (including tempfile::persist) reopens the destination
786 // directory with FILE_ADD_FILE, conflicting with our ancestor pins.
787 // A native rename with no root handle and a single basename uses
788 // the source file's existing parent instead. Keep all ancestor and
789 // source handles pinned; never relax their write/delete guards.
790 // https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
791 let name = filename.encode_wide().collect::<Vec<_>>();
792 let name_bytes = name.len() * size_of::<u16>();
793 let buffer_size = (offset_of!(FILE_RENAME_INFORMATION, FileName) + name_bytes)
794 .max(size_of::<FILE_RENAME_INFORMATION>());
795 let mut buffer = vec![0_usize; buffer_size.div_ceil(size_of::<usize>())];
796 let rename = buffer.as_mut_ptr().cast::<FILE_RENAME_INFORMATION>();
797 // SAFETY: the zeroed buffer is aligned and covers the struct plus
798 // the complete UTF-16 basename; no root means the source's parent.
799 unsafe {
800 (*rename).Anonymous.ReplaceIfExists = replace;
801 (*rename).FileNameLength = name_bytes as u32;
802 std::ptr::copy_nonoverlapping(name.as_ptr(), (*rename).FileName.as_mut_ptr(), name.len());
803 }
804 let mut attempt = 0;
805 loop {
806 let mut status = IO_STATUS_BLOCK::default();
807 // SAFETY: this synchronously opened file has DELETE access;
808 // every handle and buffer remains live throughout the call.
809 let result = unsafe {
810 NtSetInformationFile(
811 file.as_raw_handle(),
812 &mut status,
813 rename.cast(),
814 buffer_size as u32,
815 FileRenameInformation,
816 )
817 };
818 if result >= 0 {
819 return Ok(());
820 }
821 // SAFETY: converts the returned NTSTATUS without dereferencing.
822 let error = io::Error::from_raw_os_error(unsafe { RtlNtStatusToDosError(result) as i32 });
823 let Some(backoff) = crate::utils::windows_publish_retry_delay(&error, attempt) else {
824 return Err(error);
825 };
826 std::thread::sleep(backoff);
827 attempt += 1;
828 }
829 }
830
831 #[cfg(all(test, unix))]
832 mod unix_publication_tests {
833 use super::*;
834 use std::io::Read;
835
836 /// Several threads creating one new name through their own pinned handles
837 /// must all succeed. macOS can fail the losers of that race with ENOENT
838 /// unless the open asks again.
839 #[test]
840 fn racing_creates_of_one_name_all_succeed() {
841 let workspace = tempfile::tempdir().unwrap();
842 for round in 0..40 {
843 let relative = std::path::PathBuf::from(format!("locks-{round}/manager.lock"));
844 std::thread::scope(|scope| {
845 let workers: Vec<_> = (0..4)
846 .map(|_| {
847 scope.spawn(|| {
848 WorkspaceFile::open(workspace.path(), &relative, true)
849 .and_then(|file| file.open_update(true, false))
850 })
851 })
852 .collect();
853 for worker in workers {
854 worker
855 .join()
856 .unwrap()
857 .expect("every racing create succeeds");
858 }
859 });
860 }
861 }
862
863 #[test]
864 fn a_racing_reader_never_sees_a_publication_half_done() {
865 // Identical replays publish the same handle concurrently and the loser
866 // reads the winner's file back. A reader that lands between linkat and
867 // the temporary's unlink sees two links, which open_file rejects.
868 let workspace = tempfile::tempdir().unwrap();
869 for round in 0..50 {
870 let relative = std::path::PathBuf::from(format!("receipt-{round}.json"));
871 let writer = WorkspaceFile::open(workspace.path(), &relative, true).unwrap();
872 let reader = WorkspaceFile::open(workspace.path(), &relative, false).unwrap();
873 std::thread::scope(|scope| {
874 let read = scope.spawn(|| {
875 loop {
876 match reader.open_file() {
877 Ok(mut file) => {
878 let mut bytes = Vec::new();
879 file.read_to_end(&mut bytes).unwrap();
880 break bytes;
881 }
882 Err(error) if error.kind() == io::ErrorKind::NotFound => {
883 std::hint::spin_loop();
884 }
885 Err(error) => panic!("round {round}: {error}"),
886 }
887 }
888 });
889 writer.publish(b"receipt").unwrap();
890 assert_eq!(read.join().unwrap(), b"receipt");
891 });
892 }
893 assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 50);
894 }
895
896 fn mode(path: &Path) -> u32 {
897 use std::os::unix::fs::PermissionsExt;
898 std::fs::symlink_metadata(path)
899 .unwrap()
900 .permissions()
901 .mode()
902 & 0o7777
903 }
904
905 #[test]
906 fn shared_replacement_keeps_the_existing_permission_bits() {
907 use std::os::unix::fs::PermissionsExt;
908 let workspace = tempfile::tempdir().unwrap();
909 let script = workspace.path().join("deploy.sh");
910 std::fs::write(&script, b"#!/bin/sh\n").unwrap();
911 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
912
913 WorkspaceFile::open_shared(workspace.path(), Path::new("deploy.sh"), false)
914 .unwrap()
915 .replace(b"#!/bin/sh\necho edited\n")
916 .unwrap();
917
918 assert_eq!(std::fs::read(&script).unwrap(), b"#!/bin/sh\necho edited\n");
919 assert_eq!(
920 mode(&script),
921 0o755,
922 "an edit must not drop the executable bit"
923 );
924 }
925
926 #[test]
927 fn shared_replacement_drops_set_id_bits_and_keeps_owner_only_files_private() {
928 use std::os::unix::fs::PermissionsExt;
929 let workspace = tempfile::tempdir().unwrap();
930 // Set-group-id on a file of one's own group is allowed unprivileged;
931 // the sticky bit on a regular file is not (EFTYPE on macOS).
932 for (name, before, after) in [("tool", 0o6750, 0o750), ("key.pem", 0o600, 0o600)] {
933 let path = workspace.path().join(name);
934 std::fs::write(&path, b"old").unwrap();
935 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(before)).unwrap();
936 WorkspaceFile::open_shared(workspace.path(), Path::new(name), false)
937 .unwrap()
938 .replace(b"new")
939 .unwrap();
940 assert_eq!(std::fs::read(&path).unwrap(), b"new");
941 assert_eq!(mode(&path), after, "{name}");
942 }
943 }
944
945 #[test]
946 fn shared_creation_follows_the_umask_for_the_file_and_its_parents() {
947 let workspace = tempfile::tempdir().unwrap();
948 // std creates files as 0o666 and directories as 0o777 under the
949 // process umask; read that back instead of changing the umask.
950 let probe_file = workspace.path().join("probe-file");
951 std::fs::write(&probe_file, b"").unwrap();
952 let probe_dir = workspace.path().join("probe-dir");
953 std::fs::create_dir(&probe_dir).unwrap();
954
955 WorkspaceFile::open_shared(workspace.path(), Path::new("new/nested/notes.md"), true)
956 .unwrap()
957 .replace(b"notes")
958 .unwrap();
959
960 let created = workspace.path().join("new/nested/notes.md");
961 assert_eq!(std::fs::read(&created).unwrap(), b"notes");
962 assert_eq!(mode(&created), mode(&probe_file));
963 assert_eq!(mode(&workspace.path().join("new")), mode(&probe_dir));
964 assert_eq!(mode(&workspace.path().join("new/nested")), mode(&probe_dir));
965 }
966
967 #[test]
968 fn private_replacement_and_creation_stay_owner_only() {
969 use std::os::unix::fs::PermissionsExt;
970 let workspace = tempfile::tempdir().unwrap();
971 let ledger = workspace.path().join("fleet.jsonl");
972 std::fs::write(&ledger, b"old").unwrap();
973 std::fs::set_permissions(&ledger, std::fs::Permissions::from_mode(0o644)).unwrap();
974
975 WorkspaceFile::open(workspace.path(), Path::new("fleet.jsonl"), false)
976 .unwrap()
977 .replace(b"compacted")
978 .unwrap();
979 assert_eq!(mode(&ledger), 0o600);
980
981 WorkspaceFile::open(workspace.path(), Path::new("private/receipt.json"), true)
982 .unwrap()
983 .publish(b"receipt")
984 .unwrap();
985 assert_eq!(mode(&workspace.path().join("private")), 0o700);
986 assert_eq!(mode(&workspace.path().join("private/receipt.json")), 0o600);
987 }
988 }
989
990 #[cfg(all(test, windows))]
991 mod windows_publication_tests {
992 use super::*;
993 use std::os::windows::fs::OpenOptionsExt;
994
995 #[test]
996 fn publication_and_replacement_keep_ancestor_write_and_delete_guards() {
997 let workspace = tempfile::tempdir().unwrap();
998 let parent = workspace.path().join("private");
999 let ledger =
1000 WorkspaceFile::open(workspace.path(), Path::new("private/fleet.jsonl"), true).unwrap();
1001 let forbidden = std::fs::OpenOptions::new()
1002 .write(true)
1003 .custom_flags(0x0220_0000)
1004 .open(&parent)
1005 .unwrap_err();
1006 assert_eq!(forbidden.raw_os_error(), Some(32));
1007 assert!(std::fs::rename(&parent, workspace.path().join("swapped")).is_err());
1008
1009 // Both fail with MoveFileExW while the destination parent is pinned.
1010 ledger.publish(b"first").unwrap();
1011 ledger.replace(b"compacted").unwrap();
1012 assert_eq!(
1013 std::fs::read(parent.join("fleet.jsonl")).unwrap(),
1014 b"compacted"
1015 );
1016 assert_eq!(std::fs::read_dir(&parent).unwrap().count(), 1);
1017 }
1018
1019 #[test]
1020 fn replacement_survives_a_short_lived_reader_without_delete_sharing() {
1021 let workspace = tempfile::tempdir().unwrap();
1022 let relative = Path::new("fleet.jsonl");
1023 let ledger = WorkspaceFile::open(workspace.path(), relative, true).unwrap();
1024 ledger.publish(b"first").unwrap();
1025 let held = std::fs::OpenOptions::new()
1026 .read(true)
1027 .share_mode(0x1 | 0x2)
1028 .open(workspace.path().join(relative))
1029 .unwrap();
1030 let release = std::thread::spawn(move || {
1031 std::thread::sleep(std::time::Duration::from_millis(50));
1032 drop(held);
1033 });
1034 ledger.replace(b"compacted").unwrap();
1035 release.join().unwrap();
1036 assert_eq!(
1037 std::fs::read(workspace.path().join(relative)).unwrap(),
1038 b"compacted"
1039 );
1040 }
1041
1042 #[test]
1043 fn immutable_publication_preserves_existing_bytes_and_removes_its_temporary() {
1044 let workspace = tempfile::tempdir().unwrap();
1045 let artifact =
1046 WorkspaceFile::open(workspace.path(), Path::new("receipt.json"), true).unwrap();
1047 artifact.publish(b"receipt").unwrap();
1048 assert_eq!(
1049 artifact.publish(b"replacement").unwrap_err().kind(),
1050 io::ErrorKind::AlreadyExists
1051 );
1052 assert_eq!(
1053 std::fs::read(workspace.path().join("receipt.json")).unwrap(),
1054 b"receipt"
1055 );
1056 assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 1);
1057 }
1058
1059 #[test]
1060 fn artifact_paths_cannot_name_windows_alternate_data_streams() {
1061 for path in ["receipt.json:private", "dir/receipt:private", ":stream"] {
1062 assert!(
1063 !path_is_confined(Path::new(path)),
1064 "accepted stream path {path}"
1065 );
1066 }
1067 }
1068 }
1069
1070 #[cfg(all(not(unix), not(windows)))]
1071 #[derive(Debug)]
1072 pub(crate) struct WorkspaceFile;
1073 #[cfg(all(not(unix), not(windows)))]
1074 impl WorkspaceFile {
1075 pub(crate) fn open(_: &Path, _: &Path, _: bool) -> io::Result<Self> {
1076 Err(io::Error::new(
1077 io::ErrorKind::Unsupported,
1078 "Confined Fleet artifact I/O is unavailable on this platform",
1079 ))
1080 }
1081 pub(crate) fn open_shared(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
1082 Self::open(workspace, relative, create)
1083 }
1084 pub(crate) fn sibling(&self, _: &str) -> io::Result<Self> {
1085 unreachable!()
1086 }
1087 pub(crate) fn open_update(&self, _: bool, _: bool) -> io::Result<File> {
1088 unreachable!()
1089 }
1090 pub(crate) fn open_write(&self, _: bool) -> io::Result<File> {
1091 unreachable!()
1092 }
1093 pub(crate) fn replace(&self, _: &[u8]) -> io::Result<()> {
1094 unreachable!()
1095 }
1096 pub(crate) fn open_file(&self) -> io::Result<File> {
1097 unreachable!()
1098 }
1099 pub(crate) fn open_file_shared(&self) -> io::Result<File> {
1100 unreachable!()
1101 }
1102 pub(crate) fn publish(&self, _: &[u8]) -> io::Result<()> {
1103 unreachable!()
1104 }
1105 pub(crate) fn open_delivery(workspace: &Path, relative: &Path) -> io::Result<Self> {
1106 Self::open(workspace, relative, false)
1107 }
1108 pub(crate) fn open_retained(&self) -> io::Result<File> {
1109 self.open_file()
1110 }
1111 pub(crate) fn identity_probe(&self) -> io::Result<File> {
1112 self.open_file()
1113 }
1114 pub(crate) fn publish_retained(&self, _: &[u8], _: bool) -> io::Result<File> {
1115 Err(io::ErrorKind::Unsupported.into())
1116 }
1117 pub(crate) fn move_opened_to_sibling(&self, _: &File, _: &Self) -> io::Result<()> {
1118 Err(io::ErrorKind::Unsupported.into())
1119 }
1120 pub(crate) fn sync_parent(&self) -> io::Result<()> {
1121 Err(io::ErrorKind::Unsupported.into())
1122 }
1123 }
1124
1125 /// Compare already opened lock handles; a replaced lock must never create two
1126 /// independent critical sections for the same live ledger.
1127 pub(crate) fn same_file(left: &File, right: &File) -> io::Result<bool> {
1128 #[cfg(unix)]
1129 {
1130 use std::os::unix::fs::MetadataExt;
1131 let a = left.metadata()?;
1132 let b = right.metadata()?;
1133 Ok(a.dev() == b.dev() && a.ino() == b.ino())
1134 }
1135 #[cfg(windows)]
1136 {
1137 let a = crate::plugins::windows_file_identity(left)?;
1138 let b = crate::plugins::windows_file_identity(right)?;
1139 Ok(a.volume == b.volume && a.index == b.index)
1140 }
1141 #[cfg(all(not(unix), not(windows)))]
1142 {
1143 let _ = (left, right);
1144 unreachable!()
1145 }
1146 }
1147
1147 lines RUST