faultbox 0.1.2

Production black-box recorder: structured crash, corruption, and invariant-violation reports with a flight-recorder breadcrumb trail — debuggable from a report without reproduction or shipped symbols.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Creating report files and directories that only their owner can read, and
//! refusing artifact names that could escape the report directory.
//!
//! ## Why a report is owner-only
//!
//! A report is a *forensic* artifact. Redaction removes user content from the
//! strings, but the things travelling beside them are not redactable and were
//! never meant to be: a preserved artifact is a verbatim copy of the adopter's
//! store, and a minidump is the crashed process's entire address space —
//! encryption keys, session tokens, and whatever the user was working on, all
//! of it live in memory at the moment of the fault.
//!
//! Left at the process umask, those land at `0644` inside a `0755` directory,
//! which on any multi-user host means every local account can read them. So the
//! recorder creates its own directories at `0700` and its own files at `0600`,
//! and never widens either.
//!
//! ## Why temporary files are created exclusively
//!
//! Every durable write goes through a temporary sibling. `File::create` on a
//! predictable name follows a symlink planted there first, so an attacker who
//! can write to the reports directory could redirect the recorder's own write —
//! running as the reporting process — into a file of their choosing.
//!
//! `create_new(true)` is `O_CREAT | O_EXCL`, which POSIX requires to fail on a
//! symlink, so the plant is refused rather than followed. The names are also
//! unpredictable, so squatting them to deny service is guesswork rather than
//! arithmetic.
//!
//! ## Platform coverage
//!
//! The mode bits are a unix mechanism, and that is where this is enforced. On
//! Windows a created file or directory takes the inherited ACL of its parent,
//! and this crate does not set an explicit DACL — so on Windows the reports
//! directory is exactly as private as the location the adopter chose for it.
//! Under a user profile (`%LOCALAPPDATA%`) that is already per-user; somewhere
//! world-writable it is not, and no code here changes that.
//!
//! Exclusive creation, and therefore the symlink-plant refusal, applies on every
//! platform.

use std::io;
use std::path::{Path, PathBuf};

/// Mode for a directory the recorder creates: owner-only.
#[cfg(unix)]
const DIR_MODE: u32 = 0o700;
/// Mode for a file the recorder creates: owner read/write.
#[cfg(unix)]
const FILE_MODE: u32 = 0o600;

/// Create `path` and any missing parents, owner-only.
///
/// An already-existing directory is left as it is — the reports directory is
/// the adopter's to configure, and silently rewriting the permissions of a path
/// they chose would be a surprise. [`harden_dir`] tightens the directories the
/// recorder itself owns.
pub(crate) fn create_dir_all_private(path: &Path) -> io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::DirBuilderExt as _;
        std::fs::DirBuilder::new()
            .recursive(true)
            .mode(DIR_MODE)
            .create(path)
    }
    #[cfg(not(unix))]
    {
        std::fs::create_dir_all(path)
    }
}

/// Narrow an existing directory to owner-only.
///
/// Applied to report *group* directories, which belong entirely to the recorder
/// — including ones a previous version created at the umask default, whose
/// contents would otherwise stay world-readable forever. Best effort: a
/// filesystem without unix modes simply has nothing to do.
pub(crate) fn harden_dir(path: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let Ok(meta) = std::fs::metadata(path) else {
            return;
        };
        let mode = meta.permissions().mode();
        if mode & 0o7777 & !DIR_MODE != 0 {
            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(DIR_MODE));
        }
    }
    #[cfg(not(unix))]
    {
        let _ = path;
    }
}

/// Create `path` for writing, owner-only, failing if it already exists.
///
/// Exclusive creation is what makes this safe against a pre-planted symlink;
/// callers use it for temporary siblings whose names they own.
pub(crate) fn create_new_private(path: &Path) -> io::Result<std::fs::File> {
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt as _;
        options.mode(FILE_MODE);
    }
    options.open(path)
}

/// Narrow an existing file to owner-only. Used for files created by something
/// other than [`create_new_private`] — a minidump the IPC server opened, or a
/// report written by an earlier version of this crate.
pub(crate) fn harden_file(path: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let Ok(meta) = std::fs::metadata(path) else {
            return;
        };
        if meta.permissions().mode() & 0o7777 & !FILE_MODE != 0 {
            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(FILE_MODE));
        }
    }
    #[cfg(not(unix))]
    {
        let _ = path;
    }
}

/// The infix marking a path as one of the recorder's own temporaries.
///
/// Reserved, and enforced by [`validate_artifact_name`]: abandoned temporaries
/// are collected by a later capture, which recognises them by this substring. If
/// an artifact could carry it, a preserved snapshot named `db.tmp.1` would be
/// swept away as wreckage. Distinctive enough that nothing incidental matches.
pub const TEMP_INFIX: &str = ".faultbox-";

/// A process-local counter making temporary names unique within this process,
/// so two concurrent captures in one process cannot pick the same path.
static TEMP_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// Create a temporary file beside `final_path`, named `<final>.<tag>.<unique>`.
///
/// Returns the open handle and the path, so the caller can write, sync, and
/// rename. The name embeds process id, a monotonic counter and a clock reading;
/// exclusive creation retries on the (vanishingly unlikely, or adversarial)
/// collision, so a squatter can delay a write but never redirect one.
pub(crate) fn create_temp_beside(
    final_path: &Path,
    tag: &str,
) -> io::Result<(std::fs::File, PathBuf)> {
    let mut last = None;
    for _ in 0..64 {
        let path = temp_path(final_path, tag);
        match create_new_private(&path) {
            Ok(file) => return Ok((file, path)),
            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
            Err(e) => return Err(e),
        }
    }
    Err(last.unwrap_or_else(|| {
        io::Error::new(
            io::ErrorKind::AlreadyExists,
            "could not find a free temporary name",
        )
    }))
}

/// A unique sibling path for `final_path`. Split out so the directory-copy path,
/// which needs a *directory* rather than a file, can share the naming.
pub(crate) fn temp_path(final_path: &Path, tag: &str) -> PathBuf {
    let sequence = TEMP_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let mut name = final_path.file_name().map_or_else(
        || std::ffi::OsString::from("faultbox"),
        std::ffi::OsStr::to_os_string,
    );
    name.push(format!(
        "{TEMP_INFIX}{tag}.{}.{sequence}.{}",
        std::process::id(),
        crate::now_ms()
    ));
    match final_path.parent() {
        Some(parent) => parent.join(name),
        None => PathBuf::from(name),
    }
}

/// Create a temporary *directory* beside `final_path`, owner-only and
/// exclusively — the recursive-copy counterpart to [`create_temp_beside`].
pub(crate) fn create_temp_dir_beside(final_path: &Path, tag: &str) -> io::Result<PathBuf> {
    let mut last = None;
    for _ in 0..64 {
        let path = temp_path(final_path, tag);
        match create_dir_exclusive(&path) {
            Ok(()) => return Ok(path),
            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
            Err(e) => return Err(e),
        }
    }
    Err(last.unwrap_or_else(|| {
        io::Error::new(
            io::ErrorKind::AlreadyExists,
            "could not find a free temporary name",
        )
    }))
}

/// Create exactly `path` as an owner-only directory, failing if it exists.
pub(crate) fn create_dir_exclusive(path: &Path) -> io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::DirBuilderExt as _;
        std::fs::DirBuilder::new()
            .recursive(false)
            .mode(DIR_MODE)
            .create(path)
    }
    #[cfg(not(unix))]
    {
        std::fs::DirBuilder::new().recursive(false).create(path)
    }
}

/// Names the recorder keeps for its own state inside a report directory. An
/// artifact may not take one of them, or preserving would overwrite the report
/// it is attached to — or the lock protecting it.
const RESERVED_NAMES: &[&str] = &[".lock", "report.json", "latest.json", "minidump"];

/// Validate an artifact name supplied by the adopter.
///
/// The name is joined onto the report directory, so an unchecked one is a path
/// traversal: `preserve(.., "../../escaped.db", ..)` writes outside the reports
/// directory entirely, and because committing an artifact removes whatever sits
/// at the destination first, it *deletes* outside it too — a whole directory
/// tree, in the case of a preserved store.
///
/// Names in real use are plain file names (`store.corrupt`, `snap.db`), so the
/// rule is simply that: one path component of ordinary file-name characters. It
/// also rules out absolute paths, Windows drive prefixes and alternate data
/// streams, embedded NULs, and the reserved names above.
pub fn validate_artifact_name(name: &str) -> io::Result<()> {
    let reject = |why: &str| {
        Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("invalid artifact name {name:?}: {why}"),
        ))
    };

    if name.is_empty() {
        return reject("empty");
    }
    if name.len() > 255 {
        return reject("longer than 255 bytes");
    }
    if name == "." || name == ".." {
        return reject("a path traversal component");
    }
    if name.starts_with('.') {
        return reject("names beginning with a dot are reserved");
    }
    if !name
        .bytes()
        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
    {
        return reject("must be a single path component of ASCII letters, digits, '.', '_' or '-'");
    }
    if RESERVED_NAMES.iter().any(|r| name.eq_ignore_ascii_case(r)) {
        return reject("reserved for the recorder's own files");
    }
    if name.contains(TEMP_INFIX) {
        return reject("reserved: it marks the recorder's own temporary files");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn traversal_and_absolute_names_are_refused() {
        for name in [
            "../escape",
            "../../escape",
            "a/b",
            "a\\b",
            "/etc/passwd",
            "C:\\windows",
            "stream:ads",
            ".",
            "..",
            "",
            ".hidden",
        ] {
            assert!(
                validate_artifact_name(name).is_err(),
                "{name:?} must be refused"
            );
        }
    }

    #[test]
    fn the_recorders_own_files_cannot_be_claimed() {
        for name in [".lock", "report.json", "latest.json", "REPORT.JSON"] {
            assert!(
                validate_artifact_name(name).is_err(),
                "{name:?} must be refused"
            );
        }
    }

    #[test]
    fn a_nul_byte_cannot_hide_inside_a_name() {
        assert!(validate_artifact_name("snap\0.db").is_err());
    }

    #[test]
    fn ordinary_artifact_names_are_accepted() {
        for name in [
            "snap",
            "store.corrupt",
            "main.db",
            "seg-0_1.bin",
            // Looks temporary, is not reserved, and must keep working.
            "db.tmp.1",
            "restore.incoming.2",
        ] {
            assert!(
                validate_artifact_name(name).is_ok(),
                "{name:?} must be accepted"
            );
        }
    }

    /// An artifact carrying the temporary marker would be collected as
    /// wreckage by a later capture's sweep, so it cannot be allowed to.
    #[test]
    fn an_artifact_cannot_carry_the_temporary_marker() {
        assert!(validate_artifact_name("snap.faultbox-incoming.1").is_err());
        assert!(
            temp_path(Path::new("/r/snap.db"), "incoming")
                .file_name()
                .unwrap()
                .to_string_lossy()
                .contains(TEMP_INFIX),
            "the sweep and the namer must agree on the marker"
        );
    }

    #[cfg(unix)]
    #[test]
    fn created_directories_and_files_are_owner_only() {
        use std::os::unix::fs::PermissionsExt as _;

        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join("nested/group");
        create_dir_all_private(&dir).unwrap();
        assert_eq!(
            std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
            0o700
        );

        let (file, path) = create_temp_beside(&dir.join("report.json"), "tmp").unwrap();
        drop(file);
        assert_eq!(
            std::fs::metadata(&path).unwrap().permissions().mode() & 0o7777,
            0o600
        );
    }

    #[cfg(unix)]
    #[test]
    fn a_planted_symlink_is_refused_rather_than_followed() {
        let tmp = tempfile::tempdir().unwrap();
        let victim = tmp.path().join("victim");
        std::fs::write(&victim, b"original").unwrap();

        let planted = tmp.path().join("planted");
        std::os::unix::fs::symlink(&victim, &planted).unwrap();

        let err = create_new_private(&planted).expect_err("must refuse a symlink");
        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
        assert_eq!(
            std::fs::read(&victim).unwrap(),
            b"original",
            "the target must not have been written through"
        );
    }

    #[cfg(unix)]
    #[test]
    fn harden_dir_narrows_a_world_readable_directory() {
        use std::os::unix::fs::PermissionsExt as _;

        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join("legacy");
        std::fs::create_dir(&dir).unwrap();
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();

        harden_dir(&dir);
        assert_eq!(
            std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777,
            0o700
        );
    }

    #[test]
    fn temporary_names_do_not_repeat() {
        let base = Path::new("/tmp/reports/report.json");
        let a = temp_path(base, "tmp");
        let b = temp_path(base, "tmp");
        assert_ne!(a, b, "two temporaries must never collide");
        assert_eq!(a.parent(), base.parent(), "staged beside the destination");
    }
}