Skip to main content

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; a no-op on Windows, ADR-0047).
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/// Where a stripped copy goes: `photo.jpg` becomes `photo.stripped.jpg`, beside the input
171/// or in `output_dir`. Shared so every front-end names its output the same way.
172///
173/// The suffix goes before the extension so the file still opens in the right application, and
174/// the name is deliberately not a temporary-looking one — this is the file the user will
175/// publish.
176#[must_use]
177pub fn stripped_path(input: &Path, output_dir: Option<&Path>) -> PathBuf {
178    let stem = input.file_stem().unwrap_or_default();
179    let mut name = stem.to_os_string();
180    name.push(".stripped");
181    if let Some(extension) = input.extension() {
182        name.push(".");
183        name.push(extension);
184    }
185    match output_dir {
186        Some(dir) => dir.join(name),
187        None => input.with_file_name(name),
188    }
189}
190
191/// A file being written through a temporary alongside its destination, replaced by an atomic
192/// rename only once the content is complete and durable.
193///
194/// Nothing partial ever appears at the destination path. If the process dies mid-write, the
195/// destination is untouched and a `.strypt-*.tmp` file is left behind — visible, obviously
196/// incomplete, and adjacent to where it belongs, rather than a silently truncated file the
197/// user might publish.
198#[derive(Debug)]
199pub struct AtomicWrite {
200    destination: PathBuf,
201    temporary: PathBuf,
202    file: Option<File>,
203    permissions: Permissions,
204}
205
206impl AtomicWrite {
207    /// Begin writing to `destination`.
208    ///
209    /// # Errors
210    ///
211    /// [`StryptError::OutputExists`] if the destination exists and `overwrite` is
212    /// [`Overwrite::Refuse`]; [`StryptError::Io`] if the temporary file cannot be created.
213    pub fn begin(
214        destination: &Path,
215        overwrite: Overwrite,
216        permissions: Permissions,
217    ) -> Result<Self> {
218        // A pre-check, not a guarantee: another process can create the file between here and
219        // the rename. Closing that race needs a link/rename dance that behaves differently on
220        // every platform, and the realistic failure it would prevent — a user racing
221        // themselves in two terminals — is not the threat this tool is defending against. The
222        // honest position is that this is a courtesy check; the atomicity that matters is
223        // that the destination is never *partially* written.
224        if overwrite == Overwrite::Refuse && destination.exists() {
225            return Err(StryptError::OutputExists);
226        }
227
228        let temporary = temporary_path_for(destination);
229        let file = create_private(&temporary, permissions)?;
230        Ok(Self {
231            destination: destination.to_path_buf(),
232            temporary,
233            file: Some(file),
234            permissions,
235        })
236    }
237
238    /// Write bytes into the temporary file.
239    ///
240    /// # Errors
241    ///
242    /// [`StryptError::Io`] if the write fails.
243    pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
244        let Some(file) = self.file.as_mut() else {
245            return Err(StryptError::Io {
246                action: IoAction::WritingOutput,
247                source: std::io::Error::other("write after the file was finished"),
248            });
249        };
250        file.write_all(bytes).map_err(|source| StryptError::Io {
251            action: IoAction::WritingOutput,
252            source,
253        })
254    }
255
256    /// Flush, synchronise, and atomically move the temporary into place.
257    ///
258    /// The `sync_all` is not ceremony. Without it the rename can be durable while the
259    /// content behind it is not, so a crash at the wrong moment leaves a file that exists,
260    /// has the right name, and contains nothing — which for this tool means a user with a
261    /// file they believe is a stripped copy of their document.
262    ///
263    /// # Errors
264    ///
265    /// [`StryptError::Io`] if syncing or renaming fails. The temporary is removed either way.
266    pub fn commit(mut self) -> Result<()> {
267        let Some(file) = self.file.take() else {
268            return Err(StryptError::Io {
269                action: IoAction::SyncingOutput,
270                source: std::io::Error::other("already finished"),
271            });
272        };
273        if let Err(source) = file.sync_all() {
274            self.discard_temporary();
275            return Err(StryptError::Io {
276                action: IoAction::SyncingOutput,
277                source,
278            });
279        }
280        drop(file);
281
282        if let Err(source) = std::fs::rename(&self.temporary, &self.destination) {
283            self.discard_temporary();
284            return Err(StryptError::Io {
285                action: IoAction::ReplacingDestination,
286                source,
287            });
288        }
289        // Re-assert permissions after the rename. On Unix the mode travels with the inode so
290        // this is a no-op, but stating it here keeps the guarantee in one place rather than
291        // resting on a platform detail.
292        apply_permissions(&self.destination, self.permissions)?;
293        Ok(())
294    }
295
296    /// Abandon the write, leaving the destination untouched.
297    pub fn abort(mut self) {
298        self.file = None;
299        self.discard_temporary();
300    }
301
302    fn discard_temporary(&mut self) {
303        self.file = None;
304        // A failure to clean up is not worth failing the operation over — the caller already
305        // has a real error to report, and a leftover `.tmp` is visible rather than dangerous.
306        let _ = std::fs::remove_file(&self.temporary);
307    }
308}
309
310impl Drop for AtomicWrite {
311    /// Removes the temporary if the writer was neither committed nor aborted.
312    ///
313    /// This is the path taken when a handler returns `Err` mid-write, which is the normal way
314    /// a fail-closed handler gives up. Without it, every failed strip would litter a partial
315    /// file next to the user's document.
316    fn drop(&mut self) {
317        if self.file.is_some() {
318            self.discard_temporary();
319        }
320    }
321}
322
323/// Build a temporary path alongside `destination`.
324///
325/// Uniqueness comes from the process id, a monotonic counter, and the clock. This does not
326/// need to be unpredictable — the file is created with `create_new`, so a collision is a
327/// failed creation rather than a clobbered file — it only needs to avoid colliding with
328/// strypt's own concurrent writes. That is also why no random-number dependency is pulled in
329/// for it (ADR-0008).
330fn temporary_path_for(destination: &Path) -> PathBuf {
331    static COUNTER: AtomicU64 = AtomicU64::new(0);
332    let nonce = COUNTER.fetch_add(1, Ordering::Relaxed);
333    let clock: u32 = std::time::SystemTime::now()
334        .duration_since(std::time::UNIX_EPOCH)
335        .map_or(0, |d| d.subsec_nanos());
336    let name = format!(".strypt-{}-{clock}-{nonce}.tmp", std::process::id());
337    destination.parent().unwrap_or(Path::new(".")).join(name)
338}
339
340/// Create a new file, applying restrictive permissions at creation time on Unix.
341///
342/// Setting the mode in the open call rather than afterwards closes the window in which the
343/// file exists with the umask's permissions — a window another local process can read
344/// through, and one that is entirely avoidable.
345fn create_private(path: &Path, permissions: Permissions) -> Result<File> {
346    let mut options = std::fs::OpenOptions::new();
347    options.write(true).create_new(true);
348
349    #[cfg(unix)]
350    if permissions == Permissions::OwnerOnly {
351        use std::os::unix::fs::OpenOptionsExt as _;
352        options.mode(0o600);
353    }
354
355    let file = options.open(path).map_err(|source| StryptError::Io {
356        action: IoAction::CreatingTemporary,
357        source,
358    })?;
359
360    // On Windows the file inherits the parent directory's ACL and `OwnerOnly` is a no-op:
361    // std takes no security descriptor, and narrowing would need `unsafe` (ADR-0047).
362    let _ = permissions;
363    Ok(file)
364}
365
366/// Apply permissions to an existing path. A no-op off Unix.
367#[cfg_attr(not(unix), allow(clippy::unnecessary_wraps))] // fallible on Unix; one signature
368fn apply_permissions(path: &Path, permissions: Permissions) -> Result<()> {
369    #[cfg(unix)]
370    if permissions == Permissions::OwnerOnly {
371        use std::os::unix::fs::PermissionsExt as _;
372        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(
373            |source| StryptError::Io {
374                action: IoAction::SettingPermissions,
375                source,
376            },
377        )?;
378    }
379    let _ = (path, permissions);
380    Ok(())
381}
382
383#[cfg(test)]
384mod tests {
385    #![allow(clippy::unwrap_used)]
386
387    use super::*;
388
389    /// A scratch directory that removes itself, so tests never depend on an external crate
390    /// or leave files behind.
391    struct Scratch(PathBuf);
392
393    impl Scratch {
394        fn new(tag: &str) -> Self {
395            let path = std::env::temp_dir().join(format!(
396                "strypt-test-{tag}-{}-{:?}",
397                std::process::id(),
398                std::thread::current().id()
399            ));
400            std::fs::create_dir_all(&path).unwrap();
401            Self(path)
402        }
403        fn join(&self, name: &str) -> PathBuf {
404            self.0.join(name)
405        }
406    }
407
408    impl Drop for Scratch {
409        fn drop(&mut self) {
410            let _ = std::fs::remove_dir_all(&self.0);
411        }
412    }
413
414    #[test]
415    fn a_file_over_the_limit_is_refused_not_truncated() {
416        let dir = Scratch::new("limit");
417        let path = dir.join("big.bin");
418        std::fs::write(&path, vec![0u8; 4096]).unwrap();
419
420        let err = read_bounded(
421            &path,
422            Limits {
423                max_input_bytes: 1024,
424            },
425        )
426        .unwrap_err();
427        assert!(
428            matches!(
429                err,
430                StryptError::InputTooLarge {
431                    limit: 1024,
432                    actual: Some(4096)
433                }
434            ),
435            "got {err:?}"
436        );
437    }
438
439    #[test]
440    fn a_lying_size_hint_cannot_get_past_the_ceiling() {
441        // Models a source whose metadata understates its real length. Truncating silently
442        // here would hand a partial file to a handler, which would then report confidently on
443        // content that was never examined.
444        let data = vec![0u8; 4096];
445        let err = read_bounded_from(
446            data.as_slice(),
447            Limits {
448                max_input_bytes: 1024,
449            },
450            Some(16),
451        )
452        .unwrap_err();
453        assert!(
454            matches!(err, StryptError::InputTooLarge { .. }),
455            "got {err:?}"
456        );
457    }
458
459    #[test]
460    fn a_file_exactly_at_the_limit_is_accepted() {
461        let dir = Scratch::new("exact");
462        let path = dir.join("exact.bin");
463        std::fs::write(&path, vec![7u8; 1024]).unwrap();
464        let got = read_bounded(
465            &path,
466            Limits {
467                max_input_bytes: 1024,
468            },
469        )
470        .unwrap();
471        assert_eq!(got.len(), 1024);
472    }
473
474    #[test]
475    fn nothing_appears_at_the_destination_until_commit() {
476        let dir = Scratch::new("atomic");
477        let dest = dir.join("out.bin");
478
479        let mut w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
480        w.write_all(b"partial").unwrap();
481        assert!(
482            !dest.exists(),
483            "a half-written file must never be visible at the destination path"
484        );
485        w.commit().unwrap();
486        assert_eq!(std::fs::read(&dest).unwrap(), b"partial");
487    }
488
489    #[test]
490    fn an_abandoned_write_leaves_no_temporary_behind() {
491        let dir = Scratch::new("abort");
492        let dest = dir.join("out.bin");
493
494        let mut w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
495        w.write_all(b"doomed").unwrap();
496        w.abort();
497
498        assert!(!dest.exists());
499        let leftovers: Vec<_> = std::fs::read_dir(&dir.0)
500            .unwrap()
501            .filter_map(std::result::Result::ok)
502            .filter(|e| e.file_name().to_string_lossy().starts_with(".strypt-"))
503            .collect();
504        assert!(leftovers.is_empty(), "temporary files were left behind");
505    }
506
507    #[test]
508    fn dropping_a_writer_mid_failure_cleans_up() {
509        // The path a fail-closed handler takes when it gives up part-way through.
510        let dir = Scratch::new("drop");
511        let dest = dir.join("out.bin");
512        {
513            let mut w =
514                AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
515            w.write_all(b"incomplete").unwrap();
516        }
517        assert!(!dest.exists());
518        let count = std::fs::read_dir(&dir.0).unwrap().count();
519        assert_eq!(
520            count, 0,
521            "the temporary should have been dropped with the writer"
522        );
523    }
524
525    #[test]
526    fn an_existing_destination_is_refused_by_default() {
527        let dir = Scratch::new("refuse");
528        let dest = dir.join("out.bin");
529        std::fs::write(&dest, b"the user's file").unwrap();
530
531        let err = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap_err();
532        assert!(matches!(err, StryptError::OutputExists));
533        assert_eq!(
534            std::fs::read(&dest).unwrap(),
535            b"the user's file",
536            "the existing file must be untouched"
537        );
538    }
539
540    #[test]
541    fn replace_is_available_when_the_caller_asks_for_it() {
542        let dir = Scratch::new("replace");
543        let dest = dir.join("out.bin");
544        std::fs::write(&dest, b"old").unwrap();
545
546        let mut w = AtomicWrite::begin(&dest, Overwrite::Replace, Permissions::OwnerOnly).unwrap();
547        w.write_all(b"new").unwrap();
548        w.commit().unwrap();
549        assert_eq!(std::fs::read(&dest).unwrap(), b"new");
550    }
551
552    #[cfg(unix)]
553    #[test]
554    fn output_is_not_readable_by_anyone_else() {
555        use std::os::unix::fs::PermissionsExt as _;
556
557        let dir = Scratch::new("perms");
558        let dest = dir.join("out.bin");
559        let mut w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
560        w.write_all(b"sensitive").unwrap();
561        w.commit().unwrap();
562
563        let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
564        assert_eq!(mode, 0o600, "ADR-0019: stripped output is owner-only");
565    }
566
567    #[cfg(unix)]
568    #[test]
569    fn the_temporary_is_private_while_it_is_being_written() {
570        // The window that matters: the file is at its most exposed before it is finished,
571        // because that is when the user is not yet watching it.
572        use std::os::unix::fs::PermissionsExt as _;
573
574        let dir = Scratch::new("temp-perms");
575        let dest = dir.join("out.bin");
576        let w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
577        let mode = std::fs::metadata(&w.temporary)
578            .unwrap()
579            .permissions()
580            .mode()
581            & 0o777;
582        assert_eq!(mode, 0o600);
583        w.abort();
584    }
585
586    #[test]
587    fn the_temporary_sits_beside_the_destination_not_in_tmpdir() {
588        // Writing a copy of a sensitive document somewhere the user did not choose is a leak
589        // in its own right (docs/ARCHITECTURE.md §8).
590        let dir = Scratch::new("location");
591        let dest = dir.join("out.bin");
592        let w = AtomicWrite::begin(&dest, Overwrite::Refuse, Permissions::OwnerOnly).unwrap();
593        assert_eq!(w.temporary.parent(), dest.parent());
594        w.abort();
595    }
596
597    #[test]
598    fn the_stripped_copy_keeps_its_extension_last() {
599        let p = Path::new("dir/photo.jpg");
600        assert_eq!(stripped_path(p, None), Path::new("dir/photo.stripped.jpg"));
601        assert_eq!(
602            stripped_path(p, Some(Path::new("out"))),
603            Path::new("out/photo.stripped.jpg")
604        );
605        assert_eq!(
606            stripped_path(Path::new("README"), None),
607            Path::new("README.stripped")
608        );
609    }
610}