char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somnpm: do buffered reads for fs bounded read99412b5

main
7.7 KiB220 linesraw
1//! Anchored no-follow filesystem primitives for npm storage.
2//!
3//! All descents start from an open directory handle and never follow
4//! symlinks; regular files are confirmed with `fstat` on the opened file.
5
6use std::io::{self, Read, Write};
7use std::path::{Component, Path};
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt};
11#[cfg(unix)]
12use cap_std::fs::Permissions;
13use cap_std::fs::{Dir, File, OpenOptions};
14
15// The configured anchor may contain system symlinks (e.g. macOS /tmp).
16// Below that anchor, all traversal must use no-follow handles.
17pub fn open_root(path: &Path) -> io::Result<Dir> {
18    if !path.is_absolute() {
19        return Err(io::Error::other("npm path must be absolute and canonical"));
20    }
21    if path
22        .components()
23        .any(|part| matches!(part, Component::CurDir | Component::ParentDir))
24    {
25        return Err(io::Error::other("npm path must be absolute and canonical"));
26    }
27    Dir::open_ambient_dir(path, cap_std::ambient_authority())
28}
29
30/// Descend normal components without following symlinks.
31pub fn walk(root: &Dir, rel: &Path) -> io::Result<Dir> {
32    let mut directory = root.try_clone()?;
33    for part in rel.components() {
34        let Component::Normal(name) = part else {
35            return Err(io::Error::other("npm path escapes its anchor"));
36        };
37        directory = directory.open_dir_nofollow(name)?;
38    }
39    Ok(directory)
40}
41
42/// Descend, creating missing directories. Present non-directories fail.
43pub fn mkdirs(root: &Dir, rel: &Path) -> io::Result<Dir> {
44    let mut directory = root.try_clone()?;
45    for part in rel.components() {
46        let Component::Normal(name) = part else {
47            return Err(io::Error::other("npm path escapes its anchor"));
48        };
49        directory = match directory.open_dir_nofollow(name) {
50            Ok(child) => child,
51            Err(error) if error.kind() == io::ErrorKind::NotFound => {
52                match directory.create_dir(name) {
53                    Ok(()) => {}
54                    Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
55                    Err(error) => return Err(error),
56                }
57                directory.open_dir_nofollow(name)?
58            }
59            Err(error) => return Err(error),
60        };
61    }
62    Ok(directory)
63}
64
65fn single_component(name: &str) -> io::Result<()> {
66    if Path::new(name).components().count() != 1
67        || !matches!(
68            Path::new(name).components().next(),
69            Some(Component::Normal(_))
70        )
71    {
72        return Err(io::Error::other(
73            "npm file name must be one normal component",
74        ));
75    }
76    Ok(())
77}
78
79/// Open one file for reading without following symlinks. Non-blocking mode
80/// keeps FIFOs from hanging the caller; [`read_bounded`] rejects them.
81pub fn open_file(directory: &Dir, name: &str) -> io::Result<File> {
82    single_component(name)?;
83    let mut options = OpenOptions::new();
84    options.read(true).follow(FollowSymlinks::No).nonblock(true);
85    directory.open_with(name, &options)
86}
87
88/// Read at most `max + 1` bytes after confirming a regular file via `fstat`.
89pub fn read_bounded(file: &File, max: u64, what: &str) -> io::Result<Vec<u8>> {
90    let metadata = file.metadata()?;
91    if !metadata.is_file() {
92        return Err(io::Error::other(format!("{what} is not a regular file")));
93    }
94    // Bounded readers otherwise grow from tiny buffers, even for known-size files.
95    let capacity = usize::try_from(metadata.len().min(max)).unwrap_or(0);
96    let mut bytes = Vec::with_capacity(capacity);
97    file.take(max + 1).read_to_end(&mut bytes)?;
98    if bytes.len() as u64 > max {
99        return Err(io::Error::other(format!("{what} is too large")));
100    }
101    Ok(bytes)
102}
103
104/// Create a new file, failing if anything already exists there.
105pub fn write_new(directory: &Dir, name: &str, bytes: &[u8]) -> io::Result<File> {
106    let mut options = OpenOptions::new();
107    options
108        .write(true)
109        .create_new(true)
110        .follow(FollowSymlinks::No);
111    let mut file = directory.open_with(name, &options)?;
112    file.write_all(bytes)?;
113    Ok(file)
114}
115
116/// Atomically replace or create a file via a staged sibling plus rename.
117pub fn write_atomic(directory: &Dir, name: &str, bytes: &[u8]) -> io::Result<()> {
118    for _ in 0..8 {
119        let staging = unique_tmp();
120        let staged = (|| -> io::Result<()> {
121            let file = write_new(directory, &staging, bytes)?;
122            set_readable(&file)?;
123            file.sync_all()?;
124            directory.rename(&staging, directory, name)
125        })();
126        match staged {
127            Ok(()) => return Ok(()),
128            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
129            Err(error) => {
130                remove_all(directory, &staging);
131                return Err(error);
132            }
133        }
134    }
135    Err(io::Error::other("npm staging collided repeatedly"))
136}
137
138/// Best-effort staging cleanup.
139pub fn remove_all(directory: &Dir, name: &str) {
140    let _ = directory.remove_dir_all(name);
141    let _ = directory.remove_file(name);
142}
143
144#[cfg(unix)]
145pub fn set_readable(file: &File) -> io::Result<()> {
146    use std::os::unix::fs::PermissionsExt;
147    file.set_permissions(Permissions::from_std(std::fs::Permissions::from_mode(
148        0o644,
149    )))
150}
151
152#[cfg(not(unix))]
153pub fn set_readable(_file: &File) -> io::Result<()> {
154    Ok(())
155}
156
157/// Random staging name; callers use create-new semantics and retry, so a
158/// collision (or stale entry after PID reuse) fails closed instead of
159/// overwriting.
160pub fn unique_tmp() -> String {
161    let mut buf = [0u8; 8];
162    if getrandom::fill(&mut buf).is_ok() {
163        return format!(".tmp.{:016x}", u64::from_ne_bytes(buf));
164    }
165    static COUNTER: AtomicU64 = AtomicU64::new(0);
166    let id = COUNTER.fetch_add(1, Ordering::Relaxed);
167    format!(".tmp.{}-{id}", std::process::id())
168}
169
170/// Advisory coordination only; published content still needs verification.
171pub struct Guard {
172    _file: std::fs::File,
173}
174
175/// Open (creating) a guard file without following symlinks and lock it.
176/// Non-blocking open plus a regular-file check keeps FIFOs from hanging.
177pub fn lock_file(directory: &Dir, name: &str) -> io::Result<Guard> {
178    single_component(name)?;
179    let mut options = OpenOptions::new();
180    options
181        .write(true)
182        .create(true)
183        .follow(FollowSymlinks::No)
184        .nonblock(true);
185    let file = directory.open_with(name, &options)?;
186    if !file.metadata()?.is_file() {
187        return Err(io::Error::other("npm lock is not a regular file"));
188    }
189    let std_file = file.into_std();
190    std_file.lock()?;
191    Ok(Guard { _file: std_file })
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn bounded_reads_preserve_bytes_and_enforce_limits() {
200        let temp = tempfile::tempdir().unwrap();
201        let root = open_root(temp.path()).unwrap();
202        for length in [0usize, 1, 32, 4096, 65537] {
203            let expected: Vec<u8> = (0..length).map(|i| (i % 251) as u8).collect();
204            std::fs::write(temp.path().join("data"), &expected).unwrap();
205            for max in [0, length.saturating_sub(1), length, length + 1] {
206                let file = open_file(&root, "data").unwrap();
207                let result = read_bounded(&file, max as u64, "data");
208                if length <= max {
209                    assert_eq!(result.unwrap(), expected);
210                } else {
211                    assert!(result.unwrap_err().to_string().contains("too large"));
212                }
213            }
214        }
215        let sparse = std::fs::File::create(temp.path().join("sparse")).unwrap();
216        sparse.set_len(1 << 30).unwrap();
217        let file = open_file(&root, "sparse").unwrap();
218        assert!(read_bounded(&file, 64, "sparse").is_err());
219    }
220}