Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_core/src/file.rs

22.4 KiB, 40 runs

created by r1870400018:78, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1use crate::{
2 prelude::*,
3 path::NormalPath,
4};
5
6use std::{
7 fmt,
8 fs::{
9 self,
10 File,
11 OpenOptions,
12 },
13 io::Write,
14 path::{
15 Path,
16 PathBuf,
17 },
18};
19
20
21#[derive(Clone, Debug)]
22pub enum OsPath {
23 Dir(PathBuf),
24 File(PathBuf),
25}
26
27#[derive(Clone, Copy, Debug)]
28pub enum PathState {
29 DirMustExist,
30 FileMustExist,
31 Create,
32}
33
34impl PathState {
35
36 pub fn validate(
37 &self,
38 root: &PathBuf,
39 rel_path: &str,
40 )
41 -> Outcome<()>
42 {
43 let rel_path = Path::new(rel_path).normalise();
44 if rel_path.escapes() {
45 return Err(err!(
46 "The relative path '{:?}' escapes the root directory.", rel_path;
47 Invalid, Input, Path));
48 }
49 let abs_path = root.clone().join(rel_path).normalise().absolute();
50 if abs_path.exists() {
51 if let Self::DirMustExist = self {
52 if !abs_path.is_dir() {
53 return Err(err!(
54 "Path '{:?}' exists but is not a directory.", root;
55 Input, Invalid, File, Path));
56 }
57 }
58 } else {
59 match self {
60 Self::DirMustExist |
61 Self::FileMustExist => return Err(err!(
62 "The path '{:?}' must exist but was not found.", abs_path;
63 Path, File, Missing)),
64 Self::Create => res!(fs::create_dir_all(&abs_path)),
65 }
66 }
67 Ok(())
68 }
69}
70
71
72pub trait Loadable {
73 fn load<P: AsRef<Path>>(path: P) -> Outcome<Self> where Self: Sized;
74}
75
76pub fn touch(path: &Path) -> Outcome<File> {
77 Ok(res!(
78 OpenOptions::new().create(true).write(true).open(path),
79 File, Write,
80 ))
81}
82
83/// Writes `data` to `path` as key material: atomically, and at mode 0600
84/// whatever the caller's umask, so the bytes are never briefly readable by
85/// anyone else and a crash never leaves a loose or partial file where the
86/// secret should be.
87///
88/// The write lands on a `.tmp` sibling of `path`, created with mode 0600
89/// directly (never `create` then `chmod`, which leaves a window at the
90/// creating process's default mode), fsynced, then renamed over `path`. The
91/// rename replaces whatever `path` held -- including its mode -- so a
92/// pre-existing, more permissive file also ends at 0600. The directory is
93/// fsynced too, so the rename cannot survive a crash while the directory
94/// entry pointing at it does not; and the `.tmp` is removed on every error
95/// path, so a failed save never leaves the whole secret sitting under a
96/// name nothing else will read.
97#[cfg(unix)]
98pub fn save_secret(path: &Path, data: &[u8]) -> Outcome<()> {
99 use std::os::unix::fs::OpenOptionsExt;
100
101 let tmp = res!(secret_tmp_path(path));
102 // A `.tmp` left behind by an interrupted previous write may already
103 // exist at whatever mode that run's environment gave it. `.mode(0o600)`
104 // below is only honoured for a file `open` actually creates, so remove
105 // any leftover first -- otherwise reopening it would keep its old,
106 // possibly wider, permissions instead of the 0600 this call promises.
107 match fs::remove_file(&tmp) {
108 Ok(()) => {},
109 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {},
110 Err(e) => return Err(err!(e,
111 "Could not remove the stale temporary secret file {:?}.", tmp;
112 File, IO, Write)),
113 }
114 {
115 let mut f = match OpenOptions::new()
116 .write(true)
117 .create_new(true)
118 .mode(0o600)
119 .open(&tmp)
120 {
121 Ok(f) => f,
122 Err(e) => return Err(err!(e,
123 "Could not create the temporary secret file {:?}.", tmp;
124 File, IO, Create)),
125 };
126 if let Err(e) = f.write_all(data) {
127 let _ = fs::remove_file(&tmp);
128 return Err(err!(e,
129 "Could not write the temporary secret file {:?}.", tmp;
130 File, IO, Write));
131 }
132 if let Err(e) = f.sync_all() {
133 let _ = fs::remove_file(&tmp);
134 return Err(err!(e,
135 "Could not fsync the temporary secret file {:?}.", tmp;
136 File, IO, Write));
137 }
138 }
139 if let Err(e) = fs::rename(&tmp, path) {
140 let _ = fs::remove_file(&tmp);
141 return Err(err!(e,
142 "Could not rename {:?} to secret file {:?}.", tmp, path;
143 File, IO, Write));
144 }
145 res!(sync_secret_parent_dir(path));
146 Ok(())
147}
148
149/// Writes `data` to `path` atomically. No POSIX mode bits exist to restrict
150/// here, so this platform gets the write-then-rename without the 0600
151/// guarantee the unix build makes, but the fsync-before-rename still applies:
152/// without it, a crash could still leave the rename recorded while the data
153/// it pointed at never reached the disk it was written on.
154#[cfg(not(unix))]
155pub fn save_secret(path: &Path, data: &[u8]) -> Outcome<()> {
156 let tmp = res!(secret_tmp_path(path));
157 let mut f = match File::create(&tmp) {
158 Ok(f) => f,
159 Err(e) => return Err(err!(e,
160 "Could not create the temporary secret file {:?}.", tmp;
161 File, IO, Create)),
162 };
163 if let Err(e) = f.write_all(data) {
164 let _ = fs::remove_file(&tmp);
165 return Err(err!(e,
166 "Could not write the temporary secret file {:?}.", tmp;
167 File, IO, Write));
168 }
169 if let Err(e) = f.sync_all() {
170 let _ = fs::remove_file(&tmp);
171 return Err(err!(e,
172 "Could not fsync the temporary secret file {:?}.", tmp;
173 File, IO, Write));
174 }
175 drop(f);
176 if let Err(e) = fs::rename(&tmp, path) {
177 let _ = fs::remove_file(&tmp);
178 return Err(err!(e,
179 "Could not rename {:?} to secret file {:?}.", tmp, path;
180 File, IO, Write));
181 }
182 Ok(())
183}
184
185/// Creates `path` and any missing parents, as `create_dir_all` does, but at
186/// mode 0700 rather than the process default: a directory meant to hold key
187/// material must not be group- or world-searchable, since `create_dir_all`'s
188/// default mode is only ever narrowed by the umask, and umasks such as 002
189/// or 022 leave it group- or world-readable and -searchable, letting anyone
190/// in the group list, and on some setups swap, the keys inside.
191///
192/// Only directories this call actually creates get 0700; one that already
193/// exists is left at whatever mode it holds, since narrowing that is
194/// `restrict_secret`'s job. A plain `create_dir_all` off unix, where there
195/// is no mode to set.
196#[cfg(unix)]
197pub fn create_secret_dir(path: &Path) -> Outcome<()> {
198 use std::{
199 fs::DirBuilder,
200 os::unix::fs::DirBuilderExt,
201 };
202
203 if let Err(e) = DirBuilder::new().recursive(true).mode(0o700).create(path) {
204 return Err(err!(e,
205 "Could not create key directory {:?} at mode 0700.", path;
206 File, IO, Create));
207 }
208 Ok(())
209}
210
211/// A plain recursive create off unix: there is no mode to set.
212#[cfg(not(unix))]
213pub fn create_secret_dir(path: &Path) -> Outcome<()> {
214 if let Err(e) = fs::create_dir_all(path) {
215 return Err(err!(e,
216 "Could not create key directory {:?}.", path;
217 File, IO, Create));
218 }
219 Ok(())
220}
221
222/// Narrows an existing file's mode to its owner read/write bits, dropping
223/// group, other and execute bits, for a key file that predates this
224/// codebase's atomic `save_secret` writes, or that arrived by some other
225/// route -- a backup restore, an `scp`, a deploy step -- at whatever mode
226/// its source held.
227///
228/// Unlike widening a mode, narrowing one has no window to close: the file
229/// already exists at its current mode throughout, and `chmod` only ever
230/// removes bits, so there is no intermediate state where the file is any
231/// more exposed than it already was. A no-op when the mode is already 0600
232/// or narrower, and a no-op entirely off unix, where there are no POSIX mode
233/// bits to narrow. Only ever removes bits from the owner's read/write pair
234/// too -- a 0440 key ends at 0400, never gaining the write bit it did not
235/// have.
236///
237/// Returns the mode the file was narrowed from, so a caller that silences the
238/// log can still tell its user, and `None` when nothing changed.
239///
240/// A failed narrowing warns and returns `Ok(None)` rather than erroring: the
241/// file was already readable at whatever mode it held, so refusing to start
242/// over a `chmod` this process cannot make -- EPERM on a key it can read but
243/// does not own, EROFS on a read-only mount -- would trade a narrower mode
244/// for no service at all.
245#[cfg(unix)]
246pub fn restrict_secret(path: &Path) -> Outcome<Option<u32>> {
247 use std::os::unix::fs::PermissionsExt;
248
249 let meta = match fs::metadata(path) {
250 Ok(m) => m,
251 Err(e) => return Err(err!(e,
252 "Could not stat {:?} to check whether its mode needs narrowing.", path;
253 File, IO, Read)),
254 };
255 let mode = meta.permissions().mode() & 0o777;
256 if mode & !0o600 == 0 {
257 return Ok(None);
258 }
259 let narrowed = mode & 0o600; // keep only the owner rw bits already present, never add one
260 warn!("Narrowing key file {:?} from mode {:04o} to {:04o}.", path, mode, narrowed);
261 if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(narrowed)) {
262 warn!("Could not narrow {:?} from mode {:04o} to {:04o}: {}. Leaving the key at its \
263 current, already-readable mode rather than refusing to start.", path, mode, narrowed, e);
264 return Ok(None);
265 }
266 Ok(Some(mode))
267}
268
269/// A no-op off unix: there are no POSIX mode bits to narrow.
270#[cfg(not(unix))]
271pub fn restrict_secret(_path: &Path) -> Outcome<Option<u32>> {
272 Ok(None)
273}
274
275/// Fsyncs the directory holding `path`, after the rename that lands a secret
276/// there. Without this, the rename itself can survive a crash while the
277/// directory entry pointing at it does not, which can bring back a file --
278/// or the previous contents of one -- that was already reported saved.
279#[cfg(unix)]
280fn sync_secret_parent_dir(path: &Path) -> Outcome<()> {
281 let dir = match path.parent() {
282 Some(d) if !d.as_os_str().is_empty() => d,
283 _ => Path::new("."),
284 };
285 let d = match File::open(dir) {
286 Ok(d) => d,
287 Err(e) => return Err(err!(e,
288 "Could not open the directory {:?} to fsync it after saving {:?}.", dir, path;
289 File, IO, Write)),
290 };
291 if let Err(e) = d.sync_all() {
292 return Err(err!(e,
293 "Could not fsync the directory {:?} after saving {:?}.", dir, path;
294 File, IO, Write));
295 }
296 Ok(())
297}
298
299/// The sibling `.tmp` path a secret write lands on before the rename.
300fn secret_tmp_path(path: &Path) -> Outcome<PathBuf> {
301 let file_name = match path.file_name() {
302 Some(n) => n.to_os_string(),
303 None => return Err(err!(
304 "Path {:?} has no file-name component; cannot save secret material.", path;
305 Invalid, Input, Path)),
306 };
307 // Built as an `OsString`, not via `to_string_lossy`, so a non-UTF-8 file
308 // name is not mangled into one that could collide with another file's.
309 let mut tmp_name = file_name;
310 tmp_name.push(".tmp");
311 let mut tmp = path.to_path_buf();
312 tmp.set_file_name(tmp_name);
313 Ok(tmp)
314}
315
316#[derive(Debug, Default)]
317pub struct TextFileState {
318 pub path: String,
319 pub line_num: usize,
320}
321
322impl fmt::Display for TextFileState {
323 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324 write!(f, "{}:{}", self.path, self.line_num)
325 }
326}
327
328
329#[cfg(all(test, unix))]
330mod tests {
331 use super::*;
332
333 use std::{
334 os::unix::fs::PermissionsExt,
335 process::Command,
336 sync::atomic::{
337 AtomicU64,
338 Ordering,
339 },
340 };
341
342 // Combined with the PID this gives each test a scratch path that cannot
343 // collide, even when the suite runs across threads.
344 static COUNTER: AtomicU64 = AtomicU64::new(0);
345
346 fn scratch_path(label: &str) -> PathBuf {
347 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
348 std::env::temp_dir().join(fmt!(
349 "fe2o3_core_file_secret_test_{}_{}_{}", std::process::id(), n, label,
350 ))
351 }
352
353 fn mode_of(path: &Path) -> Outcome<u32> {
354 let meta = match fs::metadata(path) {
355 Ok(m) => m,
356 Err(e) => return Err(err!(e,
357 "Could not stat {:?}.", path;
358 Test, File, IO, Read)),
359 };
360 Ok(meta.permissions().mode() & 0o777)
361 }
362
363 // Set only inside the re-exec'd child below, so it knows to run the real
364 // check instead of spawning a further child.
365 const UMASK_CHILD_ENV: &str = "FE2O3_CORE_TEST_UMASK_CHILD";
366
367 /// A permissive umask must not leak into the secret's mode: 0600 has no
368 /// group or other bits, so no umask can widen it, but only if the mode
369 /// is requested at creation rather than left to the default and chmodded
370 /// after.
371 ///
372 /// `umask` is process-wide and this codebase avoids `unsafe`, so nothing
373 /// here calls it directly. Instead the test re-executes its own test
374 /// binary as a child process, letting `sh` set the umask before
375 /// `exec`-ing into it filtered to just this one test; that avoids both
376 /// an `unsafe` libc call and any race with other tests in this binary,
377 /// which never sees its umask changed at all.
378 #[test]
379 fn test_save_secret_ignores_a_permissive_umask() -> Outcome<()> {
380 if std::env::var(UMASK_CHILD_ENV).is_ok() {
381 // Inside the re-exec'd child: the shell already set umask 002
382 // before handing control to this binary, so just run the check.
383 let path = scratch_path("permissive_umask");
384 res!(save_secret(&path, b"top secret"));
385 let mode = res!(mode_of(&path));
386 let _ = fs::remove_file(&path);
387 if mode != 0o600 {
388 return Err(err!(
389 "{:?} ended at mode {:o} under umask 0o002, not 0600.", path, mode;
390 Test, Mismatch));
391 }
392 return Ok(());
393 }
394
395 let exe = match std::env::current_exe() {
396 Ok(p) => p,
397 Err(e) => return Err(err!(e,
398 "Could not find this test binary's own path to re-exec it under a set umask.";
399 Test, File, IO)),
400 };
401 let test_name = "file::tests::test_save_secret_ignores_a_permissive_umask";
402 let script = fmt!("umask 002 && exec \"$0\" --exact {}", test_name);
403 let output = match Command::new("sh")
404 .arg("-c")
405 .arg(&script)
406 .arg(&exe)
407 .env(UMASK_CHILD_ENV, "1")
408 .output()
409 {
410 Ok(o) => o,
411 Err(e) => return Err(err!(e,
412 "Could not spawn the umask-002 child re-running {:?}.", exe;
413 Test, IO)),
414 };
415 if !output.status.success() {
416 return Err(err!(
417 "The umask-002 child ({:?} --exact {}) failed: {:?}.", exe, test_name, output.status;
418 Test, Mismatch));
419 }
420 // `--exact {test_name}` matching nothing -- for example after a rename of
421 // this very test -- also exits 0, reporting "0 passed" for "running 0
422 // tests". That would make this test vacuously pass forever, so require
423 // the child to say it ran exactly the one test.
424 let stdout = String::from_utf8_lossy(&output.stdout);
425 if !stdout.contains("1 passed") {
426 return Err(err!(
427 "The umask-002 child ({:?} --exact {}) reported no matching test, so \
428 nothing was actually checked under umask 002. Child stdout: {}",
429 exe, test_name, stdout;
430 Test, Mismatch));
431 }
432 Ok(())
433 }
434
435 /// An existing, more permissive file must still end at 0600: the rename
436 /// over it replaces its mode along with its contents.
437 #[test]
438 fn test_save_secret_restricts_an_existing_permissive_file() -> Outcome<()> {
439 let path = scratch_path("existing_permissive");
440 if let Err(e) = fs::write(&path, b"old, world-readable content") {
441 return Err(err!(e, "Could not pre-seed {:?}.", path; Test, File, IO, Write));
442 }
443 if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(0o644)) {
444 return Err(err!(e, "Could not set 0644 on {:?}.", path; Test, File, IO));
445 }
446
447 res!(save_secret(&path, b"new secret"));
448
449 let mode = res!(mode_of(&path));
450 let contents = fs::read(&path);
451 let _ = fs::remove_file(&path);
452 match contents {
453 Ok(c) if c == b"new secret" => (),
454 Ok(c) => return Err(err!(
455 "{:?} held {:?} after save_secret, not the new bytes.", path, c;
456 Test, Mismatch)),
457 Err(e) => return Err(err!(e, "Could not read back {:?}.", path; Test, File, IO)),
458 }
459 if mode != 0o600 {
460 return Err(err!(
461 "{:?} was 0644 before saving and ended at {:o}, not 0600.", path, mode;
462 Test, Mismatch));
463 }
464 Ok(())
465 }
466
467 /// A `.tmp` sibling left behind at a wide mode by an interrupted prior
468 /// write -- not the target `path` itself -- must not leak that mode into
469 /// the finished file: `open`'s `mode(0o600)` is only honoured on
470 /// creation, so a stale, reused `.tmp` would otherwise keep its old bits.
471 #[test]
472 fn test_save_secret_ignores_a_stale_permissive_tmp_file() -> Outcome<()> {
473 let path = scratch_path("stale_tmp");
474 let tmp = res!(secret_tmp_path(&path));
475 if let Err(e) = fs::write(&tmp, b"leftover from a killed run") {
476 return Err(err!(e, "Could not pre-seed {:?}.", tmp; Test, File, IO, Write));
477 }
478 if let Err(e) = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)) {
479 return Err(err!(e, "Could not set 0644 on {:?}.", tmp; Test, File, IO));
480 }
481
482 res!(save_secret(&path, b"new secret"));
483
484 let mode = res!(mode_of(&path));
485 let contents = fs::read(&path);
486 let _ = fs::remove_file(&path);
487 let _ = fs::remove_file(&tmp);
488 match contents {
489 Ok(c) if c == b"new secret" => (),
490 Ok(c) => return Err(err!(
491 "{:?} held {:?} after save_secret, not the new bytes.", path, c;
492 Test, Mismatch)),
493 Err(e) => return Err(err!(e, "Could not read back {:?}.", path; Test, File, IO)),
494 }
495 if mode != 0o600 {
496 return Err(err!(
497 "{:?} ended at {:o} despite a 0644 stale .tmp, not 0600.", path, mode;
498 Test, Mismatch));
499 }
500 Ok(())
501 }
502
503 /// A wider existing mode is narrowed to 0600, with the content untouched.
504 #[test]
505 fn test_restrict_secret_narrows_a_wide_mode() -> Outcome<()> {
506 let path = scratch_path("restrict_wide");
507 if let Err(e) = fs::write(&path, b"pre-existing key material") {
508 return Err(err!(e, "Could not pre-seed {:?}.", path; Test, File, IO, Write));
509 }
510 if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(0o664)) {
511 return Err(err!(e, "Could not set 0664 on {:?}.", path; Test, File, IO));
512 }
513
514 res!(restrict_secret(&path));
515
516 let mode = res!(mode_of(&path));
517 let contents = fs::read(&path);
518 let _ = fs::remove_file(&path);
519 match contents {
520 Ok(c) if c == b"pre-existing key material" => (),
521 Ok(c) => return Err(err!(
522 "{:?} held {:?} after restrict_secret, which must not touch content.", path, c;
523 Test, Mismatch)),
524 Err(e) => return Err(err!(e, "Could not read back {:?}.", path; Test, File, IO)),
525 }
526 if mode != 0o600 {
527 return Err(err!(
528 "{:?} was 0664 and ended at {:o} after restrict_secret, not 0600.", path, mode;
529 Test, Mismatch));
530 }
531 Ok(())
532 }
533
534 /// The caller is told the mode a file was narrowed from, and `None` when
535 /// there was nothing to narrow, so an app that silences the log can still
536 /// say so itself.
537 #[test]
538 fn test_restrict_secret_reports_the_mode_it_narrowed_from() -> Outcome<()> {
539 let path = scratch_path("restrict_reports");
540 if let Err(e) = fs::write(&path, b"key material") {
541 return Err(err!(e, "Could not pre-seed {:?}.", path; Test, File, IO, Write));
542 }
543 // Mode before, what restrict_secret must report, mode after.
544 let cases = [
545 (0o644, Some(0o644), 0o600),
546 (0o600, None, 0o600),
547 (0o440, Some(0o440), 0o400),
548 (0o400, None, 0o400),
549 ];
550 let mut outcome = Ok(());
551 for (before, said, after) in cases {
552 if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(before)) {
553 outcome = Err(err!(e, "Could not set {:04o} on {:?}.", before, path; Test, File, IO));
554 break;
555 }
556 let got = match restrict_secret(&path) {
557 Ok(g) => g,
558 Err(e) => { outcome = Err(e); break; },
559 };
560 let mode = match mode_of(&path) {
561 Ok(m) => m,
562 Err(e) => { outcome = Err(e); break; },
563 };
564 if got != said || mode != after {
565 outcome = Err(err!(
566 "{:?} at {:04o}: restrict_secret reported {:?} and left {:04o}, \
567 expected {:?} and {:04o}.", path, before, got, mode, said, after;
568 Test, Mismatch));
569 break;
570 }
571 }
572 let _ = fs::remove_file(&path);
573 outcome
574 }
575
576 /// A mode already at or narrower than 0600 is left exactly as it is.
577 #[test]
578 fn test_restrict_secret_is_a_noop_when_already_narrow() -> Outcome<()> {
579 let path = scratch_path("restrict_already_narrow");
580 if let Err(e) = fs::write(&path, b"already tight") {
581 return Err(err!(e, "Could not pre-seed {:?}.", path; Test, File, IO, Write));
582 }
583 if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(0o400)) {
584 return Err(err!(e, "Could not set 0400 on {:?}.", path; Test, File, IO));
585 }
586
587 res!(restrict_secret(&path));
588
589 let mode = res!(mode_of(&path));
590 let _ = fs::remove_file(&path);
591 if mode != 0o400 {
592 return Err(err!(
593 "{:?} was 0400 and ended at {:o} after restrict_secret, which should not widen it.",
594 path, mode;
595 Test, Mismatch));
596 }
597 Ok(())
598 }
599}