Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 50 additions & 118 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,54 @@
name: Release and Publish

# Publishing is driven by publishing a GitHub release, not by pushing to a
# branch. Create the release and its tag when you intend to ship; until then
# nothing reaches crates.io, so merging to any branch is always safe.
on:
push:
branches: [ main, master ]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/**'
- '!.github/workflows/release.yml'
release:
types: [published]

env:
CARGO_TERM_COLOR: always

jobs:
# First ensure all tests pass
# Derive the version from the release tag and hold it against Cargo.toml, so a
# mistyped tag fails here rather than shipping under the wrong version.
version:
name: Resolve Version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}

- name: Resolve and verify version
id: version
run: |
TAG="${{ github.event.release.tag_name }}"
VERSION="${TAG#v}"
MANIFEST=$(grep '^version = ' dotscope/Cargo.toml | head -1 | cut -d '"' -f 2)
if [ "$VERSION" != "$MANIFEST" ]; then
echo "::error::Release tag ${TAG} implies version ${VERSION}, but dotscope/Cargo.toml declares ${MANIFEST}"
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Releasing v${VERSION}"

# Then ensure all tests pass
test:
name: Pre-Release Tests
runs-on: ubuntu-latest
needs: version

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -57,111 +85,11 @@ jobs:
- name: Check documentation
run: cargo doc --no-deps

# Create GitHub release
create-release:
name: Create Release
runs-on: ubuntu-latest
needs: test
outputs:
version: ${{ steps.version.outputs.version }}
release_created: ${{ steps.check_release.outputs.created }}

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Get version from Cargo.toml
id: version
run: |
VERSION=$(grep '^version = ' dotscope/Cargo.toml | head -1 | cut -d '"' -f 2)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"

- name: Check if release exists
id: check_release
run: |
if gh release view "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
echo "created=false" >> $GITHUB_OUTPUT
echo "Release v${{ steps.version.outputs.version }} already exists, skipping"
else
echo "created=true" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Generate changelog
id: changelog
if: steps.check_release.outputs.created == 'true'
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")

if [ -z "$LATEST_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges)
else
CHANGELOG=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
fi

if [ -z "$CHANGELOG" ]; then
CHANGELOG="- Initial release"
fi

echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

- name: Create Release
if: steps.check_release.outputs.created == 'true'
run: |
VERSION="v${{ steps.version.outputs.version }}"
cat <<BODY > release_body.md
## Changes in ${VERSION}

${{ steps.changelog.outputs.CHANGELOG }}

## Installation

### Library

Add this to your \`Cargo.toml\`:

\`\`\`toml
[dependencies]
dotscope = "${{ steps.version.outputs.version }}"
\`\`\`

Or install via cargo:

\`\`\`bash
cargo add dotscope
\`\`\`

### CLI Tool

Download the pre-built binary for your platform from the assets below and extract it.

| Platform | Asset |
|----------|-------|
| Linux (x86_64) | \`dotscope-${VERSION}-x86_64-unknown-linux-gnu.zip\` |
| macOS (Apple Silicon) | \`dotscope-${VERSION}-aarch64-apple-darwin.zip\` |
| Windows (x86_64) | \`dotscope-${VERSION}-x86_64-pc-windows-msvc.zip\` |

> **Note:** Z3 is an optional compile-time dependency used only for the \`z3\` feature (CFF reconstruction). The pre-built CLI binaries do **not** require Z3 at runtime.
BODY

gh release create "$VERSION" \
--title "Release ${VERSION}" \
--notes-file release_body.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Build CLI binaries for all platforms
# Build CLI binaries for all platforms and attach them to the release
build-cli:
name: Build CLI (${{ matrix.name }})
runs-on: ${{ matrix.os }}
needs: create-release
if: needs.create-release.outputs.release_created == 'true'
needs: [version, test]
strategy:
fail-fast: false
matrix:
Expand All @@ -182,6 +110,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand All @@ -200,31 +130,32 @@ jobs:
if: runner.os != 'Windows'
run: |
cd target/${{ matrix.target }}/release
zip dotscope-v${{ needs.create-release.outputs.version }}-${{ matrix.target }}.zip ${{ matrix.artifact }}
mv dotscope-v${{ needs.create-release.outputs.version }}-${{ matrix.target }}.zip ${{ github.workspace }}/
zip dotscope-v${{ needs.version.outputs.version }}-${{ matrix.target }}.zip ${{ matrix.artifact }}
mv dotscope-v${{ needs.version.outputs.version }}-${{ matrix.target }}.zip ${{ github.workspace }}/

- name: Package binary (Windows)
if: runner.os == 'Windows'
run: |
cd target/${{ matrix.target }}/release
Compress-Archive -Path ${{ matrix.artifact }} -DestinationPath "${{ github.workspace }}\dotscope-v${{ needs.create-release.outputs.version }}-${{ matrix.target }}.zip"
Compress-Archive -Path ${{ matrix.artifact }} -DestinationPath "${{ github.workspace }}\dotscope-v${{ needs.version.outputs.version }}-${{ matrix.target }}.zip"
shell: pwsh

- name: Upload release asset
run: gh release upload "v${{ needs.create-release.outputs.version }}" "dotscope-v${{ needs.create-release.outputs.version }}-${{ matrix.target }}.zip" --clobber
run: gh release upload "${{ github.event.release.tag_name }}" "dotscope-v${{ needs.version.outputs.version }}-${{ matrix.target }}.zip" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Publish to crates.io
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
needs: [test, create-release]
if: needs.create-release.outputs.release_created == 'true'
needs: [version, test]

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand All @@ -244,12 +175,13 @@ jobs:
verify-docs:
name: Verify Documentation
runs-on: ubuntu-latest
needs: [test, create-release]
if: needs.create-release.outputs.release_created == 'true'
needs: [version, test]

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`optimize_locals` split values it renumbered** (`SsaFunctionCilExt`): renumbering a local moved its origin but left its rename group pointing at the old slot. `rebuild_ssa` groups variables by rename group while resolving argument/local representatives by origin, so the two views disagreed about which names denote the same local and the value ended up with no reaching definition. Both now move together
- **CFF tracer could escape its visit budget**: entering an expression-switch false arm deliberately resets `total_visits` so each arm gets its own budget. On heavily nested methods that reset fired often enough to make the budget unbounded — a 348-block ConfuserEx method reached 3.3 million block visits against a 50,000 cap. A monotonic counter now bounds total work per trace without changing the per-arm semantics
- **CFF reconstruction produced invalid SSA after block cloning**: cloned blocks duplicated every definition they contained, phi operands survived pointing at predecessors the patched CFG no longer has, and operands were left naming redirected blocks. Clones now allocate fresh variable ids (after state-tainted filtering, which keys on the original ids), phi operands are pruned against the patched predecessor sets and definedness, and redirected operands are resolved through a bounded hop limit
- **Cleanup deleted live types reachable only through another deletion candidate** (#249, fix contributed in #248 by [@agski331](https://github.com/agski331)): `find_unreferenced_types` computed a single step of what is a transitive reachability problem. A candidate rescued partway through a pass never propagated that liveness onwards, so any cluster reachable solely through it was still read as isolated infrastructure — on `reactor_virtualization` that cut the assembly from 854 methods to 45, deleting a VM interpreter whose stubs remain live because nothing devirtualizes it. Reachability is now a worklist drain over a type-level call graph, in `O(V+E)` rather than `O(depth × edges)`. CustomAttribute constructor types are seeded as roots before propagation rather than filtered afterwards, so the types those constructors call survive too, and liveness propagates from a nested type to its enclosing type — deleting an enclosing type cascades to its children through `expand_type_tokens`, which was dropping nested types that live code still called. The deletion set is sorted for a reproducible order
- **Technique cleanup ran whether or not the transformation it implies succeeded**: a technique builds its cleanup request from detection findings alone, so it scheduled the decryptor, its infrastructure type, the initializer and the encrypted data for deletion even when no call site was reversed, leaving those call sites pointing at metadata that no longer exists and emptying the methods holding them. Removal now requires that nothing still calls the decryptor — a successful decryption rewrites its call site to the constant, so the absence of remaining callers is the evidence of reversal. Absence of recorded failures is not, since a decryptor that was never exercised has none either. Note that the caller check reads the SSA call graph, which covers only methods that converted successfully
- **ConfuserEx constant decryption missed builds that differ from stock 1.6.0** (#249): four defects, each masking the next. The blob index was matched against `int32` alone, so builds emitting `T Get<T>(uint32)` yielded no decryptor at all — any integer width is now accepted, the surrounding constraints carrying the selectivity. `stobj` rejected reference types, though ECMA-335 §III.4.29 defines it over any `typeTok` and makes it equivalent to `stind.ref` for reference types, which is what `stobj !!T` becomes at `T = string`. `ldelema` hand-rolled its index match to `I32`/`NativeInt` while `ldelem`/`stelem` share a helper accepting every width, so a `native uint` index aborted emulation. And `stfld` through a pointer to a value-type array element replaced the whole element with the field's value instead of updating the field inside it, which is the path LZMA's bit-decoder struct arrays take
- **ConfuserEx LZMA blobs were rejected or misparsed** (#249): the sniffer required the compressed payload to be smaller than its declared output, but LZMA expands small high-entropy input and the constants blob is XOR-encrypted before compression — a 44-byte blob compresses to 51 and was refused. The header was also modelled as 5 property bytes plus a 4-byte size, while builds calling the LZMA SDK's stream API write the standard 13-byte header with an 8-byte size; reading that as 9 bytes shifts the payload and corrupts the range coder. Both layouts are now attempted and held to the size each declares, and the size ceiling is documented as an allocation guard rather than a property of the format

### Changed

Expand Down
87 changes: 69 additions & 18 deletions dotscope/src/cilassembly/cleanup/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ pub fn expand_type_tokens(request: &CleanupRequest, assembly: &CilObject) -> Has
/// in the provided method call graph.
///
/// These are self-contained infrastructure types that can be safely deleted.
/// A type is considered unreferenced when no method outside the type (and
/// not already scheduled for deletion) calls any of its methods.
/// Reachability is computed transitively: candidate types are rooted at every
/// live non-candidate type, and a candidate reached from a root is itself
/// promoted to a root. A type is considered unreferenced only when no chain of
/// calls from live code arrives at it. Stopping after a single step would
/// delete clusters that are reachable solely through another candidate — a VM
/// dispatcher or decryptor facade that turns out to be live.
///
/// This handles two key scenarios:
/// - **Proxy devirtualization**: wrapper types whose methods are
Expand Down Expand Up @@ -175,9 +179,11 @@ pub fn find_unreferenced_types(
}
}

// Compute which candidates have live external callers
let mut has_external_caller: HashSet<Token> = HashSet::new();

// Collapse the method call graph to type granularity, keeping only edges
// whose caller is still live. Doing this once lets reachability be answered
// with a single worklist drain instead of re-scanning every call edge per
// round.
let mut type_calls: HashMap<Token, HashSet<Token>> = HashMap::new();
for (caller_token, callees) in method_call_graph {
// Skip callers that are deleted
if deleted_methods.contains(caller_token) {
Expand All @@ -190,47 +196,92 @@ pub fn find_unreferenced_types(
if deleted_types.contains(&caller_type) {
continue;
}
// Skip callers that are themselves candidates — their edges are
// intra-cluster and don't constitute external references
let caller_is_candidate = candidates.contains(&caller_type);

for callee_token in callees {
let Some(callee_type) = method_to_type.get(callee_token).copied() else {
continue;
};
// Intra-type calls say nothing about whether the type is reachable
if caller_type == callee_type {
continue;
}
// Only mark as externally referenced if the caller is NOT a candidate
if !caller_is_candidate && candidates.contains(&callee_type) {
has_external_caller.insert(callee_type);
type_calls
.entry(caller_type)
.or_default()
.insert(callee_type);
}
}

// A nested type cannot outlive its enclosing type: deleting an enclosing
// type cascades to its nested types via [`expand_type_tokens`]. Model that
// as an edge from nested to enclosing so a live nested type keeps its
// parent alive, rather than being silently dropped along with it.
for type_entry in registry.iter() {
let type_token = *type_entry.key();
if type_token.table() != 0x02 {
continue;
}
// A type already scheduled for deletion is not live and must never
// become a reachability root
if deleted_types.contains(&type_token) {
continue;
}
if let Some(enclosing) = type_entry.value().enclosing_type() {
if enclosing.token != type_token {
type_calls
.entry(type_token)
.or_default()
.insert(enclosing.token);
}
}
}

// Seed the roots: every live type that is not itself a candidate. Candidacy
// is what reachability has to resolve, so a candidate cannot serve as proof
// that another candidate is referenced.
let mut worklist: Vec<Token> = type_calls
.keys()
.filter(|type_token| !candidates.contains(type_token))
.copied()
.collect();

// CustomAttribute constructors reference types that the call graph misses.
// Types like EmbeddedAttribute and RefSafetyRulesAttribute are only referenced
// from CustomAttribute rows (their .ctors are attribute constructors), never
// from IL code. Without this check they appear unreferenced and get deleted.
// They join the roots so that whatever they call stays alive as well.
if let Some(tables) = assembly.tables() {
if let Some(attr_table) = tables.table::<CustomAttributeRaw>() {
for row in attr_table {
if row.constructor.token.is_table(TableId::MethodDef) {
if let Some(&ctor_type) = method_to_type.get(&row.constructor.token) {
if candidates.contains(&ctor_type) {
has_external_caller.insert(ctor_type);
if candidates.remove(&ctor_type) {
worklist.push(ctor_type);
}
}
}
}
}
}

// All candidates without external callers are unreferenced infrastructure
candidates
.into_iter()
.filter(|t| !has_external_caller.contains(t))
.collect()
// Propagate liveness transitively. A candidate rescued here becomes a root
// in turn — without that, a cluster reachable only through another candidate
// (a VM dispatcher, a decryptor facade) is misread as isolated infrastructure.
while let Some(live_type) = worklist.pop() {
let Some(callees) = type_calls.get(&live_type) else {
continue;
};
for callee_type in callees {
if candidates.remove(callee_type) {
worklist.push(*callee_type);
}
}
}

// Whatever is still a candidate is unreachable from live code
let mut unreferenced: Vec<Token> = candidates.into_iter().collect();
unreferenced.sort_unstable();
unreferenced
}

/// Pre-computes the set of entry-point method tokens that should not be removed.
Expand Down
Loading
Loading