feat: add base-skin integrity verification (ltk_sanitize) - #178
Open
moonshadow565 wants to merge 10 commits into
Open
feat: add base-skin integrity verification (ltk_sanitize)#178moonshadow565 wants to merge 10 commits into
moonshadow565 wants to merge 10 commits into
Conversation
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
force-pushed
the
ltk_sanitize
branch
from
July 15, 2026 21:35
4492594 to
7b59c17
Compare
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Clanker writen as well but this at least moves it from patcher source to shared crates.