Skip to content

refactor(tweak-system): fix snapshot loss on failed rollback, remove v1 profile system, add CI - #31

Merged
ehsan18t merged 10 commits into
mainfrom
refactor/tweak-system-remediation
Jul 20, 2026
Merged

refactor(tweak-system): fix snapshot loss on failed rollback, remove v1 profile system, add CI#31
ehsan18t merged 10 commits into
mainfrom
refactor/tweak-system-remediation

Conversation

@ehsan18t

Copy link
Copy Markdown
Owner

Remediation of a full audit of the tweak system (Rust backend, build-time
validator, YAML corpus) against the docs-as-spec. Planning artifacts are in
docs/TWEAK_SYSTEM_REVIEW.md, docs/TWEAK_SYSTEM_PLAN.md and docs/adr/.

Rust: -2,397 net lines. Dependencies: 45 → 42. Tests: 61 → 57 (11 deleted with
their dead code, 7 added). No CI → CI.

Critical fix

apply_tweak discarded the rollback result and deleted the snapshot regardless:

let _ = backup_service::restore_from_snapshot(&snapshot);
backup_service::delete_snapshot(&tweak_id)?;

ehsan18t added 10 commits July 20, 2026 06:40
This is a Windows-only application that is never built or run on another
platform, so the working tree should be CRLF unconditionally.

Previously .gitattributes was "* text=auto", which normalizes blobs to LF and
leaves the working-tree ending up to each developer's core.autocrlf setting.
That made CRLF a property of one machine rather than of the repository, and
eight tracked files had already drifted to LF in the working tree, including
build.rs, hosts_service.rs, firewall_service.rs and Cargo.toml.

- .gitattributes: "* text=auto eol=crlf", plus explicit binary declarations
  for fonts and images so they are never converted
- .prettierrc: endOfLine auto -> crlf
- rustfmt.toml: newline_style Auto -> Windows ("Auto" preserves whatever a
  file already has, which is how LF crept in)
- .editorconfig: end_of_line = crlf

Blobs remain LF-normalized in the object database, which keeps diffs, merges
and blame readable.
Planning artifacts for the tweak system remediation work.

- docs/TWEAK_SYSTEM_REVIEW.md: audit of the Rust backend, build-time validator
  and YAML corpus against the docs-as-spec. 47 findings (41 confirmed by an
  independent adversarial pass, 5 plausible, 1 refuted), each with a failing
  scenario, evidence and a fix sketch. Refuted findings are retained and
  labelled rather than dropped.
- docs/TWEAK_SYSTEM_PLAN.md: phased remediation plan. Records the execution
  order and, importantly, the three places where two independent research
  passes contradicted each other and how each was resolved -- notably the
  rejection of the windows-service crate, whose change_config() omits
  SERVICE_NO_CHANGE and would wipe service account passwords.
- docs/adr/0001-0004: the design decisions taken deliberately, with their
  rejected alternatives, so they are not re-litigated later.
- CONTEXT.md: project glossary. Separates "Stock Default" (what Windows ships)
  from "System Default" (what this machine was on before we touched it) --
  conflating those two is the root of several doc contradictions.
apply_tweak discarded the rollback result and then deleted the snapshot
unconditionally:

    let _ = backup_service::restore_from_snapshot(&snapshot);
    backup_service::delete_snapshot(&tweak_id)?;

restore_from_snapshot can fail in two distinct ways. It returns
Ok(RestoreResult { success: false, .. }) when service, scheduler, hosts or
firewall restores fail and are collected, and a hard Err when the registry
phase aborts -- in which case the remaining phases never run at all. Both were
thrown away.

So an apply that failed after mutating services, and whose rollback then could
not restore them, left the machine half-changed, deleted the only record of the
user's original state, and reported nothing beyond the apply error. revert_tweak
afterwards answers "No snapshot found for this tweak". This also violated the
project's own rule in .github/copilot-instructions.md: do not silently ignore
privileged operation failures.

The snapshot is now released only when the rollback is verifiably complete.
Otherwise it is kept and every unrestored resource is reported, mirroring the
partial-failure shape revert_tweak already uses so the UI can surface detail
rather than a bare error string.

Secondary fix: `delete_snapshot(..)?` replaced the real apply error with a
file-deletion error. It is now logged instead of propagated.

The classification is extracted into `classify_rollback` so the decision that
was wrong -- when is a rollback good enough to release a snapshot -- is pure and
testable without Windows state. Six tests cover it; all four negative cases were
confirmed to fail against the reintroduced bug before the fix landed.

Implements docs/adr/0001 and docs/adr/0002.
build.rs collected tweaks into a HashMap and serialized it straight to
tweaks.json. Rust's HashMap uses a randomly-seeded hasher, so the keys came out
in a different order on every build: three build directories on this machine
held three different 436,243-byte files, all from identical YAML input.

That defeats build caching, makes the artifact useless as a diffable record, and
makes it impossible to verify that a change to the YAML or to the parser left
the output untouched -- which is exactly the check needed before swapping the
YAML parser.

Switching to BTreeMap makes the key order the tweak IDs' sort order. The runtime
still deserializes into a HashMap; JSON object order is irrelevant there, so
nothing downstream changes.

Verified: two consecutive forced rebuilds now produce byte-identical output, and
a deep comparison against the pre-change artifact confirms all 189 tweaks are
semantically unchanged -- only the key order moved.
serde_yml is affected by RUSTSEC-2025-0068: unsound, every version affected, no
patched release, and the upstream repository is archived. Not exploitable here --
the parser runs at build time over nine trusted in-repo files -- but there is no
reason to carry a known-unsound unmaintained dependency.

serde_yaml_bw over the other serde_yaml forks because it is the only one still
alive. Last releases: serde_yaml_bw 2.5.6 (2026-05-02, six releases since Nov
2025); serde_yaml_ng 0.10.0 (2024-05-26); serde_norway 0.9.42 (2024-12-21);
serde_yaml 0.9.34 (deprecated upstream 2024-03-25). serde_yml's own 0.0.13
release is its deprecation notice, pointing at noyalib -- a new crate by the same
maintainer whose previous crate was archived for unsoundness, so not followed.

Verified before adopting, made possible by the preceding determinism fix:

- tweaks.json is byte-identical across all 189 tweaks, so no authored value
  parses differently under the new parser.
- The deny_unknown_fields diagnostic still enumerates every valid field, so the
  build-time typo message an author sees is unchanged.
- "value: null" still deserializes to None, producing the same "'set' action
  requires value" error. ADR-0004 is therefore unaffected, now confirmed against
  the parser actually in use rather than inferred from serde_yml's source.
The plan was largely executed (08fa2e6, 522d9e1, 5131569, 2621dda) but every
checkbox still reads "- [ ]", so a finished plan looks pending.

The boxes are deliberately left unticked rather than marked done: ticking them
without re-verifying each task individually would replace honest ambiguity with
a false record. Instead the header states what was carried forward and what no
longer applies.

Carried forward: Task 1 is genuinely incomplete. Its acceptance criterion
required REG_BINARY values to detect consistently, but the unification reached
detection.rs and stopped before inspection.rs, which still compares with raw JSON
equality. A value authored in the supported "00,A0,FF" hex form matches in one
and mismatches in the other.

No longer applicable: Tasks 2, 3 and 6 concern the profile system, which is being
deleted and rebuilt.
The v1 profile system is about to be deleted and rebuilt. The .mgx archive is
self-describing -- a Deflate ZIP with a format_version field and plain JSON
payloads -- with exactly one exception: the option content hash, which is
embedded in stored profiles to resolve options that moved index, and which
cannot be reconstructed from an archive alone.

Records the three details that would silently break a reimplementation: the
b"profile-option-v2" domain separator is hashed before the payload, the payload
is compact JSON while the archive checksums use pretty-printed JSON, and the
digest is truncated to the first 32 hex characters. Also records the legacy
field-order variant that import still accepted.

Also notes for the rebuild: system_state.json was exported and never read,
per-tweak progress events were modelled and never emitted, and the hash's
sensitivity to TweakOption's serde representation argues for a structural hash
over meaningful fields rather than over serde output.
The profile system was incomplete -- system_state was exported and never read,
per-tweak progress events were modelled and never emitted -- and it is going to
be rebuilt rather than repaired. Carrying a half-built subsystem through the
remaining refactor stages would mean keeping a second apply path working for
code that is going to be thrown away.

Backend deleted (2,169 lines): models/profile.rs, commands/profile.rs, and
services/profile/{mod,archive,export,import,migration,validation}.rs, along with
their 7 Tauri commands, their module wiring, and 6 tests.

Dependencies dropped: zip, sha2, hex and hostname. All four were used only by
profile code -- notably hex, which looked like it was used by registry_value.rs
but is not: the apparent matches there are a local variable named `hex`, error
string prose, and test names.

Also removes RuntimeContext::windows_build, which the deletion orphaned.

Frontend kept, deliberately disabled rather than removed, so the feature reads
as "coming back" rather than "gone":
- The 6 invoke() bodies in src/lib/api/profile.ts are neutralized at that single
  choke point; the ~195 lines of type definitions have no backend dependency and
  still compile. Parameters are underscore-prefixed to preserve the signatures
  for the rebuild.
- ProfileManager's onMount no longer calls loadSavedProfiles(); leaving it would
  have produced an error toast every time the tab is opened.
- A banner explains the state, and the Export/Import entry points in
  SnapshotsView and SettingsModal are disabled.

The archive format and the option content hash are recorded in
docs/spec/profile-v1.md, committed beforehand, since the hash cannot be
reconstructed from a .mgx file alone.
… drift

Removes code with no remaining callers, verified by grep across the Rust source
and the TypeScript frontend rather than by compiler warnings -- cargo check does
not flag most of it, because a pub item in an rlib is API surface, not dead code.
Only unregistering the Tauri commands made the compiler agree.

Deleted:
- models/backup.rs in full (RegistryKeyId, make_key_id, one test); nothing
  referenced it.
- The second, unused YAML pipeline in models/tweak.rs: TweakDefinitionRaw,
  from_raw, TweakFile, validate, all_registry_keys, all_service_names,
  get_registry_changes_for_version, applicable_versions, and the four tests that
  existed only to exercise them. from_raw held a duplicate of the
  ti > system > admin inference rule; only the build.rs copy that actually runs
  remains, so the two can no longer disagree.
- The `aliases` field. Its sole reader was the profile system's validation, so it
  became write-only when that was deleted. Removed from both models/tweak.rs and
  the build.rs mirror -- removing only one side would emit a field that
  TweakDefinition's deny_unknown_fields rejects, panicking on first tweak lookup.
- Eight registered-but-never-invoked Tauri commands and their bodies:
  get_available_tweaks_for_version, get_tweaks_by_category, get_tweak,
  get_backup_system_status, cleanup_old_backups, apply_registry_as_system,
  delete_registry_as_system, get_debug_mode. None appears in any frontend
  invoke() call. cleanup_old_backups migrated away from a legacy
  exe_dir/backups layout and had never been wired to anything.
- Error variants never constructed: UnsupportedWindowsVersion, StateLock,
  Timeout, and ProfileError, plus their code() arms.
- The generated TWEAK_COUNT constant, which nothing read. CATEGORY_COUNT is kept;
  tweak_loader uses it.

Added, in tweak_loader.rs: a test that forces the embedded tweak data to
deserialize into the runtime types. build.rs parses YAML with a hand-maintained
copy of these types and the runtime deserializes the result, so drift between
them surfaces as a panic inside a LazyLock on a user's machine. This turns it
into a test failure instead, and it earned its place immediately: it caught the
aliases mismatch above when a careless `git checkout` reverted one side of it.
The project had no CI: .github/ contained only copilot-instructions.md and
hooks, so the gate documented there ("ALWAYS run bun run validate before
committing") was enforced by discipline alone.

Windows-only by design -- the app is never built or run elsewhere and the Rust
calls Win32 directly.

Four jobs:

- validate: runs the project's own `bun run validate` (prettier, tsc,
  svelte-check, cargo fmt, cargo clippy -D warnings, eslint) plus
  `cargo test --all-targets`.
- line-endings: asserts a fresh checkout really is CRLF. .gitattributes pins
  this, but a violation otherwise surfaces as a confusing `cargo fmt --check`
  failure against newline_style = "Windows" rather than as the line-ending
  problem it is. This job caught two files on its first local run
  (docs/spec/profile-v1.md and Cargo.lock), both fixed here.
- reproducible-build: builds twice from identical YAML and compares the sha256
  of the generated tweaks.json. Guards the property established when build.rs
  moved from HashMap to BTreeMap; without it the regression is invisible until
  someone tries to diff the artifact.
- audit: rustsec/audit-check, which would have flagged RUSTSEC-2025-0068 in
  serde_yml on its own.

RUSTFLAGS: -D warnings is deliberately not set globally -- it applies to
dependencies too, so an upstream warning would fail our build. The clippy step
inside `bun run validate` already scopes -D warnings to this crate.
@ehsan18t
ehsan18t merged commit face079 into main Jul 20, 2026
3 of 4 checks passed
@ehsan18t
ehsan18t deleted the refactor/tweak-system-remediation branch July 20, 2026 02:06
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