返回 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 fn invalid_path() -> io::Error {
16 io::Error::new(
17 io::ErrorKind::InvalidInput,
18 "Fleet artifact path must stay within the workspace",
19 )
20 }
21
22 #[cfg(unix)]
23 #[derive(Debug)]
24 pub(crate) struct WorkspaceFile {
25 directory: File,
26 filename: std::ffi::CString,
27 }
28
29 #[cfg(unix)]
30 impl WorkspaceFile {
31 pub(crate) fn open(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
32 use std::os::fd::{AsRawFd, FromRawFd};
33 use std::os::unix::ffi::OsStrExt;
34 if !path_is_confined(relative) {
35 return Err(invalid_path());
36 }
37 let workspace = workspace.canonicalize()?;
38 // Use the established credential/artifact openat pattern, without
39 // touching credentials or creating a second filesystem store.
40 // SAFETY: static path and immediate ownership of a successful fd.
41 let fd = unsafe {
42 libc::open(
43 c"/".as_ptr(),
44 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
45 )
46 };
47 if fd < 0 {
48 return Err(io::Error::last_os_error());
49 }
50 // SAFETY: this fd was just created and has no other owner.
51 let mut directory = unsafe { File::from_raw_fd(fd) };
52 let parents = relative.parent().ok_or_else(invalid_path)?;
53 for (path, may_create) in [(workspace.as_path(), false), (parents, create)] {
54 for component in path.components() {
55 let Component::Normal(name) = component else {
56 if component == Component::RootDir {
57 continue;
58 }
59 return Err(invalid_path());
60 };
61 let name = std::ffi::CString::new(name.as_bytes())?;
62 let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
63 // SAFETY: directory pins the parent; name is one component.
64 let mut fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
65 if fd < 0
66 && may_create
67 && io::Error::last_os_error().kind() == io::ErrorKind::NotFound
68 {
69 // SAFETY: directory and relative basename remain valid.
70 if unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) } != 0
71 && io::Error::last_os_error().kind() != io::ErrorKind::AlreadyExists
72 {
73 return Err(io::Error::last_os_error());
74 }
75 // SAFETY: reject a symlink inserted after mkdirat.
76 fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
77 }
78 if fd < 0 {
79 return Err(io::Error::last_os_error());
80 }
81 // SAFETY: fd is freshly owned.
82 directory = unsafe { File::from_raw_fd(fd) };
83 }
84 }
85 Ok(Self {
86 directory,
87 filename: std::ffi::CString::new(
88 relative.file_name().ok_or_else(invalid_path)?.as_bytes(),
89 )?,
90 })
91 }
92
93 pub(crate) fn sibling(&self, name: &str) -> io::Result<Self> {
94 if !path_is_confined(Path::new(name)) || Path::new(name).components().count() != 1 {
95 return Err(invalid_path());
96 }
97 Ok(Self {
98 directory: self.directory.try_clone()?,
99 filename: std::ffi::CString::new(name)?,
100 })
101 }
102
103 pub(crate) fn open_update(&self, create: bool, append: bool) -> io::Result<File> {
104 self.open_with_flags(
105 libc::O_RDWR
106 | if create { libc::O_CREAT } else { 0 }
107 | if append { libc::O_APPEND } else { 0 },
108 )
109 }
110
111 pub(crate) fn open_file(&self) -> io::Result<File> {
112 self.open_with_flags(libc::O_RDONLY)
113 }
114
115 fn open_with_flags(&self, flags: libc::c_int) -> io::Result<File> {
116 use std::os::fd::{AsRawFd, FromRawFd};
117 use std::os::unix::fs::MetadataExt;
118 // SAFETY: a pinned parent and validated basename; never follows links.
119 let fd = unsafe {
120 libc::openat(
121 self.directory.as_raw_fd(),
122 self.filename.as_ptr(),
123 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
124 0o600,
125 )
126 };
127 if fd < 0 {
128 return Err(io::Error::last_os_error());
129 }
130 // SAFETY: fd is freshly owned.
131 let file = unsafe { File::from_raw_fd(fd) };
132 let metadata = file.metadata()?;
133 if !metadata.is_file() || metadata.nlink() != 1 {
134 return Err(io::Error::new(
135 io::ErrorKind::InvalidData,
136 "Fleet file must be a regular, non-hard-linked file",
137 ));
138 }
139 Ok(file)
140 }
141
142 pub(crate) fn publish(&self, bytes: &[u8]) -> io::Result<()> {
143 self.atomic_write(bytes, false)
144 }
145
146 pub(crate) fn replace(&self, bytes: &[u8]) -> io::Result<()> {
147 self.atomic_write(bytes, true)
148 }
149
150 fn atomic_write(&self, bytes: &[u8], replace: bool) -> io::Result<()> {
151 use std::os::fd::{AsRawFd, FromRawFd};
152 let temporary =
153 std::ffi::CString::new(format!(".fleet-write-{}.tmp", uuid::Uuid::new_v4()))
154 .expect("generated basename");
155 // SAFETY: parent is pinned; exclusive creation cannot follow a link.
156 let fd = unsafe {
157 libc::openat(
158 self.directory.as_raw_fd(),
159 temporary.as_ptr(),
160 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
161 0o600,
162 )
163 };
164 if fd < 0 {
165 return Err(io::Error::last_os_error());
166 }
167 // SAFETY: fd is freshly owned.
168 let mut file = unsafe { File::from_raw_fd(fd) };
169 let result = (|| {
170 file.write_all(bytes)?;
171 file.sync_all()?;
172 // SAFETY: both basenames are anchored to the same open parent.
173 // Replacement changes the directory entry, never a symlink target.
174 let published = unsafe {
175 if replace {
176 libc::renameat(
177 self.directory.as_raw_fd(),
178 temporary.as_ptr(),
179 self.directory.as_raw_fd(),
180 self.filename.as_ptr(),
181 )
182 } else {
183 libc::linkat(
184 self.directory.as_raw_fd(),
185 temporary.as_ptr(),
186 self.directory.as_raw_fd(),
187 self.filename.as_ptr(),
188 0,
189 )
190 }
191 };
192 if published != 0 {
193 return Err(io::Error::last_os_error());
194 }
195 Ok(())
196 })();
197 // Successful rename already consumed this temporary entry. Never
198 // unlink the vacant old name, which another writer could now reuse.
199 if !replace || result.is_err() {
200 // SAFETY: unlink this call's exclusive temporary basename.
201 if unsafe { libc::unlinkat(self.directory.as_raw_fd(), temporary.as_ptr(), 0) } != 0 {
202 return Err(io::Error::last_os_error());
203 }
204 }
205 result?;
206 self.directory.sync_all()
207 }
208 }
209
210 #[cfg(windows)]
211 #[derive(Debug)]
212 pub(crate) struct WorkspaceFile {
213 // Retaining every ancestor without delete/write sharing prevents a path
214 // swap or junction replacement while path-based Windows calls are running.
215 _ancestors: Vec<File>,
216 directory: std::path::PathBuf,
217 filename: std::ffi::OsString,
218 }
219
220 #[cfg(windows)]
221 impl WorkspaceFile {
222 pub(crate) fn open(workspace: &Path, relative: &Path, create: bool) -> io::Result<Self> {
223 use std::os::windows::fs::OpenOptionsExt;
224 if !path_is_confined(relative) {
225 return Err(invalid_path());
226 }
227 let workspace = workspace.canonicalize()?;
228 let mut ancestors = Vec::new();
229 let mut directory = std::path::PathBuf::new();
230 for (path, may_create) in [
231 (workspace.as_path(), false),
232 (relative.parent().ok_or_else(invalid_path)?, create),
233 ] {
234 for component in path.components() {
235 directory.push(component.as_os_str());
236 if matches!(component, Component::Prefix(_)) {
237 continue;
238 }
239 let open = || {
240 std::fs::OpenOptions::new()
241 .read(true)
242 .share_mode(0x0000_0001)
243 .custom_flags(0x0220_0000)
244 .open(&directory)
245 }; // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
246 let file = match open() {
247 Err(error) if may_create && error.kind() == io::ErrorKind::NotFound => {
248 match std::fs::create_dir(&directory) {
249 Ok(()) => {}
250 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
251 Err(error) => return Err(error),
252 }
253 open()?
254 }
255 result => result?,
256 };
257 let metadata = file.metadata()?;
258 if !metadata.is_dir() || crate::plugins::metadata_is_link_or_reparse(&metadata) {
259 return Err(invalid_path());
260 }
261 ancestors.push(file);
262 }
263 }
264 Ok(Self {
265 _ancestors: ancestors,
266 directory,
267 filename: relative.file_name().ok_or_else(invalid_path)?.to_owned(),
268 })
269 }
270
271 pub(crate) fn sibling(&self, name: &str) -> io::Result<Self> {
272 if !path_is_confined(Path::new(name)) || Path::new(name).components().count() != 1 {
273 return Err(invalid_path());
274 }
275 Ok(Self {
276 _ancestors: self
277 ._ancestors
278 .iter()
279 .map(File::try_clone)
280 .collect::<io::Result<_>>()?,
281 directory: self.directory.clone(),
282 filename: name.into(),
283 })
284 }
285
286 pub(crate) fn open_update(&self, create: bool, append: bool) -> io::Result<File> {
287 use std::os::windows::fs::OpenOptionsExt;
288 let file = std::fs::OpenOptions::new()
289 .read(true)
290 .write(true)
291 .append(append)
292 .create(create)
293 .truncate(false)
294 .share_mode(0x0000_0007)
295 .custom_flags(0x0020_0000)
296 .open(self.directory.join(&self.filename))?;
297 let metadata = file.metadata()?;
298 if !metadata.is_file()
299 || crate::plugins::metadata_is_link_or_reparse(&metadata)
300 || crate::plugins::windows_file_identity(&file)?.links != 1
301 {
302 return Err(io::Error::new(
303 io::ErrorKind::InvalidData,
304 "Fleet file must be regular and not linked",
305 ));
306 }
307 Ok(file)
308 }
309
310 pub(crate) fn open_file(&self) -> io::Result<File> {
311 // Existing protected reader rejects reparse points, hard links and
312 // non-regular files, and denies concurrent writes/replacement.
313 crate::plugins::manifest::open_bundle_file(&self.directory.join(&self.filename))
314 }
315
316 pub(crate) fn publish(&self, bytes: &[u8]) -> io::Result<()> {
317 self.atomic_write(bytes, false)
318 }
319
320 pub(crate) fn replace(&self, bytes: &[u8]) -> io::Result<()> {
321 self.atomic_write(bytes, true)
322 }
323
324 fn atomic_write(&self, bytes: &[u8], replace: bool) -> io::Result<()> {
325 use std::mem::{offset_of, size_of};
326 use std::os::windows::ffi::OsStrExt;
327 use std::os::windows::fs::OpenOptionsExt;
328 use std::os::windows::io::AsRawHandle;
329 use windows_sys::Wdk::Storage::FileSystem::{
330 FILE_RENAME_INFORMATION, FileRenameInformation, NtSetInformationFile,
331 };
332 use windows_sys::Win32::Foundation::RtlNtStatusToDosError;
333 use windows_sys::Win32::Storage::FileSystem::{
334 DELETE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ,
335 };
336 use windows_sys::Win32::System::IO::IO_STATUS_BLOCK;
337
338 let mut temporary =
339 tempfile::Builder::new()
340 .prefix(".fleet-write-")
341 .make_in(&self.directory, |path| {
342 std::fs::OpenOptions::new()
343 .write(true)
344 .create_new(true)
345 .access_mode(FILE_GENERIC_READ | FILE_GENERIC_WRITE | DELETE)
346 .share_mode(FILE_SHARE_READ)
347 .open(path)
348 })?;
349 let result = (|| {
350 temporary.write_all(bytes)?;
351 temporary.as_file().sync_all()?;
352
353 // MoveFileExW (including tempfile::persist) reopens the destination
354 // directory with FILE_ADD_FILE, conflicting with our ancestor pins.
355 // A native rename with no root handle and a single basename uses
356 // the source file's existing parent instead. Keep all ancestor and
357 // source handles pinned; never relax their write/delete guards.
358 // https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
359 let name = self.filename.encode_wide().collect::<Vec<_>>();
360 let name_bytes = name.len() * size_of::<u16>();
361 let buffer_size = (offset_of!(FILE_RENAME_INFORMATION, FileName) + name_bytes)
362 .max(size_of::<FILE_RENAME_INFORMATION>());
363 let mut buffer = vec![0_usize; buffer_size.div_ceil(size_of::<usize>())];
364 let rename = buffer.as_mut_ptr().cast::<FILE_RENAME_INFORMATION>();
365 // SAFETY: the zeroed buffer is aligned and covers the struct plus
366 // the complete UTF-16 basename; no root means the source's parent.
367 unsafe {
368 (*rename).Anonymous.ReplaceIfExists = replace;
369 (*rename).FileNameLength = name_bytes as u32;
370 std::ptr::copy_nonoverlapping(
371 name.as_ptr(),
372 (*rename).FileName.as_mut_ptr(),
373 name.len(),
374 );
375 }
376 let mut attempt = 0;
377 loop {
378 let mut status = IO_STATUS_BLOCK::default();
379 // SAFETY: this synchronously opened file has DELETE access;
380 // every handle and buffer remains live throughout the call.
381 let result = unsafe {
382 NtSetInformationFile(
383 temporary.as_file().as_raw_handle(),
384 &mut status,
385 rename.cast(),
386 buffer_size as u32,
387 FileRenameInformation,
388 )
389 };
390 if result >= 0 {
391 return Ok(());
392 }
393 // SAFETY: converts the returned NTSTATUS without dereferencing.
394 let error =
395 io::Error::from_raw_os_error(unsafe { RtlNtStatusToDosError(result) as i32 });
396 let Some(backoff) = crate::utils::windows_publish_retry_delay(&error, attempt)
397 else {
398 return Err(error);
399 };
400 std::thread::sleep(backoff);
401 attempt += 1;
402 }
403 })();
404 match result {
405 Ok(()) => {
406 // The old name is vacant after the rename; do not unlink an
407 // entry another process might create there afterwards.
408 temporary.disable_cleanup(true);
409 Ok(())
410 }
411 Err(error) => {
412 // Close the source's delete-denying handle before cleanup.
413 temporary.into_temp_path().close()?;
414 Err(error)
415 }
416 }
417 }
418 }
419
420 #[cfg(all(test, windows))]
421 mod windows_publication_tests {
422 use super::*;
423 use std::os::windows::fs::OpenOptionsExt;
424
425 #[test]
426 fn publication_and_replacement_keep_ancestor_write_and_delete_guards() {
427 let workspace = tempfile::tempdir().unwrap();
428 let parent = workspace.path().join("private");
429 let ledger =
430 WorkspaceFile::open(workspace.path(), Path::new("private/fleet.jsonl"), true).unwrap();
431 let forbidden = std::fs::OpenOptions::new()
432 .write(true)
433 .custom_flags(0x0220_0000)
434 .open(&parent)
435 .unwrap_err();
436 assert_eq!(forbidden.raw_os_error(), Some(32));
437 assert!(std::fs::rename(&parent, workspace.path().join("swapped")).is_err());
438
439 // Both fail with MoveFileExW while the destination parent is pinned.
440 ledger.publish(b"first").unwrap();
441 ledger.replace(b"compacted").unwrap();
442 assert_eq!(
443 std::fs::read(parent.join("fleet.jsonl")).unwrap(),
444 b"compacted"
445 );
446 assert_eq!(std::fs::read_dir(&parent).unwrap().count(), 1);
447 }
448
449 #[test]
450 fn replacement_survives_a_short_lived_reader_without_delete_sharing() {
451 let workspace = tempfile::tempdir().unwrap();
452 let relative = Path::new("fleet.jsonl");
453 let ledger = WorkspaceFile::open(workspace.path(), relative, true).unwrap();
454 ledger.publish(b"first").unwrap();
455 let held = std::fs::OpenOptions::new()
456 .read(true)
457 .share_mode(0x1 | 0x2)
458 .open(workspace.path().join(relative))
459 .unwrap();
460 let release = std::thread::spawn(move || {
461 std::thread::sleep(std::time::Duration::from_millis(50));
462 drop(held);
463 });
464 ledger.replace(b"compacted").unwrap();
465 release.join().unwrap();
466 assert_eq!(
467 std::fs::read(workspace.path().join(relative)).unwrap(),
468 b"compacted"
469 );
470 }
471
472 #[test]
473 fn immutable_publication_preserves_existing_bytes_and_removes_its_temporary() {
474 let workspace = tempfile::tempdir().unwrap();
475 let artifact =
476 WorkspaceFile::open(workspace.path(), Path::new("receipt.json"), true).unwrap();
477 artifact.publish(b"receipt").unwrap();
478 assert_eq!(
479 artifact.publish(b"replacement").unwrap_err().kind(),
480 io::ErrorKind::AlreadyExists
481 );
482 assert_eq!(
483 std::fs::read(workspace.path().join("receipt.json")).unwrap(),
484 b"receipt"
485 );
486 assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 1);
487 }
488
489 #[test]
490 fn artifact_paths_cannot_name_windows_alternate_data_streams() {
491 for path in ["receipt.json:private", "dir/receipt:private", ":stream"] {
492 assert!(
493 !path_is_confined(Path::new(path)),
494 "accepted stream path {path}"
495 );
496 }
497 }
498 }
499
500 #[cfg(all(not(unix), not(windows)))]
501 #[derive(Debug)]
502 pub(crate) struct WorkspaceFile;
503 #[cfg(all(not(unix), not(windows)))]
504 impl WorkspaceFile {
505 pub(crate) fn open(_: &Path, _: &Path, _: bool) -> io::Result<Self> {
506 Err(io::Error::new(
507 io::ErrorKind::Unsupported,
508 "Confined Fleet artifact I/O is unavailable on this platform",
509 ))
510 }
511 pub(crate) fn sibling(&self, _: &str) -> io::Result<Self> {
512 unreachable!()
513 }
514 pub(crate) fn open_update(&self, _: bool, _: bool) -> io::Result<File> {
515 unreachable!()
516 }
517 pub(crate) fn replace(&self, _: &[u8]) -> io::Result<()> {
518 unreachable!()
519 }
520 pub(crate) fn open_file(&self) -> io::Result<File> {
521 unreachable!()
522 }
523 pub(crate) fn publish(&self, _: &[u8]) -> io::Result<()> {
524 unreachable!()
525 }
526 }
527
528 /// Compare already opened lock handles; a replaced lock must never create two
529 /// independent critical sections for the same live ledger.
530 pub(crate) fn same_file(left: &File, right: &File) -> io::Result<bool> {
531 #[cfg(unix)]
532 {
533 use std::os::unix::fs::MetadataExt;
534 let a = left.metadata()?;
535 let b = right.metadata()?;
536 Ok(a.dev() == b.dev() && a.ino() == b.ino())
537 }
538 #[cfg(windows)]
539 {
540 let a = crate::plugins::windows_file_identity(left)?;
541 let b = crate::plugins::windows_file_identity(right)?;
542 Ok(a.volume == b.volume && a.index == b.index)
543 }
544 #[cfg(all(not(unix), not(windows)))]
545 {
546 let _ = (left, right);
547 unreachable!()
548 }
549 }
550
550 lines RUST