Files
designcraft/crates/engine/src/io.rs
T
Dylan M. Taylorandaiongg 5a3507510e File reads: refuse devices and pipes, cap whole-file reads (#2227)
* fix(engine): whole-file reads take regular files only, up to the format limit

* Apply review fixes

- crates/engine/src/io.rs

* Apply review fixes

- crates/engine/src/io.rs

* Fix failing CI check

- crates/engine/src/io.rs

* address review: refuse to save an encoded document longer than the open limit

* address review: open files for reading without blocking (FIFOs, stream-backed pseudo-files)

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 16:19:45 -05:00

293 lines
13 KiB
Rust

//! Reading and writing the files a command names by path.
//!
//! Every such read and write goes through this module, so the automation roots
//! ([`crate::file_access`]) are checked in one place. A command module that called `std::fs`
//! itself would bypass them: a test in `file_access` keeps that from happening.
//!
//! A path the roots refuse reads as a file that isn't there where the caller only asks whether
//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where
//! it asks for the bytes.
pub use craft_docpath::Refused;
pub use std::fs::Metadata;
use std::path::{Path, PathBuf};
/// The path to open for a path stored in a document (a link, a data source, a book chapter),
/// resolved against `document`, the path of the file it came from, when there is one.
///
/// Paths to other computers (other than the document's own network share), devices and URLs are
/// refused, and so is a result that isn't absolute: without a document, a relative path would
/// depend on the working directory, and a Windows path names no file on other platforms. A
/// refused path is never handed to the file system; callers treat it as a missing file.
pub fn document_path(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
craft_docpath::resolve_absolute(stored, document.as_deref())
}
/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names).
pub fn folder_path(stored: &str, folder: &Path) -> Result<PathBuf, Refused> {
// Resolved against a document in the folder: only the document's folder is used.
document_path(stored, Some(&folder.join("-")))
}
#[cfg(test)]
thread_local! {
static TOUCHED: std::cell::RefCell<Option<Vec<PathBuf>>> = const { std::cell::RefCell::new(None) };
}
/// Run `f` and list every path it handed to this module, in order (on this thread).
#[cfg(test)]
pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<PathBuf>) {
let previous = TOUCHED.with(|t| t.replace(Some(Vec::new())));
let out = f();
let seen = TOUCHED.with(|t| t.replace(previous)).unwrap_or_default();
(out, seen)
}
fn touch(path: &Path) {
#[cfg(test)]
TOUCHED.with(|t| {
if let Some(seen) = t.borrow_mut().as_mut() {
seen.push(path.to_path_buf());
}
});
#[cfg(not(test))]
let _ = path;
}
/// The most a whole file read in one piece ([`read_file`], [`read`], [`read_to_string`]) may
/// hold: the format's own limit on what a document unpacks to. What is opened must be a regular
/// file: a device or a pipe (`/dev/zero`, a FIFO) has no end, and reading it to the end would
/// take all the memory there is.
pub const MAX_FILE_BYTES: u64 = 4 << 30;
/// `File::open` for reading that never blocks in the open or the reads: a FIFO with no writer would
/// otherwise hang the open, and a stream-backed pseudo-file that reports as regular (a tracefs
/// `trace_pipe`) would hang the read. A read that would block is a `WouldBlock` error. Regular
/// files are unaffected by the flag. Elsewhere (Windows, wasm) it is a plain open.
fn open_read(path: &Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// `O_NONBLOCK` differs by OS and architecture; std exposes no constant for it.
#[cfg(any(target_os = "linux", target_os = "android"))]
const O_NONBLOCK: i32 = if cfg!(any(target_arch = "mips", target_arch = "mips64")) {
0x80
} else if cfg!(any(target_arch = "sparc", target_arch = "sparc64")) {
0x4000
} else {
0o4000
};
#[cfg(not(any(target_os = "linux", target_os = "android")))]
const O_NONBLOCK: i32 = 0x4; // macOS, iOS and the BSDs
options.custom_flags(O_NONBLOCK);
}
options.open(path)
}
/// The whole of the regular file at `path`, at most [`MAX_FILE_BYTES`] (the path checked already).
fn read_whole(path: &Path) -> std::io::Result<Vec<u8>> {
use std::io::Read;
if !std::fs::metadata(path)?.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file"));
}
let file = open_read(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file"));
}
if meta.len() > MAX_FILE_BYTES {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("longer than {MAX_FILE_BYTES} bytes")));
}
let mut bytes = Vec::new();
file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_FILE_BYTES {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("longer than {MAX_FILE_BYTES} bytes")));
}
Ok(bytes)
}
/// The bytes of the file at `path`, inside the automation read root when there is one.
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>, String> {
let path = path.as_ref();
read(path).map_err(|e| format!("{}: {e}", path.display()))
}
/// [`read_file`] with the file system's own error, for callers that tell "missing" from other
/// failures. A refused path is a `PermissionDenied` error carrying the roots' message.
pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {
let path = path.as_ref();
touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
read_whole(path)
}
/// [`read`] that stops past `cap` bytes: at most `cap + 1` bytes are read, so a device or a file
/// that grows while it is read can't exhaust memory. The caller compares the length with `cap`.
pub fn read_capped(path: impl AsRef<Path>, cap: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let path = path.as_ref();
touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let mut b = Vec::new();
open_read(path)?.take(cap.saturating_add(1)).read_to_end(&mut b)?;
Ok(b)
}
/// The file at `path` opened for reading, inside the automation read root when there is one, for
/// callers that read only part of it. A refused path is a `PermissionDenied` error carrying the
/// roots' message.
pub fn open(path: impl AsRef<Path>) -> std::io::Result<std::fs::File> {
let path = path.as_ref();
touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
open_read(path)
}
/// The text of the file at `path`, inside the automation read root when there is one.
pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> {
let path = path.as_ref();
let bytes = read_file(path)?;
String::from_utf8(bytes).map_err(|_| format!("{}: stream did not contain valid UTF-8", path.display()))
}
/// Make the folder at `path` and the folders above it, inside the automation write root when
/// there is one.
#[cfg(not(target_arch = "wasm32"))]
pub fn create_dir_all(path: impl AsRef<Path>) -> Result<(), String> {
let path = path.as_ref();
crate::file_access::check_write(path)?;
std::fs::create_dir_all(path).map_err(|e| format!("{}: {e}", path.display()))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> {
let path = path.as_ref();
touch(path);
crate::file_access::check_write(path)?;
// Replaced atomically where there is a real file system (see `atomic`).
#[cfg(not(target_arch = "wasm32"))]
let written = crate::atomic::write_atomic(path, bytes);
#[cfg(target_arch = "wasm32")]
let written = std::fs::write(path, bytes);
written.map_err(|e| format!("{}: {e}", path.display()))
}
/// Atomically replace the file at `path` with the contents of `staged`, a private temporary file
/// the engine made itself (so it is not checked), inside the automation write root when there is
/// one.
#[cfg(not(target_arch = "wasm32"))]
pub fn replace_with(path: impl AsRef<Path>, staged: impl AsRef<Path>) -> std::io::Result<()> {
let path = path.as_ref();
touch(path);
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let mut input = std::fs::File::open(staged)?;
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
}
/// Could the file at `path` be opened for writing (without truncating it)? Inside the automation
/// write root when there is one.
pub fn writable(path: impl AsRef<Path>) -> std::io::Result<()> {
let path = path.as_ref();
touch(path);
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::OpenOptions::new().write(true).open(path).map(drop)
}
/// The canonical form of `path` (links resolved), inside the automation read root when there is
/// one.
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
let path = path.as_ref();
touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::canonicalize(path)
}
/// What the file system says about `path` (links followed), inside the automation read root when
/// there is one. A refused path is a `PermissionDenied` error carrying the roots' message.
pub fn metadata(path: impl AsRef<Path>) -> std::io::Result<Metadata> {
let path = path.as_ref();
touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::metadata(path)
}
/// Is there a file at `path` that automation may read?
pub fn is_file(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok_and(|m| m.is_file())
}
/// Is there a folder at `path` that automation may read?
pub fn is_dir(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok_and(|m| m.is_dir())
}
/// Is there anything at `path` that automation may read?
pub fn exists(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok()
}
/// The entries of the folder at `path`, inside the automation read root when there is one.
pub fn read_dir(path: impl AsRef<Path>) -> std::io::Result<std::fs::ReadDir> {
let path = path.as_ref();
touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::read_dir(path)
}
#[cfg(test)]
mod read_tests {
use super::*;
/// A device has no end: a whole-file read refuses it instead of reading until memory runs out.
#[cfg(unix)]
#[test]
fn whole_file_reads_refuse_devices() {
let e = read_file("/dev/zero").unwrap_err();
assert!(e.contains("not a regular file"), "{e}");
assert!(read("/dev/zero").is_err());
assert!(read_to_string("/dev/zero").is_err());
assert!(read_capped("/dev/zero", 16).is_ok_and(|b| b.len() == 17), "a capped read stops by itself");
assert!(read_file("/dev/null/missing").is_err());
}
#[test]
fn whole_file_reads_read_whole_files() {
let dir = std::env::temp_dir().join(format!("dc-io-read-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.txt");
std::fs::write(&path, "héllo").unwrap();
assert_eq!(read_file(&path).unwrap(), "héllo".as_bytes());
assert_eq!(read_to_string(&path).unwrap(), "héllo");
std::fs::write(&path, [0xff, 0xfe]).unwrap();
assert!(read_to_string(&path).unwrap_err().contains("UTF-8"));
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// A FIFO nobody writes to must not hang a read (the open used to block forever).
#[test]
fn fifo_without_a_writer_does_not_block() {
let dir = std::env::temp_dir().join(format!("dc-fifo-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("f");
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
return; // no mkfifo on this host
}
let (tx, rx) = std::sync::mpsc::channel();
let p = fifo.clone();
std::thread::spawn(move || {
let _ = tx.send((read_whole(&p).is_err(), read_capped(&p, 9).is_ok()));
});
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(got, Ok((true, true)), "reads of a FIFO must return");
}
}