+ .collect(); + format!( + "NetSecurity completed but the observed rules do not match the admission pair: {}", + described.join("; ") + ) +} + +/// The log line for a LAN scope that has MOVED, or `None` when it has not. +/// +/// Compared against what is OBSERVED rather than against a remembered value: the +/// rule on the host is the only record of the scope the last reconcile wrote, and +/// a daemon that restarted has no memory of it. +/// +/// THE SAME COMPARISON THE VERDICT USES, for the same FOLD-4 reason and this is +/// the second site of that one defect: the observed side is NetSecurity's +/// spelling, so a raw string compare here announces that the scope MOVED on every +/// reconcile of an unchanged host -- `192.168.1.0/255.255.255.0` against the +/// `192.168.1.0/24` we wrote. A move that is reported when nothing moved teaches +/// the operator to ignore the line that exists to be noticed. +// [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] [impl->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] +fn scope_change(observed: &[Rule], want: &[RuleSpec]) -> Option { + let spec = want.iter().find(|spec| spec.name == RULE_NAME_LAN)?; + let existing = observed.iter().find(|rule| rule.name == RULE_NAME_LAN)?; + // THE SAME ASYMMETRY AS THE VERDICT, so the line cannot report "unchanged" + // over a spec the verdict refuses: an unscopeable spec is a rewrite, and a + // rewrite is what this line exists to announce. + if wanted_networks(&spec.remotes) == Some(remote_set(&existing.remotes)) { + return None; + } + Some(format!( + "bootstrap-firewall lan-scope moved: rule={} old=[{}] new=[{}] -- rewriting the LAN half", + RULE_NAME_LAN, + existing.remotes.join(","), + spec.remotes.join(",") + )) +} + +/// The face of a verification that never reached a verdict, AFTER the writes. +// [impl->REQ-BOOTSTRAP-FIREWALL-VERIFY-ONE-PASS] +fn unverified_after_write(error: &str) -> String { + format!( + "The admission pair was WRITTEN and then could not be verified: {error}. The rules may already be in place -- this is not a refused write. Rerun bootstrap to re-observe them before changing elevation or host policy." + ) +} + pub(super) fn cleanup() -> Result<(), String> { - powershell(&script(REMOVE_AND_VERIFY)).map(|_| ()) + powershell("cleanup", &script(REMOVE_AND_VERIFY)).map(|_| ()) } pub(super) fn is_clean() -> Result { - let output = powershell(&script( - r#" + let output = powershell( + "is-clean", + &script( + r#" $local = @(Named-Rules 'PersistentStore') $active = @(Named-Rules 'ActiveStore') ($local.Count -eq 0 -and $active.Count -eq 0) | ConvertTo-Json -Compress "#, - ))?; + ), + )?; serde_json::from_str(output.trim_start_matches('\u{feff}').trim()) .map_err(|error| format!("Cannot decode NetSecurity bootstrap cleanup evidence: {error}")) } @@ -600,6 +1059,22 @@ mod tests { // gating, arguments, path text and encoding and none of them touches scope. /// A rule as the query now reports it: fields, not a verdict. + /// A host carrying one ordinary LAN, for the specs under test. FOLD-3 makes + /// the LAN half host-derived, so every spec-building test has to say WHICH + /// host it is speaking about -- that is the behaviour change, stated once here. + fn one_lan() -> LanScope { + LanScope::Prefixes(vec!["192.168.1.0/24".to_string()]) + } + + /// The census a host with that LAN would report. + fn one_lan_census() -> Vec
{ + vec![Address { + address: "192.168.1.81".to_string(), + prefix_length: 24, + address_state: "Preferred".to_string(), + }] + } + fn observed(name: &str, port: u16, program: &str, profile: &str, remotes: &[&str]) -> Rule { Rule { name: name.to_string(), @@ -608,7 +1083,8 @@ mod tests { profile: profile.to_string(), remotes: remotes.iter().map(|r| r.to_string()).collect(), hygiene: true, - enforcement: vec!["Full".to_string()], + enforcement: vec![ENFORCEMENT_SUCCESS], + source_type: "Local".to_string(), } } @@ -621,7 +1097,7 @@ mod tests { const BOUND: u16 = 56025; const CONFIGURED: u16 = 5470; - let [want, _lan] = desired_specs("c:\\spt\\spt.exe", BOUND); + let want = &desired_specs("c:\\spt\\spt.exe", BOUND, &one_lan())[0]; assert_eq!(want.port, BOUND, "the spec carries the BOUND port"); assert_ne!(want.port, CONFIGURED); @@ -645,7 +1121,7 @@ mod tests { // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn a_program_bearing_rule_does_not_satisfy_a_spec_that_wants_no_program_filter() { - let [want, _lan] = desired_specs("c:\\spt\\spt.exe", 5470); + let want = &desired_specs("c:\\spt\\spt.exe", 5470, &one_lan())[0]; assert!(want.program.is_none(), "the working default carries no program filter"); let without = observed(want.name, 5470, "", &want.profile, DESIRED_TAILNET_REMOTES); @@ -674,20 +1150,26 @@ mod tests { // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn an_unrestricted_remote_does_not_satisfy_a_narrowed_spec() { - let [want, _lan] = desired_specs("c:\\spt\\spt.exe", 5470); + let want = &desired_specs("c:\\spt\\spt.exe", 5470, &one_lan())[0]; let unrestricted = observed(want.name, 5470, "", &want.profile, &["Any"]); let narrowed = observed(want.name, 5470, "", &want.profile, DESIRED_TAILNET_REMOTES); assert!(!spec_satisfied_by(&unrestricted, &want), "`Any` is not the narrow scope"); assert!(spec_satisfied_by(&narrowed, &want)); - // Order is not significance: the comparison is set-like and - // case-insensitive, so a reordered or recased render still matches. + // Order is not significance: the comparison is set-like, so a reordered + // render still matches. + // + // THE CASE HALF OF THIS CELL LOST ITS SUBJECT AT FOLD-3 and is recorded + // rather than quietly dropped: it used to pair `LocalSubnet` with the + // observed `localsubnet`, and both halves' remotes are now numeric + // prefixes with no case to vary. The case-insensitive comparison is still + // exercised, by `Any` in the unrestricted arm above. let reordered = RuleSpec { - remotes: vec!["LocalSubnet".into(), "100.64.0.0/10".into()], + remotes: vec!["192.168.1.0/24".into(), "100.64.0.0/10".into()], ..want.clone() }; - let seen = observed(want.name, 5470, "", &want.profile, &["100.64.0.0/10", "localsubnet"]); + let seen = observed(want.name, 5470, "", &want.profile, &["100.64.0.0/10", "192.168.1.0/24"]); assert!(spec_satisfied_by(&seen, &reordered), "render order must not decide the verdict"); // A wrong profile fails even with the right remotes — the trap that a @@ -701,13 +1183,145 @@ mod tests { // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn a_rule_failing_hygiene_is_refused_even_when_its_scope_is_exactly_right() { - let [want, _lan] = desired_specs("c:\\spt\\spt.exe", 5470); + let want = &desired_specs("c:\\spt\\spt.exe", 5470, &one_lan())[0]; let mut rule = observed(want.name, 5470, "", &want.profile, DESIRED_TAILNET_REMOTES); assert!(spec_satisfied_by(&rule, &want), "the scope is right to begin with"); rule.hygiene = false; assert!(!spec_satisfied_by(&rule, &want)); } + // ---- FOLD-4: representation is not policy (releases#304 W2) ------------- + // + // These two are the contract on the normalizers themselves, in both + // directions: equivalent spellings must compare EQUAL, and every other + // difference must stay UNEQUAL. The pair-level and `decide`-level regression + // cells over independently captured NetSecurity spellings are hertz's, and + // they ride on top of these. + + /// FOLD-4(a) — THE THREE SPELLINGS NETSECURITY READS BACK ARE THE SAME + /// ADMISSION AS THE ONE WE WROTE. + /// + /// Each arm asserts the RAW strings differ before asserting the canonical + /// forms agree: without that line the cell would pass just as well on a + /// normalizer that does nothing, and it is what makes this a regression cell + /// for the field failure rather than a tautology. + // [unit->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] + #[test] + fn equivalent_netsecurity_spellings_compare_equal_to_what_was_written() { + // An absent application filter: `Any` is how it is read back, `""` is + // how a `program: None` spec was written to mean it. + assert_ne!("Any", "", "the two spellings are different strings"); + assert!(program_unrestricted("Any") && program_unrestricted("")); + assert!(program_unrestricted(" any ") && program_unrestricted("ANY")); + + // A profile set: our render order without a space, NetSecurity's with one. + assert_ne!("Domain, Private", DESIRED_LAN_PROFILE, "different strings"); + assert_eq!(profile_set("Domain, Private"), profile_set(DESIRED_LAN_PROFILE)); + + // A network: prefix form as `lan_scope` emits it, mask form as + // NetSecurity renders it back. + for (written, read_back) in [ + ("192.168.1.0/24", "192.168.1.0/255.255.255.0"), + ("100.64.0.0/10", "100.64.0.0/255.192.0.0"), + ] { + assert_ne!(written, read_back, "different strings"); + assert_eq!( + ipv4_network(written), + ipv4_network(read_back), + "{written} and {read_back} name one network" + ); + assert!(ipv4_network(written).is_some(), "and both parse"); + } + + // Host bits are not a second network, and order is not significance. + assert_eq!(ipv4_network("192.168.1.81/24"), ipv4_network("192.168.1.0/24")); + let written = vec!["100.64.0.0/10".to_string(), "192.168.1.0/24".to_string()]; + let read_back = + vec!["192.168.1.0/255.255.255.0".to_string(), "100.64.0.0/255.192.0.0".to_string()]; + assert_eq!(remote_set(&written), remote_set(&read_back)); + } + + /// FOLD-4(b) — AND NOTHING ELSE COMPARES EQUAL. The risk a semantic + /// comparison carries is that it starts accepting scopes the policy never + /// chose, so every arm here is a difference that MUST survive normalization. + /// + /// `remote_set` is total by construction — unparseable text is compared as + /// its own lowercased literal — so malformed input answers "not equal" + /// rather than panicking or being rounded to a nearby prefix. + // [unit->REQ-BOOTSTRAP-FIREWALL-SPELLING-EQUIVALENCE] + #[test] + fn a_different_scope_never_canonicalizes_into_the_one_that_was_written() { + let written = vec!["192.168.1.0/24".to_string()]; + // POSITIVE CONTROL: the equal case is equal, so every `assert_ne` below + // is about the perturbation and not about a comparison that never matches. + assert_eq!(remote_set(&written), remote_set(&["192.168.1.0/255.255.255.0".to_string()])); + + for different in [ + "192.168.1.99", // a single host inside the prefix: narrower + "192.168.1.0/25", // half of it + "192.168.0.0/16", // wider + "0.0.0.0/0", // the whole internet + "Any", // unrestricted, and not a network at all + "192.168.2.0/24", // a different network entirely + "192.168.1.0/33", // malformed: prefix out of range + "192.168.1.0/", // malformed: no length + "192.168.1.0/-1", // malformed: negative length + "192.168.1", // malformed: not four octets + "192.168.1.256/24", // malformed: octet out of range + "192.168.1.0/255.0.255.0", // malformed: non-contiguous mask + "not-an-address", + "", + ] { + assert_ne!( + remote_set(&written), + remote_set(&[different.to_string()]), + "{different:?} is not a spelling of 192.168.1.0/24" + ); + } + + // A profile SET is compared, not expanded: an all-profile rule does not + // satisfy the LAN half, or a Private+Domain rule would be satisfied by + // one that also admits the local subnet of a PUBLIC network. + assert_ne!(profile_set("Any"), profile_set(DESIRED_LAN_PROFILE)); + assert_ne!(profile_set("Domain, Private, Public"), profile_set(DESIRED_LAN_PROFILE)); + assert_ne!(profile_set("Private"), profile_set(DESIRED_LAN_PROFILE), "a subset is not the set"); + + // And `Any` is the ABSENCE of an application filter, never a program + // named `Any` that could satisfy a spec wanting one. + let want = &desired_specs("c:\\spt\\spt.exe", 5470, &one_lan())[0]; + let program_spec = + RuleSpec { program: Some(crate::firewall::normalize_path("C:\\spt\\spt.exe")), ..want.clone() }; + let unrestricted = observed(want.name, 5470, "Any", &want.profile, DESIRED_TAILNET_REMOTES); + assert!(spec_satisfied_by(&unrestricted, want), "control: it satisfies the spec wanting none"); + assert!( + !spec_satisfied_by(&unrestricted, &program_spec), + "an unfiltered rule must not satisfy a spec that wants a program filter" + ); + assert!(!program_unrestricted("C:\\spt\\Any.exe"), "a path ending in Any is still a path"); + + // AND MALFORMED TEXT DOES NOT BECOME ACCEPTABLE BY MATCHING ITSELF + // (doyle's boundary, 2026-09-12). The observed side falls back to a + // literal so it can be compared at all; the WANTED side must parse, or + // two identical unparseable strings would agree and stand in for a scope + // neither names. + let spec = RuleSpec { remotes: vec!["not-a-network".to_string()], ..want.clone() }; + let identical = observed(want.name, 5470, "", &want.profile, &["not-a-network"]); + assert_eq!( + remote_set(&spec.remotes), + remote_set(&identical.remotes), + "control: as OBSERVED text the two sides are the same literal" + ); + assert!( + !spec_satisfied_by(&identical, &spec), + "a spec whose remotes are not networks is satisfied by nothing, including a rule carrying the identical unparseable text" + ); + assert_eq!(wanted_networks(&spec.remotes), None, "and the wanted side is what refuses"); + assert!( + wanted_networks(&want.remotes).is_some(), + "control: the real spec's remotes DO parse, so the refusal above is the perturbation and not a function that never parses anything" + ); + } + /// Build a store holding exactly the rules the pair wants. fn reconciled_store(want: &[RuleSpec]) -> Vec { want.iter() @@ -724,7 +1338,7 @@ mod tests { // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn a_store_holding_only_the_tailnet_half_is_not_a_reconciled_pair() { - let want = desired_specs("c:/spt/spt.exe", 5470); + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); let full = reconciled_store(&want); assert!(pair_satisfied_by(&full, &want), "the full pair is reconciled"); @@ -744,17 +1358,17 @@ mod tests { // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn a_half_carrying_the_other_halfs_scope_does_not_satisfy_the_pair() { - let want = desired_specs("c:/spt/spt.exe", 5470); - let [tailnet, lan] = &want; + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); + let (tailnet, lan) = (&want[0], &want[1]); assert_ne!(tailnet.name, lan.name, "the halves are distinct names"); assert_ne!(tailnet.remotes, lan.remotes, "and distinct remote scopes"); assert_eq!(lan.profile, "Private,Domain", "the LAN half is not an all-profile rule"); - assert_eq!(lan.remotes, vec!["LocalSubnet".to_string()]); + assert_eq!(lan.remotes, vec!["192.168.1.0/24".to_string()], "derived from the host census"); // Swap the two halves' remotes: every name is present, every scope the // pair wants is present SOMEWHERE, and it is still not reconciled. let swapped = vec![ - observed(tailnet.name, 5470, "", &tailnet.profile, &["LocalSubnet"]), + observed(tailnet.name, 5470, "", &tailnet.profile, &["192.168.1.0/24"]), observed(lan.name, 5470, "", &lan.profile, &["100.64.0.0/10"]), ]; assert!( @@ -768,12 +1382,12 @@ mod tests { // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn a_duplicate_of_one_name_is_ambiguous_while_the_two_name_pair_is_not() { - let want = desired_specs("c:/spt/spt.exe", 5470); + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); let full = reconciled_store(&want); // The happy path: TWO rules in each store, and no ambiguity error. assert_eq!(full.len(), 2, "a reconciled store holds both halves"); - let state = Snapshot { persistent: full.clone(), active: full.clone() }; + let state = Snapshot { active: full.clone(), addresses: one_lan_census() }; assert_eq!( decide(&state, "c:/spt/spt.exe", 5470), Ok(true), @@ -783,16 +1397,179 @@ mod tests { // The real ambiguity: the SAME name twice. let mut duplicated = full.clone(); duplicated.push(full[0].clone()); - let state = Snapshot { persistent: duplicated, active: full }; + let state = Snapshot { active: duplicated, addresses: one_lan_census() }; let error = decide(&state, "c:/spt/spt.exe", 5470).expect_err("duplicates are refused"); assert!(error.contains(want[0].name), "the refusal names the duplicated rule: {error}"); } + /// FOLD-3: the derived scope is a NETWORK, not the host's own address, and + /// every exclusion is a refusal to widen admission. The `/0` case is the one + /// that matters: rendered rather than refused it would be `0.0.0.0/0`, which + /// admits the entire internet to the bootstrap port. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn the_lan_scope_is_the_network_prefix_and_every_widening_address_is_excluded() { + let census = vec![ + Address { address: "192.168.1.81".into(), prefix_length: 24, address_state: "Preferred".into() }, + // the host's own address on a second LAN, same network as the first row's peer set + Address { address: "10.2.3.4".into(), prefix_length: 8, address_state: "Preferred".into() }, + // EXCLUDED, each for its own reason + Address { address: "127.0.0.1".into(), prefix_length: 8, address_state: "Preferred".into() }, + Address { address: "169.254.7.9".into(), prefix_length: 16, address_state: "Preferred".into() }, + Address { address: "100.98.197.12".into(), prefix_length: 32, address_state: "Preferred".into() }, + Address { address: "192.168.9.9".into(), prefix_length: 24, address_state: "Tentative".into() }, + Address { address: "172.16.0.5".into(), prefix_length: 0, address_state: "Preferred".into() }, + ]; + let scope = lan_scope(&census); + assert_eq!( + scope, + LanScope::Prefixes(vec!["10.0.0.0/8".to_string(), "192.168.1.0/24".to_string()]), + "the network address, not the host address; loopback, link-local, CGNAT, non-Preferred and an out-of-range prefix all excluded" + ); + let rendered = format!("{scope:?}"); + assert!(!rendered.contains("0.0.0.0/0"), "a /0 is never rendered: {rendered}"); + assert!(!rendered.contains("192.168.1.81"), "the host address is not a scope: {rendered}"); + assert!(!rendered.contains("100."), "the tailnet range is the other half's scope: {rendered}"); + } + + /// A HOST WITH NO LAN IS A STATE, NOT AN ERROR TO FALL OUT OF. The LAN half is + /// OMITTED rather than written with an empty remote set, and the verdict says + /// why -- an empty-remote rule would admit nothing while reading, in every + /// later census, exactly like a written half. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn a_host_with_no_connected_ipv4_omits_the_lan_half_and_says_so() { + let tailnet_only = vec![ + Address { address: "100.98.197.12".into(), prefix_length: 32, address_state: "Preferred".into() }, + Address { address: "127.0.0.1".into(), prefix_length: 8, address_state: "Preferred".into() }, + Address { address: "169.254.1.2".into(), prefix_length: 16, address_state: "Preferred".into() }, + ]; + assert_eq!(lan_scope(&tailnet_only), LanScope::NoneConnected); + + let want = desired_specs("c:/spt/spt.exe", 5470, &LanScope::NoneConnected); + assert_eq!(want.len(), 1, "the LAN half is omitted, not emptied"); + assert_eq!(want[0].name, RULE_NAME_TAILNET); + + // The tailnet half is PRESENT and correct, and the verdict is still not true. + let state = Snapshot { active: reconciled_store(&want), addresses: tailnet_only }; + let error = decide(&state, "c:/spt/spt.exe", 5470) + .expect_err("a host that cannot carry the LAN half does not verify"); + assert!(error.contains("no connected IPv4"), "the face names the cause: {error}"); + assert!( + error.contains("Elevation cannot repair"), + "and says what will not fix it, since the retry path offers elevation: {error}" + ); + } + + /// DOYLE'S FOLD-3 CONDITION 3, red on purpose: a pair written under census A + /// does NOT verify under census B, and the composer under B wants B. That + /// non-stationarity IS the behaviour change -- moving networks rewrites the + /// LAN rule on the next bootstrap -- and ADR-0059 Amendment 2 says so. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn a_pair_written_on_one_subnet_does_not_verify_on_another_and_the_rewrite_is_named() { + let census_b = vec![Address { + address: "10.7.0.22".into(), + prefix_length: 24, + address_state: "Preferred".into(), + }]; + let want_a = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); + let written_on_a = reconciled_store(&want_a); + + // Same rules, new network: UNVERIFIED, not silently accepted. + let state = Snapshot { active: written_on_a.clone(), addresses: census_b.clone() }; + assert_eq!( + decide(&state, "c:/spt/spt.exe", 5470), + Ok(false), + "a pair matching an older census is not verified" + ); + + // And the composer under B wants B, so the next reconcile repairs it. + let want_b = desired_specs("c:/spt/spt.exe", 5470, &lan_scope(&census_b)); + assert_eq!(want_b[1].remotes, vec!["10.7.0.0/24".to_string()]); + + // The rewrite is LOGGED with both sides. A silent rewrite is the same + // defect as a silent verify. + let line = scope_change(&written_on_a, &want_b).expect("the move is reported"); + assert!(line.contains("192.168.1.0/24"), "the old scope: {line}"); + assert!(line.contains("10.7.0.0/24"), "the new scope: {line}"); + + // Unchanged scope says nothing: the log is for moves, not for every start. + assert_eq!(scope_change(&written_on_a, &want_a), None); + } + + /// PERSISTENCE IS PART OF THE VERDICT. A `Dynamic` rule admits packets right + /// now and disappears at reboot, and by name, port, profile and remotes it is + /// our own pair exactly -- only the source store separates them. One pass + /// certifies both facts, so the verdict must read the source or the pass + /// certifies the weaker claim silently. + // [unit->REQ-BOOTSTRAP-FIREWALL-VERIFY-ONE-PASS] + #[test] + fn an_effective_pair_from_a_non_local_source_is_refused_by_name() { + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); + let full = reconciled_store(&want); + assert_eq!( + decide(&Snapshot { active: full.clone(), addresses: one_lan_census() }, "c:/spt/spt.exe", 5470), + Ok(true), + "the same pair from the local store verifies" + ); + + for source in ["Dynamic", "GroupPolicy", ""] { + let mut transient = full.clone(); + transient[1].source_type = source.to_string(); + let error = decide(&Snapshot { active: transient, addresses: one_lan_census() }, "c:/spt/spt.exe", 5470) + .expect_err("a non-local source is refused"); + assert!(error.contains(want[1].name), "the refusal names the rule: {error}"); + assert!( + source.is_empty() || error.contains(source), + "the refusal names the observed source: {error}" + ); + } + } + + /// DOYLE'S RED-ON-PURPOSE CELL (releases#304 W2, 2026-09-12): a pair present + /// in the persistent store but absent or overridden in the effective store + /// must NOT verify. Under the one-pass shape the persistent store is not + /// evidence at all, so the assertion is that an empty effective store is + /// UNRECONCILED rather than "nothing to check". + // [unit->REQ-BOOTSTRAP-FIREWALL-VERIFY-ONE-PASS] + #[test] + fn an_empty_effective_store_does_not_verify() { + let state = Snapshot { active: Vec::new(), addresses: one_lan_census() }; + assert_eq!(decide(&state, "c:/spt/spt.exe", 5470), Ok(false)); + } + + /// CLAUSE (c): the two post-write failures are different findings and must + /// not share a face. A mismatch is a verdict; a verify that never reached one + /// leaves the host state unknown with the writes already landed, and an + /// operator who reads it as a refused write goes hunting elevation it had. + // [unit->REQ-BOOTSTRAP-FIREWALL-VERIFY-ONE-PASS] + #[test] + fn a_write_that_could_not_be_verified_does_not_read_as_a_refused_write() { + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); + let mismatch = mismatch_message(&want); + let unverified = unverified_after_write("powershell.exe: firewall command timed out"); + assert_ne!(mismatch, unverified); + assert!(unverified.contains("WRITTEN"), "it states the write landed: {unverified}"); + assert!( + unverified.contains("not a refused write"), + "and says what it is not, since that is the misreading it exists to stop: {unverified}" + ); + assert!( + unverified.contains("timed out"), + "the underlying reason rides along: {unverified}" + ); + assert!( + !mismatch.contains("WRITTEN"), + "a completed verdict does not borrow the unverified face" + ); + } + /// The effector writes BOTH halves, from the same specs the verdict reads. // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn the_write_path_renders_both_halves_and_no_program_filter_by_default() { - let want = desired_specs("c:/spt/spt.exe", 5470); + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); let rendered = render_writes(&want); assert_eq!(rendered.matches("New-NetFirewallRule").count(), 2, "one call per half"); @@ -800,11 +1577,472 @@ mod tests { assert!(rendered.contains(spec.name), "the render names {}", spec.name); } assert!(rendered.contains("-RemoteAddress 100.64.0.0/10"), "the tailnet scope is written"); - assert!(rendered.contains("-RemoteAddress LocalSubnet"), "the LAN scope is written"); + assert!(rendered.contains("-RemoteAddress 192.168.1.0/24"), "the derived LAN scope is written"); assert!(rendered.contains("-Profile Private,Domain"), "the LAN half is profile-split"); assert!( !rendered.contains("-Program"), "the working default carries no program filter, and `Any` is not a substitute" ); } + + // ---- observed-spelling regression cells (hertz, releases#304 W2) ---- + // + // Appended onto FOLD-4 (10d18b7f). Boundary agreed with todlando: everything + // outside `mod tests` is his, this block is append-only at the end, its + // helpers live inside it, and no sibling is renamed. + // + // THE LEVEL DISTINCTION IS THE WHOLE REASON THESE EXIST BESIDE FOLD-4's TWO + // CELLS, and it is stated here so a later reader does not delete them as + // duplicates. FOLD-4(a)/(b) assert the NORMALIZERS: `program_unrestricted`, + // `profile_set`, `ipv4_network`, `remote_set` — token in, token out. These + // assert the VERDICT: `spec_satisfied_by`, `pair_satisfied_by` and `decide`, + // which is where a correct normalizer can still be called on one side only, + // dropped by a refactor, or reached after a short-circuit. A token cell + // passing while a verdict cell fails is exactly the gap this block covers. + // + // TWO ROUNDS OF OVERLAP TRIMMING have already removed everything a sibling + // owns; the owners are named at each site so nothing comes back as "missing": + // - the EXHAUSTIVE enumeration of wrong and malformed scopes is FOLD-4(b), + // at token level. H1 below keeps three REPRESENTATIVES at verdict level. + // - `Any` against a program-REQUIRING spec is FOLD-4(b). E1 keeps only the + // converse, which no sibling has. + // - hygiene, missing half, port drift, wider-remote `Any` and the + // source-store arm are all owned by pre-FOLD-4 cells (:945, :968, :856, + // :912, :1136). + // - the WANTED-side malformed direction is FOLD-4(b)'s. + + /// H1 — A DIFFERENT SCOPE IS REJECTED AT THE VERDICT, NOT ONLY AT THE TOKEN. + /// + /// FOLD-4(b) enumerates the wrong-scope and malformed spellings against + /// `remote_set`. This runs three representatives — one narrower, one wider, + /// one malformed — through `pair_satisfied_by`, so the claim is about the + /// admission decision rather than about the comparison function it calls. + /// If `spec_satisfied_by` ever normalizes one side only, or a short-circuit + /// returns before the remote axis is consulted, FOLD-4(b) still passes and + /// this fails. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn a_scope_that_differs_is_rejected_at_the_pair_verdict_not_only_at_the_token() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + + // POSITIVE CONTROL: the unperturbed store satisfies. Without it every + // assertion below passes equally well on a fixture that never matched. + let matching = reconciled_store(&want); + assert!(pair_satisfied_by(&matching, &want), "control: the unperturbed pair must satisfy"); + + for wrong in [ + "192.168.1.99", // narrower: one host inside the derived prefix + "0.0.0.0/0", // wider: the whole internet + "not-an-address" // malformed: no network at all + ] { + let mut store = matching.clone(); + for rule in store.iter_mut().filter(|rule| rule.name == RULE_NAME_LAN) { + rule.remotes = vec![wrong.to_string()]; + } + assert!( + !pair_satisfied_by(&store, &want), + "{wrong:?} is a different scope from the derived prefix, not a spelling of it" + ); + } + } + + /// H2 — AN UNENFORCED RULE IS STILL REFUSED, AFTER THE PAIR MATCHES. + /// + /// Enforcement is decided at its own `decide` arm, on a field the FOLD-4 + /// normalizers never see, and it returns `Err` rather than `Ok(false)`. No + /// sibling asserts it: the source-store arm has :1136, this one had nothing. + /// It exists so a comparison repair cannot fold enforcement into a WEAKER + /// comparison — a rule that is configured but not in force must stay a LOUD + /// refusal and not a quiet non-match. That intent is unchanged; only its + /// mechanism moved. It once guarded against folding enforcement into STRING + /// handling, because the field was Vec; the field is now Vec and + /// the live risk is equality silently becoming MEMBERSHIP, so the cases below + /// include a repeated success code and a success code beside a non-success one. + /// + /// No case here asserts a NAME for any code. The host that produced the A7 + /// capture rendered its codes as display text and exposed no Values qualifier, + /// so no code-to-name mapping is derivable from it; what makes 1 the success + /// code is the documented specification, asserted nowhere but in the constant. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn an_unenforced_rule_is_refused_loudly_after_the_pair_matches() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + let effective = reconciled_store(&want); + + // POSITIVE CONTROL: otherwise a reconciled pair, so each refusal below is + // attributable to the ONE field it changes. + let good = Snapshot { active: effective.clone(), addresses: one_lan_census() }; + assert_eq!( + decide(&good, "c:/spt/spt.exe", 29470), + Ok(true), + "control: the unperturbed snapshot decides reconciled" + ); + + // Every case is a REFUSAL, and the list is the requirement's acceptance + // table written as literals: [0], [2], [5], [20], [1,1], [1,5] and []. + // [5,20] is carried beyond that list because two non-success codes is a + // distinct shape from one. The arms that matter after the repair are the + // REPEATED success code and the success code accompanied by a non-success + // one — they fail only if `as_slice() != ENFORCEMENT_CERTIFIED` is ever + // softened into "contains the success code". + // + // WHAT THIS FILE CANNOT COVER, so that it is not read as covered: the + // requirement's null arms and its distinguishable REPRESENTATION FAULT + // live in the PowerShell query, before any JSON exists. Their Rust-side + // shadow is the deserialization cell below — a null element and a null + // field both refuse there; the faults themselves are the + // extraction-boundary exercise's subject. + // [unit->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + for status in [ + vec![0u16], + vec![2u16], + vec![5u16], + vec![20u16], + vec![ENFORCEMENT_SUCCESS, ENFORCEMENT_SUCCESS], + vec![ENFORCEMENT_SUCCESS, 5u16], + vec![5u16, 20u16], + Vec::new(), + ] { + let mut unenforced = effective.clone(); + unenforced[0].enforcement = status.clone(); + let state = Snapshot { active: unenforced, addresses: one_lan_census() }; + let refusal = decide(&state, "c:/spt/spt.exe", 29470) + .expect_err("a rule that is not fully enforced is refused"); + assert!( + refusal.contains("enforcement"), + "the refusal names the enforcement arm rather than reading as a non-match \ + (status {status:?}): {refusal}" + ); + } + } + + + /// H3 — MALFORMED ENFORCEMENT EVIDENCE FAILS DESERIALIZATION, NOT THE VERDICT. + /// + /// `enforcement: Vec` is the transport guard: anything the query cannot + /// carry as a number must fail HERE, where it surfaces as a query error, rather + /// than arriving as a code `decide` would then judge. The display spelling is the + /// case that actually occurred in the field; the rest are its neighbours. + /// + /// A POSITIVE CONTROL runs first. Without it every rejection below would also + /// pass against a fixture that was malformed for some unrelated reason, and the + /// test would be asserting nothing about enforcement at all. + // [unit->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + #[test] + fn malformed_enforcement_evidence_fails_deserialization() { + fn rule_json(enforcement: &str) -> String { + format!( + r#"{{"name":"n","program":"Any","ports":["29470"],"profile":"Any", + "remotes":["Any"],"hygiene":true,"enforcement":{enforcement}, + "sourceType":"Local"}}"# + ) + } + + let ok: Rule = serde_json::from_str(&rule_json("[1]")) + .expect("control: a numeric code parses, so the refusals below are about the value"); + assert_eq!(ok.enforcement, vec![ENFORCEMENT_SUCCESS]); + + for bad in [ + r#"["Enforced"]"#, // the field defect, in the spelling the host rendered + r#"["Full"]"#, // the pre-repair spelling the gate used to demand + r#"["1"]"#, // numeric-looking string: a stringly pipeline + "[null]", // a null element + "null", // the whole field null + "[1.5]", // fractional, which a coercing reader would round + "[-1]", // negative + "[65536]", // outside u16 + "[true]", // a boolean arriving as JSON. NOTE THE LIMIT: this cell does + // NOT guard against PowerShell coercion. [int]$true is 1, and + // that conversion happens BEFORE any JSON exists, so what + // reaches here is already the number 1 and parses cleanly. + // Only the extraction-boundary exercise can catch that. + r#"[{"code":1}]"#, // structured rather than scalar + ] { + assert!( + serde_json::from_str::(&rule_json(bad)).is_err(), + "enforcement {bad} must fail deserialization rather than reach the verdict" + ); + } + } + + // The EQUIVALENCE half: the three axes FOLD-4 normalizes, asserted through the + // verdict on the pair as NetSecurity actually rendered it. + // + // PROVENANCE OF EVERY CAPTURED STRING BELOW — the whole point of this half. + // Captured by todlando 2026-09-12 06:30–06:31Z on HFENDULEAM, + // Windows 11 Pro 10.0.26200 build 26200, with BOTH bootstrap rules still + // present, via `Get-NetFirewallRule -PolicyStore ` + // piped per rule into Get-NetFirewallPortFilter, Get-NetFirewallAddressFilter, + // Get-NetFirewallApplicationFilter and Get-NetFirewallInterfaceTypeFilter. + // Artifact: a7-run/matcher-fields-b-precondition.csv, four rows, both names in + // both stores. + // NOT the failure-time snapshot: the product never logs its `active` array and + // the run was over. This is an independent capture of the same rules minutes + // later, and it is cited as that and nothing more. + // + // WHAT IS CAPTURED, AND USED HERE AS LITERALS: program, profile, remotes. + // WHAT IS **NOT** CAPTURED, AND THEREFORE SET RATHER THAN QUOTED: + // - `hygiene`: SYNTHETIC. Computed in the QUERY from the Security, Interface and Service + // filters plus DynamicTarget and the Platform/Owner emptiness checks. Those + // cmdlets were never run, so the inputs do not exist. `true` here ISOLATES + // the representation axes; it does not assert what the host would report. + // - `enforcement`: SYNTHETIC. The capture reads {ProfileInactive, NoLocalUser} in + // ActiveStore. Those are the host’s ADAPTED DISPLAY NAMES, not codes, and THE + // BOOTSTRAP PAIR’S NUMERIC VALUES REMAIN UNKNOWN: the raw UInt16 1 was measured + // on a SEPARATE, pre-existing 5470 rule, not on either bootstrap rule. So nothing + // here may be read as "the pair really carried 1 and only rendered badly". + // ENFORCEMENT_SUCCESS is used because it keeps these cells about the COMPARISON + // axes; it asserts nothing about what that host would have reported. The captured + // value is a SEPARATE FINDING, reported to doyle, not smuggled into a fixture. + // - the port: SYNTHETIC. Its captured value is not in hand. The port axis is not an + // equivalence axis and R5 covers its drift, so the spec's port is used. + + /// The tailnet half EXACTLY as NetSecurity rendered it (see provenance above). + /// Everything not a captured field is named in that block. + fn captured_tailnet(port: u16) -> Rule { + Rule { + name: RULE_NAME_TAILNET.to_string(), + program: "Any".to_string(), // captured + ports: vec![port.to_string()], // SYNTHETIC, not captured — see above + profile: "Any".to_string(), // captured + remotes: vec!["100.64.0.0/255.192.0.0".to_string()], // captured — MASK form + hygiene: true, // SYNTHETIC, not captured — see above + enforcement: vec![ENFORCEMENT_SUCCESS], // SYNTHETIC, not captured — see above + source_type: "Local".to_string(), // captured + } + } + + /// The LAN half EXACTLY as NetSecurity rendered it. Note `Domain, Private`: + /// NetSecurity's own order, with a space, against the constant's `Private,Domain`. + fn captured_lan(port: u16) -> Rule { + Rule { + name: RULE_NAME_LAN.to_string(), + program: "Any".to_string(), // captured + ports: vec![port.to_string()], // SYNTHETIC, not captured — see above + profile: "Domain, Private".to_string(), // captured + remotes: vec!["192.168.1.0/255.255.255.0".to_string()], // captured — MASK form + hygiene: true, // SYNTHETIC, not captured — see above + enforcement: vec![ENFORCEMENT_SUCCESS], // SYNTHETIC, not captured — see above + source_type: "Local".to_string(), // captured + } + } + + /// E1 — AN UNRESTRICTED PROGRAM IS UNRESTRICTED, HOWEVER IT IS SPELLED. + /// + /// `DESIRED_PROGRAM = false` means both specs want NO program filter, and the + /// Rust side reads that as `observed.program.is_empty()`. NetSecurity reports a + /// rule carrying no application filter as `Program=Any` — one value, never the + /// empty string. On THIS capture's spelling the empty case cannot occur, so both + /// halves fail here. Whether NetSecurity spells it `Any` on every host is NOT + /// established by one capture. This is the cell the suite could not express: its + /// fixtures passed `""` directly and so asserted the round trip against itself. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn an_unrestricted_program_spelled_any_satisfies_a_spec_wanting_none() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + let observed = vec![captured_tailnet(29470), captured_lan(29470)]; + assert!( + pair_satisfied_by(&observed, &want), + "Program=Any is how NetSecurity spells 'no application filter'; it is the \ + shape the policy asked for, not a narrowing" + ); + + // THE CONVERSE IS NOT HERE, AND THAT IS DELIBERATE: an observed `Any` against a + // spec that REQUIRES a named binary is claimed by todlando's FOLD-4(b), which + // carries both directions with a positive control on the same fixture. This cell + // keeps only the half no sibling has: `Any` SATISFYING a None spec, through + // `pair_satisfied_by` on the captured pair. + } + + /// E2 — A PROFILE SET IS A SET, NOT A STRING. + /// + /// The spec emits `Private,Domain`; NetSecurity renders the same set as + /// `Domain, Private` — its own flag order, with a space. Compared as strings + /// these differ; compared as sets they are equal, and they admit exactly the + /// same traffic. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn a_profile_set_compares_by_membership_not_by_rendering() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + let lan_spec = want.iter().find(|spec| spec.name == RULE_NAME_LAN).expect("the LAN half"); + assert_eq!(lan_spec.profile, "Private,Domain", "the spec's own rendering, for contrast"); + + assert!( + spec_satisfied_by(&captured_lan(29470), lan_spec), + "'Domain, Private' and 'Private,Domain' are one set in two renderings" + ); + } + + /// E3 — A NETWORK IS A NETWORK, IN PREFIX FORM OR MASK FORM. + /// + /// FOLD-3 emits `192.168.1.0/24` and `100.64.0.0/10`; NetSecurity reads them + /// back as `192.168.1.0/255.255.255.0` and `100.64.0.0/255.192.0.0`. Same + /// networks, same admitted peers, different strings. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn an_ipv4_network_compares_by_value_in_prefix_or_mask_form() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + for spec in &want { + let observed = if spec.name == RULE_NAME_LAN { captured_lan(29470) } else { captured_tailnet(29470) }; + assert!( + spec_satisfied_by(&observed, spec), + "{} — mask form and prefix form name the same network: spec {:?} vs observed {:?}", + spec.name, + spec.remotes, + observed.remotes + ); + } + } + + /// E4 — THE WHOLE CAPTURED PAIR DECIDES RECONCILED. + /// + /// E1–E3 one axis at a time; this is all three at once through the real entry + /// point, which is what the product actually calls. It is the cell whose failure + /// was reported from the field. + /// + /// NOTE THE LIMIT, and it is why this cell says `Ok(true)` rather than "the + /// product would have reported reconciled": `hygiene` and `enforcement` here are + /// SYNTHETIC, not captured (see the provenance block). IF the captured enforcement + /// value RECURS, this same snapshot refuses at `decide`'s enforcement arm — separately, + /// loudly, and correctly. That is a CONDITIONAL about a value nobody has + /// re-measured, not a prediction about the next run. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn the_captured_pair_decides_reconciled_once_spellings_compare_semantically() { + let state = Snapshot { + active: vec![captured_tailnet(29470), captured_lan(29470)], + addresses: one_lan_census(), + }; + assert_eq!(decide(&state, "c:/spt/spt.exe", 29470), Ok(true)); + } + + + + /// SCOPE: the WRITE BODY -- what `render_writes` emits. Its sibling cell + /// `the_query_body_makes_one_store_pass_and_reads_no_persistent_store` is scoped + /// to the QUERY BODY, and the two `PersistentStore` counts are OPPOSITE BY + /// DESIGN: 2 here (both halves are persisted) and 0 there (the query leg reads + /// no store constant). They are about different strings, so a later reader must + /// NOT reconcile one into agreement with the other. Neither is scoped to the + /// COMPOSED script, where `OWNERSHIP` legitimately contributes both tokens. + /// + /// ARM 1 (releases#304 W-2): the pair is ONE UNINTERRUPTED WRITE of two + /// creates, and its LAN half is scoped to the derived literal prefix. + /// + /// WHY ADJACENCY IS THE PROPERTY AND NOT A STYLE NOTE. `pair_satisfied_by` + /// made the VERDICT total over both halves; nothing made the WRITE atomic in + /// SHAPE. A statement landing between the two creates -- a conditional, a + /// store switch, a scope reassignment, an `$ErrorActionPreference` reset -- + /// would let the second half be written under a state the first was not, and + /// every scope cell in this module would still pass, because they all read + /// `render_writes` per spec and none reads the SEQUENCE. One composer over + /// one census is a property of the emitted text, so it is asserted there. + /// + /// THE POSITIVE CONTROL IS LOAD-BEARING, NOT DECORATION. After FOLD-3 a + /// CORRECT product no longer emits the string `LocalSubnet`, so a predicate + /// that only checks for its absence passes on a render it never looked at -- + /// and passes identically if `render_writes` returns "". The control is the + /// derived prefix that MUST appear; the two absence assertions are evidence + /// only because the control matched first. (The trap was named in advance by + /// todlando, and it is hertz's own zero-match-filter class: a filter that + /// cannot match reads as absence.) + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn the_write_body_renders_an_adjacent_persistentstore_pair_scoped_to_the_derived_prefix() { + const CALL: &str = "New-NetFirewallRule"; + const END: &str = "| Out-Null"; + + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); + let rendered = render_writes(&want); + + // POSITIVE CONTROL FIRST: the predicate can see this render's remotes at + // all. Everything below is void if this line does not hold. + assert!( + rendered.contains("-RemoteAddress 192.168.1.0/24"), + "positive control: the derived LAN prefix must be in the render, or the absence assertions below are about nothing: {rendered}" + ); + // Meaningful ONLY because the control above matched. + assert!( + !rendered.contains("LocalSubnet"), + "the keyword measured not to admit is not rendered: {rendered}" + ); + assert!( + !rendered.contains("-RemoteAddress Any"), + "neither half widens its remote to Any: {rendered}" + ); + + // ADJACENCY: between the end of the first create and the start of the + // second there is whitespace and nothing else. + let first_end = rendered.find(END).expect("the first create ends in a pipeline to Out-Null") + + END.len(); + let second_start = rendered[first_end..] + .find(CALL) + .map(|offset| offset + first_end) + .expect("the second create follows the first"); + let between = &rendered[first_end..second_start]; + assert!( + between.trim().is_empty(), + "the two creates are adjacent; a statement between the halves could write the second under a state the first was not: {between:?}" + ); + + // The create COUNT is deliberately not asserted here: it is owned by the + // sibling cell `the_write_path_renders_both_halves_and_no_program_filter_by_default`. + // This cell reads the SEQUENCE, which no sibling does. + // Both halves are written to the store the pair is persisted in, so a + // half landing in ActiveStore alone cannot read as a written pair. + assert_eq!( + rendered.matches("-PolicyStore PersistentStore").count(), + 2, + "each half is written to PersistentStore: {rendered}" + ); + } + + + // [unit->REQ-BOOTSTRAP-FIREWALL-VERIFY-ONE-PASS] + /// SCOPE: the QUERY BODY -- the `QUERY` const alone. Its sibling cell + /// `the_write_body_renders_an_adjacent_persistentstore_pair_scoped_to_the_derived_prefix` + /// is scoped to the WRITE BODY and asserts `PersistentStore` appears TWICE. + /// The 0 here and the 2 there are OPPOSITE BY DESIGN, about different + /// strings; do not reconcile one into agreement with the other. + /// THE ONE STORE PASS IS A PROPERTY OF THE QUERY TEXT AND WAS CHECKED ONLY BY + /// HAND. The hand predicate fails on a correct product: over the COMPOSED + /// script it reads three `Named-Rules` and one `PersistentStore`, because + /// `OWNERSHIP` legitimately DEFINES a `Remove-Owned` helper that the query + /// leg never invokes. A predicate that looks right and reads wrong sends the + /// reader to blame the render, so the property is scoped to the body here -- + /// and the rationale comments the body carries are stripped, since they name + /// the very enumeration FOLD-3 deleted. + #[test] + fn the_query_body_makes_one_store_pass_and_reads_no_persistent_store() { + let executable = QUERY + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .collect::>() + .join("\n"); + + assert_eq!( + executable.matches("Named-Rules").count(), + 1, + "exactly one rule enumeration; the second pass is the cost FOLD-3 deleted" + ); + assert_eq!( + executable.matches("PersistentStore").count(), + 0, + "persistence is judged from PolicyStoreSourceType, never by reading the second store" + ); + assert_eq!( + executable.matches("Get-NetIPAddress").count(), + 1, + "the census rides the same invocation as the rules it will be compared against" + ); + + // The strip is LOAD-BEARING, not cosmetic. Without it this body reads one + // `PersistentStore` -- in the comment explaining why that store is no + // longer read -- and the assertion above would fail on correct text. + assert!( + QUERY.contains("PersistentStore"), + "the rationale comment still names the enumeration FOLD-3 removed" + ); + } + } [shipping-module-diff: exited; cursor=109646]