1use std::fs::{File, Metadata};
25use std::io::{self, Read, Write};
26use std::os::fd::{BorrowedFd, OwnedFd};
27use std::os::unix::fs::{FileTypeExt, MetadataExt};
28use std::path::Path;
29
30use rustix::fs::{self as rfs, AtFlags, Mode, OFlags};
31use rustix::io::Errno;
32
33fn dir_flags() -> OFlags {
34 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC
35}
36
37fn file_read_flags() -> OFlags {
38 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::NOCTTY | OFlags::CLOEXEC
39}
40
41fn is_symlink_errno(e: Errno) -> bool {
42 e == Errno::LOOP || e == Errno::MLINK
44}
45
46fn unsafe_entry(name: &str, what: &str) -> io::Error {
47 io::Error::new(
48 io::ErrorKind::InvalidData,
49 format!("{name:?} is {what}; refusing to follow it"),
50 )
51}
52
53pub fn open_root(path: &Path) -> io::Result<OwnedFd> {
60 Ok(rfs::open(
61 path,
62 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
63 Mode::empty(),
64 )?)
65}
66
67pub fn open_dir_at(parent: BorrowedFd<'_>, name: &str) -> io::Result<OwnedFd> {
73 match rfs::openat(parent, name, dir_flags(), Mode::empty()) {
74 Ok(fd) => Ok(fd),
75 Err(e) if is_symlink_errno(e) || e == Errno::NOTDIR => {
76 Err(unsafe_entry(name, "a symlink or not a directory"))
77 }
78 Err(e) => Err(e.into()),
79 }
80}
81
82pub fn ensure_dir_at(parent: BorrowedFd<'_>, name: &str) -> io::Result<OwnedFd> {
88 match rfs::mkdirat(parent, name, Mode::from_raw_mode(0o755)) {
89 Ok(()) => {}
90 Err(e) if e == Errno::EXIST => {}
91 Err(e) => return Err(e.into()),
92 }
93 open_dir_at(parent, name)
94}
95
96pub fn entry_exists_at(dir: BorrowedFd<'_>, name: &str) -> io::Result<bool> {
101 match rfs::statat(dir, name, AtFlags::SYMLINK_NOFOLLOW) {
102 Ok(_) => Ok(true),
103 Err(e) if e == Errno::NOENT => Ok(false),
104 Err(e) => Err(e.into()),
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum OpenRefusal {
111 Missing,
113 Rejected(String),
116}
117
118fn describe(ft: std::fs::FileType) -> &'static str {
119 if ft.is_dir() {
120 "a directory"
121 } else if ft.is_symlink() {
122 "a symlink"
123 } else if ft.is_fifo() {
124 "a fifo"
125 } else if ft.is_socket() {
126 "a socket"
127 } else if ft.is_block_device() || ft.is_char_device() {
128 "a device"
129 } else {
130 "a special file"
131 }
132}
133
134pub fn open_regular_at(
141 dir: BorrowedFd<'_>,
142 name: &str,
143 max_bytes: u64,
144) -> Result<(File, Metadata), OpenRefusal> {
145 let fd = match rfs::openat(dir, name, file_read_flags(), Mode::empty()) {
146 Ok(fd) => fd,
147 Err(e) if e == Errno::NOENT => return Err(OpenRefusal::Missing),
148 Err(e) if is_symlink_errno(e) => {
149 return Err(OpenRefusal::Rejected("symbolic link".into()));
150 }
151 Err(e) => return Err(OpenRefusal::Rejected(format!("cannot open: {e}"))),
152 };
153 let file = File::from(fd);
154 let meta = file
155 .metadata()
156 .map_err(|e| OpenRefusal::Rejected(format!("cannot stat: {e}")))?;
157 let ft = meta.file_type();
158 if !ft.is_file() {
159 return Err(OpenRefusal::Rejected(format!(
160 "not a regular file ({})",
161 describe(ft)
162 )));
163 }
164 if meta.nlink() > 1 {
165 return Err(OpenRefusal::Rejected(format!(
166 "regular file with {} hard links",
167 meta.nlink()
168 )));
169 }
170 if meta.len() > max_bytes {
171 return Err(OpenRefusal::Rejected(format!(
172 "file too large ({} bytes > {max_bytes} byte limit)",
173 meta.len()
174 )));
175 }
176 Ok((file, meta))
177}
178
179pub fn read_capped(file: File, max_bytes: u64) -> io::Result<Vec<u8>> {
185 let mut buf = Vec::new();
186 file.take(max_bytes.saturating_add(1))
187 .read_to_end(&mut buf)?;
188 if u64::try_from(buf.len()).unwrap_or(u64::MAX) > max_bytes {
189 return Err(io::Error::new(
190 io::ErrorKind::InvalidData,
191 format!("file grew beyond the {max_bytes} byte limit while reading"),
192 ));
193 }
194 Ok(buf)
195}
196
197pub fn create_new_at(dir: BorrowedFd<'_>, name: &str, content: &[u8]) -> io::Result<()> {
204 let fd = rfs::openat(
205 dir,
206 name,
207 OFlags::WRONLY
208 | OFlags::CREATE
209 | OFlags::EXCL
210 | OFlags::NOFOLLOW
211 | OFlags::NOCTTY
212 | OFlags::CLOEXEC,
213 Mode::from_raw_mode(0o644),
214 )?;
215 let mut file = File::from(fd);
216 file.write_all(content)?;
217 file.flush()
218}
219
220#[cfg(test)]
221mod tests {
222 use std::os::fd::AsFd;
223 use std::os::unix::fs::symlink;
224
225 use super::*;
226
227 #[test]
228 fn dirs_below_the_root_are_never_followed() {
229 let tmp = tempfile::tempdir().unwrap();
230 let root = tmp.path().join("groups");
231 std::fs::create_dir_all(root.join("real")).unwrap();
232 let elsewhere = tmp.path().join("elsewhere");
233 std::fs::create_dir_all(&elsewhere).unwrap();
234 symlink(&elsewhere, root.join("link")).unwrap();
235 std::fs::write(root.join("file"), "x").unwrap();
236
237 let fd = open_root(&root).unwrap();
238 assert!(open_dir_at(fd.as_fd(), "real").is_ok());
239 let err = open_dir_at(fd.as_fd(), "link").unwrap_err();
240 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
241 let err = open_dir_at(fd.as_fd(), "file").unwrap_err();
242 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
243 let err = open_dir_at(fd.as_fd(), "missing").unwrap_err();
244 assert_eq!(err.kind(), io::ErrorKind::NotFound);
245
246 assert!(ensure_dir_at(fd.as_fd(), "new").is_ok());
248 assert!(root.join("new").is_dir());
249 assert!(ensure_dir_at(fd.as_fd(), "new").is_ok());
250 assert!(ensure_dir_at(fd.as_fd(), "link").is_err());
251 }
252
253 #[test]
254 fn root_may_be_an_operator_symlink() {
255 let tmp = tempfile::tempdir().unwrap();
256 std::fs::create_dir_all(tmp.path().join("real")).unwrap();
257 symlink(tmp.path().join("real"), tmp.path().join("alias")).unwrap();
258 assert!(open_root(&tmp.path().join("alias")).is_ok());
259 }
260
261 #[test]
262 fn regular_files_only() {
263 let tmp = tempfile::tempdir().unwrap();
264 let dir = tmp.path();
265 std::fs::write(dir.join("ok"), "hello").unwrap();
266 std::fs::write(dir.join("target"), "secret").unwrap();
267 symlink(dir.join("target"), dir.join("sym")).unwrap();
268 std::fs::hard_link(dir.join("target"), dir.join("hard")).unwrap();
269 std::fs::create_dir(dir.join("sub")).unwrap();
270 std::fs::write(dir.join("big"), vec![b'a'; 64]).unwrap();
271 let fifo = dir.join("fifo");
272 rfs::mkfifoat(rfs::CWD, &fifo, Mode::from_raw_mode(0o600)).unwrap();
273
274 let fd = open_root(dir).unwrap();
275 let (file, meta) = open_regular_at(fd.as_fd(), "ok", 1024).unwrap();
276 assert_eq!(meta.len(), 5);
277 assert_eq!(read_capped(file, 1024).unwrap(), b"hello");
278
279 assert_eq!(
280 open_regular_at(fd.as_fd(), "missing", 1024).unwrap_err(),
281 OpenRefusal::Missing
282 );
283 for name in ["sym", "hard", "sub", "big", "fifo"] {
284 assert!(
285 matches!(
286 open_regular_at(fd.as_fd(), name, 32),
287 Err(OpenRefusal::Rejected(_))
288 ),
289 "{name} must be rejected"
290 );
291 }
292 }
293
294 #[test]
295 fn read_capped_detects_growth() {
296 let tmp = tempfile::tempdir().unwrap();
297 std::fs::write(tmp.path().join("f"), "0123456789").unwrap();
298 let file = File::open(tmp.path().join("f")).unwrap();
299 assert!(read_capped(file, 4).is_err());
300 }
301
302 #[test]
303 fn create_new_never_writes_through_existing_entries() {
304 let tmp = tempfile::tempdir().unwrap();
305 let dir = tmp.path().join("d");
306 std::fs::create_dir(&dir).unwrap();
307 let victim = tmp.path().join("victim");
308 std::fs::write(&victim, "original").unwrap();
309 symlink(&victim, dir.join("live-link")).unwrap();
310 symlink(tmp.path().join("nowhere"), dir.join("dangling")).unwrap();
311
312 let fd = open_root(&dir).unwrap();
313 assert!(entry_exists_at(fd.as_fd(), "dangling").unwrap());
314 assert!(!entry_exists_at(fd.as_fd(), "fresh").unwrap());
315 create_new_at(fd.as_fd(), "fresh", b"content").unwrap();
316 assert_eq!(
317 std::fs::read_to_string(dir.join("fresh")).unwrap(),
318 "content"
319 );
320 assert_eq!(
321 create_new_at(fd.as_fd(), "fresh", b"again")
322 .unwrap_err()
323 .kind(),
324 io::ErrorKind::AlreadyExists
325 );
326 assert!(create_new_at(fd.as_fd(), "live-link", b"pwned").is_err());
327 assert!(create_new_at(fd.as_fd(), "dangling", b"pwned").is_err());
328 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
329 assert!(!tmp.path().join("nowhere").exists());
330 }
331}