strypt_core/io.rs
1//! Bounded reading and atomic writing.
2//!
3//! This module holds the two file operations that can hurt a user independently of any
4//! parser bug: reading an input large enough to exhaust memory, and writing an output in a
5//! way that can leave a half-sanitised file where the original was.
6//!
7//! # Where temporary files go, and why it matters
8//!
9//! The temporary file is created **in the destination's own directory**, never in `TMPDIR`.
10//! Two reasons, and the second is the important one:
11//!
12//! 1. `rename` is only atomic within a filesystem. A temp file on a different mount turns the
13//! final step into a copy, which is precisely the non-atomic behaviour being avoided.
14//! 2. `TMPDIR` is somewhere else on the disk. Writing a copy of a sensitive document to
15//! somewhere the user did not choose — and did not know to clean up — is a leak in its own
16//! right, and on an amnesic system such as Tails it may be the one location that is not
17//! what the user assumed it was (`docs/ARCHITECTURE.md` §8).
18//!
19//! The temporary file is removed on every failure path.
20
21use std::fs::File;
22use std::io::{Read, Write};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicU64, Ordering};
25
26use crate::error::{IoAction, Result, StryptError};
27
28/// Resource ceilings applied before and during parsing.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct Limits {
32 /// Largest input strypt will read, in bytes.
33 pub max_input_bytes: u64,
34}
35
36impl Limits {
37 /// The default input ceiling: 512 MiB.
38 ///
39 /// Chosen to clear the largest files the Phase 1 formats plausibly produce — a
40 /// high-resolution scanned PDF runs to a few hundred megabytes — while still refusing a
41 /// file whose only purpose is to exhaust memory. It is a ceiling on the *input*; a PDF
42 /// rewrite holds the parsed document as well, so peak usage is a multiple of this. That
43 /// matters on the constrained, RAM-only systems this tool is aimed at, which is why the
44 /// number is deliberately not "as much as will fit".
45 ///
46 /// Provisional: revisit with measured figures once the handlers exist (`docs/PRD.md` §9
47 /// still carries estimates rather than measurements).
48 pub const DEFAULT_MAX_INPUT_BYTES: u64 = 512 * 1024 * 1024;
49}
50
51impl Limits {
52 /// Limits with a caller-chosen input ceiling.
53 ///
54 /// A constructor rather than a struct literal because this type is `#[non_exhaustive]`:
55 /// new ceilings will be added, and a front-end built against an older version must keep
56 /// compiling rather than silently missing one.
57 #[must_use]
58 pub const fn with_max_input_bytes(max_input_bytes: u64) -> Self {
59 Self { max_input_bytes }
60 }
61}
62
63impl Default for Limits {
64 fn default() -> Self {
65 Self {
66 max_input_bytes: Self::DEFAULT_MAX_INPUT_BYTES,
67 }
68 }
69}
70
71/// Read `path` into memory, refusing anything above `limits.max_input_bytes`.
72///
73/// The size is checked twice: once against the directory entry, so an oversized file is
74/// refused without reading a byte of it, and again while reading, because the first answer
75/// came from metadata that a hostile or merely unusual source can misreport — a growing file,
76/// a named pipe, a synthetic filesystem. Trusting the first check alone would make the limit
77/// advisory.
78///
79/// # Errors
80///
81/// [`StryptError::InputTooLarge`] if the file exceeds the limit; [`StryptError::Io`] if it
82/// cannot be measured or read.
83pub fn read_bounded(path: &Path, limits: Limits) -> Result<Vec<u8>> {
84 let file = File::open(path).map_err(|source| StryptError::Io {
85 action: IoAction::ReadingInput,
86 source,
87 })?;
88 let declared = file
89 .metadata()
90 .map_err(|source| StryptError::Io {
91 action: IoAction::MeasuringInput,
92 source,
93 })?
94 .len();
95 if declared > limits.max_input_bytes {
96 return Err(StryptError::InputTooLarge {
97 limit: limits.max_input_bytes,
98 actual: Some(declared),
99 });
100 }
101 read_bounded_from(file, limits, Some(declared))
102}
103
104/// Read a stream into memory under the same ceiling as [`read_bounded`].
105///
106/// `hint` pre-allocates when a trustworthy size is known. It is only ever a hint: the read
107/// itself is what enforces the limit.
108fn read_bounded_from<R: Read>(source: R, limits: Limits, hint: Option<u64>) -> Result<Vec<u8>> {
109 // Read one byte past the ceiling. If that byte arrives, the source lied about its size
110 // and the input is over the limit — a `take(limit)` alone would silently truncate, which
111 // would hand a partial file to a handler and produce a report about content that was
112 // never there.
113 let probe = limits.max_input_bytes.saturating_add(1);
114 let mut buffer = Vec::new();
115 if let Some(hint) = hint {
116 // Reserve only what the ceiling permits: `Vec::with_capacity(n_from_file)` driven by
117 // an attacker-controlled number is itself the memory-exhaustion bug.
118 let reserve = hint.min(limits.max_input_bytes);
119 if let Ok(reserve) = usize::try_from(reserve) {
120 buffer
121 .try_reserve_exact(reserve)
122 .map_err(|_| StryptError::InputTooLarge {
123 limit: limits.max_input_bytes,
124 actual: Some(hint),
125 })?;
126 }
127 }
128 let read = source
129 .take(probe)
130 .read_to_end(&mut buffer)
131 .map_err(|source| StryptError::Io {
132 action: IoAction::ReadingInput,
133 source,
134 })?;
135 if u64::try_from(read).unwrap_or(u64::MAX) > limits.max_input_bytes {
136 return Err(StryptError::InputTooLarge {
137 limit: limits.max_input_bytes,
138 actual: None,
139 });
140 }
141 Ok(buffer)
142}
143
144/// What to do when the destination already exists.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum Overwrite {
147 /// Refuse. The default everywhere: overwriting the user's file is a decision only the
148 /// user gets to make.
149 Refuse,
150 /// Replace it. Requires `--force` at the CLI.
151 Replace,
152}
153
154/// Whether output permissions are tightened.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum Permissions {
157 /// Owner read/write only (`0600` on Unix).
158 ///
159 /// The default, and the decision recorded in ADR-0019. A stripped file is the *more*
160 /// sensitive artefact of the pair, not the less: the user is about to publish it, and a
161 /// world-readable copy sitting in a shared directory in the meantime is an avoidable
162 /// exposure. Source timestamps and permissions are never copied onto the output —
163 /// modification time is itself metadata, and preserving it would hand back a fact the
164 /// user believed they had just removed.
165 OwnerOnly,
166 /// Whatever the platform's default for a new file is (the process umask on Unix).
167 Inherit,
168}
169
170/// A file being written through a temporary alongside its destination, replaced by an atomic
171/// rename only once the content is complete and durable.
172///
173/// Nothing partial ever appears at the destination path. If the process dies mid-write, the
174/// destination is untouched and a `.strypt-*.tmp` file is left behind — visible, obviously
175/// incomplete, and adjacent to where it belongs, rather than a silently truncated file the
176/// user might publish.
177#[derive(Debug)]
178pub struct AtomicWrite {
179 destination: PathBuf,
180 temporary: PathBuf,
181 file: Option<File>,
182 permissions: Permissions,
183}
184
185impl AtomicWrite {
186 /// Begin writing to `destination`.
187 ///
188 /// # Errors
189 ///
190 /// [`StryptError::Io`] if the destination exists and `overwrite` is
191 /// [`Overwrite::Refuse`], or if the temporary file cannot be created.
192 pub fn begin(
193 destination: &Path,
194 overwrite: Overwrite,
195 permissions: Permissions,
196 ) -> Result<Self> {
197 // A pre-check, not a guarantee: another process can create the file between here and
198 // the rename. Closing that race needs a link/rename dance that behaves differently on
199 // every platform, and the realistic failure it would prevent — a user racing
200 // themselves in two terminals — is not the threat this tool is defending against. The
201 // honest position is that this is a courtesy check; the atomicity that matters is
202 // that the destination is never *partially* written.
203 if overwrite == Overwrite::Refuse && destination.exists() {
204 return Err(StryptError::Io {
205 action: IoAction::CreatingTemporary,
206 source: std::io::Error::new(
207 std::io::ErrorKind::AlreadyExists,
208 "destination exists",
209 ),
210 });
211 }
212
213 let temporary = temporary_path_for(destination);
214 let file = create_private(&temporary, permissions)?;
215 Ok(Self {
216 destination: destination.to_path_buf(),
217 temporary,
218 file: Some(file),
219 permissions,
220 })
221 }
222
223 /// Write bytes into the temporary file.
224 ///
225 /// # Errors
226 ///
227 /// [`StryptError::Io`] if the write fails.
228 pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
229 let Some(file) = self.file.as_mut() else {
230 return Err(StryptError::Io {
231 action: IoAction::WritingOutput,
232 source: std::io::Error::other("write after the file was finished"),
233 });
234 };
235 file.write_all(bytes).map_err(|source| StryptError::Io {
236 action: IoAction::WritingOutput,
237 source,
238 })
239 }
240
241 /// Flush, synchronise, and atomically move the temporary into place.
242 ///
243 /// The `sync_all` is not ceremony. Without it the rename can be durable while the
244 /// content behind it is not, so a crash at the wrong moment leaves a file that exists,
245 /// has the right name, and contains nothing — which for this tool means a user with a
246 /// file they believe is a stripped copy of their document.
247 ///
248 /// # Errors
249 ///
250 /// [`StryptError::Io`] if syncing or renaming fails. The temporary is removed either way.
251 pub fn commit(mut self) -> Result<()> {
252 let Some(file) = self.file.take() else {
253 return Err(StryptError::Io {
254 action: IoAction::SyncingOutput,
255 source: std::io::Error::other("already finished"),
256 });
257 };
258 if let Err(source) = file.sync_all() {
259 self.discard_temporary();
260 return Err(StryptError::Io {
261 action: IoAction::SyncingOutput,
262 source,
263 });
264 }
265 drop(file);
266
267 if let Err(source) = std::fs::rename(&self.temporary, &self.destination) {
268 self.discard_temporary();
269 return Err(StryptError::Io {
270 action: IoAction::ReplacingDestination,
271 source,
272 });
273 }
274 // Re-assert permissions after the rename. On Unix the mode travels with the inode so
275 // this is a no-op, but stating it here keeps the guarantee in one place rather than
276 // resting on a platform detail.
277 apply_permissions(&self.destination, self.permissions)?;
278 Ok(())
279 }
280
281 /// Abandon the write, leaving the destination untouched.
282 pub fn abort(mut self) {
283 self.file = None;
284 self.discard_temporary();
285 }
286
287 fn discard_temporary(&mut self) {
288 self.file = None;
289 // A failure to clean up is not worth failing the operation over — the caller already
290 // has a real error to report, and a leftover `.tmp` is visible rather than dangerous.
291 let _ = std::fs::remove_file(&self.temporary);
292 }
293}
294
295impl Drop for AtomicWrite {
296 /// Removes the temporary if the writer was neither committed nor aborted.
297 ///
298 /// This is the path taken when a handler returns `Err` mid-write, which is the normal way
299 /// a fail-closed handler gives up. Without it, every failed strip would litter a partial
300 /// file next to the user's document.
301 fn drop(&mut self) {
302 if self.file.is_some() {
303 self.discard_temporary();
304 }
305 }
306}
307
308/// Build a temporary path alongside `destination`.
309///
310/// Uniqueness comes from the process id, a monotonic counter, and the clock. This does not
311/// need to be unpredictable — the file is created with `create_new`, so a collision is a
312/// failed creation rather than a clobbered file — it only needs to avoid colliding with
313/// strypt's own concurrent writes. That is also why no random-number dependency is pulled in
314/// for it (ADR-0008).
315fn temporary_path_for(destination: &Path) -> PathBuf {
316 static COUNTER: AtomicU64 = AtomicU64::new(0);
317 let nonce = COUNTER.fetch_add(1, Ordering::Relaxed);
318 let clock: u32 = std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .map_or(0, |d| d.subsec_nanos());
321 let name = format!(".strypt-{}-{clock}-{nonce}.tmp", std::process::id());
322 destination.parent().unwrap_or(Path::new(".")).join(name)
323}
324
325/// Create a new file, applying restrictive permissions at creation time on Unix.
326///
327/// Setting the mode in the open call rather than afterwards closes the window in which the
328/// file exists with the umask's permissions — a window another local process can read
329/// through, and one that is entirely avoidable.
330fn create_private(path: &Path, permissions: Permissions) -> Result<File> {
331 let mut options = std::fs::OpenOptions::new();
332 options.write(true).create_new(true);
333
334 #[cfg(unix)]
335 if permissions == Permissions::OwnerOnly {
336 use std::os::unix::fs::OpenOptionsExt as _;
337 options.mode(0o600);
338 }
339
340 let file = options.open(path).map_err(|source| StryptError::Io {
341 action: IoAction::CreatingTemporary,
342 source,
343 })?;
344
345 // On Windows the ACL model has no umask equivalent and the new file inherits the parent
346 // directory's ACL. strypt does not currently narrow that, so `Permissions::OwnerOnly` is
347 // weaker there than on Unix. Recorded as a known limitation rather than papered over
348 // (ADR-0019); Phase 3's platform validation is where it gets addressed.
349 let _ = permissions;
350 Ok(file)
351}
352
353/// Apply permissions to an existing path. A no-op off Unix.
354fn apply_permissions(path: &Path, permissions: Permissions) -> Result<()> {
355 #[cfg(unix)]
356 if permissions == Permissions::OwnerOnly {
357 use std::os::unix::fs::PermissionsExt as _;
358 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(
359 |source| StryptError::Io {
360 action: IoAction::SettingPermissions,
361 source,
362 },
363 )?;
364 }
365 let _ = (path, permissions);
366 Ok(())
367}
368
369#[cfg(test)]
370mod tests {
371 #![allow(clippy::unwrap_used)]
372
373 use super::*;
374
375 /// A scratch directory that removes itself, so tests never depend on an external crate
376 /// or leave files behind.
377 struct Scratch(PathBuf);
378
379 impl Scratch {
380 fn new(tag: &str) -> Self {
381 let path = std::env::temp_dir().join(format!(
382 "strypt-test-{tag}-{}-{:?}",
383 std::process::id(),
384 std::thread::current().id()
385 ));
386 std::fs::create_dir_all(&path).unwrap();
387 Self(path)
388 }
389 fn join(&self, name: &str) -> PathBuf {
390 self.0.join(name)
391 }
392 }
393
394 impl Drop for Scratch {
395 fn drop(&mut self) {
396 let _ = std::fs::remove_dir_all(&self.0);
397 }
398 }
399
400 #[test]
401 fn a_file_over_the_limit_is_refused_not_truncated() {
402 let dir = Scratch::new("limit");
403 let path = dir.join("big.bin");
404 std::fs::write(&path, vec![0u8; 4096]).unwrap();
405
406 let err = read_bounded(
407 &path,
408 Limits {
409 max_input_bytes: 1024,
410 },
411 )
412 .unwrap_err();
413 assert!(
414 matches!(
415 err,
416 StryptError::InputTooLarge {
417 limit: 1024,
418 actual: Some(4096)
419 }
420 ),
421 "got {err:?}"
422 );
423 }
424
425 #[test]
426 fn a_lying_size_hint_cannot_get_past_the_ceiling() {
427 // Models a source whose metadata understates its real length. Truncating silently
428 // here would hand a partial file to a handler, which would then report confidently on
429 // content that was never examined.
430 let data = vec![0u8; 4096];
431 let err = read_bounded_from(
432 data.as_slice(),
433 Limits {
434 max_input_bytes: 1024,
435 },
436 Some(16),
437 )
438 .unwrap_err();
439 assert!(
440 matches!(err, StryptError::InputTooLarge { .. }),
441 "got {err:?}"
442 );
443 }
444
445 #[test]
446 fn a_file_exactly_at_the_limit_is_accepted() {
447 let dir = Scratch::new("exact");
448 let path = dir.join("exact.bin");
449 std::fs::write(&path, vec![7u8; 1024]).unwrap();
450 let got = read_bounded(
451 &path,
452 Limits {
453 max_input_bytes: 1024,
454 },
455 )
456 .unwrap();
457 assert_eq!(got.len(), 1024);
458 }
459
460 #[test]
461 fn nothing_appears_at_the_destination_until_commit() {
462 let dir = Scratch::new("atomic");
463 let dest = dir.join("out.bin");
464
465 let mut w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
466 w.write_all(b"partial").unwrap();
467 assert!(
468 !dest.exists(),
469 "a half-written file must never be visible at the destination path"
470 );
471 w.commit().unwrap();
472 assert_eq!(std::fs::read(&dest).unwrap(), b"partial");
473 }
474
475 #[test]
476 fn an_abandoned_write_leaves_no_temporary_behind() {
477 let dir = Scratch::new("abort");
478 let dest = dir.join("out.bin");
479
480 let mut w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
481 w.write_all(b"doomed").unwrap();
482 w.abort();
483
484 assert!(!dest.exists());
485 let leftovers: Vec<_> = std::fs::read_dir(&dir.0)
486 .unwrap()
487 .filter_map(std::result::Result::ok)
488 .filter(|e| e.file_name().to_string_lossy().starts_with(".strypt-"))
489 .collect();
490 assert!(leftovers.is_empty(), "temporary files were left behind");
491 }
492
493 #[test]
494 fn dropping_a_writer_mid_failure_cleans_up() {
495 // The path a fail-closed handler takes when it gives up part-way through.
496 let dir = Scratch::new("drop");
497 let dest = dir.join("out.bin");
498 {
499 let mut w =
500 AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
501 w.write_all(b"incomplete").unwrap();
502 }
503 assert!(!dest.exists());
504 let count = std::fs::read_dir(&dir.0).unwrap().count();
505 assert_eq!(
506 count, 0,
507 "the temporary should have been dropped with the writer"
508 );
509 }
510
511 #[test]
512 fn an_existing_destination_is_refused_by_default() {
513 let dir = Scratch::new("refuse");
514 let dest = dir.join("out.bin");
515 std::fs::write(&dest, b"the user's file").unwrap();
516
517 let err = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap_err();
518 assert!(matches!(err, StryptError::Io { .. }));
519 assert_eq!(
520 std::fs::read(&dest).unwrap(),
521 b"the user's file",
522 "the existing file must be untouched"
523 );
524 }
525
526 #[test]
527 fn replace_is_available_when_the_caller_asks_for_it() {
528 let dir = Scratch::new("replace");
529 let dest = dir.join("out.bin");
530 std::fs::write(&dest, b"old").unwrap();
531
532 let mut w = AtomicWrite::begin(&dest, Overwrite::Replace, Permissions::OwnerOnly).unwrap();
533 w.write_all(b"new").unwrap();
534 w.commit().unwrap();
535 assert_eq!(std::fs::read(&dest).unwrap(), b"new");
536 }
537
538 #[cfg(unix)]
539 #[test]
540 fn output_is_not_readable_by_anyone_else() {
541 use std::os::unix::fs::PermissionsExt as _;
542
543 let dir = Scratch::new("perms");
544 let dest = dir.join("out.bin");
545 let mut w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
546 w.write_all(b"sensitive").unwrap();
547 w.commit().unwrap();
548
549 let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
550 assert_eq!(mode, 0o600, "ADR-0019: stripped output is owner-only");
551 }
552
553 #[cfg(unix)]
554 #[test]
555 fn the_temporary_is_private_while_it_is_being_written() {
556 // The window that matters: the file is at its most exposed before it is finished,
557 // because that is when the user is not yet watching it.
558 use std::os::unix::fs::PermissionsExt as _;
559
560 let dir = Scratch::new("temp-perms");
561 let dest = dir.join("out.bin");
562 let w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
563 let mode = std::fs::metadata(&w.temporary)
564 .unwrap()
565 .permissions()
566 .mode()
567 & 0o777;
568 assert_eq!(mode, 0o600);
569 w.abort();
570 }
571
572 #[test]
573 fn the_temporary_sits_beside_the_destination_not_in_tmpdir() {
574 // Writing a copy of a sensitive document somewhere the user did not choose is a leak
575 // in its own right (docs/ARCHITECTURE.md §8).
576 let dir = Scratch::new("location");
577 let dest = dir.join("out.bin");
578 let w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
579 assert_eq!(w.temporary.parent(), dest.parent());
580 w.abort();
581 }
582}