1use std::{
7 ffi::OsStr,
8 fs::File,
9 io,
10 path::{Component, Path},
11};
12
13pub fn relative(path: &Path) -> io::Result<()> {
14 if path.as_os_str().is_empty() || !path.components().all(|c| matches!(c, Component::Normal(_)))
15 {
16 return Err(io::Error::new(
17 io::ErrorKind::InvalidInput,
18 "installation paths must be relative, without traversal",
19 ));
20 }
21 #[cfg(windows)]
22 for component in path.components() {
23 let value = component.as_os_str().to_string_lossy();
24 let stem = value
25 .split('.')
26 .next()
27 .unwrap_or_default()
28 .trim_end_matches(' ')
29 .to_ascii_uppercase();
30 let device = matches!(
31 stem.as_str(),
32 "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" | "CLOCK$"
33 ) || (["COM", "LPT"].iter().any(|prefix| {
34 stem.strip_prefix(prefix).is_some_and(|n| {
35 matches!(
36 n,
37 "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
38 )
39 })
40 }));
41 if value.contains(':') || value.ends_with(['.', ' ']) || device {
42 return Err(io::Error::new(
43 io::ErrorKind::InvalidInput,
44 "Windows aliases and alternate streams are not installation paths",
45 ));
46 }
47 }
48 Ok(())
49}
50fn name(name: &OsStr) -> io::Result<&Path> {
51 let path = Path::new(name);
52 relative(path)?;
53 if path.components().count() != 1 {
54 return Err(io::Error::new(
55 io::ErrorKind::InvalidInput,
56 "expected one child name",
57 ));
58 }
59 Ok(path)
60}
61
62pub struct Directory {
63 file: File,
64 #[cfg(windows)]
65 path: std::path::PathBuf,
66 #[cfg(windows)]
67 ancestors: Vec<File>,
68 #[cfg(windows)]
69 pin: std::sync::Arc<File>,
70}
71impl Directory {
72 pub fn open(path: &Path) -> io::Result<Self> {
73 platform::open(path)
74 }
75 pub fn child(&self, child: &OsStr) -> io::Result<Self> {
76 platform::child(self, name(child)?)
77 }
78 pub fn descend(&self, path: &Path) -> io::Result<Self> {
79 let mut directory = self.try_clone()?;
80 if !path.as_os_str().is_empty() {
81 relative(path)?;
82 for component in path.components() {
83 directory = directory.child(component.as_os_str())?;
84 }
85 }
86 Ok(directory)
87 }
88 pub fn try_clone(&self) -> io::Result<Self> {
89 platform::clone(self)
90 }
91 pub fn identity(&self) -> io::Result<Vec<u8>> {
92 platform::identity(&self.file)
93 }
94 pub fn open_file(&self, child: &OsStr) -> io::Result<File> {
95 platform::file(self, name(child)?, false)
96 }
97 pub fn create_file(&self, child: &OsStr) -> io::Result<File> {
98 platform::file(self, name(child)?, true)
99 }
100 pub fn hard_link(&self, source: &OsStr, destination: &OsStr) -> io::Result<()> {
101 platform::link(self, name(source)?, name(destination)?)
102 }
103 pub fn durable_rename(&self, source: &OsStr, destination: &OsStr) -> io::Result<()> {
106 platform::rename(self, name(source)?, name(destination)?)
107 }
108 pub fn remove_file(&self, child: &OsStr) -> io::Result<()> {
109 platform::remove(self, name(child)?)
110 }
111}
112
113#[cfg(unix)]
114mod platform {
115 use std::{
116 ffi::CString,
117 os::{
118 fd::{AsRawFd, FromRawFd},
119 unix::{
120 ffi::OsStrExt,
121 fs::{MetadataExt, OpenOptionsExt},
122 },
123 },
124 };
125
126 use super::*;
127 fn c(path: &Path) -> io::Result<CString> {
128 CString::new(path.as_os_str().as_bytes())
129 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"))
130 }
131 fn result(rc: i32) -> io::Result<()> {
132 if rc == 0 {
133 Ok(())
134 } else {
135 Err(io::Error::last_os_error())
136 }
137 }
138 fn open_at(parent: &Directory, path: &Path, flags: i32) -> io::Result<File> {
139 let path = c(path)?;
140 let fd = unsafe {
142 libc::openat(
143 parent.file.as_raw_fd(),
144 path.as_ptr(),
145 flags | libc::O_CLOEXEC | libc::O_NOFOLLOW,
146 0o600,
147 )
148 };
149 if fd < 0 {
150 return Err(io::Error::last_os_error());
151 }
152 Ok(unsafe { File::from_raw_fd(fd) })
154 }
155 pub fn open(path: &Path) -> io::Result<Directory> {
156 let file = std::fs::OpenOptions::new()
157 .read(true)
158 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
159 .open(path)?;
160 Ok(Directory { file })
161 }
162 pub fn child(parent: &Directory, path: &Path) -> io::Result<Directory> {
163 Ok(Directory {
164 file: open_at(parent, path, libc::O_RDONLY | libc::O_DIRECTORY)?,
165 })
166 }
167 pub fn clone(directory: &Directory) -> io::Result<Directory> {
168 Ok(Directory {
169 file: directory.file.try_clone()?,
170 })
171 }
172 pub fn identity(file: &File) -> io::Result<Vec<u8>> {
173 let meta = file.metadata()?;
174 Ok([meta.dev().to_le_bytes(), meta.ino().to_le_bytes()].concat())
175 }
176 pub fn file(directory: &Directory, path: &Path, create: bool) -> io::Result<File> {
177 let flags = if create {
178 libc::O_RDWR | libc::O_CREAT | libc::O_EXCL
179 } else {
180 libc::O_RDONLY | libc::O_NONBLOCK
181 };
182 let file = open_at(directory, path, flags)?;
183 if !file.metadata()?.is_file() {
184 return Err(io::Error::new(
185 io::ErrorKind::InvalidInput,
186 "artifact must be a regular file",
187 ));
188 }
189 Ok(file)
190 }
191 pub fn link(dir: &Directory, source: &Path, destination: &Path) -> io::Result<()> {
192 let source = c(source)?;
193 let destination = c(destination)?;
194 result(unsafe {
197 libc::linkat(
198 dir.file.as_raw_fd(),
199 source.as_ptr(),
200 dir.file.as_raw_fd(),
201 destination.as_ptr(),
202 0,
203 )
204 })
205 }
206 pub fn rename(dir: &Directory, source: &Path, destination: &Path) -> io::Result<()> {
207 let source = c(source)?;
208 let destination = c(destination)?;
209 result(unsafe {
211 libc::renameat(
212 dir.file.as_raw_fd(),
213 source.as_ptr(),
214 dir.file.as_raw_fd(),
215 destination.as_ptr(),
216 )
217 })?;
218 dir.file.sync_all()
219 }
220 pub fn remove(dir: &Directory, child: &Path) -> io::Result<()> {
221 let child = c(child)?;
222 result(unsafe { libc::unlinkat(dir.file.as_raw_fd(), child.as_ptr(), 0) })?;
224 dir.file.sync_all()
225 }
226}
227
228#[cfg(windows)]
229mod platform {
230 use std::os::windows::{
231 ffi::OsStrExt,
232 fs::{MetadataExt, OpenOptionsExt},
233 io::{AsRawHandle, FromRawHandle},
234 };
235
236 use windows_sys::{
237 Wdk::{
238 Foundation::OBJECT_ATTRIBUTES,
239 Storage::FileSystem::{
240 FILE_CREATE, FILE_DELETE_ON_CLOSE, FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE,
241 FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, NtCreateFile,
242 },
243 },
244 Win32::{
245 Foundation::{
246 OBJ_CASE_INSENSITIVE, OBJ_DONT_REPARSE, RtlNtStatusToDosError, UNICODE_STRING,
247 },
248 Storage::FileSystem::{
249 DELETE, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
250 FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_ID_INFO,
251 FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileIdInfo,
252 GetFileInformationByHandleEx, SYNCHRONIZE,
253 },
254 System::IO::IO_STATUS_BLOCK,
255 },
256 };
257
258 use super::*;
259 fn directory_file(path: &Path) -> io::Result<File> {
260 let file = std::fs::OpenOptions::new()
264 .access_mode(FILE_READ_ATTRIBUTES | FILE_TRAVERSE | SYNCHRONIZE)
265 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
266 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
267 .open(path)?;
268 validate_directory(&file)?;
269 Ok(file)
270 }
271 fn validate_directory(file: &File) -> io::Result<()> {
272 let meta = file.metadata()?;
273 if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
274 return Err(io::Error::new(
275 io::ErrorKind::InvalidInput,
276 "installation ancestor is a reparse point or not a directory",
277 ));
278 }
279 Ok(())
280 }
281 fn relative_open(parent: &File, child: &Path, directory: bool, pin: bool) -> io::Result<File> {
282 name(child.as_os_str())?;
283 let mut wide: Vec<u16> = child.as_os_str().encode_wide().collect();
284 let length = wide
285 .len()
286 .checked_mul(2)
287 .and_then(|n| u16::try_from(n).ok())
288 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "child name too long"))?;
289 if wide.contains(&0) {
290 return Err(io::Error::new(
291 io::ErrorKind::InvalidInput,
292 "NUL in child name",
293 ));
294 }
295 let object_name = UNICODE_STRING {
296 Length: length,
297 MaximumLength: length,
298 Buffer: wide.as_mut_ptr(),
299 };
300 let attributes = OBJECT_ATTRIBUTES {
301 Length: std::mem::size_of::<OBJECT_ATTRIBUTES>() as u32,
302 RootDirectory: parent.as_raw_handle(),
303 ObjectName: &object_name,
304 Attributes: OBJ_CASE_INSENSITIVE | OBJ_DONT_REPARSE,
305 ..Default::default()
306 };
307 let mut handle = std::ptr::null_mut();
308 let mut status_block = IO_STATUS_BLOCK::default();
309 let access = if pin {
310 FILE_GENERIC_READ | FILE_GENERIC_WRITE | DELETE
311 } else {
312 FILE_READ_ATTRIBUTES | FILE_TRAVERSE | SYNCHRONIZE
313 };
314 let options = FILE_OPEN_REPARSE_POINT
315 | FILE_SYNCHRONOUS_IO_NONALERT
316 | if directory {
317 FILE_DIRECTORY_FILE
318 } else {
319 FILE_NON_DIRECTORY_FILE
320 }
321 | if pin { FILE_DELETE_ON_CLOSE } else { 0 };
322 let status = unsafe {
325 NtCreateFile(
326 &mut handle,
327 access,
328 &attributes,
329 &mut status_block,
330 std::ptr::null(),
331 0,
332 if directory {
336 FILE_SHARE_READ | FILE_SHARE_WRITE
337 } else {
338 FILE_SHARE_READ
339 },
340 if pin { FILE_CREATE } else { FILE_OPEN },
341 options,
342 std::ptr::null(),
343 0,
344 )
345 };
346 if status < 0 {
347 return Err(io::Error::from_raw_os_error(
349 unsafe { RtlNtStatusToDosError(status) } as i32,
350 ));
351 }
352 Ok(unsafe { File::from_raw_handle(handle) })
354 }
355 fn pinned(file: File, path: std::path::PathBuf, ancestors: Vec<File>) -> io::Result<Directory> {
356 validate_directory(&file)?;
357 let pin_path = crate::fs_atomic::temp_path(Path::new("hosted-dir-pin"));
358 let pin_name = pin_path
359 .file_name()
360 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing pin name"))?;
361 let pin = relative_open(&file, Path::new(pin_name), false, true)?;
362 validate_directory(&file)?;
363 let path = path.canonicalize()?;
367 Ok(Directory {
372 file,
373 path,
374 ancestors,
375 pin: std::sync::Arc::new(pin),
376 })
377 }
378 pub fn open(path: &Path) -> io::Result<Directory> {
379 let path = std::path::absolute(path)?;
380 let mut prefix = std::path::PathBuf::new();
381 let mut ancestors = Vec::new();
382 let mut current = None;
383 for component in path.components() {
384 prefix.push(component);
385 if component == Component::RootDir {
386 current = Some(directory_file(&prefix)?);
387 } else if let Component::Normal(child) = component {
388 let parent = current.take().ok_or_else(|| {
389 io::Error::new(io::ErrorKind::InvalidInput, "missing volume root")
390 })?;
391 let next = relative_open(&parent, Path::new(child), true, false)?;
392 validate_directory(&next)?;
393 ancestors.push(parent);
394 current = Some(next);
395 }
396 if matches!(component, Component::ParentDir | Component::CurDir) {
397 return Err(io::Error::new(
398 io::ErrorKind::InvalidInput,
399 "invalid root path",
400 ));
401 }
402 }
403 let file = current
404 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing directory"))?;
405 pinned(file, path, ancestors)
406 }
407 pub fn child(parent: &Directory, child: &Path) -> io::Result<Directory> {
408 let path = parent.path.join(child);
409 let mut ancestors = parent
410 .ancestors
411 .iter()
412 .map(File::try_clone)
413 .collect::<io::Result<Vec<_>>>()?;
414 ancestors.push(parent.file.try_clone()?);
415 let file = relative_open(&parent.file, child, true, false)?;
416 pinned(file, path, ancestors)
417 }
418 pub fn clone(dir: &Directory) -> io::Result<Directory> {
419 Ok(Directory {
420 file: dir.file.try_clone()?,
421 path: dir.path.clone(),
422 pin: dir.pin.clone(),
423 ancestors: dir
424 .ancestors
425 .iter()
426 .map(File::try_clone)
427 .collect::<io::Result<_>>()?,
428 })
429 }
430 pub fn identity(file: &File) -> io::Result<Vec<u8>> {
431 let mut info = std::mem::MaybeUninit::<FILE_ID_INFO>::uninit();
432 if unsafe {
434 GetFileInformationByHandleEx(
435 file.as_raw_handle(),
436 FileIdInfo,
437 info.as_mut_ptr().cast(),
438 std::mem::size_of::<FILE_ID_INFO>() as u32,
439 )
440 } == 0
441 {
442 return Err(io::Error::last_os_error());
443 }
444 let info = unsafe { info.assume_init() };
446 Ok([
447 info.VolumeSerialNumber.to_le_bytes().as_slice(),
448 &info.FileId.Identifier,
449 ]
450 .concat())
451 }
452 pub fn file(dir: &Directory, child: &Path, create: bool) -> io::Result<File> {
453 let mut options = std::fs::OpenOptions::new();
454 options
455 .read(true)
456 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
457 if create {
458 options.write(true).create_new(true);
459 }
460 let file = options.open(dir.path.join(child))?;
461 let meta = file.metadata()?;
462 if !meta.is_file() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
463 return Err(io::Error::new(
464 io::ErrorKind::InvalidInput,
465 "artifact must be a regular file",
466 ));
467 }
468 Ok(file)
469 }
470 pub fn link(dir: &Directory, source: &Path, destination: &Path) -> io::Result<()> {
471 std::fs::hard_link(dir.path.join(source), dir.path.join(destination))
472 }
473 pub fn rename(dir: &Directory, source: &Path, destination: &Path) -> io::Result<()> {
474 crate::fs_atomic::durable_rename(&dir.path.join(source), &dir.path.join(destination))
475 }
476 pub fn remove(dir: &Directory, child: &Path) -> io::Result<()> {
477 std::fs::remove_file(dir.path.join(child))
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use std::io::Write;
484
485 use super::*;
486 #[cfg(windows)]
487 #[test]
488 fn windows_aliases_are_not_relative_installation_paths() {
489 for value in [
490 "NUL",
491 "COM1.txt",
492 "COM¹",
493 "LPT².txt",
494 "COM1 .txt",
495 "CONIN$",
496 "a:b",
497 "a.",
498 ] {
499 assert!(relative(Path::new(value)).is_err(), "accepted {value}");
500 }
501 assert!(relative(Path::new("parent/pin")).is_ok());
502 }
503 #[test]
504 fn flushed_publish_and_restore_use_durable_renames() {
505 let root = tempfile::tempdir().expect("root");
506 let directory = Directory::open(root.path()).expect("capability");
507 for (name, bytes) in [("backup", b"old"), ("new", b"new")] {
508 let mut file = directory.create_file(OsStr::new(name)).expect("create");
509 file.write_all(bytes).expect("write");
510 file.sync_all().expect("file flush");
511 drop(file);
512 }
513 directory
514 .durable_rename(OsStr::new("new"), OsStr::new("pin"))
515 .expect("publish");
516 assert_eq!(std::fs::read(root.path().join("pin")).expect("pin"), b"new");
517 directory
518 .hard_link(OsStr::new("backup"), OsStr::new("restore"))
519 .expect("preserve undo");
520 directory
521 .durable_rename(OsStr::new("restore"), OsStr::new("pin"))
522 .expect("restore");
523 assert_eq!(std::fs::read(root.path().join("pin")).expect("pin"), b"old");
524 assert!(root.path().join("backup").exists());
525 }
526 #[cfg(windows)]
527 #[test]
528 fn held_windows_ancestors_prevent_parent_replacement() {
529 let root = tempfile::tempdir().expect("root");
530 std::fs::create_dir_all(root.path().join("ancestor/parent")).expect("parent");
531 let directory = Directory::open(root.path()).expect("root capability");
532 let ancestor = directory
533 .child(OsStr::new("ancestor"))
534 .expect("ancestor capability");
535 let parent = ancestor
536 .child(OsStr::new("parent"))
537 .expect("parent capability");
538 drop(ancestor);
539 drop(directory);
540 let parent_path = root.path().join("ancestor/parent");
541 assert!(std::fs::rename(&parent_path, root.path().join("moved-parent")).is_err());
542 assert!(std::fs::rename(root.path().join("ancestor"), root.path().join("moved")).is_err());
543 let mut file = parent.create_file(OsStr::new("new")).expect("held create");
544 file.write_all(b"data").expect("write");
545 file.sync_all().expect("non-privileged file flush");
546 drop(file);
547 parent
548 .durable_rename(OsStr::new("new"), OsStr::new("pin"))
549 .expect("non-privileged write-through move");
550 assert_eq!(
551 std::fs::read(parent_path.join("pin")).expect("pin"),
552 b"data"
553 );
554 assert!(std::fs::rename(&parent_path, root.path().join("moved-parent")).is_err());
555 assert!(std::fs::rename(root.path().join("ancestor"), root.path().join("moved")).is_err());
556 drop(parent);
557 std::fs::rename(&parent_path, root.path().join("moved-parent"))
558 .expect("released leaf handle");
559 std::fs::rename(root.path().join("ancestor"), root.path().join("moved"))
560 .expect("released handles");
561 }
562 #[cfg(windows)]
563 #[test]
564 fn windows_attribute_only_junction_substitution_is_blocked() {
565 use std::os::windows::{ffi::OsStrExt, fs::OpenOptionsExt, io::AsRawHandle};
566
567 use windows_sys::Win32::{
568 Storage::FileSystem::{
569 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
570 FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES,
571 },
572 System::{IO::DeviceIoControl, Ioctl::FSCTL_SET_REPARSE_POINT},
573 };
574 fn set_junction(directory: &Path, target: &Path) -> io::Result<()> {
575 let print: Vec<u16> = target.as_os_str().encode_wide().collect();
576 let substitute: Vec<u16> = format!("\\??\\{}", target.display())
577 .encode_utf16()
578 .collect();
579 let mut data = Vec::new();
580 data.extend_from_slice(&0xA0000003u32.to_le_bytes()); data.extend_from_slice(
582 &((8 + 2 * (substitute.len() + print.len() + 2)) as u16).to_le_bytes(),
583 );
584 data.extend_from_slice(&0u16.to_le_bytes());
585 for value in [
586 0,
587 substitute.len() * 2,
588 (substitute.len() + 1) * 2,
589 print.len() * 2,
590 ] {
591 data.extend_from_slice(&(value as u16).to_le_bytes());
592 }
593 for value in substitute.into_iter().chain([0]).chain(print).chain([0]) {
594 data.extend_from_slice(&value.to_le_bytes());
595 }
596 let file = std::fs::OpenOptions::new()
598 .access_mode(FILE_WRITE_ATTRIBUTES)
599 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
600 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
601 .open(directory)?;
602 let mut returned = 0;
603 if unsafe {
605 DeviceIoControl(
606 file.as_raw_handle(),
607 FSCTL_SET_REPARSE_POINT,
608 data.as_ptr().cast(),
609 data.len() as u32,
610 std::ptr::null_mut(),
611 0,
612 &mut returned,
613 std::ptr::null_mut(),
614 )
615 } == 0
616 {
617 Err(io::Error::last_os_error())
618 } else {
619 Ok(())
620 }
621 }
622 let root = tempfile::tempdir().expect("root");
623 let outside = tempfile::tempdir().expect("outside");
624 for name in ["control", "parent"] {
625 std::fs::create_dir(root.path().join(name)).expect("directory");
626 }
627 set_junction(&root.path().join("control"), outside.path())
628 .expect("non-privileged junction control");
629 let directory = Directory::open(root.path()).expect("root capability");
630 assert!(directory.child(OsStr::new("control")).is_err());
631 let parent = directory.child(OsStr::new("parent")).expect("held parent");
632 let error = set_junction(&root.path().join("parent"), outside.path())
633 .expect_err("nonempty pin must prevent reparse substitution");
634 assert_eq!(error.raw_os_error(), Some(145)); let mut file = parent
636 .create_file(OsStr::new("new"))
637 .expect("contained create");
638 file.write_all(b"inside").expect("write");
639 file.sync_all().expect("flush");
640 drop(file);
641 parent
642 .durable_rename(OsStr::new("new"), OsStr::new("artifact"))
643 .expect("publish");
644 assert!(!outside.path().join("artifact").exists());
645 drop(parent);
646 drop(directory);
647 std::fs::remove_dir(root.path().join("control")).expect("remove control junction");
648 }
649}