diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..e4350ca --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,119 @@ +name: Rust checks + +on: + pull_request: + paths: + - 'src/**' + - 'tests/**' + - 'benches/**' + - 'examples/**' + - 'build.rs' + - 'Cargo.toml' + - 'Cargo.lock' + - 'README.md' + - '.cargo/**' + - 'rust-toolchain*' + - '.github/workflows/rust.yml' + push: + branches: [master] + paths: + - 'src/**' + - 'tests/**' + - 'benches/**' + - 'examples/**' + - 'build.rs' + - 'Cargo.toml' + - 'Cargo.lock' + - 'README.md' + - '.cargo/**' + - 'rust-toolchain*' + - '.github/workflows/rust.yml' + workflow_dispatch: + inputs: + miri: + description: Run the focused Miri safety tests + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: rust-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + +jobs: + stable: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.99.0' + components: rustfmt, clippy + - run: cargo fmt --check + - name: Tests and strict Clippy for each stable feature configuration + shell: bash + run: | + set -euo pipefail + cargo test --no-default-features + cargo test --features std + cargo test --features entities + cargo clippy --all-targets --no-default-features -- -D warnings + cargo clippy --all-targets --features entities -- -D warnings + - name: Benchmark smoke checks (no performance claims) + run: | + cargo bench --bench tl -- --test + cargo bench --features std --bench tl -- --test + cargo bench --features entities --bench audit -- --test + + compatibility: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.92.0' + - run: cargo check --all-targets --no-default-features + - run: cargo check --all-targets --features entities + + portable-simd: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly-2026-09-25 + - run: cargo test --no-default-features --features portable-simd + - run: cargo test --features entities,portable-simd + + miri: + if: github.event_name == 'workflow_dispatch' && inputs.miri + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + MIRI_AUTOSETUP: '1' + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly-2026-09-25 + components: miri + - run: cargo miri test --lib --features std tests::audit + - run: cargo miri test --lib --features std owned + - run: cargo miri test --lib --features std inline diff --git a/Cargo.toml b/Cargo.toml index 8fb2154..642afb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,3 +53,12 @@ codegen-units = 1 incremental = false debug = false overflow-checks = false + +[[bench]] +name = "audit" +harness = false +required-features = ["entities"] + +[[example]] +name = "audit_allocations" +required-features = ["entities"] diff --git a/README.md b/README.md index 09a9054..0a36662 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ tl is a fast HTML parser written in pure Rust. By default this crate builds without `std` or `alloc`. Enable `std` for the -allocating convenience API: +allocating convenience API. The examples below target the current Git revision +(next breaking 0.3.x release), including APIs not present in published 0.2.0: ```toml -rustedbytes-tl = { version = "0.1", features = ["std"] } +rustedbytes-tl = { git = "https://github.com/RustedBytes/rustedbytes-tl", features = ["std"] } ``` For the nightly portable SIMD path, enable `portable-simd` and build with @@ -37,6 +38,9 @@ Enable `entities` to use `decoded_inner_text` for HTML character references. See [compatibility notes](docs/silkworm-compatibility.md) for supported selectors, remaining HTML5 differences, query limits and release compatibility. +For checks, benchmarks, safety changes and migration guidance, see the +[Rust audit notes](docs/rust-audit.md). + ## Provenance This crate is a fork of [`astral-tl`](https://github.com/astral-sh/astral-tl), modified to diff --git a/benches/audit.rs b/benches/audit.rs new file mode 100644 index 0000000..d816641 --- /dev/null +++ b/benches/audit.rs @@ -0,0 +1,36 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; + +fn fixture() -> String { + let mut input = String::from(""); + input +} + +fn benchmarks(c: &mut Criterion) { + let input = fixture(); + let dom = tl::parse(&input, tl::ParserOptions::default()).unwrap(); + let node = dom + .query_selector("ul") + .unwrap() + .next() + .unwrap() + .get(dom.parser()) + .unwrap(); + assert_eq!(dom.query_selector("li:has(a)").unwrap().count(), 200); + assert_eq!( + node.decoded_inner_text(dom.parser()), + "plain text".repeat(200) + ); + c.bench_function("audit/has_200", |b| { + b.iter(|| black_box(dom.query_selector(black_box("li:has(a)")).unwrap().count())) + }); + c.bench_function("audit/decoded_text_200", |b| { + b.iter(|| black_box(node.decoded_inner_text(black_box(dom.parser())))) + }); +} +criterion_group!(benches, benchmarks); +criterion_main!(benches); diff --git a/docs/rust-audit.md b/docs/rust-audit.md new file mode 100644 index 0000000..781b2a2 --- /dev/null +++ b/docs/rust-audit.md @@ -0,0 +1,139 @@ +# Rust audit and measured improvements + +Baseline: merged PR #1, commit `46a4f9f`. The crate is edition 2024 with no declared +MSRV; compatibility is checked on Rust 1.92 without raising a compiler floor. +Current stable was verified from the [official release notes](https://doc.rust-lang.org/stable/releases.html): +Rust 1.99.0, released 2026-10-01. No dependency versions or allocator were changed. + +The review applied correctness testing, unsafe auditing, idiom/API review, +standard-library and performance optimization, Criterion measurement, Rust +modernization, CI and documentation guidance. The library has no async runtime, +FFI or PyO3 boundary, so those workflows are not applicable. This is a focused +source and test review, not proof that every input/platform is correct. + +## Confirmed defects and fixes + +| Area | Trigger and consequence | Fix and evidence | +| --- | --- | --- | +| Inline raw storage, high severity | Safe `inline_parts_mut()` allowed replacing a live slot with uninitialized storage; later get/drop would access invalid values. | The public method now requires `unsafe` and documents initialization and unique ownership obligations. Two compile-fail examples enforce the safe-call boundary. | +| Map spill, high severity | A key's `Hash` or `Eq` panicked after an entry was moved into a temporary heap map, while the source still counted that slot as initialized. Unwinding could drop moved/uninitialized storage. | Reduce the initialized prefix before moving each entry. A `Hash` panic test counts all three destructors and then accesses/drops the remaining map; it passes Miri. | +| Container cloning, medium severity | A later `Clone` panicked, leaking earlier clones held in an unguarded MaybeUninit array. | Build into an owner with an incrementally updated initialized prefix. Both baseline regressions fail (0 destructor calls versus 2 expected); candidate regressions and Miri pass. | +| Bytes ordering, medium severity | Equal content in borrowed and owned storage compared non-equal under derived pointer-based Ord; BTreeSet could retain duplicate equal values. | Implement lexicographic byte-content Ord/PartialOrd, consistent with Eq/Hash. The baseline regression fails; candidate and Miri pass. | +| Duplicate map keys, medium severity | Re-inserting an existing inline key grew storage or spilled rather than replacing it, unlike the heap-backed map. | Replace existing values before checking capacity. Works with/without std. The HTML parser retains the first ordinary attribute using a dedicated insertion path. | + +The raw-storage signature change is intentional and requires the planned breaking +0.3.x release. Existing safe code should use `get_mut`, `as_mut_slice` (vector), +or map insertion instead. Code that genuinely manipulates raw storage must use +an unsafe block and establish the documented obligations; simply wrapping the +old code in unsafe does not establish them. No release or version bump occurs +in this change. Ordinary parser/selector public signatures remain unchanged. +After a panic during map spill, entries may have been consumed; remaining +storage stays valid and each live owner is dropped once. Transactional rollback +is not promised. + +The std-only allocator used for probes is an example, not a production allocator. +New production unsafe reads have local initialized-prefix proofs. The empty +MaybeUninit array now uses safe inline-const initialization. + +## Performance boundaries and results + +Fixture: 200 identical `
  • plain text
  • ` rows inside `