Skip to content

Commit 4a029c8

Browse files
committed
Stage 2: test seams + CI (57 -> 75 tests) over the real registry
- capture/apply/detect/restore round-trips against a real HKCU scratch subtree (incl. a never-existed value being deleted not zeroed; ADR-0002 reachability) - pin the comparison divergence: values_match vs registry_values_match on binary hex - hosts parsing fixtures: CRLF/LF/mixed, comments, tabs, multi-hostname lines - firewall netsh argument construction; injection claim corrected for that path - AppHandle removed from the apply chain (OnceLock in debug.rs) - ROADMAP.md as the authoritative stage tracker; stage 2 outcome + audit corrections
1 parent face079 commit 4a029c8

13 files changed

Lines changed: 959 additions & 99 deletions

File tree

docs/ROADMAP.md

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Tweak System Remediation — Roadmap
2+
3+
The authoritative stage-by-stage tracker for the tweak-system remediation. This is the map; the detail
4+
lives elsewhere and is linked per stage:
5+
6+
- [TWEAK_SYSTEM_REVIEW.md](./TWEAK_SYSTEM_REVIEW.md) — the 47-finding audit
7+
- [TWEAK_SYSTEM_PLAN.md](./TWEAK_SYSTEM_PLAN.md) — reasoning, crate research, contradiction resolutions, stage-2 outcome
8+
- [adr/](./adr/) — the four design decisions taken deliberately
9+
- [spec/profile-v1.md](./spec/profile-v1.md) — the deleted profile format, preserved for the rebuild
10+
11+
**Guiding priorities, in order:** correctness first, then less code, then optimized. Where these conflict,
12+
the conflict is named rather than silently resolved.
13+
14+
---
15+
16+
## Status at a glance
17+
18+
| Stage | What | Status |
19+
| --- | --- | --- |
20+
| 0 | Critical rollback fix + `serde_yml` swap + build determinism | ✅ done (PR #31) |
21+
| 1 | Delete: profile system + dead code (~2,830 LOC) | ✅ done (PR #31) |
22+
| 2 | Test seams + CI (57 → 75 tests) | ✅ done (this branch) |
23+
| 3 | Effect layer: one "did-it-work" contract + API swaps | ⏳ next |
24+
| 3a | Elevation module (SYSTEM / TrustedInstaller) | 🔬 researching |
25+
| 4 | Collapse duplication (detection+inspection, build schema, loader) | ▫ planned |
26+
| 5 | Design fixes: the ADRs (Needs Attention, atomic rollback, hosts, snapshot identity) | ▫ planned |
27+
| 6 | UI: System Default selectable, Needs Attention surfaced | ▫ planned |
28+
| 7 | Docs rewritten against what exists | ▫ planned |
29+
|| YAML corpus fixes (independent) | ▫ unblocked, can start anytime |
30+
| L | Later: profile rebuild, comprehensive logging | ▫ deferred |
31+
32+
**Ordering rule that is easy to get wrong:** stage 4 (collapse detection+inspection) must precede stage 5.
33+
"Needs Attention" is a new status state; adding it while detection and inspection are still two divergent
34+
implementations means implementing it twice — and they already disagree.
35+
36+
---
37+
38+
## ✅ Stage 0 — Critical fixes (done, PR #31)
39+
40+
- `apply.rs` no longer discards the rollback result and deletes the snapshot regardless. A snapshot is
41+
released only on a verified-complete rollback; otherwise it is kept and every unrestored resource
42+
reported. The decision is a pure `classify_rollback` with 6 tests. Implements ADR-0001, ADR-0002.
43+
- `serde_yml``serde_yaml_bw` (RUSTSEC-2025-0068: unsound, archived). Verified byte-identical output.
44+
- `build.rs` emits `tweaks.json` deterministically (`HashMap``BTreeMap`).
45+
- CRLF enforced as a repo property.
46+
47+
## ✅ Stage 1 — Delete (done, PR #31)
48+
49+
- Profile system removed entirely (~2,169 LOC, 7 commands, `zip`/`sha2`/`hex`/`hostname`). UI kept but
50+
disabled. Format recorded in [spec/profile-v1.md](./spec/profile-v1.md) first.
51+
- Dead code removed (~660 LOC): `models/backup.rs`, the second YAML pipeline in `models/tweak.rs`,
52+
`aliases`, 8 never-invoked Tauri commands, 4 unused `Error` variants.
53+
- A drift guard added: embedded tweak data must deserialize into the runtime types.
54+
55+
## ✅ Stage 2 — Test seams + CI (done, this branch)
56+
57+
Tests 57 → 75, driving the **real** registry under an HKCU scratch subtree rather than a mock. `AppHandle`
58+
removed from the apply chain (a `OnceLock` in `debug.rs`). Round-trips, hosts parsing, firewall arg
59+
building, and the detection↔inspection divergence all pinned. Accepted gaps and two audit corrections
60+
recorded in [TWEAK_SYSTEM_PLAN.md § Stage 2 outcome](./TWEAK_SYSTEM_PLAN.md).
61+
62+
---
63+
64+
## ⏳ Stage 3 — The effect layer's success contract
65+
66+
**The root-cause stage.** Six confirmed findings are one bug wearing different clothes: an error converted
67+
into a benign-looking value, so a failed privileged operation reports success and rollback never fires.
68+
69+
| Site | Error becomes |
70+
| --- | --- |
71+
| `capture.rs:588` | access-denied → `existed: false`**revert deletes a value that existed** |
72+
| `detection.rs:234` | read failure → "missing" → `*_missing_is_match` → inferred MATCH |
73+
| `ti_elevation.rs:468` | exit code returned, caller discards it |
74+
| `firewall_service.rs:25` | netsh exit status ignored → create silently no-ops |
75+
| `restore.rs:388` | provably-failed restore returns `Ok(())` → snapshot deleted |
76+
| `service_ops.rs:89` | `net.exe` exit 2 treated as success |
77+
78+
**Goal:** one shared definition of "did it work" across the five effect services, replacing five different
79+
ones. Until this lands, ADR-0001 and ADR-0002 are decorative.
80+
81+
**Tasks**
82+
- `service_control.rs` → raw `windows-sys` (already a dependency; kills locale-dependent `sc.exe`/`net.exe`
83+
string parsing). Keep the 4 public signatures so callers do not change. Handle the gotchas already
84+
catalogued in the plan (`SERVICE_NO_CHANGE`, `QueryServiceConfigW` sizing, `STOP_PENDING` polling,
85+
`EnumDependentServicesW`).
86+
- Task Scheduler → `windows` crate COM (`TASK_STATE` is a numeric enum — the actual fix for the locale class).
87+
- `capture.rs:588` → stop mapping access-denied to `existed: false`. **Blocker for ADR-0003.**
88+
- `winreg` stays: it already distinguishes `NotFound` from `PermissionDenied`; the 8 `.unwrap_or(false)`
89+
call sites coerce that away. Our bug, not the crate's.
90+
- Firewall: locale-dependent parsing is the only remaining driver (injection is disproven for that path —
91+
stage 2). Lower priority; hand-roll COM or fix the exit-status check, decide when we get here.
92+
- Collapse the elevated duplicates and the `if use_ti / else if use_system / else` triad behind one
93+
`Elevation` enum.
94+
95+
**Verify:** the HKCU round-trip harness from stage 2 covers registry; extend the parser-fixture and
96+
nonexistent-name patterns for services/scheduler. A failed elevated op must now surface as an `Err`.
97+
98+
**Unblocks:** stage 5 (the ADRs need truthful failure signals), stage 4's confidence.
99+
100+
## ⏳ Stage 3a — Elevation module (researched; scoped)
101+
102+
The owner was not confident the hand-written SYSTEM/TrustedInstaller elevation (~1,294 lines of `unsafe`
103+
FFI) is correct. A three-probe research pass (crate landscape, safety audit, design alternatives) settled
104+
it. Detail in [TWEAK_SYSTEM_PLAN.md § Elevation](./TWEAK_SYSTEM_PLAN.md); the verdict:
105+
106+
**Do not replace it with an elevation crate — none worth having exists.**
107+
- crates.io has **zero** TrustedInstaller crates (verified). The whole landscape is a few C++/Go/twinBASIC
108+
PoCs; the current Rust code faithfully ports the de-facto reference (`nfedera/run-as-trustedinstaller`).
109+
- Every "SYSTEM elevation" crate solves a *different* problem — interactive UAC-to-admin, or privilege
110+
*dropping* — none silently duplicate winlogon's token. `runas-rs` is also **GPL-3.0** (would infect a
111+
shipped product). Adopting any wrapper of this technique means trusting an unaudited single author with
112+
the ability to run code as TrustedInstaller — an unacceptable supply-chain trade.
113+
114+
**The hard parts are correct — this partly refutes the audit.** Token duplication, the parent-process
115+
spoof (attribute list sized-then-initialised, parent handle outliving `CreateProcessW`,
116+
`DeleteProcThreadAttributeList` on all paths), and handle/`SC_HANDLE` lifetimes are all sound. No active
117+
leak was found; the audit's "unclosed handles on error paths" is largely **not** borne out. The real
118+
weakness there is *fragility*: ~15 manual `CloseHandle` sites where one future early-return leaks.
119+
120+
**The genuine defects are all one systemic mistake: shell command strings.** Every elevated op is funnelled
121+
through `cmd.exe /c <string>` with values escaped by the broken `escape_shell_arg` (`common.rs:72`, which
122+
mixes caret-escaping and quote-doubling — two incompatible models, both wrong inside `cmd` quotes). That
123+
one design yields:
124+
125+
| Sev | Site | Effect | Review # |
126+
| --- | --- | --- | --- |
127+
| **critical** | `ti_elevation.rs:92` | `\"`-escape + `cmd /c` → a `"`+`&` in a PowerShell script runs a separate command as SYSTEM/TI. Author-controlled today, so a *correctness* landmine with SYSTEM blast radius; a real vuln if user input ever reaches it (profile rebuild). | #27 |
128+
| high | `system_elevation.rs:194` | REG_SZ with a space or `%VAR%` is double-mangled and silently corrupted | #26 |
129+
| high | `ti_elevation.rs:468` + callers `helpers.rs:772,870` | schtasks exit code discarded (`.map(\|_\| ())`) → failed elevated task = success | #8 |
130+
| medium | `system_elevation.rs:110`, `ti_elevation.rs:398` | `GetExitCodeProcess` return ignored, `WAIT_FAILED` not distinguished from `WAIT_TIMEOUT` → possible silent `Ok(0)` | new |
131+
| medium | `common.rs:107` | `SeDebugPrivilege` enabled process-wide and never dropped | new |
132+
133+
**Scope (fits inside stage 3's contract — this IS how ops apply under SYSTEM/TI):**
134+
1. **Delete the `cmd.exe /c` design and `escape_shell_arg`.** Registry and service ops (the bulk) call
135+
Win32 APIs directly with **typed** args (`RegSetValueExW`/`RegDeleteValueW`,
136+
`ChangeServiceConfigW`/`StartServiceW`/`ControlService`) in the elevated broker — dissolving the
137+
injection *and* corruption classes and turning exit codes into return values. This is literally stage
138+
3's "did-it-work contract" at the elevated layer.
139+
2. PowerShell (the only irreducible interpreter case) → spawn `powershell.exe` directly as argv with
140+
`-EncodedCommand` (base64 UTF-16LE); no shell ever parses script bytes.
141+
3. schtasks helpers map non-zero exit → `Err` at the source, so no caller can drop it.
142+
4. Adopt the official **`windows`** crate (0.62, Microsoft, 272M downloads) for *this module only* — not as
143+
an elevation crate but for `Owned<HANDLE>` (CloseHandle-on-drop) + typed `Result`s, making the correct
144+
cleanup structural. Coexists with `windows-sys` elsewhere. Keep `to_wide_string` (an 8-line helper
145+
doesn't justify `widestring`); reject `sysinfo` (heavy) for the ToolHelp lookup.
146+
5. Do **not** move to in-process `ImpersonateLoggedOnUser`: under `panic = "abort"` a panic before
147+
`RevertToSelf` leaks SYSTEM onto the thread. Keep the spawn-a-broker design.
148+
149+
Verified caveat on the `windows` crate: `Owned`'s `Drop` still won't run on panic (same as today), but it
150+
covers the normal + `?`-early-return paths, which is strictly more than the manual pattern.
151+
152+
## ▫ Stage 4 — Collapse duplication
153+
154+
- **detection.rs + inspection.rs → one comparison core** (−331 LOC; the highest-value item). They disagree
155+
in four verified ways (REG_BINARY hex, `*_missing_is_match`, vacuous-empty-option, service errors). The
156+
stage-2 test `values_match_disagrees_with_registry_values_match_on_binary_hex_strings` is the red marker:
157+
when the collapse works, that test starts failing and should be deleted, not inverted.
158+
- **Share the build schema** (−304 LOC): move pure types to `models/tweak_schema.rs`, `build.rs` includes
159+
only that. Drift becomes a compile error instead of a runtime panic.
160+
- **Kill the loader clones**: `get_tweaks_for_version` deep-clones up to 189 tweaks per call; return
161+
`&'static` references.
162+
163+
**Must precede stage 5.**
164+
165+
## ▫ Stage 5 — Design fixes (the ADRs)
166+
167+
- Rollback always attempts all five phases, never aborts early (ADR-0001).
168+
- **Needs Attention** state: retains its snapshot, names unrestorable resources, offers Retry and explicit
169+
"Keep current state". Enforces the ADR-0002 invariant (snapshot deleted only by verified restore or
170+
explicit consent).
171+
- `tempfile` for atomic snapshot writes (verified `MoveFileExW` + `MOVEFILE_REPLACE_EXISTING`). Drop `fs4`
172+
for `std::fs::File::lock`.
173+
- Hosts file: rewrite ~120 LOC by hand (no crate survives the requirements). Fixes the BOM, line-ending
174+
(`join("\n")` at `hosts_service.rs:210`), and multi-hostname bugs pinned in stage 2.
175+
- **Snapshot identity** (ADR-0003 consequence): stamp snapshots with `MachineGuid`, warn on mismatch.
176+
**Prerequisite:** add `#[serde(default)]` + a schema version to `TweakSnapshot` FIRST (finding #18), or
177+
the added field breaks every existing snapshot.
178+
179+
## ▫ Stage 6 — UI
180+
181+
- System Default made selectable whenever `has_backup` (ADR-0003). `TweakCard.svelte:176` currently only
182+
unstages; the dropdown entry is `disabled` and vanishes once any option is applied.
183+
- Needs Attention surfaced with its Retry / Keep-current-state actions.
184+
185+
## ▫ Stage 7 — Docs
186+
187+
`TWEAK_SYSTEM.md`, `TWEAK_AUTHORING.md`, `APP_CONTEXT.md` updated together against final behaviour:
188+
atomicity wording per ADR-0001 (*attempted atomically, with failure surfaced* — not a guarantee), System
189+
Default, Needs Attention, snapshot location, the full five-phase detection algorithm.
190+
191+
---
192+
193+
## ▫ Independent track — YAML corpus
194+
195+
Unblocked by everything above; can start anytime. From the review:
196+
197+
- **`legacy_network_protocols` silently re-enables SMBv1** in two options while its name/description/info
198+
mention only LLMNR/WPAD/NetBIOS. **Treat as urgent** — a live security-relevant data bug needing no
199+
refactor.
200+
- 98 "(Default)" options write explicit Group Policy values that stock Windows ships *absent*.
201+
- Asymmetric toggles where option[1] does not undo everything option[0] does.
202+
- `mouse_input_mode` options 1 and 3 are byte-identical (option 3 unreachable).
203+
- Two gaming tweaks overwrite the same `DirectXUserGlobalSettings` string, erasing each other.
204+
205+
## ▫ Deferred — Later
206+
207+
- **Profile system rebuild** from scratch, sharing one machine-identity mechanism with the snapshot install
208+
ID. Read [spec/profile-v1.md](./spec/profile-v1.md) first — `.mgx` archives from 3.0.0 are recoverable
209+
only if the option content hash is reproduced exactly.
210+
- **Comprehensive logging** users can attach to bug reports. `tauri-plugin-log` stays (it does file logging
211+
with rotation); fix the dead `TargetKind::Webview` target and add `LogDir`.
212+
213+
---
214+
215+
## Dependency ledger (net so far and planned)
216+
217+
| Removed | Added |
218+
| --- | --- |
219+
| `serde_yml` (RUSTSEC) → `serde_yaml_bw` ||
220+
| `zip`, `sha2`, `hex`, `hostname` (with profiles) ||
221+
| planned: `fs4``std::fs::File::lock` | planned: `windows` (COM), `tempfile` |
222+
| planned: `reqwest``ureq` (−28 crates) | planned: `schemars` (build/dev) |
223+
224+
Elevation crate decisions are pending stage-3a research.

docs/TWEAK_SYSTEM_PLAN.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,68 @@ paths, which is the real fix for the injection surface — no crate does this, i
210210

211211
---
212212

213+
## Stage 2 outcome — what is covered, and what is deliberately not
214+
215+
Tests went 57 → 75. The approach that made it possible was not the one planned.
216+
217+
**No mocking, and almost no seams.** Because the app is Windows-only and CI runs on
218+
Windows, the tests drive the **real** registry under `HKCU\Software\MagicXToolboxTest`.
219+
`require_write_access` only demands admin for HKLM, so this works unelevated locally and
220+
on CI. Per-test unique subkeys give parallel isolation with no serialisation, and cleanup
221+
runs in a `Drop` guard — which the test profile honours on panic, since `panic = "abort"`
222+
applies to `[profile.release]` only. Snapshot storage needed no seam either: under
223+
`cargo test`, `current_exe()` already resolves to `target/debug/deps`.
224+
225+
A mock would have been written against our *belief* about what Windows returns, which is
226+
precisely what the audit found to be wrong in several places.
227+
228+
**The one seam taken: `AppHandle` removed from the apply chain.** It was threaded through
229+
11 signatures and ~13 call sites purely to reach `emit_debug_log`, which no-ops unless
230+
debug mode is on; `app.emit`/`state`/`path` appear nowhere in the chain. `debug.rs` now
231+
holds it in a `OnceLock` set during setup. Rejected: threading `<R: Runtime>` through all
232+
11 signatures, and tauri's `test` feature (which does not help alone — `mock_app` yields
233+
`AppHandle<MockRuntime>`).
234+
235+
### Covered
236+
237+
| Area | Guards |
238+
| --- | --- |
239+
| capture → apply → detect → restore | 5 real round trips, incl. a never-existed value being **deleted** not zeroed, and an option switch leaving the original snapshot reachable (ADR-0002) |
240+
| Hosts parsing | CRLF/LF/mixed, comments, inline comments, tabs, hostname-less lines |
241+
| Firewall arg building | minimal vector, required fields, optional fields, hostile input staying in one argv element |
242+
| build ↔ runtime type drift | embedded data must deserialize into the runtime types |
243+
| Comparison divergence | `values_match` vs `registry_values_match` on REG_BINARY hex |
244+
245+
### Accepted gaps — recorded, not overlooked
246+
247+
- **`apply_tweak` / `revert_tweak` / `batch_*` orchestration.** They resolve tweaks from the
248+
compiled-in `TWEAKS` map, so a test can only pass a real shipped id — i.e. mutate the
249+
developer's actual machine. They also branch on `get_runtime_context().is_admin`, which
250+
differs between an elevated CI runner and a normal developer shell. Splitting resolution
251+
from orchestration (`apply_resolved_tweak`) would fix this and is worth doing; it was not
252+
done here because it moves the body of the app's most dangerous function and deserves its
253+
own reviewable commit.
254+
- **Service, scheduler and firewall apply/restore end-to-end.** There is no per-user scratch
255+
namespace equivalent to HKCU; creating a real scratch service or firewall rule needs admin
256+
and mutates the runner. The honest ceiling without elevation is the existing
257+
nonexistent-name pattern plus parser fixtures. Mitigated by `restore.rs` collecting these
258+
as failures rather than aborting.
259+
- **`get_applied_tweaks` / `validate_all_snapshots`.** Both enumerate the whole snapshots
260+
directory and `validate_all_snapshots` *deletes* what it judges stale, so under the
261+
parallel harness they would see and destroy other tests' fixtures. Testable behind a
262+
shared `Mutex` with containment assertions; not yet written.
263+
264+
### Corrections stage 2 made to the audit
265+
266+
- **The firewall injection finding is wrong for that path.** Arguments go to
267+
`Command::new("netsh").args()`, which builds argv through `CreateProcessW` with no shell,
268+
so shell metacharacters are inert. The real risk is narrower — netsh's own `key=value`
269+
parsing — and now has a regression guard. The PowerShell and `cmd.exe` hooks build command
270+
*strings* and remain a genuine concern.
271+
- **New finding, not previously anchored:** `remove_hosts_entry` rebuilds the file with
272+
`new_lines.join("\n")` (`hosts_service.rs:210`), so removing any entry rewrites every line
273+
ending in the file to LF and drops the trailing newline.
274+
213275
## Phase 3 — Detection, status, and the parse/clone question
214276

215277
- **Collapse `detection.rs` + `inspection.rs` onto one comparison core** (−331 LOC, highest-value item).

0 commit comments

Comments
 (0)