mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 22:35:26 +00:00
* 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>
293 lines
13 KiB
Rust
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");
|
|
}
|
|
}
|