Skip to content

feat: add base-skin integrity verification (ltk_sanitize) - #178

Open
moonshadow565 wants to merge 10 commits into
mainfrom
ltk_sanitize
Open

feat: add base-skin integrity verification (ltk_sanitize)#178
moonshadow565 wants to merge 10 commits into
mainfrom
ltk_sanitize

Conversation

@moonshadow565

Copy link
Copy Markdown
Contributor

Clanker writen as well but this at least moves it from patcher source to shared crates.

moonshadow565 and others added 2 commits July 15, 2026 23:34
Verify the closed-world assertion the in-game verifier enforces: every
mesh asset a champion's base skin (skin0) references must resolve inside
the WAD that references it. Real mods violate this in non-hostile ways —
dangling references (broken or outdated mods, e.g. assets removed from
the game in a past patch) and assets shipped into the wrong WAD (usually
a localized WAD instead of the champion WAD). Both currently disable the
entire overlay at injection with no warning ahead of time.

- ltk_sanitize (new crate): shared implementation usable by the manager,
  the CLI, and eventually the in-game verifier itself, so the assertion
  cannot drift between implementations. ChunkSource abstracts where
  chunks come from (mounted WAD, or a mod archive virtually merged over
  the original — archives are checked fully in-memory, never extracted).
  Failures the original game WAD causes are reported as baseline
  anomalies (greppable "base-skin baseline anomaly" log prefix), never
  blamed on a mod.
- ltk_overlay: collect per-mod SkinIntegrityOffender reports after each
  build (persisted in overlay state, drained like linked-bin offenders,
  ready for a manager-side "broken mod" prompt), plus check_single_mod
  for the no-build path.
- league-mod: new `sanitize` command checking .fantome/.modpkg/mod dirs
  straight from the archive.

Validated against 2,586 real mods: 107/2,309 (4.6%) of a curated clean
corpus violate the assertion — 42 shipped all mesh assets into the
en_US WAD (auto-repairable by future reference-driven routing), 65
reference assets that no longer exist. Zero false positives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A set texture slot pointing at a path that resolves nowhere is a known
authoring idiom (it suppresses the vanilla base texture): 59 of the 107
mods flagged in the clean-mods corpus and 3/275 in very-clean have only
this violation. A dangling reference admits no attacker-controlled
bytes — there is no chunk to load, scan, or swap — so tolerating it
keeps the closed-world anti-evasion property intact. Skeleton and
simple-skin stay hard failures, the policy never applies to the
original-WAD baseline, and a tolerated ref still appears in the report
as Missing; only the verdict changes.

SkinPolicy lives in ltk_sanitize and is threaded through every consumer
(overlay build, check_single_mod, CLI --strict) so the manager's
pre-flight prediction cannot diverge from the in-game verifier once it
bootstraps from the same crate. The policy is defined on the per-WAD
view (does the reference resolve in this WAD), because that is all the
in-game verifier can ever see. Default is tolerant; SkinPolicy::strict()
restores fail-closed judgment for audits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@moonshadow565 moonshadow565 changed the title Ltk sanitize feat: add base-skin integrity verification (ltk_sanitize) Jul 15, 2026
moonshadow565 and others added 8 commits July 16, 2026 02:11
…n refs

Pre-merge review follow-ups for ltk_sanitize:

- RefStatus::Modified now carries ChunkChecksums: the declared TOC
  checksum paired with the xxh3 of the decompressed bytes. The pair is
  all-or-nothing — a declared checksum alone must never become
  attestable for bytes nobody read — which lets the in-game fast track
  drop its KNOWN_RF re-fetch entirely.
- A chunk that is present in the TOC but whose bytes cannot be read now
  classifies as Missing(RefMissingKind::Unreadable) and is a violation
  under every policy: unlike a dangling reference, a stored chunk
  exists and admits attacker-controlled bytes, so the dangling-texture
  tolerance never applies.
- Report types now expose hashes as plain integers (u64 chunk hashes,
  u32 bin-entry hashes) instead of ltk_hash wrapper types, so consumers
  pinned to a different ltk_hash version never hit a type clash;
  documented as crate policy.
- Document that unsetting the texture property (the sibling idiom to a
  dangling texture ref) needs no policy, and that SkippedUnmodified
  does not inspect referenced chunk contents — consumers gating further
  scanning on it inherit that blind spot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RefStatus classification was TOC-checksum-only, but zstd is not
canonical: a repack can re-encode identical bytes into a different TOC
checksum, so an untouched asset read as Modified — and the docs
enshrined that misclassification as intended.

check_base_skin already decompresses and fingerprints the merged chunk
to build ChunkChecksums, so it held half of the correct comparison. On
a TOC mismatch it now also loads the ORIGINAL chunk (only on that
branch, and only when the original contains it) and compares
decompressed checksums: equal means Unmodified (re-compressed untouched
asset), different means Modified with the fingerprints as before.

TOC-equal chunks still classify Unmodified without any loads. An
original-side chunk that exists but cannot be read proves nothing and
is never the mod's problem: the merged chunk stays Modified with its
fingerprints — no error, no Missing(Unreadable), which describes the
merged side only. The merged-side all-or-nothing fingerprint invariant
is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	Cargo.lock
Carry the parsed BinObject for both sides of the check on the report:
`object` (merged entry, None exactly when resolution failed) and
`original_object` (vanilla baseline, always present — a failed baseline
is a BaselineAnomaly, not a report). Consumers can read properties the
check does not model without re-resolving the skin graph, and diff
merged against vanilla without mounting the original WAD again.

The Report variant is boxed to keep SkinCheckOutcome small now that a
report carries two parsed bin entries (clippy::large_enum_variant).
ltk_meta and BinObject are re-exported as the source of truth for the
new fields' types.

Also fixes a pre-existing rustfmt violation in check_base_skin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Texture slot never carried a reliable correctness signal: it is
optional (material-override skins omit it) and a dangling texture
reference is a known authoring idiom for suppressing the vanilla base
texture. Stop parsing the property entirely — the check now covers only
the two always-required slots, Skeleton and SimpleSkin.

With texture gone the whole policy layer loses its purpose: SkinPolicy's
only knob was allow_dangling_texture, so the struct, the default/strict
split, and the policy params on check_base_skin, is_broken, and
violations are removed. Any Missing reference is now simply a violation.
MeshSlot::is_required goes too — every remaining slot is required.

Downstream: ltk_overlay drops the SkinPolicy re-export and
check_single_mod's policy param; league-mod loses the sanitize --strict
flag whose only meaning was texture policy. Both were introduced on this
branch and never released.

Note: a texture chunk that is present but unreadable was previously a
violation; the texture ref is now never inspected, so that case is
invisible to this check.

Net -283 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tains-only sources

Restructure SkinCheckOutcome so every state is total — nothing optional
in any variant:

- SkippedUnmodified: vanilla root bin (decided by decompressed content).
- BaselineAnomaly: the original WAD broke an assumption (unchanged).
- ModAnomaly: the mod's violation, mirroring the baseline judgment in
  the same fail-closed order (corrupt bin, unresolvable entry, missing
  required slot, unusable reference). Replaces the SkinIntegrity report
  and its is_broken()/violations() grab-bag; the thiserror Display is
  the user-facing diagnostic.
- Modified(ModifiedSkin): the passing case — parsed skin0 entries of
  both sides plus per-slot MeshRefs (skeleton, simple_skin), with a
  missing reference structurally unrepresentable.

Fingerprints switch from the (declared TOC checksum, xxh3) pair to a
single SHA-256 of the decompressed bytes: every fingerprinted path had
already loaded the full chunk, the declared TOC value was untrusted by
design, and xxh3-64 collisions are forgeable — unacceptable when
consumers attest modified assets against known-fingerprint sets. One
digest per chunk serves both equality against the original slot and the
fingerprint carried on Modified.

With the checksum value unused, ChunkSource::checksum collapses into
contains — the trait is now contains + load, TOC checksums are never
read (the spoofed-checksum evasion becomes unrepresentable), and
ltk_overlay's pseudo-checksum hack in ModChunkSource is gone. Drops
the xxhash-rust dependency for sha2.

Tests move to tests/check.rs as integration tests like the other
crates: one per invariant, driving everything through the public API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The resolve walk never stops for an unreadable bin -- it records it and
keeps going, so the entry may still be defined by a later linked bin. Both
judges ignored that: they drained `outcome.corrupt` before ever looking at
`outcome.entry`, so a successfully resolved skin0 was thrown away because
some other bin in the graph happened to be unreadable.

Corruption is no longer a verdict on its own. `resolve_or_explain` now
holds the policy for both sides: an entry that resolved is judged normally
and the unreadable bins ride along on `ModifiedSkin::corrupt_bins`;
corruption is reported only as the cause of an `EntryNotFound`, where an
unreadable bin may well have been the one defining the entry. Every other
resolve error is a verdict the walk reached on its own -- entry found with
the wrong class, root absent, graph absurd -- and is no longer masked by
incidental corruption elsewhere.

Same fix on the baseline side, where the bug was worse: a corrupt bin in
the original that did not block resolution aborted the check outright, so
the mod was never judged at all. Corruption the baseline survived is
warn-logged rather than carried -- an unreadable bin in the *original* is
never the mod's problem to answer for, the same call `original_sha256`
already makes for an unreadable original chunk.

Breaking: `ModifiedSkin` gains a `corrupt_bins` field. Consumers that must
vouch for the whole skin (the in-game verifier's fast track) should read a
non-empty list as "cannot vouch" and fall through to their full scan.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant