1#![allow(
3 unsafe_code,
4 reason = "The no-follow openat primitive binds reads to a validated directory descriptor."
5)]
6
7use std::fs::File;
8use std::io;
9use std::path::Path;
10
11#[cfg(unix)]
12use std::io::Write;
13#[cfg(unix)]
14use std::os::fd::{AsRawFd, FromRawFd};
15
16#[cfg(unix)]
19pub fn open_directory_handle(path: &Path) -> io::Result<File> {
20 open_directory_beneath(path)
21}
22
23#[cfg(not(unix))]
24pub fn open_directory_handle(_path: &Path) -> io::Result<File> {
25 Err(io::Error::new(
26 io::ErrorKind::Unsupported,
27 "handle-bound directory operations are unavailable on this platform",
28 ))
29}
30
31#[cfg(unix)]
35pub fn set_command_working_directory(command: &mut std::process::Command, directory: &File) -> io::Result<()> {
36 use std::os::unix::process::CommandExt;
37
38 let descriptor = directory.as_raw_fd();
39 let change_directory = move || {
40 let result = unsafe { libc::fchdir(descriptor) };
43 if result == 0 {
44 Ok(())
45 } else {
46 Err(io::Error::last_os_error())
47 }
48 };
49 unsafe {
52 command.pre_exec(change_directory);
53 }
54 Ok(())
55}
56
57#[cfg(not(unix))]
58pub fn set_command_working_directory(_command: &mut std::process::Command, _directory: &File) -> io::Result<()> {
59 Err(io::Error::new(
60 io::ErrorKind::Unsupported,
61 "handle-bound process directories are unavailable on this platform",
62 ))
63}
64
65#[cfg(unix)]
68pub fn open_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
69 use std::ffi::CString;
70 use std::os::fd::{AsRawFd, FromRawFd};
71 use std::os::unix::ffi::OsStrExt;
72 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
73 use std::path::Component;
74
75 let invalid =
76 || io::Error::new(io::ErrorKind::InvalidInput, "expected an absolute root and a normal relative file path");
77 if !root.is_absolute() || relative.as_os_str().is_empty() || relative.is_absolute() {
78 return Err(invalid());
79 }
80 let mut directory = std::fs::OpenOptions::new()
81 .read(true)
82 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
83 .open("/")?;
84 let mut names = Vec::new();
85 for component in root.components() {
86 match component {
87 Component::RootDir => {}
88 Component::Normal(name) => names.push(name),
89 _ => return Err(invalid()),
90 }
91 }
92 for component in relative.components() {
93 match component {
94 Component::Normal(name) => names.push(name),
95 _ => return Err(invalid()),
96 }
97 }
98 let count = names.len();
99 for (index, name) in names.into_iter().enumerate() {
100 let name = CString::new(name.as_bytes()).map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
101 let last = index + 1 == count;
102 let flags = libc::O_RDONLY
103 | libc::O_NOFOLLOW
104 | libc::O_CLOEXEC
105 | if last { libc::O_NONBLOCK } else { libc::O_DIRECTORY };
106 let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
109 if descriptor < 0 {
110 return Err(io::Error::last_os_error());
111 }
112 directory = unsafe { File::from_raw_fd(descriptor) };
114 }
115 let metadata = directory.metadata()?;
116 if !metadata.is_file() || metadata.nlink() != 1 {
117 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular single-link file"));
118 }
119 Ok(directory)
120}
121
122#[cfg(unix)]
129pub fn ensure_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
130 let mut directory = open_directory_beneath(root)?;
131 for component in normal_components(relative, false)? {
132 directory = open_or_create_directory_at(&directory, &component)?;
133 }
134 Ok(())
135}
136
137#[cfg(unix)]
140pub fn validate_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
141 let mut directory = open_directory_beneath(root)?;
142 for component in normal_components(relative, false)? {
143 directory = open_directory_at(&directory, &component)?;
144 }
145 Ok(())
146}
147
148#[cfg(unix)]
152pub fn write_file_beneath(root: &Path, relative: &Path, contents: &[u8]) -> io::Result<()> {
153 let mut file = create_file_beneath(root, relative)?;
154 file.write_all(contents)?;
155 file.sync_all()
156}
157
158#[cfg(unix)]
161pub fn create_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
162 open_new_file_beneath(root, relative)
163}
164
165#[cfg(unix)]
168pub fn copy_file_beneath(
169 source_root: &Path,
170 source_relative: &Path,
171 destination_root: &Path,
172 destination_relative: &Path,
173) -> io::Result<()> {
174 let mut source = open_file_beneath(source_root, source_relative)?;
175 let mut destination = open_new_file_beneath(destination_root, destination_relative)?;
176 io::copy(&mut source, &mut destination)?;
177 destination.sync_all()
178}
179
180#[cfg(unix)]
183pub fn create_symlink_beneath(root: &Path, relative: &Path, target: &Path) -> io::Result<()> {
184 use std::os::unix::ffi::OsStrExt;
185
186 let components = normal_components(relative, false)?;
187 let (file_name, parent_components) = components
188 .split_last()
189 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative symlink path"))?;
190 let mut directory = open_directory_beneath(root)?;
191 for component in parent_components {
192 directory = open_or_create_directory_at(&directory, component)?;
193 }
194
195 let name = c_string(file_name)?;
196 let target = std::ffi::CString::new(target.as_os_str().as_bytes())
197 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
198 let result = unsafe { libc::symlinkat(target.as_ptr(), directory.as_raw_fd(), name.as_ptr()) };
201 if result < 0 {
202 return Err(io::Error::last_os_error());
203 }
204 Ok(())
205}
206
207#[cfg(unix)]
211pub fn open_lock_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
212 use std::os::unix::fs::MetadataExt;
213
214 let components = normal_components(relative, false)?;
215 let (file_name, parent_components) = components
216 .split_last()
217 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative lock file path"))?;
218 let mut directory = open_directory_beneath(root)?;
219 for component in parent_components {
220 directory = open_directory_at(&directory, component)?;
221 }
222
223 let name = c_string(file_name)?;
224 let descriptor = unsafe {
228 libc::openat(
229 directory.as_raw_fd(),
230 name.as_ptr(),
231 libc::O_RDWR | libc::O_CREAT | libc::O_NOFOLLOW | libc::O_CLOEXEC,
232 0o600,
233 )
234 };
235 if descriptor < 0 {
236 return Err(io::Error::last_os_error());
237 }
238 let file = unsafe { File::from_raw_fd(descriptor) };
240 let metadata = file.metadata()?;
241 if !metadata.is_file() || metadata.nlink() != 1 {
242 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular single-link lock file"));
243 }
244 Ok(file)
245}
246
247#[cfg(not(unix))]
248pub fn ensure_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
249 let components = normal_components(relative, false)?;
250 let mut current = root.to_path_buf();
251 let root_metadata = std::fs::symlink_metadata(¤t)?;
252 if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
253 return Err(io::Error::other("trusted root must be a regular directory"));
254 }
255 for component in components {
256 current.push(component);
257 match std::fs::symlink_metadata(¤t) {
258 Ok(metadata) if metadata.file_type().is_symlink() => {
259 return Err(io::Error::other(format!("refusing symlink directory {}", current.display())));
260 }
261 Ok(metadata) if !metadata.is_dir() => {
262 return Err(io::Error::other(format!("{} is not a directory", current.display())));
263 }
264 Ok(_) => {}
265 Err(error) if error.kind() == io::ErrorKind::NotFound => {
266 std::fs::create_dir(¤t)?;
267 }
268 Err(error) => return Err(error),
269 }
270 }
271 Ok(())
272}
273
274#[cfg(not(unix))]
275pub fn validate_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
276 let components = normal_components(relative, false)?;
277 let mut current = root.to_path_buf();
278 let root_metadata = std::fs::symlink_metadata(¤t)?;
279 if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
280 return Err(io::Error::other("trusted root must be a regular directory"));
281 }
282 for component in components {
283 current.push(component);
284 let metadata = std::fs::symlink_metadata(¤t)?;
285 if metadata.file_type().is_symlink() || !metadata.is_dir() {
286 return Err(io::Error::other(format!("{} is not a regular directory", current.display())));
287 }
288 }
289 Ok(())
290}
291
292#[cfg(not(unix))]
293pub fn write_file_beneath(root: &Path, relative: &Path, contents: &[u8]) -> io::Result<()> {
294 let _ = (root, relative, contents);
295 Err(io::Error::new(
296 io::ErrorKind::Unsupported,
297 "handle-bound file writes are unavailable on this platform",
298 ))
299}
300
301#[cfg(not(unix))]
302pub fn copy_file_beneath(
303 _source_root: &Path,
304 _source_relative: &Path,
305 _destination_root: &Path,
306 _destination_relative: &Path,
307) -> io::Result<()> {
308 Err(io::Error::new(
309 io::ErrorKind::Unsupported,
310 "handle-bound file copies are unavailable on this platform",
311 ))
312}
313
314#[cfg(not(unix))]
315pub fn create_symlink_beneath(_root: &Path, _relative: &Path, _target: &Path) -> io::Result<()> {
316 Err(io::Error::new(
317 io::ErrorKind::Unsupported,
318 "handle-bound symlink creation is unavailable on this platform",
319 ))
320}
321
322#[cfg(not(unix))]
325pub fn open_lock_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
326 use std::fs::OpenOptions;
327
328 let components = normal_components(relative, false)?;
329 let (file_name, parent_components) = components
330 .split_last()
331 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative lock file path"))?;
332 let parent = parent_components.iter().fold(root.to_path_buf(), |mut path, component| {
333 path.push(component);
334 path
335 });
336 let relative_parent = parent
337 .strip_prefix(root)
338 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "bound path escaped trusted root"))?;
339 ensure_directory_beneath(root, relative_parent)?;
340 let path = parent.join(file_name);
341 if let Ok(metadata) = std::fs::symlink_metadata(&path)
342 && (metadata.file_type().is_symlink() || !metadata.is_file())
343 {
344 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular lock file"));
345 }
346 let file = OpenOptions::new().read(true).write(true).create(true).open(&path)?;
347 let metadata = std::fs::symlink_metadata(&path)?;
348 if metadata.file_type().is_symlink() || !metadata.is_file() {
349 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular lock file"));
350 }
351 Ok(file)
352}
353
354#[cfg(unix)]
355fn open_directory_beneath(root: &Path) -> io::Result<File> {
356 let mut directory = open_directory_at_path(Path::new("/"))?;
357 for component in normal_components(root, true)? {
358 directory = open_directory_at(&directory, &component)?;
359 }
360 Ok(directory)
361}
362
363#[cfg(unix)]
364fn open_new_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
365 let components = normal_components(relative, false)?;
366 let (file_name, parent_components) = components
367 .split_last()
368 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative file path"))?;
369 let mut directory = open_directory_beneath(root)?;
370 for component in parent_components {
371 directory = open_or_create_directory_at(&directory, component)?;
372 }
373
374 let name = c_string(file_name)?;
375 let flags = libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC;
376 let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags, 0o600) };
379 if descriptor < 0 {
380 return Err(io::Error::last_os_error());
381 }
382 Ok(unsafe { File::from_raw_fd(descriptor) })
384}
385
386#[cfg(unix)]
387fn open_directory_at_path(path: &Path) -> io::Result<File> {
388 use std::os::unix::fs::OpenOptionsExt;
389
390 std::fs::OpenOptions::new()
391 .read(true)
392 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
393 .open(path)
394}
395
396#[cfg(unix)]
397fn open_directory_at(parent: &File, component: &std::ffi::OsString) -> io::Result<File> {
398 let name = c_string(component)?;
399 let descriptor = unsafe {
402 libc::openat(
403 parent.as_raw_fd(),
404 name.as_ptr(),
405 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
406 )
407 };
408 if descriptor < 0 {
409 return Err(io::Error::last_os_error());
410 }
411 Ok(unsafe { File::from_raw_fd(descriptor) })
413}
414
415#[cfg(unix)]
416fn open_or_create_directory_at(parent: &File, component: &std::ffi::OsString) -> io::Result<File> {
417 match open_directory_at(parent, component) {
418 Ok(directory) => Ok(directory),
419 Err(error) if error.kind() == io::ErrorKind::NotFound => {
420 let name = c_string(component)?;
421 let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) };
424 if result < 0 {
425 let mkdir_error = io::Error::last_os_error();
426 if mkdir_error.kind() != io::ErrorKind::AlreadyExists {
427 return Err(mkdir_error);
428 }
429 }
430 open_directory_at(parent, component)
431 }
432 Err(error) => Err(error),
433 }
434}
435
436fn normal_components(path: &Path, absolute: bool) -> io::Result<Vec<std::ffi::OsString>> {
437 use std::path::Component;
438
439 if absolute != path.is_absolute() || (!absolute && path.as_os_str().is_empty()) {
440 return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid bound path"));
441 }
442 path.components()
443 .filter_map(|component| match component {
444 Component::Prefix(_) if absolute => None,
445 Component::RootDir if absolute => None,
446 Component::Normal(name) => Some(Ok(name.to_os_string())),
447 _ => Some(Err(io::Error::new(io::ErrorKind::InvalidInput, "bound path contains traversal"))),
448 })
449 .collect()
450}
451
452#[cfg(unix)]
453fn c_string(name: &std::ffi::OsString) -> io::Result<std::ffi::CString> {
454 use std::os::unix::ffi::OsStrExt;
455
456 std::ffi::CString::new(name.as_os_str().as_bytes())
457 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
458}
459
460#[cfg(not(unix))]
461pub fn open_file_beneath(_root: &Path, _relative: &Path) -> io::Result<File> {
462 Err(io::Error::new(io::ErrorKind::Unsupported, "bound no-follow reads are unavailable on this platform"))
463}
464
465#[cfg(not(unix))]
466pub fn create_file_beneath(_root: &Path, _relative: &Path) -> io::Result<File> {
467 Err(io::Error::new(
468 io::ErrorKind::Unsupported,
469 "bound no-follow file creation is unavailable on this platform",
470 ))
471}
472
473#[cfg(all(test, unix))]
474mod tests {
475 use super::*;
476 use std::io::Read;
477 use std::os::unix::fs::symlink;
478
479 #[test]
480 fn bound_read_rejects_parent_final_links_and_traversal() {
481 let temp = tempfile::tempdir().unwrap();
482 let root = crate::canonicalize(temp.path()).unwrap();
483 std::fs::create_dir(root.join("real")).unwrap();
484 std::fs::write(root.join("real/output"), "retained output").unwrap();
485 symlink(root.join("real"), root.join("alias")).unwrap();
486 symlink(root.join("real/output"), root.join("link")).unwrap();
487 assert!(open_file_beneath(&root, Path::new("alias/output")).is_err());
488 assert!(open_file_beneath(&root, Path::new("link")).is_err());
489 assert!(open_file_beneath(&root, Path::new("../outside")).is_err());
490 assert!(open_file_beneath(&root, Path::new("real")).is_err());
491 let mut opened = open_file_beneath(&root, Path::new("real/output")).unwrap();
492 std::fs::rename(root.join("real"), root.join("retained")).unwrap();
493 symlink("/", root.join("real")).unwrap();
494 let mut text = String::new();
495 opened.read_to_string(&mut text).unwrap();
496 assert_eq!(text, "retained output");
497 }
498}