Skip to content

feat(fs): sha256_file, write_etc_atomic, probe_config_files — closes Plan §6.4#9

Merged
UnbreakableMJ merged 2 commits into
mainfrom
feat/fs-primitives
Apr 27, 2026
Merged

feat(fs): sha256_file, write_etc_atomic, probe_config_files — closes Plan §6.4#9
UnbreakableMJ merged 2 commits into
mainfrom
feat/fs-primitives

Conversation

@UnbreakableMJ

Copy link
Copy Markdown
Contributor

M1-W1-G — closes Plan §6.4. pearlite-fs is the third pure crate of M1 Week 1. Public API: sha256_file (chunked, constant memory), write_etc_atomic (PRD §7.4 recipe with chmod/chown before rename), probe_config_files. Workspace deps added: nix 0.31, hex 0.4. 12 tests passing; 2 fork-based crash tests stubbed as #[ignore] for M1 W2. clippy/fmt/SPDX/audit clean.

🤖 Generated with Claude Code

UnbreakableMJ and others added 2 commits April 27, 2026 19:08
M1-W1-G — closes Plan §6.4. pearlite-fs is the third pure crate of M1
Week 1; primitives the engine and adapters share for hashing files,
atomically writing /etc with mode/owner/group preserved, and probing
declared ConfigEntry targets into a ConfigFileInventory.

Public API:
- pub fn sha256_file(p: &Path) -> Result<[u8; 32], FsError>
- pub fn write_etc_atomic(target, content, mode, owner, group) ->
  Result<(), FsError>
- pub fn probe_config_files(targets: &[ConfigEntry]) -> ConfigFileInventory

ConfigEntry / ConfigFileInventory / ConfigFileMeta are re-exported
read-only from pearlite-schema (PR #3).

Internal modules: hash.rs, atomic.rs, chown.rs, inventory.rs, errors.rs.

Implementation notes:
- hash.rs: 64 KiB chunked read; constant memory regardless of file
  size. Heap-allocated buffer (clippy::large_stack_arrays denies the
  on-stack version).
- atomic.rs: PRD §7.4 recipe — temp + chmod + chown BEFORE rename so
  the file appears with the right ownership atomically — followed by
  fsync of the file's data, atomic rename, fsync of the parent
  directory.
- chown.rs: nix::unistd::User/Group for name<->id resolution; libc
  chown via nix; Permissions::from_mode for chmod. Plan §6.4 hard
  rule: no std::process::Command for ownership ops.
- inventory.rs: stat each target, hash, encode hex, look up
  uid/gid -> name. Missing targets are silently absent — the diff
  engine treats absence as drift in its own pass.

Workspace deps added:
- nix = { version = "0.31", features = ["user", "fs"] }
- hex = "0.4"

Tests:
- hash: known_file_known_digest (SHA-256("abc")), empty_file_known_digest,
  missing_file_yields_io_error.
- chown: unknown_user_yields_typed_error, unknown_group_yields_typed_error.
- atomic: write_then_read_round_trips, mode_preserved_through_temp,
  chown_to_self_succeeds (chowns to the running user since CI lacks
  root), unknown_owner_yields_typed_error.
- inventory: missing_target_yields_none_in_inventory,
  present_target_records_sha256_and_mode, empty_target_list_yields_empty_inventory.
- 2 fork-based tests stubbed as #[ignore]:
  crash_after_write_before_rename and
  rename_atomic_visible_to_concurrent_reader. These need rusty-fork
  machinery and land in M1 W2 with engine integration tests.

Plan §6.4 "Done when" status:
- sha256 implementation is constant-memory (chunked I/O). ✓
- Atomic write tested via simulated kill-9 in unit tests using forked
  processes. PARTIAL — ignored stubs in place; full test machinery
  arrives in M1 W2.
- No std::process::Command — chown happens through libc directly via
  nix. ✓
- pearlite-audit reports no §13 violations. ✓

Verification:
- cargo test -p pearlite-fs  (12 passed; 0 failed; 2 ignored)
- 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>
cargo-deny treats `pearlite-schema = { path = "..." }` as a wildcard
dep because no version constraint is present. The workspace lints have
`wildcards = "deny"`. Pinning to "0.1" matches workspace.package.version
(0.1.0) and the workspace SemVer baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@UnbreakableMJ
UnbreakableMJ merged commit 9c7d438 into main Apr 27, 2026
3 checks passed
@UnbreakableMJ
UnbreakableMJ deleted the feat/fs-primitives branch April 27, 2026 16:29
UnbreakableMJ added a commit that referenced this pull request Apr 27, 2026
… §6.5 (#10)

* feat(nickel): land NickelEvaluator trait, LiveNickel, MockNickel, load_host

Plan §6.5 — first M1 Week 2 adapter probe path. The thinnest possible
shim around the `nickel` binary: argv-array subprocess invocation,
stdout capture, delegation to pearlite_schema::from_resolved_toml.
No Nickel parsing in Rust per Plan §6.5 hard rule.

Public surface (re-exported from lib.rs):

- pub trait NickelEvaluator { fn evaluate(&self, &Path) -> Result<String, NickelError>; }
- pub struct LiveNickel { binary: PathBuf }
- pub struct MockNickel — feature-gated behind test-mocks, in-memory
  canned-output map BTreeMap<PathBuf, String>.
- pub fn load_host(host_file: &Path, eval: &dyn NickelEvaluator)
  -> Result<DeclaredState, NickelError>.
- pub enum NickelError — NotInPath (hints `paru -S nickel-lang`),
  Io, EvaluationFailed { code, stderr }, NotUtf8, Schema (transparent
  to pearlite_schema::SchemaError), MockMissing.

LiveNickel:
- Default constructor resolves `nickel` from $PATH.
- with_binary() lets tests inject a known-good path.
- Spawn translates ErrorKind::NotFound to NickelError::NotInPath
  with a runnable hint.
- Captures stderr verbatim; surfaces with exit code on non-zero.

CI:
- Add nickel-lang to taiki-e/install-action's tool list in T2 Unit so
  the live tests actually exercise nickel rather than silent-skipping.

Tests (5 passing locally; live tests silent-skip when nickel isn't on
PATH — CI installs it so they exercise there):

- mock: canned_output_round_trips_to_declared_state.
- mock: missing_path_yields_mock_missing_error.
- live: nickel_not_in_path_error_class — pointed at a bogus path.
- live: version_probe_succeeds — nickel --version contains "nickel".
- live: minimal_host_evaluates — fixture host_minimal.ncl evaluates
  to TOML that parses as DeclaredState.

Fixture: fixtures/nickel/host_minimal.ncl mirrors the schema fixture.

Cargo.toml: pearlite-schema dep pinned to "0.1" per the cargo-deny
wildcard rule (PR #9 precedent). New `test-mocks` feature.

Verification:
- cargo test -p pearlite-nickel  (5 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>

* fix(ci): install nickel-lang-cli, not nickel-lang

The library crate `nickel-lang` has no binaries; the CLI binary lives
in the `nickel-lang-cli` crate. taiki-e/install-action's fallback to
cargo-binstall correctly reported "no binaries specified" and failed
the T2 Unit job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): replace extractions/setup-just with taiki-e/install-action

extractions/setup-just@v3 hit "no release for just matching version
specifier" on every recent PR run — the action's default version
resolution is broken. taiki-e/install-action is already used in every
job for cargo tooling; adding `just` to its tool list is the
consistent fix and avoids the second action entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant