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
| 1 | use crate::{ |
| 2 | prelude::*, |
| 3 | path::NormalPath, |
| 4 | }; |
| 5 | |
| 6 | use 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)] |
| 22 | pub enum OsPath { |
| 23 | Dir(PathBuf), |
| 24 | File(PathBuf), |
| 25 | } |
| 26 | |
| 27 | #[derive(Clone, Copy, Debug)] |
| 28 | pub enum PathState { |
| 29 | DirMustExist, |
| 30 | FileMustExist, |
| 31 | Create, |
| 32 | } |
| 33 | |
| 34 | impl 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 | |
| 72 | pub trait Loadable { |
| 73 | fn load<P: AsRef<Path>>(path: P) -> Outcome<Self> where Self: Sized; |
| 74 | } |
| 75 | |
| 76 | pub 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)] |
| 98 | pub 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))] |
| 155 | pub 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)] |
| 197 | pub 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))] |
| 213 | pub 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)] |
| 246 | pub 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))] |
| 271 | pub 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)] |
| 280 | fn 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. |
| 300 | fn 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)] |
| 317 | pub struct TextFileState { |
| 318 | pub path: String, |
| 319 | pub line_num: usize, |
| 320 | } |
| 321 | |
| 322 | impl 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))] |
| 330 | mod 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 | } |