Skip to content

Commit c15abb7

Browse files
feat(state): FileSystem trait, LiveFs, StateStore<F> with read/write/append (#7)
Chunk M1-W1-E of M1 plan. Lands the I/O surface for state.toml. Public surface: - pub trait FileSystem with three primitives: read_string, write_temp_then_rename, fsync_dir. Splitting rename and dir-fsync lets a future MockFs simulate "crashed between rename and fsync" in chunk M1-W1-F. - pub struct LiveFs — production impl backed by std::fs and tempfile::NamedTempFile. - pub struct StateStore<F: FileSystem = LiveFs> with: fn new(path) (default LiveFs) fn with_fs(fs, path) (test injection) fn path() -> &Path fn read() -> Result<State, StateError> fn write_atomic(&State) -> Result<(), StateError> fn append_history(HistoryEntry) fn append_failure(FailureRef) fn record_reconciliation(ReconciliationEntry) Atomic write per PRD §7.4: temp file in same dir + sync_all + rename + fsync of parent directory entry. The two-step decomposition (write + fsync_dir) keeps the parent fsync separately mockable. read() rejects future schema_version with StateError::UnsupportedSchemaVersion. Migration framework lands in chunk M1-W1-F. append_*() are read-modify-write_atomic — file is rewritten in full but [managed], [adopted], and [reserved] sections retain their values on round-trip. Byte-level surgical preservation of unknown sections outside [reserved] is deferred (would require toml_edit; the [reserved] BTreeMap covers the documented forward-compat namespace). Cargo.toml: tempfile is now a real (non-dev) dependency because LiveFs uses NamedTempFile in production. Tests (LiveFs against tempdir): - full_state_round_trip - unknown_fields_preserved_in_reserved - read_missing_file_yields_not_found - read_unsupported_schema_version - write_fails_when_parent_missing - history_append_does_not_rewrite_managed (asserts managed/adopted/ reserved unchanged in value, history grows) - append_failure_grows_failures_array - record_reconciliation_appends_entry What's NOT in this chunk: - Migration framework (chunk M1-W1-F). - MockFs feature-gate + crash-during-write tests (chunk M1-W1-F). - Property tests via proptest (chunk M1-W1-F). - contract: state_host_mismatch_detected (engine-side concern; the StateError variant is defined but not raised here — the engine compares state.host to hostname() at preflight). Verification: - cargo test -p pearlite-state (8 passed; 0 failed) - cargo clippy --workspace --all-targets -- -D warnings (zero warnings) - cargo fmt --all --check - scripts/ci/check-spdx.sh - pearlite-audit check . (1 check, 0 violations) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f104d88 commit c15abb7

4 files changed

Lines changed: 409 additions & 2 deletions

File tree

crates/pearlite-state/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@ time = { workspace = true }
1818
uuid = { workspace = true }
1919
thiserror = { workspace = true }
2020
schemars = { workspace = true }
21-
# toml_edit lands in chunk M1-W1-E with the atomic-write implementation
22-
# that needs it for unknown-field preservation.
21+
tempfile = { workspace = true }
22+
23+
[dev-dependencies]
24+
tempfile = { workspace = true }

crates/pearlite-state/src/io.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Filesystem trait + production [`LiveFs`] implementation for atomic
5+
//! `state.toml` writes per PRD §7.4.
6+
7+
use std::fs::File;
8+
use std::io::Write as _;
9+
use std::path::Path;
10+
use tempfile::NamedTempFile;
11+
12+
/// Filesystem operations the [`StateStore`](crate::StateStore) needs.
13+
///
14+
/// Three primitives is enough: reading the file, writing through
15+
/// temp-and-rename, and flushing the parent directory entry. Splitting
16+
/// the rename and the dir-fsync into separate calls is what lets a mock
17+
/// simulate the "crashed between rename and fsync" failure mode in
18+
/// chunk M1-W1-F.
19+
pub trait FileSystem: Send + Sync {
20+
/// Read a UTF-8 file into a string.
21+
///
22+
/// # Errors
23+
/// Returns the underlying I/O error verbatim; callers translate it
24+
/// into [`StateError::Io`](crate::StateError::Io).
25+
fn read_string(&self, p: &Path) -> std::io::Result<String>;
26+
27+
/// Write `data` to a sibling temp file, fsync it, then atomically
28+
/// rename to `p`.
29+
///
30+
/// The temp file lives in `p`'s parent directory so the rename is
31+
/// guaranteed-atomic on btrfs (PRD §7.4).
32+
///
33+
/// # Errors
34+
/// Returns the underlying I/O error verbatim.
35+
fn write_temp_then_rename(&self, p: &Path, data: &[u8]) -> std::io::Result<()>;
36+
37+
/// `fsync(2)` on the directory entry that holds `p`.
38+
///
39+
/// Without the directory fsync the rename can be reordered relative
40+
/// to subsequent activity on btrfs, breaking the "old version or new
41+
/// version, never partial" invariant.
42+
///
43+
/// # Errors
44+
/// Returns the underlying I/O error verbatim.
45+
fn fsync_dir(&self, p: &Path) -> std::io::Result<()>;
46+
}
47+
48+
/// Production [`FileSystem`] implementation backed by `std::fs` and
49+
/// [`tempfile::NamedTempFile`].
50+
#[derive(Clone, Copy, Debug, Default)]
51+
pub struct LiveFs;
52+
53+
impl FileSystem for LiveFs {
54+
fn read_string(&self, p: &Path) -> std::io::Result<String> {
55+
std::fs::read_to_string(p)
56+
}
57+
58+
fn write_temp_then_rename(&self, p: &Path, data: &[u8]) -> std::io::Result<()> {
59+
let parent = p.parent().ok_or_else(|| {
60+
std::io::Error::new(
61+
std::io::ErrorKind::InvalidInput,
62+
"atomic write target has no parent directory",
63+
)
64+
})?;
65+
let mut tmp = NamedTempFile::new_in(parent)?;
66+
tmp.as_file_mut().write_all(data)?;
67+
tmp.as_file_mut().sync_all()?;
68+
tmp.persist(p).map_err(std::io::Error::from)?;
69+
Ok(())
70+
}
71+
72+
fn fsync_dir(&self, p: &Path) -> std::io::Result<()> {
73+
let parent = p.parent().ok_or_else(|| {
74+
std::io::Error::new(
75+
std::io::ErrorKind::InvalidInput,
76+
"fsync_dir target has no parent directory",
77+
)
78+
})?;
79+
let dir = File::open(parent)?;
80+
dir.sync_all()?;
81+
Ok(())
82+
}
83+
}

crates/pearlite-state/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@
1111
mod errors;
1212
mod failure;
1313
mod history;
14+
mod io;
1415
mod reconciliation;
1516
mod schema;
17+
mod store;
1618

1719
pub use errors::StateError;
1820
pub use failure::FailureRef;
1921
pub use history::{HistoryEntry, SnapshotRef};
22+
pub use io::{FileSystem, LiveFs};
2023
pub use reconciliation::{ReconciliationAction, ReconciliationEntry};
2124
pub use schema::{
2225
Adopted, ConfigFileRecord, KernelRecord, Managed, SCHEMA_VERSION, ServicesState, State,
2326
UserEnvRecord,
2427
};
28+
pub use store::StateStore;

0 commit comments

Comments
 (0)