par-term-config 0.14.0

Configuration system for par-term terminal emulator
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
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
//! Crash-safe atomic file writes for user data that cannot be reconstructed.
//!
//! Sessions, profiles, the dynamic-profile cache, `config.yaml`, the assistant
//! history and the user's shell rc files are written through
//! [`save_bytes_atomic`] and its wrappers. Each save serializes into a temporary
//! file **in the same directory** as the target, fsyncs it, then renames it over
//! the target. A crash or a full disk therefore leaves either the complete
//! previous file or the complete new one — never a truncated mix of the two.
//!
//! This matters more than the usual "corrupt file" argument because of the
//! recovery behaviour of the callers: a truncated session or profile file is
//! read back as *empty*, which every loader treats as "no saved data" rather
//! than as an error. The user loses the data with no diagnostic. Session save
//! also runs at shutdown, when an abrupt kill is most likely. For a shell rc
//! file the failure is worse still: the content is user-authored and
//! unreconstructable, and a truncated `.zshrc` breaks their login shell.
//!
//! # Crate placement
//!
//! This lives in `par-term-config` (Layer 1, no internal dependencies) rather
//! than in the root crate, because `par-term-config`, `par-term-settings-ui`
//! and `par-term-update` all need it and none of them can depend on the root
//! crate. The root crate re-exports it from `crate::atomic_save`.
//!
//! # Permissions (SEC-021)
//!
//! Two policies, chosen per call site:
//!
//! - [`save_bytes_atomic`] and its wrappers force mode `0o600`. Use these for
//!   par-term's own state in par-term's own directories (`config.yaml`,
//!   `state.yaml`, `arrangements.yaml`, `command_history.yaml`, the assistant
//!   history and prompt store, the bundle manifest). They may hold secrets, and
//!   nothing outside par-term reads them.
//!
//! - [`save_bytes_atomic_preserving_mode`] and its wrapper keep whatever mode
//!   the target already has. Use these for files that belong to the *user*
//!   rather than to par-term — shell rc files, GLSL shader sources, exports to
//!   a path chosen in a save dialog. Forcing `0o600` on those is wrong: an rc
//!   file may legitimately be group-readable, and a bundled `0o644` shader must
//!   not be silently tightened just because the user edited it.
//!
//! In both cases the staging file is created `0o600` **before any bytes are
//! written**, so contents are never briefly world-readable; the
//! mode-preserving variant widens it back to the target's mode only once the
//! payload is on disk. When the target does not exist there is no mode to
//! preserve and `0o600` is kept: an rc file, a shader and a snippet export are
//! all read only by the owning user, so `0o600` is never the unsafe direction.
//!
//! # Windows
//!
//! `std::fs::rename` maps to `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`, so
//! replacing an existing target is supported — but it fails with a sharing
//! violation while another process holds the target open (antivirus and search
//! indexers do this routinely). The rename is retried with a short backoff; if
//! it still fails the temporary file is removed and the error is returned, so
//! the previous file survives and the failure is loud rather than lossy.

use anyhow::{Context, Result};
use serde::Serialize;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

/// Distinguishes concurrent saves to the same target within one process.
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

/// What permissions the replaced file should end up with.
#[derive(Clone, Copy)]
enum ModePolicy {
    /// Always `0o600`: par-term's own state, which may hold secrets.
    Private,
    /// Keep the target's current mode; `0o600` when the target is new.
    PreserveTarget,
}

/// Build the temporary path used to stage a write to `path`.
///
/// Always a sibling of `path`: a cross-filesystem rename is a copy, which is
/// not atomic, so the staging file must live in the target's own directory.
fn temp_path_for(path: &Path) -> PathBuf {
    let file_name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "par-term-save".to_string());
    let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
    path.with_file_name(format!("{file_name}.tmp.{}.{unique}", std::process::id()))
}

/// The mode to give the staged file just before it is renamed over `target`.
///
/// `None` means "leave it at the `0o600` it was staged with".
#[cfg(unix)]
fn final_mode(target: &Path, policy: ModePolicy) -> Option<u32> {
    use std::os::unix::fs::PermissionsExt;

    match policy {
        ModePolicy::Private => None,
        // `symlink_metadata` deliberately: if the target is a symlink the
        // rename replaces the link itself, so the link's own mode is what the
        // caller had, not the mode of whatever it pointed at.
        ModePolicy::PreserveTarget => fs::symlink_metadata(target)
            .ok()
            .map(|m| m.permissions().mode() & 0o7777)
            .filter(|mode| *mode != 0o600),
    }
}

/// Permissions are a Unix concept; on other platforms the staged file is
/// renamed as-is under either policy.
#[cfg(not(unix))]
fn final_mode(_target: &Path, policy: ModePolicy) -> Option<u32> {
    match policy {
        ModePolicy::Private | ModePolicy::PreserveTarget => None,
    }
}

/// Write `bytes` into the staging file and flush them all the way to disk.
///
/// `final_mode` is applied after the payload is written and before the caller
/// renames, so the file is never more permissive than `0o600` while it is being
/// filled in.
#[cfg_attr(not(unix), allow(unused_variables))]
fn write_and_sync(temp_path: &Path, bytes: &[u8], final_mode: Option<u32>) -> Result<()> {
    let mut options = fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);

    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }

    let mut file = options
        .open(temp_path)
        .with_context(|| format!("Failed to create temporary file {temp_path:?}"))?;

    // SEC-021: `mode()` above only applies when the open *creates* the file, so a
    // stale staging file left by a crashed process would keep its old mode. Set
    // it explicitly while the file is still empty — before the first byte is
    // written, never after.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        file.set_permissions(fs::Permissions::from_mode(0o600))
            .with_context(|| format!("Failed to restrict permissions on {temp_path:?}"))?;
    }

    file.write_all(bytes)
        .with_context(|| format!("Failed to write temporary file {temp_path:?}"))?;

    #[cfg(unix)]
    if let Some(mode) = final_mode {
        use std::os::unix::fs::PermissionsExt;
        file.set_permissions(fs::Permissions::from_mode(mode))
            .with_context(|| format!("Failed to restore permissions on {temp_path:?}"))?;
    }

    file.sync_all()
        .with_context(|| format!("Failed to flush temporary file {temp_path:?} to disk"))?;
    Ok(())
}

#[cfg(not(windows))]
fn rename_into_place(from: &Path, to: &Path) -> Result<()> {
    fs::rename(from, to).with_context(|| format!("Failed to rename {from:?} to {to:?}"))
}

#[cfg(windows)]
fn rename_into_place(from: &Path, to: &Path) -> Result<()> {
    const ATTEMPTS: u32 = 5;

    let mut last_err = None;
    for attempt in 0..ATTEMPTS {
        match fs::rename(from, to) {
            Ok(()) => return Ok(()),
            Err(e) => {
                last_err = Some(e);
                if attempt + 1 < ATTEMPTS {
                    std::thread::sleep(std::time::Duration::from_millis(
                        50 * u64::from(attempt + 1),
                    ));
                }
            }
        }
    }

    Err(last_err.expect("loop body runs at least once")).with_context(|| {
        format!(
            "Failed to rename {from:?} to {to:?} after {ATTEMPTS} attempts; \
             the target may be held open by another process"
        )
    })
}

/// Best-effort fsync of the parent directory so the rename itself is durable.
#[cfg(unix)]
fn sync_parent_dir(path: &Path) {
    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty())
        && let Ok(dir) = fs::File::open(parent)
    {
        let _ = dir.sync_all();
    }
}

#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) {}

fn save_bytes_with_policy(path: &Path, bytes: &[u8], policy: ModePolicy) -> Result<()> {
    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
        fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create directory {parent:?}"))?;
    }

    let mode = final_mode(path, policy);
    let temp_path = temp_path_for(path);

    if let Err(e) = write_and_sync(&temp_path, bytes, mode) {
        let _ = fs::remove_file(&temp_path);
        return Err(e.context(format!(
            "Failed to stage write for {path:?}; the previous file was left unchanged"
        )));
    }

    if let Err(e) = rename_into_place(&temp_path, path) {
        let _ = fs::remove_file(&temp_path);
        return Err(e.context(format!(
            "Failed to replace {path:?}; the previous file was left unchanged"
        )));
    }

    sync_parent_dir(path);
    Ok(())
}

/// Atomically replace `path` with `bytes`, forcing mode `0o600`.
///
/// Creates the parent directory if needed, stages the write in a sibling
/// temporary file (mode `0o600` on Unix), fsyncs it, and renames it over the
/// target. On any failure the staging file is removed and the previous
/// contents of `path` are left untouched.
///
/// For files that belong to the user rather than to par-term, use
/// [`save_bytes_atomic_preserving_mode`] instead.
///
/// # Errors
///
/// Returns an error if the parent directory cannot be created, if the staging
/// file cannot be written, fsynced or permission-restricted, or if the rename
/// fails.
pub fn save_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
    save_bytes_with_policy(path, bytes, ModePolicy::Private)
}

/// Atomically replace `path` with `contents`, forcing mode `0o600`.
///
/// See [`save_bytes_atomic`].
///
/// # Errors
///
/// As [`save_bytes_atomic`].
pub fn save_string_atomic(path: &Path, contents: &str) -> Result<()> {
    save_bytes_atomic(path, contents.as_bytes())
}

/// Serialize `value` as YAML and atomically replace `path` with it, forcing
/// mode `0o600`.
///
/// Serialization happens before the target is touched, so a serialization
/// failure cannot damage the existing file.
///
/// # Errors
///
/// Returns an error if `value` cannot be serialized to YAML, plus everything
/// [`save_bytes_atomic`] can fail with.
pub fn save_yaml_atomic<T>(path: &Path, value: &T) -> Result<()>
where
    T: Serialize + ?Sized,
{
    let yaml = serde_yaml_ng::to_string(value)
        .with_context(|| format!("Failed to serialize data for {path:?}"))?;
    save_string_atomic(path, &yaml)
}

/// Atomically replace `path` with `bytes`, keeping the target's current mode.
///
/// Identical to [`save_bytes_atomic`] except for permissions: the staged file
/// is still created `0o600`, but immediately before the rename it is set back
/// to whatever mode `path` already had. A target that does not exist yet is
/// created `0o600`.
///
/// Use this for files par-term does not own: the user's shell rc files, GLSL
/// shader sources, and exports written to a path chosen in a save dialog.
/// Forcing `0o600` on those would silently change permissions the user chose.
///
/// # Errors
///
/// As [`save_bytes_atomic`]. Reading the target's current mode is best-effort:
/// if it cannot be stat'ed the `0o600` staging mode is kept rather than failing
/// the save.
pub fn save_bytes_atomic_preserving_mode(path: &Path, bytes: &[u8]) -> Result<()> {
    save_bytes_with_policy(path, bytes, ModePolicy::PreserveTarget)
}

/// Atomically replace `path` with `contents`, keeping the target's current mode.
///
/// See [`save_bytes_atomic_preserving_mode`].
///
/// # Errors
///
/// As [`save_bytes_atomic_preserving_mode`].
pub fn save_string_atomic_preserving_mode(path: &Path, contents: &str) -> Result<()> {
    save_bytes_atomic_preserving_mode(path, contents.as_bytes())
}

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

    /// A type whose `Serialize` impl always fails, to exercise the
    /// "save failed partway" path without needing a full disk.
    struct AlwaysFailsToSerialize;

    impl Serialize for AlwaysFailsToSerialize {
        fn serialize<S: serde::Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
            Err(serde::ser::Error::custom(
                "deliberate serialization failure",
            ))
        }
    }

    fn dir_entries(dir: &Path) -> Vec<String> {
        let mut names: Vec<String> = fs::read_dir(dir)
            .expect("read_dir")
            .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
            .collect();
        names.sort();
        names
    }

    #[cfg(unix)]
    fn mode_of(path: &Path) -> u32 {
        use std::os::unix::fs::PermissionsExt;
        fs::metadata(path).expect("metadata").permissions().mode() & 0o777
    }

    #[test]
    fn temp_path_is_a_sibling_of_the_target() {
        let target = Path::new("/some/dir/profiles.yaml");
        let temp = temp_path_for(target);

        // Same directory, so the rename never crosses a filesystem boundary.
        assert_eq!(temp.parent(), target.parent());
        assert_ne!(temp, target);
        assert!(
            temp.file_name()
                .expect("temp has a file name")
                .to_string_lossy()
                .starts_with("profiles.yaml.tmp.")
        );
    }

    #[test]
    fn temp_paths_are_unique_within_a_process() {
        let target = Path::new("/some/dir/profiles.yaml");
        assert_ne!(temp_path_for(target), temp_path_for(target));
    }

    #[test]
    fn save_creates_parent_directory() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("nested").join("dir").join("data.yaml");

        save_string_atomic(&path, "hello").expect("save");
        assert_eq!(fs::read_to_string(&path).expect("read"), "hello");
    }

    #[test]
    fn save_replaces_existing_content_and_leaves_no_temp_file() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("data.yaml");

        save_string_atomic(&path, "first").expect("first save");
        save_string_atomic(&path, "second").expect("second save");

        assert_eq!(fs::read_to_string(&path).expect("read"), "second");
        assert_eq!(dir_entries(temp.path()), vec!["data.yaml".to_string()]);
    }

    #[test]
    fn failed_save_leaves_the_previous_file_intact() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("data.yaml");

        save_yaml_atomic(&path, &"good data".to_string()).expect("initial save");
        let before = fs::read_to_string(&path).expect("read before");

        let err =
            save_yaml_atomic(&path, &AlwaysFailsToSerialize).expect_err("serialization must fail");
        assert!(
            err.to_string().contains("Failed to serialize"),
            "unexpected error: {err:#}"
        );

        // The previous good content survives byte-for-byte, and no staging file
        // is left behind for a later save to trip over.
        assert_eq!(fs::read_to_string(&path).expect("read after"), before);
        assert_eq!(dir_entries(temp.path()), vec!["data.yaml".to_string()]);
    }

    #[test]
    fn stale_temp_file_does_not_break_a_later_save() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("data.yaml");

        // Simulate a crash that left a staging file behind.
        fs::write(temp.path().join("data.yaml.tmp.999999.0"), "garbage").expect("stale temp");

        save_string_atomic(&path, "fresh").expect("save over a stale temp");
        assert_eq!(fs::read_to_string(&path).expect("read"), "fresh");
    }

    #[test]
    fn yaml_roundtrip() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("data.yaml");

        let value = vec!["a".to_string(), "b".to_string()];
        save_yaml_atomic(&path, &value).expect("save");

        let loaded: Vec<String> =
            serde_yaml_ng::from_str(&fs::read_to_string(&path).expect("read")).expect("parse");
        assert_eq!(loaded, value);
    }

    #[cfg(unix)]
    #[test]
    fn completed_save_has_mode_0600() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("data.yaml");

        save_string_atomic(&path, "secret").expect("save");

        assert_eq!(mode_of(&path), 0o600);
    }

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

        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("data.yaml");

        fs::write(&path, "old").expect("seed");
        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");

        save_string_atomic(&path, "new").expect("save");

        assert_eq!(mode_of(&path), 0o600);
    }

    #[cfg(unix)]
    #[test]
    fn staging_file_is_never_world_readable() {
        let temp = tempdir().expect("tempdir");
        let staging = temp.path().join("staged");

        write_and_sync(&staging, b"secret", None).expect("stage");

        assert_eq!(mode_of(&staging), 0o600);
    }

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

        let temp = tempdir().expect("tempdir");
        let staging = temp.path().join("staged");

        // A crashed process could leave a staging file with a permissive mode.
        fs::write(&staging, "stale").expect("seed");
        fs::set_permissions(&staging, fs::Permissions::from_mode(0o666)).expect("chmod");

        write_and_sync(&staging, b"secret", None).expect("stage");

        assert_eq!(mode_of(&staging), 0o600);
        assert_eq!(fs::read_to_string(&staging).expect("read"), "secret");
    }

    // ---- mode-preserving variant -------------------------------------------

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

        let temp = tempdir().expect("tempdir");
        let path = temp.path().join(".zshrc");

        fs::write(&path, "export PATH=/bin\n").expect("seed");
        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");

        save_string_atomic_preserving_mode(&path, "export PATH=/bin\nnew line\n").expect("save");

        assert_eq!(mode_of(&path), 0o644, "the user's own mode must survive");
        assert_eq!(
            fs::read_to_string(&path).expect("read"),
            "export PATH=/bin\nnew line\n"
        );
    }

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

        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("script.sh");

        fs::write(&path, "#!/bin/sh\n").expect("seed");
        fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");

        save_string_atomic_preserving_mode(&path, "#!/bin/sh\necho hi\n").expect("save");

        assert_eq!(mode_of(&path), 0o755);
    }

    #[cfg(unix)]
    #[test]
    fn preserving_save_creates_a_new_file_private() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("fresh.glsl");

        save_string_atomic_preserving_mode(&path, "void main() {}").expect("save");

        assert_eq!(
            mode_of(&path),
            0o600,
            "with no target to preserve, 0600 is the safe default"
        );
    }

    #[test]
    fn preserving_save_is_still_atomic_and_leaves_no_temp_file() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join(".bashrc");

        save_string_atomic_preserving_mode(&path, "first").expect("first save");
        save_string_atomic_preserving_mode(&path, "second").expect("second save");

        assert_eq!(fs::read_to_string(&path).expect("read"), "second");
        assert_eq!(dir_entries(temp.path()), vec![".bashrc".to_string()]);
    }

    #[test]
    fn preserving_save_failure_leaves_the_previous_rc_file_intact() {
        let temp = tempdir().expect("tempdir");
        // A directory where the file belongs makes the rename fail after the
        // payload is already written and fsynced.
        let path = temp.path().join("blocked");
        fs::create_dir(&path).expect("blocking directory");

        let err = save_string_atomic_preserving_mode(&path, "payload")
            .expect_err("renaming over a directory must fail");
        assert!(
            format!("{err:#}").contains("left unchanged"),
            "unexpected error: {err:#}"
        );

        let leftovers: Vec<String> = dir_entries(temp.path())
            .into_iter()
            .filter(|n| n.contains(".tmp."))
            .collect();
        assert!(leftovers.is_empty(), "staging files left: {leftovers:?}");
    }
}