shell_tunnel/fs/platform.rs
1//! Cross-platform filesystem differences, absorbed in one place.
2//!
3//! Path separators, reserved names, and stream syntax differ enough between
4//! Windows and Unix that scattering the checks would guarantee one of them is
5//! forgotten. ROADMAP:29 asks for this to exist from the start rather than be
6//! retrofitted.
7
8/// Windows device names, which resolve to devices rather than files no matter
9/// which directory they appear in. Compared without extension: `CON.txt` is
10/// still the console.
11const WINDOWS_RESERVED: &[&str] = &[
12 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
13 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
14];
15
16/// Whether one path component is acceptable as-is.
17///
18/// Applied on every platform, not just Windows. A tree written on Linux and
19/// read on Windows would otherwise contain names the other side cannot open,
20/// and a jail that behaves differently per host is not a jail anyone can reason
21/// about.
22///
23/// `..` is *not* rejected here — escape is decided by canonicalisation
24/// (`FsRoot::resolve_existing`), and a substring rule would also reject the
25/// perfectly ordinary `my..file.txt`.
26pub fn check_component(component: &str) -> Result<(), &'static str> {
27 if component.is_empty() {
28 return Err("empty path component");
29 }
30 if component.contains('\0') {
31 return Err("path contains a null byte");
32 }
33 // Alternate Data Streams: `file.txt:secret` addresses hidden content on
34 // NTFS. Nothing legitimate in a transfer API needs it.
35 if component.contains(':') {
36 return Err("path contains a stream separator");
37 }
38 if component.ends_with('.') || component.ends_with(' ') {
39 return Err("path component ends with a dot or space");
40 }
41 let stem = component
42 .split('.')
43 .next()
44 .unwrap_or(component)
45 .to_ascii_uppercase();
46 if WINDOWS_RESERVED.contains(&stem.as_str()) {
47 return Err("path component is a reserved device name");
48 }
49 Ok(())
50}
51
52/// A number that changes when the path starts referring to a different file.
53///
54/// Unix has an inode; Windows has no equivalent reachable from
55/// `std::fs::metadata` (`file_index()` is only populated through a `File`
56/// handle), so it contributes nothing there and the ETag rests on size and
57/// mtime alone. Recorded rather than worked around: a validator that claims
58/// more than the platform gives is worse than one that is honest.
59/// The filesystem roots a machine-wide scope is measured against.
60///
61/// Unix has exactly one; every mount hangs below it. Windows has one per
62/// drive and nothing above them, which is the whole reason a machine-wide
63/// scope cannot be expressed as a single path there — `--fs-root C:\` can
64/// never reach `D:`, by construction rather than by policy.
65///
66/// Probed once, by asking whether each letter is a directory. A drive that
67/// appears afterwards is deliberately not picked up: a server should reach
68/// what it was started with, not silently widen when someone plugs in a disk.
69/// Canonicalised, which on Windows means the verbatim form (`\\?\C:\`).
70/// `Path::canonicalize` returns verbatim paths, so an anchor left in its
71/// plain `C:\` form would fail `starts_with` against every resolved path and
72/// the containment check would refuse everything that actually exists.
73#[cfg(windows)]
74pub fn filesystem_anchors() -> Vec<std::path::PathBuf> {
75 (b'A'..=b'Z')
76 .map(|letter| std::path::PathBuf::from(format!("{}:\\", letter as char)))
77 .filter(|anchor| anchor.is_dir())
78 .filter_map(|anchor| anchor.canonicalize().ok())
79 .collect()
80}
81
82/// See the Windows variant: one root, and everything hangs below it.
83#[cfg(not(windows))]
84pub fn filesystem_anchors() -> Vec<std::path::PathBuf> {
85 vec![std::path::PathBuf::from("/")]
86}
87
88#[cfg(unix)]
89pub fn file_identity(meta: &std::fs::Metadata) -> u64 {
90 use std::os::unix::fs::MetadataExt;
91 meta.ino()
92}
93
94#[cfg(not(unix))]
95pub fn file_identity(_meta: &std::fs::Metadata) -> u64 {
96 0
97}
98
99/// Remove one filesystem entry, choosing the syscall a symlink actually needs.
100///
101/// `meta` must come from `symlink_metadata` (lstat), never `metadata` (stat)
102/// — the caller needs to know about the entry itself, not whatever it points
103/// to, or every symlink looks identical to its target and this can never
104/// tell a directory symlink from a real directory.
105#[cfg(unix)]
106pub fn remove_entry(path: &std::path::Path, _meta: &std::fs::Metadata) -> std::io::Result<()> {
107 // `unlink` removes the link itself no matter what it points to — file,
108 // directory, or nothing — so there is no branch to make here.
109 std::fs::remove_file(path)
110}
111
112/// Windows counterpart of the Unix `remove_entry` above.
113///
114/// `DeleteFileW` (which `std::fs::remove_file` wraps) refuses a directory
115/// reparse point outright, even though unlinking one is exactly as safe as
116/// unlinking a file symlink — nothing under it is touched either way.
117/// `RemoveDirectoryW` (`std::fs::remove_dir`) is what actually unlinks a
118/// directory reparse point without recursing into it; it only recurses into
119/// a *real* directory's contents, which both of this function's callers keep
120/// away from it: `api::fs::delete_file` refuses a real directory outright
121/// unless `recursive=true` names it, and from there `fs::tree::remove_tree`
122/// calls this only for a non-directory entry (`meta.is_dir()`, from `lstat`,
123/// routes a real directory to `remove_dir` itself instead — a directory
124/// *symlink* still reaches here, same as everywhere else in this function).
125#[cfg(windows)]
126pub fn remove_entry(path: &std::path::Path, meta: &std::fs::Metadata) -> std::io::Result<()> {
127 if meta.is_symlink() {
128 // Follows the link on purpose — the one place in this function that
129 // means to — to learn whether the target is a directory.
130 if let Ok(target) = std::fs::metadata(path) {
131 if target.is_dir() {
132 return std::fs::remove_dir(path);
133 }
134 }
135 }
136 std::fs::remove_file(path)
137}
138
139#[cfg(not(any(unix, windows)))]
140pub fn remove_entry(path: &std::path::Path, _meta: &std::fs::Metadata) -> std::io::Result<()> {
141 std::fs::remove_file(path)
142}
143
144/// Whether `err` reports that the filesystem has run out of space.
145///
146/// Checked against the numeric OS error code, never the rendered message: an
147/// earlier version of the upload API answered this by matching substrings
148/// like `"space"` or `"full"` in `io::Error`'s `Display` output, which
149/// renders in the system locale (wrong on a non-English system) and would
150/// also fire on an ordinary error that happens to name a directory "full".
151/// A caller distinguishing "the disk is full, retry after freeing space"
152/// from "the server has a bug, file a report" needs this to be reliable —
153/// the two respond completely differently.
154///
155/// `EDQUOT` (`libc::EDQUOT`) accompanies `ENOSPC` as its quota-analogue
156/// sibling — a per-user or per-directory quota can be exhausted well before
157/// the volume itself is full, and from a client's perspective both answers
158/// are the same instruction ("free something up and retry"). Both are
159/// `libc`'s *named* constants rather than a literal number precisely
160/// because their numeric value is not portable across Unix-likes (`EDQUOT`
161/// is 122 on Linux, 69 on macOS and the BSDs) — `libc` already carries the
162/// platform-correct value for each target, so naming it is also more
163/// correct than hardcoding one.
164///
165/// Windows has three counterparts, not one: `ERROR_DISK_FULL` (112),
166/// `ERROR_HANDLE_DISK_FULL` (39/`0x27`) — the latter is what a handle-based
167/// write (exactly the path `UploadStore::append`'s `Write` impl takes)
168/// reports for a full volume — and `ERROR_DISK_QUOTA_EXCEEDED` (1295), which
169/// is `EDQUOT`'s counterpart and was missing while the Unix side had it: a
170/// per-user quota on an NTFS volume is exhausted with the volume itself far
171/// from full, and answering `500` there tells a client to file a bug about a
172/// condition it could have fixed by freeing space. All three are documented
173/// literals rather than named constants: there is no crate in this tree's
174/// dependency graph that names them (no `windows-sys`, and this task adds no
175/// new dependencies). 1295's identity was confirmed on Windows by rendering
176/// it — "The requested file operation failed because the storage quota was
177/// exceeded" — rather than read off a table.
178///
179/// `ERROR_NOT_ENOUGH_QUOTA` (1816) is deliberately *not* here despite the
180/// name: it renders as "Not enough quota is available to process this
181/// command" and reports a process memory quota, which is not a condition
182/// freeing disk space resolves.
183#[cfg(unix)]
184pub fn is_out_of_space(err: &std::io::Error) -> bool {
185 matches!(err.raw_os_error(), Some(code) if code == libc::ENOSPC || code == libc::EDQUOT)
186}
187
188#[cfg(windows)]
189pub fn is_out_of_space(err: &std::io::Error) -> bool {
190 /// `ERROR_DISK_FULL`, from `winerror.h`.
191 const ERROR_DISK_FULL: i32 = 112;
192 /// `ERROR_HANDLE_DISK_FULL` (`0x27`), from `winerror.h` — reported for a
193 /// full volume on a handle-based write, which is the path uploads take.
194 const ERROR_HANDLE_DISK_FULL: i32 = 39;
195 /// `ERROR_DISK_QUOTA_EXCEEDED`, from `winerror.h` — a quota exhausted on a
196 /// volume with space left, which is `EDQUOT`'s counterpart.
197 const ERROR_DISK_QUOTA_EXCEEDED: i32 = 1295;
198 // `io::Error::raw_os_error()` reports the raw Win32 error code, not an
199 // `errno` — none of these is to be confused with any POSIX `ENOSPC`
200 // or `EDQUOT` numbering.
201 matches!(
202 err.raw_os_error(),
203 Some(code)
204 if code == ERROR_DISK_FULL
205 || code == ERROR_HANDLE_DISK_FULL
206 || code == ERROR_DISK_QUOTA_EXCEEDED
207 )
208}
209
210#[cfg(not(any(unix, windows)))]
211pub fn is_out_of_space(_err: &std::io::Error) -> bool {
212 false
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn ordinary_names_pass() {
221 assert!(check_component("config.json").is_ok());
222 assert!(check_component("my-app_v2").is_ok());
223 }
224
225 #[test]
226 fn a_name_containing_two_dots_is_not_traversal() {
227 // The bug the old substring rule had: `..` inside a name is ordinary.
228 assert!(check_component("my..file.txt").is_ok());
229 assert!(check_component("..config").is_ok());
230 }
231
232 #[test]
233 fn reserved_device_names_are_refused() {
234 assert!(check_component("CON").is_err());
235 assert!(check_component("con").is_err());
236 assert!(check_component("NUL.txt").is_err());
237 assert!(check_component("COM1.log").is_err());
238 // Not reserved: the stem differs.
239 assert!(check_component("CONSOLE").is_ok());
240 }
241
242 #[test]
243 fn stream_separators_are_refused() {
244 assert!(check_component("file.txt:secret").is_err());
245 }
246
247 #[test]
248 fn trailing_dot_or_space_is_refused() {
249 assert!(check_component("name.").is_err());
250 assert!(check_component("name ").is_err());
251 }
252
253 #[test]
254 fn null_bytes_are_refused() {
255 assert!(check_component("na\0me").is_err());
256 }
257
258 #[test]
259 fn empty_components_are_refused() {
260 assert!(check_component("").is_err());
261 }
262
263 /// Cannot deterministically fill a disk to force a real `ENOSPC` in a
264 /// test, so this is the honest substitute: pin `is_out_of_space` against
265 /// the raw OS codes directly, the same numeric values `raw_os_error()`
266 /// would actually report.
267 #[cfg(unix)]
268 #[test]
269 fn is_out_of_space_matches_enospc_and_edquot_only() {
270 let enospc = std::io::Error::from_raw_os_error(libc::ENOSPC);
271 assert!(is_out_of_space(&enospc));
272
273 // The quota-analogue sibling must match too, not just ENOSPC itself.
274 let edquot = std::io::Error::from_raw_os_error(libc::EDQUOT);
275 assert!(is_out_of_space(&edquot));
276
277 // A different errno — e.g. ENOENT — must not be mistaken for either.
278 let enoent = std::io::Error::from_raw_os_error(libc::ENOENT);
279 assert!(!is_out_of_space(&enoent));
280
281 // Not an OS error at all.
282 let other = std::io::Error::other("not an os error");
283 assert!(!is_out_of_space(&other));
284 }
285
286 #[cfg(windows)]
287 #[test]
288 fn is_out_of_space_matches_disk_full_codes_only() {
289 const ERROR_DISK_FULL: i32 = 112;
290 const ERROR_HANDLE_DISK_FULL: i32 = 39;
291 const ERROR_DISK_QUOTA_EXCEEDED: i32 = 1295;
292 const ERROR_NOT_ENOUGH_QUOTA: i32 = 1816;
293
294 let disk_full = std::io::Error::from_raw_os_error(ERROR_DISK_FULL);
295 assert!(is_out_of_space(&disk_full));
296
297 // The handle-based-write sibling must match too — this is the code
298 // an actual full-volume `Write` (the path uploads take) reports.
299 let handle_disk_full = std::io::Error::from_raw_os_error(ERROR_HANDLE_DISK_FULL);
300 assert!(is_out_of_space(&handle_disk_full));
301
302 // A quota exhausted on a volume with space left is the same
303 // instruction to the client, and is what the Unix side's `EDQUOT`
304 // already covered.
305 let quota = std::io::Error::from_raw_os_error(ERROR_DISK_QUOTA_EXCEEDED);
306 assert!(is_out_of_space("a));
307
308 // Named like a quota and is not one: 1816 is a process memory quota,
309 // which freeing disk space does not resolve.
310 let memory_quota = std::io::Error::from_raw_os_error(ERROR_NOT_ENOUGH_QUOTA);
311 assert!(!is_out_of_space(&memory_quota));
312
313 // A different Win32 code — e.g. ERROR_FILE_NOT_FOUND (2) — must not
314 // be mistaken for any of them.
315 let not_found = std::io::Error::from_raw_os_error(2);
316 assert!(!is_out_of_space(¬_found));
317
318 let other = std::io::Error::other("not an os error");
319 assert!(!is_out_of_space(&other));
320 }
321}