Skip to content

Commit 2f875d0

Browse files
feat(schema): nix.installer.expected_sha256 + NIX_INSTALLER_REQUIRED contract (#55)
Adds the per-host Nix bootstrap declaration that ADR-0012 routes through `pearlite bootstrap`: the Determinate installer's SHA-256 pin lives next to the consumer (the host that needs it), not in a repo-wide bootstrap file. - New `pearlite_schema::NixDecl` and `NixInstallerDecl` types in `nix.rs`. Wired through `lib.rs` and as `DeclaredState::nix: Option<NixDecl>` (default None). - New cross-field contract `NIX_INSTALLER_REQUIRED`: when any user has `home_manager.enabled = true`, the host's `nix.installer.expected_sha256` must be present and well-formed (64 lowercase hex chars). HM-disabled hosts may omit the block. - `host_full.toml` fixture grows a `[nix.installer]` table so the pre-existing `validate_clean_full_fixture` keeps passing under the new contract. - Four new validator tests covering the four cases (missing block with HM, malformed sha, uppercase hex, HM-disabled-everywhere). Tests: 307 passing (was 303, +4). Clippy clean. fmt clean. audit clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bcc8b64 commit 2f875d0

7 files changed

Lines changed: 157 additions & 0 deletions

File tree

crates/pearlite-diff/src/compose.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ mod tests {
350350
users: Vec::new(),
351351
remove: pearlite_schema::RemovePolicy::default(),
352352
snapshots: pearlite_schema::SnapshotPolicy::default(),
353+
nix: None,
353354
}
354355
}
355356

crates/pearlite-schema/src/declared.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
88

99
use crate::config::{ConfigEntry, RemovePolicy};
1010
use crate::host::{HostMeta, KernelDecl};
11+
use crate::nix::NixDecl;
1112
use crate::packages::PackageSet;
1213
use crate::services::ServicesDecl;
1314
use crate::snapshots::SnapshotPolicy;
@@ -44,4 +45,9 @@ pub struct DeclaredState {
4445
/// Snapshot retention policy.
4546
#[serde(default)]
4647
pub snapshots: SnapshotPolicy,
48+
/// Optional nix bootstrap declaration. Required by the schema
49+
/// validator iff any user has `home_manager.enabled = true` —
50+
/// see [`ContractViolation::NIX_INSTALLER_REQUIRED`](crate::ContractViolation).
51+
#[serde(default)]
52+
pub nix: Option<NixDecl>,
4753
}

crates/pearlite-schema/src/errors.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ impl ContractViolation {
3535
/// `meta.arch_level` does not match the declared per-feature-level
3636
/// repository: e.g. `arch_level = "v3"` with non-empty `packages.cachyos-v4`.
3737
pub const ARCH_LEVEL_MISMATCH: &'static str = "ARCH_LEVEL_MISMATCH";
38+
/// At least one user has `home_manager.enabled = true` but the
39+
/// `nix.installer.expected_sha256` pin is missing or malformed
40+
/// (must be 64 lowercase hex chars). See ADR-0012.
41+
pub const NIX_INSTALLER_REQUIRED: &'static str = "NIX_INSTALLER_REQUIRED";
3842
}
3943

4044
impl std::fmt::Display for ContractViolation {

crates/pearlite-schema/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod config;
1414
mod declared;
1515
mod errors;
1616
mod host;
17+
mod nix;
1718
mod packages;
1819
mod parse;
1920
mod probed;
@@ -26,6 +27,7 @@ pub use config::{ConfigEntry, RemovePolicy};
2627
pub use declared::DeclaredState;
2728
pub use errors::{ContractViolation, SchemaError};
2829
pub use host::{ArchLevel, HostMeta, KernelDecl};
30+
pub use nix::{NixDecl, NixInstallerDecl};
2931
pub use packages::PackageSet;
3032
pub use parse::from_resolved_toml;
3133
pub use probed::{

crates/pearlite-schema/src/nix.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Nix bootstrap declaration block.
5+
//!
6+
//! Modeled per ADR-0012 (`docs/adr/0012-nix-bootstrap-wiring.md`):
7+
//! the per-host Nickel config carries the SHA-256 pin of the
8+
//! Determinate Nix installer that `pearlite bootstrap` will execute
9+
//! on first run. The pin lives next to the consumer (the host that
10+
//! needs nix) rather than in a repo-wide bootstrap file or a
11+
//! Pearlite-baked release constant.
12+
13+
use schemars::JsonSchema;
14+
use serde::{Deserialize, Serialize};
15+
16+
/// Per-host nix bootstrap declaration.
17+
///
18+
/// Optional at the [`DeclaredState`](crate::DeclaredState) level: hosts
19+
/// that don't run Home Manager don't need to declare it. When any user
20+
/// on the host has `home_manager.enabled = true`, the schema validator
21+
/// requires this block — see
22+
/// [`ContractViolation::NIX_INSTALLER_REQUIRED`](crate::ContractViolation).
23+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24+
pub struct NixDecl {
25+
/// Determinate Nix installer pin.
26+
pub installer: NixInstallerDecl,
27+
}
28+
29+
/// SHA-256 pin for the Determinate Nix installer script.
30+
///
31+
/// ADR-004 requires the installer-fetch step to verify the script's
32+
/// content hash before execution. The expected hash is operator-supplied
33+
/// per host, refreshed when Determinate ships a new installer version.
34+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35+
pub struct NixInstallerDecl {
36+
/// SHA-256 of the installer script body, as 64 lowercase hex
37+
/// characters. Verified by
38+
/// [`pearlite_userenv::NixInstaller::install`] before the script is
39+
/// executed.
40+
pub expected_sha256: String,
41+
}

crates/pearlite-schema/src/validate.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ use std::collections::BTreeMap;
2222
/// disjoint.
2323
/// - **`ARCH_LEVEL_MISMATCH`** — `meta.arch_level = "v3"` forbids a
2424
/// non-empty `packages.cachyos-v4`, and symmetrically.
25+
/// - **`NIX_INSTALLER_REQUIRED`** — any user with
26+
/// `home_manager.enabled = true` requires a present, well-formed
27+
/// `nix.installer.expected_sha256` (64 lowercase hex chars).
2528
///
2629
/// # Errors
2730
/// Returns every violation found, not just the first.
@@ -30,6 +33,7 @@ pub fn validate(d: &DeclaredState) -> Result<(), Vec<ContractViolation>> {
3033
check_duplicate_packages(d, &mut violations);
3134
check_kernel_modules_unique(d, &mut violations);
3235
check_arch_level_matches_packages(d, &mut violations);
36+
check_nix_installer_required(d, &mut violations);
3337
if violations.is_empty() {
3438
Ok(())
3539
} else {
@@ -112,6 +116,42 @@ fn check_arch_level_matches_packages(d: &DeclaredState, violations: &mut Vec<Con
112116
}
113117
}
114118

119+
fn check_nix_installer_required(d: &DeclaredState, violations: &mut Vec<ContractViolation>) {
120+
let any_hm_enabled = d
121+
.users
122+
.iter()
123+
.any(|u| u.home_manager.as_ref().is_some_and(|hm| hm.enabled));
124+
if !any_hm_enabled {
125+
return;
126+
}
127+
match d.nix.as_ref() {
128+
None => {
129+
violations.push(ContractViolation {
130+
id: ContractViolation::NIX_INSTALLER_REQUIRED,
131+
message:
132+
"at least one user has home_manager.enabled = true but [nix.installer] is not declared"
133+
.to_owned(),
134+
});
135+
}
136+
Some(nix) => {
137+
let sha = &nix.installer.expected_sha256;
138+
if sha.len() != 64
139+
|| !sha
140+
.bytes()
141+
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
142+
{
143+
violations.push(ContractViolation {
144+
id: ContractViolation::NIX_INSTALLER_REQUIRED,
145+
message: format!(
146+
"nix.installer.expected_sha256 must be 64 lowercase hex chars, got {} chars",
147+
sha.len()
148+
),
149+
});
150+
}
151+
}
152+
}
153+
}
154+
115155
fn first_duplicate(items: &[String]) -> Option<&str> {
116156
let mut seen = std::collections::BTreeSet::new();
117157
for item in items {
@@ -213,6 +253,66 @@ mod tests {
213253
assert_eq!(err[0].id, ContractViolation::ARCH_LEVEL_MISMATCH);
214254
}
215255

256+
#[test]
257+
fn validate_nix_installer_required_when_hm_enabled_but_block_missing() {
258+
let mut d = from_resolved_toml(FULL).expect("parse");
259+
d.nix = None;
260+
261+
let err = validate(&d).expect_err("missing [nix.installer] must be flagged");
262+
assert!(
263+
err.iter()
264+
.any(|v| v.id == ContractViolation::NIX_INSTALLER_REQUIRED
265+
&& v.message.contains("home_manager.enabled = true"))
266+
);
267+
}
268+
269+
#[test]
270+
fn validate_nix_installer_sha_must_be_64_lowercase_hex() {
271+
let mut d = from_resolved_toml(FULL).expect("parse");
272+
d.nix = Some(crate::NixDecl {
273+
installer: crate::NixInstallerDecl {
274+
expected_sha256: "not-hex".to_owned(),
275+
},
276+
});
277+
278+
let err = validate(&d).expect_err("malformed sha must be flagged");
279+
assert!(
280+
err.iter()
281+
.any(|v| v.id == ContractViolation::NIX_INSTALLER_REQUIRED
282+
&& v.message.contains("64 lowercase hex chars"))
283+
);
284+
}
285+
286+
#[test]
287+
fn validate_nix_installer_rejects_uppercase_hex() {
288+
let mut d = from_resolved_toml(FULL).expect("parse");
289+
let upper = "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef";
290+
d.nix = Some(crate::NixDecl {
291+
installer: crate::NixInstallerDecl {
292+
expected_sha256: upper.to_owned(),
293+
},
294+
});
295+
296+
let err = validate(&d).expect_err("uppercase hex must be flagged");
297+
assert!(
298+
err.iter()
299+
.any(|v| v.id == ContractViolation::NIX_INSTALLER_REQUIRED)
300+
);
301+
}
302+
303+
#[test]
304+
fn validate_no_nix_block_required_when_hm_disabled_everywhere() {
305+
let mut d = from_resolved_toml(FULL).expect("parse");
306+
for user in &mut d.users {
307+
if let Some(hm) = user.home_manager.as_mut() {
308+
hm.enabled = false;
309+
}
310+
}
311+
d.nix = None;
312+
313+
validate(&d).expect("HM disabled everywhere → nix block optional");
314+
}
315+
216316
#[test]
217317
fn validate_collects_multiple_violations() {
218318
let mut d = from_resolved_toml(MINIMAL).expect("parse");

fixtures/schema/host_full.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,6 @@ ignore = ["nano"]
5858

5959
[snapshots]
6060
keep = 30
61+
62+
[nix.installer]
63+
expected_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

0 commit comments

Comments
 (0)