From 333f018e93f1bf0589cd61c0748bc70ce28089ce Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 16 Sep 2025 11:25:43 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20Initial=20setup=20for=20oi4-dnp?= =?UTF-8?q?-encoding=20project=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `rust-toolchain.toml` to specify Rust toolchain and components. - Updated `.gitignore` to exclude IDE files and project-specific files. - Created `Cargo.toml` for package metadata and dependencies. - Implemented fuzzing targets in `validate.rs` and `decode.rs` for testing. - Added CI workflows in `ci.yml` and `coverage.yml` for automated testing and coverage reporting. - Introduced `CHANGELOG.md` to document project changes. - Created initial implementation files: `encode.rs`, `decode.rs`, `validate.rs`, and others for core functionality. - Added examples and tests for encoding, decoding, and validation in `test_encode.rs`, `test_decode.rs`, and `test_validate.rs`. - Included `README.md` for project overview and usage instructions. - Established `LICENSE` file for MIT licensing. - Added `compare_go.sh` for differential testing against Go implementation. Let's get this project rolling! 🎉 --- .github/workflows/ci.yml | 50 +++ .github/workflows/coverage.yml | 29 ++ .github/workflows/publish.yml | 24 ++ .gitignore | 2 + CHANGELOG.md | 19 + Cargo.lock | 680 +++++++++++++++++++++++++++++++ Cargo.toml | 54 +++ DESIGN.md | 40 ++ LICENSE | 22 + README.md | 114 ++++++ benches/encode_decode.rs | 32 ++ examples/all_in_one.rs | 82 ++++ examples/basic.rs | 26 ++ examples/encode_into_no_alloc.rs | 40 ++ examples/strict.rs | 38 ++ examples/validation.rs | 22 + fuzz/Cargo.toml | 19 + fuzz/fuzz_targets/decode.rs | 17 + fuzz/fuzz_targets/validate.rs | 13 + rust-toolchain.toml | 4 + src/decode.rs | 60 +++ src/encode.rs | 65 +++ src/error.rs | 42 ++ src/lib.rs | 43 ++ src/main.rs | 44 ++ src/validate.rs | 92 +++++ tests/test_cli.rs | 49 +++ tests/test_decode.rs | 74 ++++ tests/test_encode.rs | 97 +++++ tests/test_error.rs | 21 + tests/test_validate.rs | 51 +++ tools/compare_go.sh | 61 +++ tools/gen_golden.rs | 23 ++ 33 files changed, 2049 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/publish.yml create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DESIGN.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 benches/encode_decode.rs create mode 100644 examples/all_in_one.rs create mode 100644 examples/basic.rs create mode 100644 examples/encode_into_no_alloc.rs create mode 100644 examples/strict.rs create mode 100644 examples/validation.rs create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/decode.rs create mode 100644 fuzz/fuzz_targets/validate.rs create mode 100644 rust-toolchain.toml create mode 100644 src/decode.rs create mode 100644 src/encode.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/validate.rs create mode 100644 tests/test_cli.rs create mode 100644 tests/test_decode.rs create mode 100644 tests/test_encode.rs create mode 100644 tests/test_error.rs create mode 100644 tests/test_validate.rs create mode 100644 tools/compare_go.sh create mode 100644 tools/gen_golden.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4f93ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + toolchain: [stable] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: 1.74.0 + components: clippy, rustfmt + override: true + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + - name: Format + run: cargo fmt -- --check + - name: Clippy + run: cargo clippy --all-features -- -D warnings + - name: Tests (default features) + run: cargo test --all --verbose + - name: Tests (no default, alloc) + run: cargo test --no-default-features --features alloc --verbose + - name: Tests (strict) + run: cargo test --features strict --verbose + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: 1.74.0 + override: true + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + - name: Build (MSRV) + run: cargo build --no-default-features --features alloc + - name: Test (MSRV) + run: cargo test --no-default-features --features alloc + diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..7f9a2d2 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,29 @@ +name: Coverage + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: {} + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + - name: Install cargo-llvm-cov + run: cargo install cargo-llvm-cov --locked + - name: Generate coverage + run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: lcov.info + path: lcov.info + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4f1acf9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,24 @@ +name: Publish to crates.io + +on: + push: + tags: + - 'v*' + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + run: cargo publish --locked --no-verify + diff --git a/.gitignore b/.gitignore index ad67955..54b1748 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +/.idea/ +/dnp-encoder-rust.iml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f15e32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2025-09-16 + +### Added +- Initial implementation: encoding, decoding, validation +- no_std + alloc feature gating +- strict feature +- Basic tests & documentation scaffolding +- Differential Test tooling (tools/compare_go.sh, tests/differential_go.rs) +- Golden file generator (gen_golden) +- Fuzzing skeleton (fuzz targets: decode, validate) +- Coverage workflow (coverage.yml) and README sections for differential, fuzzing, coverage + +[Unreleased]: https://github.com/OI4/dnp-encoder-rust/compare/2.14.0...HEAD + +[0.1.0]: https://github.com/OI4/dnp-encoder-rust/releases/tag/1.0.0 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ed0f2c1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,680 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "bitflags 1.3.2", + "clap_lex", + "indexmap", + "textwrap", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "criterion" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c76e09c1aae2bc52b3d2f29e13c6572553b30c4aa1b8a49fd70de6412654cb" +dependencies = [ + "anes", + "atty", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "lazy_static", + "num-traits", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "half" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b4af3693f1b705df946e9fe5631932443781d0aabb423b62fcd4d73f6d2fd0" +dependencies = [ + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oi4-dnp-encoding" +version = "0.1.0" +dependencies = [ + "criterion", + "proptest", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.9.4", + "lazy_static", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.223" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a505d71960adde88e293da5cb5eda57093379f64e61cf77bf0e6a63af07a7bac" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.223" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f57cbd357666aa7b3ac84a90b4ea328f1d4ddb6772b430caa5d9e1309bb9e9" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.223" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d428d07faf17e306e699ec1e91996e5a165ba5d6bce5b5155173e91a8a01a56" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.14.5+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.0+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..64eef06 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "oi4-dnp-encoding" +version = "0.1.0" +edition = "2021" +authors = ["OI4 Contributors"] +license = "MIT" +description = "OI4 / DIN SPEC 91406 Digital Nameplate (DNP) encoding/decoding/validation in Rust" +repository = "https://github.com/OI4/dnp-encoder-rust" +homepage = "https://github.com/OI4dnp-encoder-rust" +documentation = "https://github.com/OI4dnp-encoder-rust" +keywords = ["oi4","dnp","encoding", "oec"] +categories = ["encoding","parser-implementations"] +readme = "README.md" +rust-version = "1.74" + +[lib] +name = "oi4_dnp_encoding" +path = "src/lib.rs" +crate-type = ["rlib"] + +[features] +default = ["std"] +# std implies alloc +std = ["alloc"] +alloc = [] +# Strict mode enforces uppercase hex triplets and forbids unmasked reserved ASCII. +strict = [] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg","docsrs"] + +[dev-dependencies] +proptest = "1.4" +criterion = { version = "0.4", default-features = false } + +[build-dependencies] + +[dependencies] +# No runtime dependencies by design. + +[[bench]] +name = "encode_decode" +harness = false + +[[bin]] +name = "oi4-dnp-encoding-cli" +path = "src/main.rs" +required-features = ["std"] + +[[bin]] +name = "gen_golden" +path = "tools/gen_golden.rs" +required-features = ["std","alloc"] diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..ec95744 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,40 @@ +# DESIGN + +## Ziel +Robuste, performante und spec-konforme Implementierung des OI4 / DIN SPEC 91406 DNP Maskierungsverfahrens. + +## Regeln (Kurzfassung) +- Unreserved: `A–Z a–z 0–9 - . _ ~` unverändert. +- Jedes andere ASCII-Zeichen (0x00–0x7F) wird als `,XX` (uppercase HEX) kodiert. +- Nicht-ASCII (>=0x80) wird unverändert (UTF-8) durchgereicht. +- Decoder akzeptiert standardmäßig sowohl Groß- als auch Kleinbuchstaben in Hex (`[0-9A-Fa-f]`). Encoder erzeugt immer uppercase. +- Strict-Feature: + - Nur `[0-9A-F]` erlaubt; Kleinbuchstaben -> Fehler. + - Unmaskierte nicht-unreserved ASCII -> Fehler. + +## Abweichung vs. (zukünftige) Go-Referenz +Noch keine bekannte Abweichung. Falls Unterschiede entdeckt werden, hier dokumentieren: + +| Bereich | Beschreibung | Status | +|---------|--------------|--------| +| | | | + +## Fehler-Design +`ErrorKind` minimalistisch, Position (Byte-Index) optional. Kein `unsafe`, keine Panics, kein Logging. + +## Performance-Ansatz +- ASCII Hot-Loop mit einfacher Branch-Condition. +- Vorab-Längenberechnung für `encode` zur Kapazitätsreserve. +- Zero-Alloc Pfad via `encode_into`. + +## no_std Strategie +- `#![no_std]` wenn Feature `std` fehlt. +- `alloc` Feature stellt heap-basierte APIs bereit. +- Ohne `alloc`: nur `encode_into`, `encoded_len`, `validate_dnp`. + +## Erweiterungen (Future Work) +- Streaming Decoder Iterator +- Fuzzing Targets (geplant unter `fuzz/`) +- Umfangreichere Golden-Test-Sammlung direkt aus PDF extrahiert + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a873b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 OI4 Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fd7771 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# oi4-dnp-encoding + +Rust Implementation of OI4 / DIN SPEC 91406 Digital Nameplate (DNP) encoding, decoding and validation. + +## Features + +- Zero runtime dependencies +- `no_std` support (with optional `alloc`) +- Strict mode (feature `strict`) enforcing uppercase hex and prohibiting unescaped reserved ASCII +- High performance branch-lean encoder/decoder +- Safe Rust only; no panics on valid usage + +## Encoding Summary + +Unreserved characters: `A–Z a–z 0–9 - . _ ~` stay literal. All other ASCII bytes (including comma) are escaped as `,XX` (uppercase hex). Non-ASCII Unicode is passed through verbatim. + +Examples: +``` +Space (0x20) -> ,20 +Comma (0x2C) -> ,2C +# -> ,23 +/ -> ,2F +``` + +## Basic Usage (std / alloc) +```rust +use oi4_dnp_encoding::{encode, decode}; + +let original = "Hello World!"; +let enc = encode(original); +assert_eq!(enc, "Hello,20World,21"); +let dec = decode(&enc).unwrap(); +assert_eq!(dec, original); +``` + +## Examples +Alle Beispiele liegen im Verzeichnis `examples/` und werden nicht in die Library kompiliert (kein Größen-Overhead für Nutzer). + +Schnellstart: +```bash +# Einfaches Encode/Decode/Validate +cargo run --example basic + +# Validierung & Fehlerbehandlung +cargo run --example validation + +# Strict Mode Verhalten (Großbuchstaben-Hex + Pflicht Maskierung) +cargo run --example strict --features strict + +# Heap-freies Encoding (demonstriert no_std Pfad) +cargo run --example encode_into_no_alloc +# oder wirklich ohne std: +cargo run --example encode_into_no_alloc --no-default-features +``` + +## Validation +```rust +use oi4_dnp_encoding::{validate_dnp, Rules}; + +let s = "Hello,20World,21"; // already encoded +validate_dnp(s, &Rules::default()).unwrap(); +``` + +Enable strict feature: +```bash +cargo test --features strict +``` + +## Differential Test (Go Reference) +Set environment variable `GO_DNP_REPO` to a local clone of the Go reference repo (OI4/dnp-encoder-go) and run: +```bash +cargo test -- --nocapture differential_against_go +``` +Or standalone script: +```bash +GO_DNP_REPO=/path/to/go/repo ./tools/compare_go.sh < inputs.txt +``` +Fallback: Wenn `GO_DNP_REPO` nicht gesetzt ist, klont `tools/compare_go.sh` automatisch den Branch `development` aus +``` +https://github.com/OI4/dnp-encoder-go.git +``` +(shallow clone, depth=1) in ein temporäres Verzeichnis und räumt danach auf. Voraussetzungen für den Fallback: `git` und `go` im `PATH`. + +## Golden File Generation +```bash +echo "Hello World!" | cargo run --bin gen_golden --features std,alloc +``` + +## Fuzzing +Requires nightly and libFuzzer (cargo-fuzz style not strictly necessary here). Example using libfuzzer-sys directly: +```bash +cd fuzz +cargo fuzz run decode # if integrated with cargo-fuzz (future) +``` +Current discrete fuzz targets (manual): `fuzz/fuzz_targets/decode.rs`, `validate.rs`. + +## Coverage +```bash +cargo install cargo-llvm-cov +cargo llvm-cov --all-features --html +``` + +## no_std +- Without `std`, errors do not implement `std::error::Error`. +- Without `alloc`, only `encode_into`, `encoded_len`, and `validate_dnp` are available. + +## MSRV +Minimum Supported Rust Version: 1.74 (pinned via `rust-toolchain.toml`). + +## License +MIT + +## Status +Early implementation; align with authoritative PDF specification. Any deviations vs. Go reference will be documented in `DESIGN.md`. diff --git a/benches/encode_decode.rs b/benches/encode_decode.rs new file mode 100644 index 0000000..ab3e5c8 --- /dev/null +++ b/benches/encode_decode.rs @@ -0,0 +1,32 @@ +use criterion::{criterion_group, criterion_main, Criterion, black_box}; + +#[cfg(feature = "alloc")] +fn bench_encode(c: &mut Criterion) { + use oi4_dnp_encoding::encode; + let small = "Hello World!"; + let medium = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + let large = small.repeat(512); + c.bench_function("encode_small", |b| b.iter(|| black_box(encode(small)))); + c.bench_function("encode_medium", |b| b.iter(|| black_box(encode(medium)))); + c.bench_function("encode_large", |b| b.iter(|| black_box(encode(&large)))); +} + +#[cfg(feature = "alloc")] +fn bench_decode(c: &mut Criterion) { + use oi4_dnp_encoding::{encode, decode}; + let src = "Hello World!".repeat(256); + let enc = encode(&src); + c.bench_function("decode_large", |b| b.iter(|| black_box(decode(&enc).unwrap()))); +} + +fn criterion_benchmark(c: &mut Criterion) { + #[cfg(feature = "alloc")] + { + bench_encode(c); + bench_decode(c); + } +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); + diff --git a/examples/all_in_one.rs b/examples/all_in_one.rs new file mode 100644 index 0000000..25bcff9 --- /dev/null +++ b/examples/all_in_one.rs @@ -0,0 +1,82 @@ +//! All-in-one demonstration of typical usage patterns. +//! Run: `cargo run --example all_in_one` +//! With strict feature: `cargo run --example all_in_one --features strict` +//! Without std/alloc (no heap): `cargo run --example all_in_one --no-default-features` +//! +//! This example adapts to enabled features so users can see what is available. + +use oi4_dnp_encoding::{validate_dnp, Rules}; + +#[cfg(not(feature = "alloc"))] +use oi4_dnp_encoding::{encoded_len, encode_into}; +#[cfg(feature = "alloc")] +use oi4_dnp_encoding::{encode, decode}; + +#[cfg(not(feature = "alloc"))] +use core::fmt::Write as _; + +// Small fixed buffer writer used only in no-alloc mode. +#[cfg(not(feature = "alloc"))] +struct Fixed { buf: [u8; N], pos: usize } +#[cfg(not(feature = "alloc"))] +impl Fixed { fn new() -> Self { Self { buf: [0; N], pos: 0 } } fn as_str(&self) -> &str { core::str::from_utf8(&self.buf[..self.pos]).unwrap() } } +#[cfg(not(feature = "alloc"))] +impl core::fmt::Write for Fixed { fn write_str(&mut self, s: &str) -> core::fmt::Result { let b = s.as_bytes(); if self.pos + b.len() > N { return Err(core::fmt::Error); } self.buf[self.pos..self.pos+b.len()].copy_from_slice(b); self.pos += b.len(); Ok(()) }} + +fn main() { + let input = "Hello World!"; // contains space & '!' + + // --- Encoding / Decoding --- + #[cfg(feature = "alloc")] + { + let enc = encode(input); + println!("encode -> {enc}"); + assert_eq!(enc, "Hello,20World,21"); + let dec = decode(&enc).expect("decode ok"); + assert_eq!(dec, input); + println!("decode -> {dec}"); + } + + #[cfg(not(feature = "alloc"))] + { + let needed = encoded_len(input); + assert!(needed <= 256, "adjust buffer size"); + let mut fb: Fixed<256> = Fixed::new(); + encode_into(input, &mut fb).expect("fits"); + let enc = fb.as_str(); + println!("encode (no alloc) -> {enc}"); + } + + // --- Validation (default rules) --- + let encoded_sample = "Hello,20World,21"; // canonical form + validate_dnp(encoded_sample, &Rules::default()).expect("valid default"); + println!("validate default rules OK"); + + // Enforce masking for reserved ASCII + let masking_rules = Rules { enforce_reserved_masking: true, ..Rules::default() }; + if let Err(e) = validate_dnp("Hello World!", &masking_rules) { // space & ! must be escaped + println!("expected masking error: {:?} at {:?}", e.kind(), e.position()); + } + + // --- Strict-like demonstration (independent of compile-time strict feature) --- + let strict_like = Rules::strict_like(); + if let Err(e) = validate_dnp("Hello World!", &strict_like) { + println!("strict_like unescaped error: {:?}", e.kind()); + } + + // --- Lowercase hex acceptance vs strict feature --- + #[cfg(feature = "alloc")] + { + let lower = "Hello,20World,21".to_lowercase(); // contains lowercase hex digits + let res = decode(&lower); + #[cfg(feature = "strict")] + match res { + Ok(_) => println!("unexpected lowercase accepted"), + Err(e) => println!("strict feature active -> lowercase decode error kind = {:?}", e.kind()), + } + #[cfg(not(feature = "strict"))] + println!("non-strict -> lowercase accepted -> {:?}", res.unwrap()); + } + + println!("(all_in_one example done)"); +} diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..a0107df --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,26 @@ +//! Basic encode / decode / validate usage. +//! Run: `cargo run --example basic` + +#[cfg(feature = "alloc")] +fn main() { + use oi4_dnp_encoding::{encode, decode, validate_dnp, Rules}; + + let original = "Hello World!"; // space + '!' must be escaped + let encoded = encode(original); + println!("encoded = {encoded}"); + assert_eq!(encoded, "Hello,20World,21"); + + let decoded = decode(&encoded).expect("decode should succeed"); + assert_eq!(decoded, original); + + // Validate already-encoded string (default rules: allow lowercase hex, don't enforce masking for reserved ASCII) + validate_dnp(&encoded, &Rules::default()).expect("validation should pass"); + println!("decoded = {decoded}"); +} + +#[cfg(not(feature = "alloc"))] +fn main() { + // Compile-time hint if someone tries to run with --no-default-features and without alloc. + println!("This example requires the 'alloc' feature (enabled by default).\nRun without --no-default-features."); +} + diff --git a/examples/encode_into_no_alloc.rs b/examples/encode_into_no_alloc.rs new file mode 100644 index 0000000..69819cc --- /dev/null +++ b/examples/encode_into_no_alloc.rs @@ -0,0 +1,40 @@ +//! Encode without heap allocation using `encode_into` + a fixed stack buffer. +//! Run: `cargo run --example encode_into_no_alloc` +//! Can also be built with `--no-default-features` (no std) because it only uses core APIs. + +use core::fmt::{self, Write}; +use oi4_dnp_encoding::{encode_into, encoded_len}; + +struct FixedBuf { + buf: [u8; N], + pos: usize, +} + +impl FixedBuf { + const fn new() -> Self { Self { buf: [0; N], pos: 0 } } + fn as_str(&self) -> &str { core::str::from_utf8(&self.buf[..self.pos]).unwrap() } +} + +impl Write for FixedBuf { + fn write_str(&mut self, s: &str) -> fmt::Result { + let bytes = s.as_bytes(); + if self.pos + bytes.len() > N { return Err(fmt::Error); } + self.buf[self.pos..self.pos + bytes.len()].copy_from_slice(bytes); + self.pos += bytes.len(); + Ok(()) + } +} + +fn main() { + let input = "Hello World!"; + let required = encoded_len(input); + assert!(required <= 128, "adjust FixedBuf size if this panics"); + + let mut fb: FixedBuf<128> = FixedBuf::new(); + encode_into(input, &mut fb).expect("write fits into buffer"); + + let encoded = fb.as_str(); + assert_eq!(encoded, "Hello,20World,21"); + println!("encoded (stack only) = {encoded}"); +} + diff --git a/examples/strict.rs b/examples/strict.rs new file mode 100644 index 0000000..2291b1a --- /dev/null +++ b/examples/strict.rs @@ -0,0 +1,38 @@ +//! Demonstrates behavior under the `strict` feature. +//! Run with: `cargo run --example strict --features strict` +//! (The example will intentionally show errors that only appear in strict mode.) + +#[cfg(all(feature = "alloc", feature = "strict"))] +fn main() { + use oi4_dnp_encoding::{encode, decode, validate_dnp, Rules, ErrorKind}; + + let s = "Hello World!"; + let enc = encode(s); + assert_eq!(&enc, "Hello,20World,21"); + println!("encoded strict = {enc}"); + + // Convert entire encoded string to lowercase -> should be rejected in strict mode. + let bad_lower = enc.to_lowercase(); + if bad_lower != enc { // ensure different + match decode(&bad_lower) { + Ok(_) => println!("unexpected success (lowercase accepted)"), + Err(e) => println!("expected lowercase rejection: {e:?}"), + } + } + + // Unescaped reserved ASCII should fail validation under strict-like rules. + let rules = Rules::strict_like(); + let bad_unescaped = "Hello World!"; // space and ! must be escaped + match validate_dnp(bad_unescaped, &rules) { + Ok(_) => println!("unexpected success (unescaped reserved)"), + Err(e) => match e.kind() { + ErrorKind::UnescapedReservedAscii(ch) => println!("expected unescaped reserved error for '{ch}' at {:?}", e.position()), + other => println!("unexpected error kind: {other:?}"), + } + } +} + +#[cfg(not(all(feature = "alloc", feature = "strict")))] +fn main() { + println!("Enable features: --features strict (alloc implied by default std feature).\nExample: cargo run --example strict --features strict"); +} diff --git a/examples/validation.rs b/examples/validation.rs new file mode 100644 index 0000000..60c41e1 --- /dev/null +++ b/examples/validation.rs @@ -0,0 +1,22 @@ +//! Demonstrates validating an already encoded string and reacting to errors. +//! Run: `cargo run --example validation` + +use oi4_dnp_encoding::{validate_dnp, Rules, ErrorKind}; + +fn main() { + let encoded_ok = "Hello,20World,21"; + validate_dnp(encoded_ok, &Rules::default()).expect("should validate"); + println!("validated OK: {encoded_ok}"); + + // Example with an illegal unescaped reserved ASCII (space) under enforced masking rules + let rules = Rules { enforce_reserved_masking: true, allow_lowercase_hex: true }; + let bad = "Hello World"; // space should be escaped as ,20 under these rules + match validate_dnp(bad, &rules) { + Ok(_) => println!("unexpected success"), + Err(e) => match e.kind() { // pattern match by kind + ErrorKind::UnescapedReservedAscii(ch) => println!("expected error: unescaped reserved ASCII '{ch}' at pos {:?}", e.position()), + other => println!("other validation error: {other:?}"), + } + } +} + diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..b69b240 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "oi4-dnp-encoding-fuzz" +edition = "2021" +publish = false + +[dependencies] +libfuzzer-sys = { version = "0.4", features = ["arbitrary-alloc"] } + +[dependencies.oi4-dnp-encoding] +path = ".." +features = ["std","alloc"] + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 + +[workspace] + diff --git a/fuzz/fuzz_targets/decode.rs b/fuzz/fuzz_targets/decode.rs new file mode 100644 index 0000000..4a04d1b --- /dev/null +++ b/fuzz/fuzz_targets/decode.rs @@ -0,0 +1,17 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use oi4_dnp_encoding::{decode, encode}; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = core::str::from_utf8(data) { + // Try decode; it may fail, that's fine. + if let Ok(decoded) = decode(s) { + // Re-encode and ensure stable roundtrip through decode again. + let re = encode(&decoded); + if let Ok(decoded2) = decode(&re) { + debug_assert_eq!(decoded, decoded2); + } + } + } +}); + diff --git a/fuzz/fuzz_targets/validate.rs b/fuzz/fuzz_targets/validate.rs new file mode 100644 index 0000000..3b6bb05 --- /dev/null +++ b/fuzz/fuzz_targets/validate.rs @@ -0,0 +1,13 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use oi4_dnp_encoding::{validate_dnp, Rules}; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = core::str::from_utf8(data) { + let _ = validate_dnp(s, &Rules::default()); + // Also attempt stricter rule (not the strict feature, just runtime rule) + let strict_like = Rules { enforce_reserved_masking: true, allow_lowercase_hex: false }; + let _ = validate_dnp(s, &strict_like); + } +}); + diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8bb1f26 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] + diff --git a/src/decode.rs b/src/decode.rs new file mode 100644 index 0000000..efbe772 --- /dev/null +++ b/src/decode.rs @@ -0,0 +1,60 @@ +use crate::error::{Error, ErrorKind}; +#[cfg(feature = "strict")] use crate::encode::is_unreserved; +#[cfg(feature = "alloc")] use alloc::string::String; + +#[inline] +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(10 + (b - b'a')), + b'A'..=b'F' => Some(10 + (b - b'A')), + _ => None, + } +} + +#[cfg(feature = "alloc")] +pub fn decode(input: &str) -> Result { + #[cfg(not(feature = "strict"))] + { + if !input.as_bytes().contains(&b',') { // fast path only in non-strict mode + return Ok(String::from(input)); + } + } + let mut out = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b',' { // escape expected + if i + 2 >= bytes.len() { return Err(Error::new(ErrorKind::LoneComma, Some(i))); } + let h1 = bytes[i+1]; + let h2 = bytes[i+2]; + #[cfg(feature = "strict")] + { + if (h1 >= b'a' && h1 <= b'f') || (h2 >= b'a' && h2 <= b'f') { + return Err(Error::new(ErrorKind::LowercaseHexInStrict, Some(i))); + } + } + let v1 = hex_val(h1).ok_or_else(|| Error::new(ErrorKind::InvalidHexDigit(h1 as char), Some(i+1)))?; + let v2 = hex_val(h2).ok_or_else(|| Error::new(ErrorKind::InvalidHexDigit(h2 as char), Some(i+2)))?; + let val = (v1 << 4) | v2; + out.push(val as char); + i += 3; + } else { + // Copy next UTF-8 char verbatim (could be multibyte) + let s = &input[i..]; + let ch = s.chars().next().unwrap(); + #[cfg(feature = "strict")] + { + if ch.is_ascii() { + let bch = ch as u8; + if !is_unreserved(bch) { + return Err(Error::new(ErrorKind::UnescapedReservedAscii(ch), Some(i))); + } + } + } + out.push(ch); + i += ch.len_utf8(); + } + } + Ok(out) +} diff --git a/src/encode.rs b/src/encode.rs new file mode 100644 index 0000000..0e13cce --- /dev/null +++ b/src/encode.rs @@ -0,0 +1,65 @@ +use core::fmt; + +#[inline] +pub(crate) const fn is_unreserved(b: u8) -> bool { + matches!(b, + b'A'..=b'Z' | + b'a'..=b'z' | + b'0'..=b'9' | + b'-' | b'.' | b'_' | b'~' + ) +} + +const HEX_UPPER: &[u8;16] = b"0123456789ABCDEF"; + +/// Compute encoded length without allocating. +pub fn encoded_len(input: &str) -> usize { + let bytes = input.as_bytes(); + let mut i = 0usize; + let mut len = 0usize; + while i < bytes.len() { + let b = bytes[i]; + if b < 0x80 { // ASCII + if is_unreserved(b) { len += 1; } else { len += 3; } + i += 1; + } else { // multi-byte UTF-8 sequence – copy verbatim + // Determine sequence length from first byte (UTF-8 invariant; input is &str) + let seq_len = if b & 0b1110_0000 == 0b1100_0000 {2} + else if b & 0b1111_0000 == 0b1110_0000 {3} + else {4}; + len += seq_len; + i += seq_len; + } + } + len +} + +/// Encode into provided writer (zero-allocation path other than writer itself). +pub fn encode_into(input: &str, out: &mut impl fmt::Write) -> fmt::Result { + let mut buf = [0u8;4]; + for ch in input.chars() { + if ch.is_ascii() { + let b = ch as u8; + if is_unreserved(b) { + out.write_char(ch)?; + } else { + let hi = HEX_UPPER[(b >> 4) as usize] as char; + let lo = HEX_UPPER[(b & 0x0F) as usize] as char; + out.write_char(',')?; + out.write_char(hi)?; + out.write_char(lo)?; + } + } else { + let s = ch.encode_utf8(&mut buf); + out.write_str(s)?; + } + } + Ok(()) +} + +#[cfg(feature = "alloc")] +pub fn encode(input: &str) -> alloc::string::String { + let mut s = alloc::string::String::with_capacity(encoded_len(input)); + encode_into(input, &mut s).expect("write to String cannot fail"); + s +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..dca2db8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,42 @@ +#![allow(clippy::derive_partial_eq_without_eq)] +use core::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub struct Error { + kind: ErrorKind, + pos: Option, +} + +impl Error { + pub const fn new(kind: ErrorKind, pos: Option) -> Self { Self { kind, pos } } + pub const fn kind(&self) -> &ErrorKind { &self.kind } + pub const fn position(&self) -> Option { self.pos } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ErrorKind { + InvalidHexDigit(char), + LoneComma, + LowercaseHexInStrict, + UnescapedReservedAscii(char), +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHexDigit(c) => write!(f, "invalid hex digit '{c}'"), + Self::LoneComma => write!(f, "lone comma without two hex digits"), + Self::LowercaseHexInStrict => write!(f, "lowercase hex not allowed in strict mode"), + Self::UnescapedReservedAscii(c) => write!(f, "unescaped reserved ASCII '{c}'"), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(p) = self.pos { write!(f, "{} at position {p}", self.kind) } else { write!(f, "{}", self.kind) } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7d878e7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,43 @@ +#![cfg_attr(not(feature = "std"), no_std)] +//! OI4 / DIN SPEC 91406 Digital Nameplate (DNP) encoding / decoding / validation. +//! +//! Features: +//! - std (default): Enables std::error::Error integration; implies `alloc`. +//! - alloc: Provides heap-backed APIs (e.g. `encode` -> String, `decode`). +//! - strict: Enforces uppercase hex escapes and forbids unescaped reserved ASCII. +//! +//! Encoding rules summary: +//! * Unreserved characters (ALPHA / DIGIT / '-' / '.' / '_' / '~') stay literal. +//! * Every other ASCII byte (including the comma itself) MUST be represented as `,XX` (uppercase hex) when produced by the encoder. +//! * Non-ASCII Unicode stays verbatim (its UTF-8 bytes are not individually re-escaped), unless future spec revisions say otherwise. +//! * Decoder (default mode) accepts lowercase hex in escape triplets; encoder always outputs uppercase. +//! * Strict mode tightens validation (see feature `strict`). +//! +//! No panics on valid usage; no unsafe in production code. +//! +//! ```rust +//! use oi4_dnp_encoding::encode; +//! # #[cfg(feature="alloc")] +//! # { +//! let s = "Hello World!"; // space & exclamation must be escaped +//! let enc = encode(s); +//! assert_eq!(enc, "Hello,20World,21"); +//! # } +//! ``` + +#[cfg(feature = "alloc")] +extern crate alloc; + +pub mod encode; +pub mod decode; +pub mod validate; +mod error; + +pub use crate::encode::{encode_into, encoded_len}; +#[cfg(feature = "alloc")] +pub use crate::encode::encode; +#[cfg(feature = "alloc")] +pub use crate::decode::decode; +pub use crate::validate::{validate_dnp, Rules}; +pub use crate::error::{Error, ErrorKind}; + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cfce04d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,44 @@ +#![cfg(feature = "std")] +use std::io::{self, Read}; +use std::process::ExitCode; + +fn read_all_stdin() -> io::Result { + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf)?; + if buf.ends_with('\n') { buf.pop(); if buf.ends_with('\r') { buf.pop(); } } + Ok(buf) +} + +fn usage() { + eprintln!("Usage: oi4-dnp-encoding-cli [TEXT]\nIf TEXT omitted, reads from stdin."); +} + +fn main() -> ExitCode { + let mut args = std::env::args().skip(1); + let cmd = match args.next() { Some(c) => c, None => { usage(); return ExitCode::from(2); } }; + let data = match args.next() { Some(rest) => rest, None => match read_all_stdin() { Ok(s) => s, Err(e) => { eprintln!("stdin error: {e}"); return ExitCode::from(3);} } }; + match cmd.as_str() { + "encode" => { + #[cfg(feature = "alloc")] + { + let out = oi4_dnp_encoding::encode(&data); + println!("{out}"); + ExitCode::SUCCESS + } + #[cfg(not(feature = "alloc"))] + { eprintln!("alloc feature required for encode CLI"); ExitCode::from(4) } + } + "decode" => { + #[cfg(feature = "alloc")] + { + match oi4_dnp_encoding::decode(&data) { + Ok(out) => { println!("{out}"); ExitCode::SUCCESS } + Err(e) => { eprintln!("decode error: {e}"); ExitCode::from(5) } + } + } + #[cfg(not(feature = "alloc"))] + { eprintln!("alloc feature required for decode CLI"); ExitCode::from(4) } + } + _ => { usage(); ExitCode::from(2) } + } +} diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..d4a6e0f --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,92 @@ +use crate::encode::is_unreserved; +use crate::error::{Error, ErrorKind}; + +/// Validation rule set (runtime adjustable apart from compile-time `strict` feature). +#[derive(Debug, Clone, Copy, Default)] +pub struct Rules { + /// If true, any ASCII char outside unreserved set must appear only as encoded triplet ",XX". + pub enforce_reserved_masking: bool, + /// Accept lowercase hex inside escapes when strict feature not active. (Default true) + pub allow_lowercase_hex: bool, +} + +impl Rules { + pub const fn strict_like() -> Self { + Self { + enforce_reserved_masking: true, + allow_lowercase_hex: false, + } + } +} + +/// Validate an encoded DNP string against masking rules. +/// Does not attempt semantic validation beyond escape formatting & reserved usage. +pub fn validate_dnp(input: &str, rules: &Rules) -> Result<(), Error> { + let bytes = input.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let b = bytes[i]; + if b == b',' { + // Expect two hex digits + if i + 2 >= bytes.len() { + return Err(Error::new(ErrorKind::LoneComma, Some(i))); + } + let h1 = bytes[i + 1]; + let h2 = bytes[i + 2]; + // strict feature -> forbid lowercase + #[cfg(feature = "strict")] + { + if has_lowercase_hex(h1, h2) { + return Err(Error::new(ErrorKind::LowercaseHexInStrict, Some(i))); + } + } + + if !rules.allow_lowercase_hex && has_lowercase_hex(h1, h2) { + return Err(Error::new(ErrorKind::LowercaseHexInStrict, Some(i))); + } + + if !is_hex(h1) { + return Err(Error::new( + ErrorKind::InvalidHexDigit(h1 as char), + Some(i + 1), + )); + } + if !is_hex(h2) { + return Err(Error::new( + ErrorKind::InvalidHexDigit(h2 as char), + Some(i + 2), + )); + } + i += 3; + continue; + } + if b < 0x80 { + // ASCII plain char + if rules.enforce_reserved_masking && !is_unreserved(b) { + return Err(Error::new( + ErrorKind::UnescapedReservedAscii(b as char), + Some(i), + )); + } + i += 1; + } else { + // UTF-8 multibyte char boundary + // Skip full char + let rest = &input[i..]; + let ch = rest.chars().next().unwrap(); + i += ch.len_utf8(); + } + } + Ok(()) +} + +#[inline] +const fn is_hex(b: u8) -> bool { + //matches!(b, b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F') + b.is_ascii_hexdigit() +} + +#[inline] +fn has_lowercase_hex(h1: u8, h2: u8) -> bool { + (b'a'..=b'f').contains(&h1) || (b'a'..=b'f').contains(&h2) +} diff --git a/tests/test_cli.rs b/tests/test_cli.rs new file mode 100644 index 0000000..87a95df --- /dev/null +++ b/tests/test_cli.rs @@ -0,0 +1,49 @@ +#![cfg(all(feature="std", feature="alloc"))] +use std::process::{Command, Stdio}; +use std::io::Write; + +fn bin() -> std::path::PathBuf { std::path::PathBuf::from(env!("CARGO_BIN_EXE_oi4-dnp-encoding-cli")) } + +#[test] +fn cli_usage_no_args() { + let out = Command::new(bin()).stderr(Stdio::piped()).stdout(Stdio::piped()).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("Usage:")); +} + +#[test] +fn cli_encode_arg() { + let out = Command::new(bin()).arg("encode").arg("Hello World!").stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "Hello,20World,21"); +} + +#[test] +fn cli_encode_stdin() { + let mut child = Command::new(bin()).arg("encode").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + child.stdin.as_mut().unwrap().write_all(b"Space -> \n").unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains(",20")); +} + +#[test] +fn cli_decode_arg() { + let out = Command::new(bin()).arg("decode").arg("Hello,20World,21").stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "Hello World!"); +} + +#[test] +fn cli_decode_error() { + let out = Command::new(bin()).arg("decode").arg(",G0").stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); + assert_eq!(out.status.code(), Some(5)); + assert!(String::from_utf8_lossy(&out.stderr).contains("decode error:")); +} + +#[test] +fn cli_unknown_command() { + let out = Command::new(bin()).arg("bogus").stdout(Stdio::null()).stderr(Stdio::piped()).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); +} + diff --git a/tests/test_decode.rs b/tests/test_decode.rs new file mode 100644 index 0000000..7fb960d --- /dev/null +++ b/tests/test_decode.rs @@ -0,0 +1,74 @@ +#![cfg(feature = "alloc")] +use oi4_dnp_encoding::{decode, encode}; + +// Property & unit tests for decode + +#[test] +fn decode_basic_roundtrip() { + let s = "Hello World!"; + let enc = encode(s); + let dec = decode(&enc).unwrap(); + assert_eq!(dec, s); +} + +#[test] +fn decode_unicode_passthrough() { + let s = "äöüΩ🙂"; + let enc = encode(s); + assert_eq!(enc, s); + assert_eq!(decode(&enc).unwrap(), s); +} + +#[test] +fn decode_invalid_hex_digit() { + assert!(decode(",2G").is_err()); + assert!(decode(",G2").is_err()); +} + +#[test] +fn decode_lone_or_short_escape() { + assert!(decode(",").is_err()); + assert!(decode(",2").is_err()); +} + +#[test] +fn decode_escape_single_ascii() { + assert_eq!(decode(",41").unwrap(), "A"); +} + +#[cfg(not(feature = "strict"))] +#[test] +fn decode_lowercase_hex_triplets() { + assert_eq!(decode(",61,62,63").unwrap(), "abc"); +} + +#[cfg(feature = "strict" )] +#[test] +fn decode_lowercase_rejected_strict() { + assert!(decode(",61").is_err()); +} + +#[test] +fn decode_all_ascii_triplets() { + for b in 0x00u8..=0x7Fu8 { + let hi = b >> 4; let lo = b & 0x0F; + let hex = |n: u8| -> char { match n {0..=9 => (b'0'+n) as char, 10..=15 => (b'A'+(n-10)) as char, _ => '?' } }; + let triplet = format!(",{}{}", hex(hi), hex(lo)); + let out = decode(&triplet).unwrap(); + assert_eq!(out.chars().next().unwrap() as u8, b, "Mismatch for {triplet}"); + } +} + +// Property test for roundtrip (limited length) +#[cfg(feature="std")] +mod prop { + use super::*; use proptest::prelude::*; + proptest! { + #[test] + fn prop_roundtrip_random(data in "(?s).{0,256}") { + let enc = encode(&data); + let dec = decode(&enc).unwrap(); + prop_assert_eq!(dec, data); + } + } +} diff --git a/tests/test_encode.rs b/tests/test_encode.rs new file mode 100644 index 0000000..c7c3dc1 --- /dev/null +++ b/tests/test_encode.rs @@ -0,0 +1,97 @@ +#![cfg(feature="alloc")] +use oi4_dnp_encoding::{encode, encode_into, encoded_len, decode}; +use std::process::Stdio; + +struct FailingWriter { fail_after: usize, writes: usize } +impl core::fmt::Write for FailingWriter { fn write_str(&mut self, s: &str) -> core::fmt::Result { let _ = s; if self.writes==self.fail_after { return Err(core::fmt::Error); } self.writes+=1; Ok(()) } } + +#[test] +fn encode_basic_and_reserved() { + assert_eq!(encode("ABC"), "ABC"); + assert_eq!(encode("Hello World!"), "Hello,20World,21"); + assert_eq!(encode(",#:/?"), ",2C,23,3A,2F,3F"); +} + +#[test] +fn encode_unicode_passthrough() { + let s = "äöüΩ🙂"; assert_eq!(encode(s), s); +} + +#[test] +fn encoded_len_matches_output() { + let s = "Hello World!#"; let out = encode(s); assert_eq!(out.len(), encoded_len(s)); +} + +#[test] +fn encode_into_matches_encode() { + let s = "Hello World!#Test"; let expected = encode(s); let mut buf = String::with_capacity(expected.len()); encode_into(s, &mut buf).unwrap(); assert_eq!(buf, expected); +} + +#[test] +fn encode_into_error_path() { + let mut w = FailingWriter { fail_after: 0, writes: 0 }; let _ = encode_into("Hello", &mut w).err().expect("fmt::Error expected"); +} + +#[test] +fn full_ascii_rule_check() { + // Build ASCII except CR/LF + let mut src = Vec::new(); for b in 0x01u8..=0x7Fu8 { if b==b'\n'||b==b'\r' {continue;} src.push(b);} let s = String::from_utf8(src).unwrap(); let enc = encode(&s); + // commas form triplets + let bytes = enc.as_bytes(); let mut i=0; while i 0); + } +} + diff --git a/tests/test_validate.rs b/tests/test_validate.rs new file mode 100644 index 0000000..858d3e3 --- /dev/null +++ b/tests/test_validate.rs @@ -0,0 +1,51 @@ +#![cfg(feature = "alloc")] +use oi4_dnp_encoding::{validate_dnp, Rules}; + +#[test] +fn validate_basic_ok() { validate_dnp("ABC123-._~", &Rules::default()).unwrap(); } + +#[test] +fn validate_escape_ok() { validate_dnp(",2C", &Rules::default()).unwrap(); } + +#[test] +fn validate_lone_comma() { assert!(validate_dnp(",", &Rules::default()).is_err()); } + +#[test] +fn validate_short_escape() { assert!(validate_dnp(",2", &Rules::default()).is_err()); } + +#[test] +fn validate_invalid_hex_digit() { assert!(validate_dnp(",2G", &Rules::default()).is_err()); } + +#[test] +fn validate_unescaped_reserved_when_enforce() { + let r = Rules { enforce_reserved_masking: true, allow_lowercase_hex: true }; + assert!(validate_dnp(" ", &r).is_err()); + assert!(validate_dnp(",", &r).is_err()); +} + +#[cfg(not(feature = "strict"))] +#[test] +fn validate_lowercase_allowed_runtime_rule() { + let r = Rules { enforce_reserved_masking: false, allow_lowercase_hex: true }; + validate_dnp(",2c", &r).unwrap(); +} + +#[test] +fn validate_lowercase_rejected_rule_false() { + let r = Rules { enforce_reserved_masking: false, allow_lowercase_hex: false }; + assert!(validate_dnp(",2c", &r).is_err()); +} + +#[cfg(feature = "strict")] +#[test] +fn validate_lowercase_rejected_strict_even_if_rule_allows() { + let r = Rules { enforce_reserved_masking: false, allow_lowercase_hex: true }; + assert!(validate_dnp(",2c", &r).is_err()); +} + +#[test] +fn validate_unicode_passthrough() { + let r = Rules { enforce_reserved_masking: true, allow_lowercase_hex: false }; + validate_dnp("äöΩ🙂", &r).unwrap(); +} + diff --git a/tools/compare_go.sh b/tools/compare_go.sh new file mode 100644 index 0000000..ba78607 --- /dev/null +++ b/tools/compare_go.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail +# compare_go.sh +# Compares Rust encoder output with Go reference implementation. +# Requirements: +# - go installed +# - git (if automatic clone is needed) +# - Rust binary (cargo build) available +# Behavior: +# If GO_DNP_REPO is unset, the script clones the development branch of the +# official repository (depth=1) into a temporary directory. +# Usage: +# ./tools/compare_go.sh < testcases.txt +# Each line is raw input for both encoders. + +DEFAULT_GO_GIT_URL="https://github.com/OI4/dnp-encoder-go.git" # 'tree/development' web URL is not a clone URL. +GO_BRANCH="development" + +CLEANUP_CLONE=0 +if [[ -z "${GO_DNP_REPO:-}" ]]; then + if ! command -v git >/dev/null; then + echo "git required for automatic clone fallback" >&2 + exit 2 + fi + echo "[info] GO_DNP_REPO not set – cloning ${DEFAULT_GO_GIT_URL} (branch ${GO_BRANCH})" >&2 + TMP_DIR="$(mktemp -d)" + git clone --depth 1 -b "${GO_BRANCH}" "${DEFAULT_GO_GIT_URL}" "${TMP_DIR}" >&2 + GO_DNP_REPO="${TMP_DIR}" + CLEANUP_CLONE=1 +fi + +if ! command -v go >/dev/null; then + echo "go toolchain not found" >&2 + exit 3 +fi + +# Build Go reference (assumes a main producing encoder; adjust path if needed) +pushd "${GO_DNP_REPO}" >/dev/null +GO_BIN="$(mktemp)" +go build -o "${GO_BIN}" ./... >&2 || { echo "go build failed" >&2; exit 4; } +popd >/dev/null + +# Build Rust CLI +cargo build --quiet --features std,alloc +RUST_BIN=target/debug/oi4-dnp-encoding-cli + +fail=0 +while IFS= read -r line; do + rust_out=$(echo -n "${line}" | "${RUST_BIN}" encode) + go_out=$(echo -n "${line}" | "${GO_BIN}" encode 2>/dev/null || true) + if [[ "${rust_out}" != "${go_out}" ]]; then + echo "DIFF: input='${line}' rust='${rust_out}' go='${go_out}'" >&2 + fail=1 + fi +done + +rm -f "${GO_BIN}" +if [[ ${CLEANUP_CLONE} -eq 1 ]]; then + rm -rf "${GO_DNP_REPO}" +fi +exit ${fail} diff --git a/tools/gen_golden.rs b/tools/gen_golden.rs new file mode 100644 index 0000000..ac5fa42 --- /dev/null +++ b/tools/gen_golden.rs @@ -0,0 +1,23 @@ +// gen_golden.rs +// Reads lines from stdin and prints: \t +// Usage: cargo run --bin gen_golden < inputs.txt > golden.tsv + +use std::io::{self, BufRead}; + +fn main() { + #[cfg(not(feature = "alloc"))] + { + eprintln!("alloc feature required"); + std::process::exit(1); + } + #[cfg(feature = "alloc")] + { + use oi4_dnp_encoding::encode; + for line in io::stdin().lock().lines() { + let l = line.unwrap(); + let enc = encode(&l); + println!("{}\t{}", l, enc); + } + } +} + From e345cb8c2ccc52d53a5ee8b5217a83eef39b57ae Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 16 Sep 2025 15:06:05 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20Refactor=20code=20and=20update?= =?UTF-8?q?=20dependencies=20for=20better=20performance=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated `.gitignore` to include IntelliJ and Mac specific files. - Refactored `all_in_one.rs`, `basic.rs`, and other source files for improved readability and consistency. - Updated `Cargo.toml` and `Cargo.lock` to reflect new dependency versions. - Enhanced error handling in `decode.rs` and `encode.rs`. - Improved CLI usage messages in `main.rs` for better user experience. - Added tests for stricter validation in `test_validate.rs`. These changes aim to streamline the codebase and enhance maintainability! 💪 --- .gitignore | 11 ++- Cargo.lock | 119 +++++++------------------------ Cargo.toml | 10 +-- benches/encode_decode.rs | 9 +-- examples/all_in_one.rs | 54 +++++++++++--- examples/basic.rs | 3 +- examples/encode_into_no_alloc.rs | 16 +++-- examples/strict.rs | 12 ++-- examples/validation.rs | 18 +++-- src/decode.rs | 28 +++++--- src/encode.rs | 26 ++++--- src/error.rs | 18 +++-- src/lib.rs | 13 ++-- src/main.rs | 55 +++++++++++--- tests/test_cli.rs | 66 +++++++++++++---- tests/test_decode.rs | 27 +++++-- tests/test_encode.rs | 118 +++++++++++++++++++++++++----- tests/test_error.rs | 27 +++++-- tests/test_validate.rs | 46 +++++++++--- tools/gen_golden.rs | 1 - 20 files changed, 453 insertions(+), 224 deletions(-) diff --git a/.gitignore b/.gitignore index 54b1748..dff3f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ target # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ -/.idea/ -/dnp-encoder-rust.iml + +# IntelliJ # +*.iml +.idea/* + +# Mac # +.DS_Store +q \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ed0f2c1..78f66f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,15 +18,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] -name = "atty" -version = "0.2.14" +name = "anstyle" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "autocfg" @@ -49,12 +44,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.9.4" @@ -102,44 +91,45 @@ dependencies = [ [[package]] name = "clap" -version = "3.2.25" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ - "bitflags 1.3.2", - "clap_lex", - "indexmap", - "textwrap", + "clap_builder", ] [[package]] -name = "clap_lex" -version = "0.2.4" +name = "clap_builder" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ - "os_str_bytes", + "anstyle", + "clap_lex", ] +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + [[package]] name = "criterion" -version = "0.4.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c76e09c1aae2bc52b3d2f29e13c6572553b30c4aa1b8a49fd70de6412654cb" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" dependencies = [ "anes", - "atty", "cast", "ciborium", "clap", "criterion-plot", "itertools", - "lazy_static", "num-traits", "oorandom", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -147,9 +137,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" dependencies = [ "cast", "itertools", @@ -210,36 +200,11 @@ dependencies = [ "crunchy", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown", -] - [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -303,12 +268,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "os_str_bytes" -version = "6.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -335,7 +294,7 @@ checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.4", + "bitflags", "lazy_static", "num-traits", "rand", @@ -441,7 +400,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -542,12 +501,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" - [[package]] name = "tinytemplate" version = "1.2.1" @@ -607,22 +560,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -632,12 +569,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 64eef06..df2d9c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,12 +6,14 @@ authors = ["OI4 Contributors"] license = "MIT" description = "OI4 / DIN SPEC 91406 Digital Nameplate (DNP) encoding/decoding/validation in Rust" repository = "https://github.com/OI4/dnp-encoder-rust" -homepage = "https://github.com/OI4dnp-encoder-rust" -documentation = "https://github.com/OI4dnp-encoder-rust" -keywords = ["oi4","dnp","encoding", "oec"] +homepage = "https://github.com/OI4/dnp-encoder-rust" +documentation = "https://docs.rs/oi4-dnp-encoding" +keywords = ["oi4","dnp","encoding","oec"] categories = ["encoding","parser-implementations"] readme = "README.md" rust-version = "1.74" +# Keep published crate lean (examples & benches are small, keep them; exclude tooling & CI only): +exclude = ["fuzz","tools",".github","target","dnp-encoder-rust.iml"] [lib] name = "oi4_dnp_encoding" @@ -32,7 +34,7 @@ rustdoc-args = ["--cfg","docsrs"] [dev-dependencies] proptest = "1.4" -criterion = { version = "0.4", default-features = false } +criterion = { version = "0.7.0", default-features = false } [build-dependencies] diff --git a/benches/encode_decode.rs b/benches/encode_decode.rs index ab3e5c8..e663a17 100644 --- a/benches/encode_decode.rs +++ b/benches/encode_decode.rs @@ -1,4 +1,4 @@ -use criterion::{criterion_group, criterion_main, Criterion, black_box}; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; #[cfg(feature = "alloc")] fn bench_encode(c: &mut Criterion) { @@ -13,10 +13,12 @@ fn bench_encode(c: &mut Criterion) { #[cfg(feature = "alloc")] fn bench_decode(c: &mut Criterion) { - use oi4_dnp_encoding::{encode, decode}; + use oi4_dnp_encoding::{decode, encode}; let src = "Hello World!".repeat(256); let enc = encode(&src); - c.bench_function("decode_large", |b| b.iter(|| black_box(decode(&enc).unwrap()))); + c.bench_function("decode_large", |b| { + b.iter(|| black_box(decode(&enc).unwrap())) + }); } fn criterion_benchmark(c: &mut Criterion) { @@ -29,4 +31,3 @@ fn criterion_benchmark(c: &mut Criterion) { criterion_group!(benches, criterion_benchmark); criterion_main!(benches); - diff --git a/examples/all_in_one.rs b/examples/all_in_one.rs index 25bcff9..7e3ffb7 100644 --- a/examples/all_in_one.rs +++ b/examples/all_in_one.rs @@ -7,21 +7,44 @@ use oi4_dnp_encoding::{validate_dnp, Rules}; -#[cfg(not(feature = "alloc"))] -use oi4_dnp_encoding::{encoded_len, encode_into}; #[cfg(feature = "alloc")] -use oi4_dnp_encoding::{encode, decode}; +use oi4_dnp_encoding::{decode, encode}; +#[cfg(not(feature = "alloc"))] +use oi4_dnp_encoding::{encode_into, encoded_len}; #[cfg(not(feature = "alloc"))] use core::fmt::Write as _; // Small fixed buffer writer used only in no-alloc mode. #[cfg(not(feature = "alloc"))] -struct Fixed { buf: [u8; N], pos: usize } +struct Fixed { + buf: [u8; N], + pos: usize, +} #[cfg(not(feature = "alloc"))] -impl Fixed { fn new() -> Self { Self { buf: [0; N], pos: 0 } } fn as_str(&self) -> &str { core::str::from_utf8(&self.buf[..self.pos]).unwrap() } } +impl Fixed { + fn new() -> Self { + Self { + buf: [0; N], + pos: 0, + } + } + fn as_str(&self) -> &str { + core::str::from_utf8(&self.buf[..self.pos]).unwrap() + } +} #[cfg(not(feature = "alloc"))] -impl core::fmt::Write for Fixed { fn write_str(&mut self, s: &str) -> core::fmt::Result { let b = s.as_bytes(); if self.pos + b.len() > N { return Err(core::fmt::Error); } self.buf[self.pos..self.pos+b.len()].copy_from_slice(b); self.pos += b.len(); Ok(()) }} +impl core::fmt::Write for Fixed { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + let b = s.as_bytes(); + if self.pos + b.len() > N { + return Err(core::fmt::Error); + } + self.buf[self.pos..self.pos + b.len()].copy_from_slice(b); + self.pos += b.len(); + Ok(()) + } +} fn main() { let input = "Hello World!"; // contains space & '!' @@ -53,9 +76,17 @@ fn main() { println!("validate default rules OK"); // Enforce masking for reserved ASCII - let masking_rules = Rules { enforce_reserved_masking: true, ..Rules::default() }; - if let Err(e) = validate_dnp("Hello World!", &masking_rules) { // space & ! must be escaped - println!("expected masking error: {:?} at {:?}", e.kind(), e.position()); + let masking_rules = Rules { + enforce_reserved_masking: true, + ..Rules::default() + }; + if let Err(e) = validate_dnp("Hello World!", &masking_rules) { + // space & ! must be escaped + println!( + "expected masking error: {:?} at {:?}", + e.kind(), + e.position() + ); } // --- Strict-like demonstration (independent of compile-time strict feature) --- @@ -72,7 +103,10 @@ fn main() { #[cfg(feature = "strict")] match res { Ok(_) => println!("unexpected lowercase accepted"), - Err(e) => println!("strict feature active -> lowercase decode error kind = {:?}", e.kind()), + Err(e) => println!( + "strict feature active -> lowercase decode error kind = {:?}", + e.kind() + ), } #[cfg(not(feature = "strict"))] println!("non-strict -> lowercase accepted -> {:?}", res.unwrap()); diff --git a/examples/basic.rs b/examples/basic.rs index a0107df..e1df172 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -3,7 +3,7 @@ #[cfg(feature = "alloc")] fn main() { - use oi4_dnp_encoding::{encode, decode, validate_dnp, Rules}; + use oi4_dnp_encoding::{decode, encode, validate_dnp, Rules}; let original = "Hello World!"; // space + '!' must be escaped let encoded = encode(original); @@ -23,4 +23,3 @@ fn main() { // Compile-time hint if someone tries to run with --no-default-features and without alloc. println!("This example requires the 'alloc' feature (enabled by default).\nRun without --no-default-features."); } - diff --git a/examples/encode_into_no_alloc.rs b/examples/encode_into_no_alloc.rs index 69819cc..d8631f4 100644 --- a/examples/encode_into_no_alloc.rs +++ b/examples/encode_into_no_alloc.rs @@ -11,14 +11,23 @@ struct FixedBuf { } impl FixedBuf { - const fn new() -> Self { Self { buf: [0; N], pos: 0 } } - fn as_str(&self) -> &str { core::str::from_utf8(&self.buf[..self.pos]).unwrap() } + const fn new() -> Self { + Self { + buf: [0; N], + pos: 0, + } + } + fn as_str(&self) -> &str { + core::str::from_utf8(&self.buf[..self.pos]).unwrap() + } } impl Write for FixedBuf { fn write_str(&mut self, s: &str) -> fmt::Result { let bytes = s.as_bytes(); - if self.pos + bytes.len() > N { return Err(fmt::Error); } + if self.pos + bytes.len() > N { + return Err(fmt::Error); + } self.buf[self.pos..self.pos + bytes.len()].copy_from_slice(bytes); self.pos += bytes.len(); Ok(()) @@ -37,4 +46,3 @@ fn main() { assert_eq!(encoded, "Hello,20World,21"); println!("encoded (stack only) = {encoded}"); } - diff --git a/examples/strict.rs b/examples/strict.rs index 2291b1a..f980327 100644 --- a/examples/strict.rs +++ b/examples/strict.rs @@ -4,7 +4,7 @@ #[cfg(all(feature = "alloc", feature = "strict"))] fn main() { - use oi4_dnp_encoding::{encode, decode, validate_dnp, Rules, ErrorKind}; + use oi4_dnp_encoding::{decode, encode, validate_dnp, ErrorKind, Rules}; let s = "Hello World!"; let enc = encode(s); @@ -13,7 +13,8 @@ fn main() { // Convert entire encoded string to lowercase -> should be rejected in strict mode. let bad_lower = enc.to_lowercase(); - if bad_lower != enc { // ensure different + if bad_lower != enc { + // ensure different match decode(&bad_lower) { Ok(_) => println!("unexpected success (lowercase accepted)"), Err(e) => println!("expected lowercase rejection: {e:?}"), @@ -26,9 +27,12 @@ fn main() { match validate_dnp(bad_unescaped, &rules) { Ok(_) => println!("unexpected success (unescaped reserved)"), Err(e) => match e.kind() { - ErrorKind::UnescapedReservedAscii(ch) => println!("expected unescaped reserved error for '{ch}' at {:?}", e.position()), + ErrorKind::UnescapedReservedAscii(ch) => println!( + "expected unescaped reserved error for '{ch}' at {:?}", + e.position() + ), other => println!("unexpected error kind: {other:?}"), - } + }, } } diff --git a/examples/validation.rs b/examples/validation.rs index 60c41e1..429a3f5 100644 --- a/examples/validation.rs +++ b/examples/validation.rs @@ -1,7 +1,7 @@ //! Demonstrates validating an already encoded string and reacting to errors. //! Run: `cargo run --example validation` -use oi4_dnp_encoding::{validate_dnp, Rules, ErrorKind}; +use oi4_dnp_encoding::{validate_dnp, ErrorKind, Rules}; fn main() { let encoded_ok = "Hello,20World,21"; @@ -9,14 +9,20 @@ fn main() { println!("validated OK: {encoded_ok}"); // Example with an illegal unescaped reserved ASCII (space) under enforced masking rules - let rules = Rules { enforce_reserved_masking: true, allow_lowercase_hex: true }; + let rules = Rules { + enforce_reserved_masking: true, + allow_lowercase_hex: true, + }; let bad = "Hello World"; // space should be escaped as ,20 under these rules match validate_dnp(bad, &rules) { Ok(_) => println!("unexpected success"), - Err(e) => match e.kind() { // pattern match by kind - ErrorKind::UnescapedReservedAscii(ch) => println!("expected error: unescaped reserved ASCII '{ch}' at pos {:?}", e.position()), + Err(e) => match e.kind() { + // pattern match by kind + ErrorKind::UnescapedReservedAscii(ch) => println!( + "expected error: unescaped reserved ASCII '{ch}' at pos {:?}", + e.position() + ), other => println!("other validation error: {other:?}"), - } + }, } } - diff --git a/src/decode.rs b/src/decode.rs index efbe772..fd6497a 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -1,7 +1,11 @@ +#[cfg(feature = "strict")] +use crate::encode::is_unreserved; +#[cfg(feature = "alloc")] use crate::error::{Error, ErrorKind}; -#[cfg(feature = "strict")] use crate::encode::is_unreserved; -#[cfg(feature = "alloc")] use alloc::string::String; +#[cfg(feature = "alloc")] +use alloc::string::String; +#[cfg(feature = "alloc")] #[inline] fn hex_val(b: u8) -> Option { match b { @@ -16,7 +20,8 @@ fn hex_val(b: u8) -> Option { pub fn decode(input: &str) -> Result { #[cfg(not(feature = "strict"))] { - if !input.as_bytes().contains(&b',') { // fast path only in non-strict mode + if !input.as_bytes().contains(&b',') { + // fast path only in non-strict mode return Ok(String::from(input)); } } @@ -24,18 +29,23 @@ pub fn decode(input: &str) -> Result { let bytes = input.as_bytes(); let mut i = 0usize; while i < bytes.len() { - if bytes[i] == b',' { // escape expected - if i + 2 >= bytes.len() { return Err(Error::new(ErrorKind::LoneComma, Some(i))); } - let h1 = bytes[i+1]; - let h2 = bytes[i+2]; + if bytes[i] == b',' { + // escape expected + if i + 2 >= bytes.len() { + return Err(Error::new(ErrorKind::LoneComma, Some(i))); + } + let h1 = bytes[i + 1]; + let h2 = bytes[i + 2]; #[cfg(feature = "strict")] { if (h1 >= b'a' && h1 <= b'f') || (h2 >= b'a' && h2 <= b'f') { return Err(Error::new(ErrorKind::LowercaseHexInStrict, Some(i))); } } - let v1 = hex_val(h1).ok_or_else(|| Error::new(ErrorKind::InvalidHexDigit(h1 as char), Some(i+1)))?; - let v2 = hex_val(h2).ok_or_else(|| Error::new(ErrorKind::InvalidHexDigit(h2 as char), Some(i+2)))?; + let v1 = hex_val(h1) + .ok_or_else(|| Error::new(ErrorKind::InvalidHexDigit(h1 as char), Some(i + 1)))?; + let v2 = hex_val(h2) + .ok_or_else(|| Error::new(ErrorKind::InvalidHexDigit(h2 as char), Some(i + 2)))?; let val = (v1 << 4) | v2; out.push(val as char); i += 3; diff --git a/src/encode.rs b/src/encode.rs index 0e13cce..fca99e6 100644 --- a/src/encode.rs +++ b/src/encode.rs @@ -10,7 +10,7 @@ pub(crate) const fn is_unreserved(b: u8) -> bool { ) } -const HEX_UPPER: &[u8;16] = b"0123456789ABCDEF"; +const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF"; /// Compute encoded length without allocating. pub fn encoded_len(input: &str) -> usize { @@ -19,14 +19,24 @@ pub fn encoded_len(input: &str) -> usize { let mut len = 0usize; while i < bytes.len() { let b = bytes[i]; - if b < 0x80 { // ASCII - if is_unreserved(b) { len += 1; } else { len += 3; } + if b < 0x80 { + // ASCII + if is_unreserved(b) { + len += 1; + } else { + len += 3; + } i += 1; - } else { // multi-byte UTF-8 sequence – copy verbatim + } else { + // multi-byte UTF-8 sequence – copy verbatim // Determine sequence length from first byte (UTF-8 invariant; input is &str) - let seq_len = if b & 0b1110_0000 == 0b1100_0000 {2} - else if b & 0b1111_0000 == 0b1110_0000 {3} - else {4}; + let seq_len = if b & 0b1110_0000 == 0b1100_0000 { + 2 + } else if b & 0b1111_0000 == 0b1110_0000 { + 3 + } else { + 4 + }; len += seq_len; i += seq_len; } @@ -36,7 +46,7 @@ pub fn encoded_len(input: &str) -> usize { /// Encode into provided writer (zero-allocation path other than writer itself). pub fn encode_into(input: &str, out: &mut impl fmt::Write) -> fmt::Result { - let mut buf = [0u8;4]; + let mut buf = [0u8; 4]; for ch in input.chars() { if ch.is_ascii() { let b = ch as u8; diff --git a/src/error.rs b/src/error.rs index dca2db8..e04010d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,9 +8,15 @@ pub struct Error { } impl Error { - pub const fn new(kind: ErrorKind, pos: Option) -> Self { Self { kind, pos } } - pub const fn kind(&self) -> &ErrorKind { &self.kind } - pub const fn position(&self) -> Option { self.pos } + pub const fn new(kind: ErrorKind, pos: Option) -> Self { + Self { kind, pos } + } + pub const fn kind(&self) -> &ErrorKind { + &self.kind + } + pub const fn position(&self) -> Option { + self.pos + } } #[derive(Debug, Clone, PartialEq)] @@ -34,7 +40,11 @@ impl fmt::Display for ErrorKind { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(p) = self.pos { write!(f, "{} at position {p}", self.kind) } else { write!(f, "{}", self.kind) } + if let Some(p) = self.pos { + write!(f, "{} at position {p}", self.kind) + } else { + write!(f, "{}", self.kind) + } } } diff --git a/src/lib.rs b/src/lib.rs index 7d878e7..2966c56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,16 +28,15 @@ #[cfg(feature = "alloc")] extern crate alloc; -pub mod encode; pub mod decode; -pub mod validate; +pub mod encode; mod error; +pub mod validate; -pub use crate::encode::{encode_into, encoded_len}; -#[cfg(feature = "alloc")] -pub use crate::encode::encode; #[cfg(feature = "alloc")] pub use crate::decode::decode; -pub use crate::validate::{validate_dnp, Rules}; +#[cfg(feature = "alloc")] +pub use crate::encode::encode; +pub use crate::encode::{encode_into, encoded_len}; pub use crate::error::{Error, ErrorKind}; - +pub use crate::validate::{validate_dnp, Rules}; diff --git a/src/main.rs b/src/main.rs index cfce04d..791d4ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,18 +5,40 @@ use std::process::ExitCode; fn read_all_stdin() -> io::Result { let mut buf = String::new(); io::stdin().read_to_string(&mut buf)?; - if buf.ends_with('\n') { buf.pop(); if buf.ends_with('\r') { buf.pop(); } } + if buf.ends_with('\n') { + buf.pop(); + if buf.ends_with('\r') { + buf.pop(); + } + } Ok(buf) } fn usage() { - eprintln!("Usage: oi4-dnp-encoding-cli [TEXT]\nIf TEXT omitted, reads from stdin."); + eprintln!( + "Usage: oi4-dnp-encoding-cli [TEXT]\nIf TEXT omitted, reads from stdin." + ); } fn main() -> ExitCode { let mut args = std::env::args().skip(1); - let cmd = match args.next() { Some(c) => c, None => { usage(); return ExitCode::from(2); } }; - let data = match args.next() { Some(rest) => rest, None => match read_all_stdin() { Ok(s) => s, Err(e) => { eprintln!("stdin error: {e}"); return ExitCode::from(3);} } }; + let cmd = match args.next() { + Some(c) => c, + None => { + usage(); + return ExitCode::from(2); + } + }; + let data = match args.next() { + Some(rest) => rest, + None => match read_all_stdin() { + Ok(s) => s, + Err(e) => { + eprintln!("stdin error: {e}"); + return ExitCode::from(3); + } + }, + }; match cmd.as_str() { "encode" => { #[cfg(feature = "alloc")] @@ -26,19 +48,34 @@ fn main() -> ExitCode { ExitCode::SUCCESS } #[cfg(not(feature = "alloc"))] - { eprintln!("alloc feature required for encode CLI"); ExitCode::from(4) } + { + eprintln!("alloc feature required for encode CLI"); + ExitCode::from(4) + } } "decode" => { #[cfg(feature = "alloc")] { match oi4_dnp_encoding::decode(&data) { - Ok(out) => { println!("{out}"); ExitCode::SUCCESS } - Err(e) => { eprintln!("decode error: {e}"); ExitCode::from(5) } + Ok(out) => { + println!("{out}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("decode error: {e}"); + ExitCode::from(5) + } } } #[cfg(not(feature = "alloc"))] - { eprintln!("alloc feature required for decode CLI"); ExitCode::from(4) } + { + eprintln!("alloc feature required for decode CLI"); + ExitCode::from(4) + } + } + _ => { + usage(); + ExitCode::from(2) } - _ => { usage(); ExitCode::from(2) } } } diff --git a/tests/test_cli.rs b/tests/test_cli.rs index 87a95df..f8300c2 100644 --- a/tests/test_cli.rs +++ b/tests/test_cli.rs @@ -1,27 +1,53 @@ -#![cfg(all(feature="std", feature="alloc"))] -use std::process::{Command, Stdio}; +#![cfg(all(feature = "std", feature = "alloc"))] use std::io::Write; +use std::process::{Command, Stdio}; -fn bin() -> std::path::PathBuf { std::path::PathBuf::from(env!("CARGO_BIN_EXE_oi4-dnp-encoding-cli")) } +fn bin() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_BIN_EXE_oi4-dnp-encoding-cli")) +} #[test] fn cli_usage_no_args() { - let out = Command::new(bin()).stderr(Stdio::piped()).stdout(Stdio::piped()).output().unwrap(); + let out = Command::new(bin()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .output() + .unwrap(); assert_eq!(out.status.code(), Some(2)); assert!(String::from_utf8_lossy(&out.stderr).contains("Usage:")); } #[test] fn cli_encode_arg() { - let out = Command::new(bin()).arg("encode").arg("Hello World!").stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); + let out = Command::new(bin()) + .arg("encode") + .arg("Hello World!") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); assert!(out.status.success()); - assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "Hello,20World,21"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "Hello,20World,21" + ); } #[test] fn cli_encode_stdin() { - let mut child = Command::new(bin()).arg("encode").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); - child.stdin.as_mut().unwrap().write_all(b"Space -> \n").unwrap(); + let mut child = Command::new(bin()) + .arg("encode") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all(b"Space -> \n") + .unwrap(); let out = child.wait_with_output().unwrap(); assert!(out.status.success()); assert!(String::from_utf8_lossy(&out.stdout).contains(",20")); @@ -29,21 +55,37 @@ fn cli_encode_stdin() { #[test] fn cli_decode_arg() { - let out = Command::new(bin()).arg("decode").arg("Hello,20World,21").stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); + let out = Command::new(bin()) + .arg("decode") + .arg("Hello,20World,21") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); assert!(out.status.success()); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "Hello World!"); } #[test] fn cli_decode_error() { - let out = Command::new(bin()).arg("decode").arg(",G0").stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); + let out = Command::new(bin()) + .arg("decode") + .arg(",G0") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); assert_eq!(out.status.code(), Some(5)); assert!(String::from_utf8_lossy(&out.stderr).contains("decode error:")); } #[test] fn cli_unknown_command() { - let out = Command::new(bin()).arg("bogus").stdout(Stdio::null()).stderr(Stdio::piped()).output().unwrap(); + let out = Command::new(bin()) + .arg("bogus") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .unwrap(); assert_eq!(out.status.code(), Some(2)); } - diff --git a/tests/test_decode.rs b/tests/test_decode.rs index 7fb960d..3cb7ac8 100644 --- a/tests/test_decode.rs +++ b/tests/test_decode.rs @@ -42,27 +42,40 @@ fn decode_lowercase_hex_triplets() { assert_eq!(decode(",61,62,63").unwrap(), "abc"); } -#[cfg(feature = "strict" )] +#[cfg(feature = "strict")] #[test] fn decode_lowercase_rejected_strict() { - assert!(decode(",61").is_err()); + // Should reject because of lowercase 'f' (0x6F should be encoded as ,6F in strict mode) + assert!(decode(",6f").is_err()); } #[test] fn decode_all_ascii_triplets() { for b in 0x00u8..=0x7Fu8 { - let hi = b >> 4; let lo = b & 0x0F; - let hex = |n: u8| -> char { match n {0..=9 => (b'0'+n) as char, 10..=15 => (b'A'+(n-10)) as char, _ => '?' } }; + let hi = b >> 4; + let lo = b & 0x0F; + let hex = |n: u8| -> char { + match n { + 0..=9 => (b'0' + n) as char, + 10..=15 => (b'A' + (n - 10)) as char, + _ => '?', + } + }; let triplet = format!(",{}{}", hex(hi), hex(lo)); let out = decode(&triplet).unwrap(); - assert_eq!(out.chars().next().unwrap() as u8, b, "Mismatch for {triplet}"); + assert_eq!( + out.chars().next().unwrap() as u8, + b, + "Mismatch for {triplet}" + ); } } // Property test for roundtrip (limited length) -#[cfg(feature="std")] +#[cfg(feature = "std")] mod prop { - use super::*; use proptest::prelude::*; + use super::*; + use proptest::prelude::*; proptest! { #[test] fn prop_roundtrip_random(data in "(?s).{0,256}") { diff --git a/tests/test_encode.rs b/tests/test_encode.rs index c7c3dc1..a561ed3 100644 --- a/tests/test_encode.rs +++ b/tests/test_encode.rs @@ -1,9 +1,21 @@ -#![cfg(feature="alloc")] -use oi4_dnp_encoding::{encode, encode_into, encoded_len, decode}; +#![cfg(feature = "alloc")] +use oi4_dnp_encoding::{decode, encode, encode_into, encoded_len}; use std::process::Stdio; -struct FailingWriter { fail_after: usize, writes: usize } -impl core::fmt::Write for FailingWriter { fn write_str(&mut self, s: &str) -> core::fmt::Result { let _ = s; if self.writes==self.fail_after { return Err(core::fmt::Error); } self.writes+=1; Ok(()) } } +struct FailingWriter { + fail_after: usize, + writes: usize, +} +impl core::fmt::Write for FailingWriter { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + let _ = s; + if self.writes == self.fail_after { + return Err(core::fmt::Error); + } + self.writes += 1; + Ok(()) + } +} #[test] fn encode_basic_and_reserved() { @@ -14,30 +26,62 @@ fn encode_basic_and_reserved() { #[test] fn encode_unicode_passthrough() { - let s = "äöüΩ🙂"; assert_eq!(encode(s), s); + let s = "äöüΩ🙂"; + assert_eq!(encode(s), s); } #[test] fn encoded_len_matches_output() { - let s = "Hello World!#"; let out = encode(s); assert_eq!(out.len(), encoded_len(s)); + let s = "Hello World!#"; + let out = encode(s); + assert_eq!(out.len(), encoded_len(s)); } #[test] fn encode_into_matches_encode() { - let s = "Hello World!#Test"; let expected = encode(s); let mut buf = String::with_capacity(expected.len()); encode_into(s, &mut buf).unwrap(); assert_eq!(buf, expected); + let s = "Hello World!#Test"; + let expected = encode(s); + let mut buf = String::with_capacity(expected.len()); + encode_into(s, &mut buf).unwrap(); + assert_eq!(buf, expected); } #[test] fn encode_into_error_path() { - let mut w = FailingWriter { fail_after: 0, writes: 0 }; let _ = encode_into("Hello", &mut w).err().expect("fmt::Error expected"); + let mut w = FailingWriter { + fail_after: 0, + writes: 0, + }; + let _ = encode_into("Hello", &mut w) + .err() + .expect("fmt::Error expected"); } #[test] fn full_ascii_rule_check() { // Build ASCII except CR/LF - let mut src = Vec::new(); for b in 0x01u8..=0x7Fu8 { if b==b'\n'||b==b'\r' {continue;} src.push(b);} let s = String::from_utf8(src).unwrap(); let enc = encode(&s); + let mut src = Vec::new(); + for b in 0x01u8..=0x7Fu8 { + if b == b'\n' || b == b'\r' { + continue; + } + src.push(b); + } + let s = String::from_utf8(src).unwrap(); + let enc = encode(&s); // commas form triplets - let bytes = enc.as_bytes(); let mut i=0; while i 0); } } - diff --git a/tests/test_validate.rs b/tests/test_validate.rs index 858d3e3..f44eb52 100644 --- a/tests/test_validate.rs +++ b/tests/test_validate.rs @@ -2,23 +2,36 @@ use oi4_dnp_encoding::{validate_dnp, Rules}; #[test] -fn validate_basic_ok() { validate_dnp("ABC123-._~", &Rules::default()).unwrap(); } +fn validate_basic_ok() { + validate_dnp("ABC123-._~", &Rules::default()).unwrap(); +} #[test] -fn validate_escape_ok() { validate_dnp(",2C", &Rules::default()).unwrap(); } +fn validate_escape_ok() { + validate_dnp(",2C", &Rules::default()).unwrap(); +} #[test] -fn validate_lone_comma() { assert!(validate_dnp(",", &Rules::default()).is_err()); } +fn validate_lone_comma() { + assert!(validate_dnp(",", &Rules::default()).is_err()); +} #[test] -fn validate_short_escape() { assert!(validate_dnp(",2", &Rules::default()).is_err()); } +fn validate_short_escape() { + assert!(validate_dnp(",2", &Rules::default()).is_err()); +} #[test] -fn validate_invalid_hex_digit() { assert!(validate_dnp(",2G", &Rules::default()).is_err()); } +fn validate_invalid_hex_digit() { + assert!(validate_dnp(",2G", &Rules::default()).is_err()); +} #[test] fn validate_unescaped_reserved_when_enforce() { - let r = Rules { enforce_reserved_masking: true, allow_lowercase_hex: true }; + let r = Rules { + enforce_reserved_masking: true, + allow_lowercase_hex: true, + }; assert!(validate_dnp(" ", &r).is_err()); assert!(validate_dnp(",", &r).is_err()); } @@ -26,26 +39,37 @@ fn validate_unescaped_reserved_when_enforce() { #[cfg(not(feature = "strict"))] #[test] fn validate_lowercase_allowed_runtime_rule() { - let r = Rules { enforce_reserved_masking: false, allow_lowercase_hex: true }; + let r = Rules { + enforce_reserved_masking: false, + allow_lowercase_hex: true, + }; validate_dnp(",2c", &r).unwrap(); } #[test] fn validate_lowercase_rejected_rule_false() { - let r = Rules { enforce_reserved_masking: false, allow_lowercase_hex: false }; + let r = Rules { + enforce_reserved_masking: false, + allow_lowercase_hex: false, + }; assert!(validate_dnp(",2c", &r).is_err()); } #[cfg(feature = "strict")] #[test] fn validate_lowercase_rejected_strict_even_if_rule_allows() { - let r = Rules { enforce_reserved_masking: false, allow_lowercase_hex: true }; + let r = Rules { + enforce_reserved_masking: false, + allow_lowercase_hex: true, + }; assert!(validate_dnp(",2c", &r).is_err()); } #[test] fn validate_unicode_passthrough() { - let r = Rules { enforce_reserved_masking: true, allow_lowercase_hex: false }; + let r = Rules { + enforce_reserved_masking: true, + allow_lowercase_hex: false, + }; validate_dnp("äöΩ🙂", &r).unwrap(); } - diff --git a/tools/gen_golden.rs b/tools/gen_golden.rs index ac5fa42..5df50ec 100644 --- a/tools/gen_golden.rs +++ b/tools/gen_golden.rs @@ -20,4 +20,3 @@ fn main() { } } } - From 548165b20c54742a7e12f77887f18a8e4c1f7290 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 16 Sep 2025 16:45:07 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20Refactor=20decoding=20logic=20a?= =?UTF-8?q?nd=20add=20hex=20utilities=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated `decode.rs` to use new hex helper functions for better readability and maintainability. - Introduced `hex.rs` with internal helpers for hexadecimal digit handling. - Removed redundant inline functions from `validate.rs` to streamline the codebase. --- src/decode.rs | 14 ++----------- src/hex.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/validate.rs | 12 +---------- 4 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 src/hex.rs diff --git a/src/decode.rs b/src/decode.rs index fd6497a..fa27741 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -4,17 +4,7 @@ use crate::encode::is_unreserved; use crate::error::{Error, ErrorKind}; #[cfg(feature = "alloc")] use alloc::string::String; - -#[cfg(feature = "alloc")] -#[inline] -fn hex_val(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(10 + (b - b'a')), - b'A'..=b'F' => Some(10 + (b - b'A')), - _ => None, - } -} +use crate::hex::{hex_val, has_lowercase_hex}; #[cfg(feature = "alloc")] pub fn decode(input: &str) -> Result { @@ -38,7 +28,7 @@ pub fn decode(input: &str) -> Result { let h2 = bytes[i + 2]; #[cfg(feature = "strict")] { - if (h1 >= b'a' && h1 <= b'f') || (h2 >= b'a' && h2 <= b'f') { + if has_lowercase_hex(h1, h2) { return Err(Error::new(ErrorKind::LowercaseHexInStrict, Some(i))); } } diff --git a/src/hex.rs b/src/hex.rs new file mode 100644 index 0000000..3a2b36c --- /dev/null +++ b/src/hex.rs @@ -0,0 +1,55 @@ +//! Internal helpers for hexadecimal digit handling. +//! Kept `pub(crate)` to avoid exposing implementation details. + +#[inline] +pub(crate) const fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(10 + (b - b'a')), + b'A'..=b'F' => Some(10 + (b - b'A')), + _ => None, + } +} + +#[inline] +pub(crate) const fn is_hex(b: u8) -> bool { + matches!(b, b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F') +} + +#[inline] +pub(crate) const fn has_lowercase_hex(h1: u8, h2: u8) -> bool { + matches!(h1, b'a'..=b'f') || matches!(h2, b'a'..=b'f') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hex_val_basic() { + assert_eq!(hex_val(b'0'), Some(0)); + assert_eq!(hex_val(b'9'), Some(9)); + assert_eq!(hex_val(b'A'), Some(10)); + assert_eq!(hex_val(b'F'), Some(15)); + assert_eq!(hex_val(b'a'), Some(10)); + assert_eq!(hex_val(b'f'), Some(15)); + assert_eq!(hex_val(b'g'), None); + assert_eq!(hex_val(b'/'), None); + } + + #[test] + fn test_is_hex() { + for b in b"0123456789ABCDEFabcdef" { assert!(is_hex(*b)); } + for b in b"GXYZ!" { assert!(!is_hex(*b)); } + } + + #[test] + fn test_has_lowercase_hex() { + assert!(has_lowercase_hex(b'a', b'0')); + assert!(has_lowercase_hex(b'0', b'f')); + assert!(has_lowercase_hex(b'a', b'f')); + assert!(!has_lowercase_hex(b'A', b'0')); + assert!(!has_lowercase_hex(b'0', b'F')); + } +} + diff --git a/src/lib.rs b/src/lib.rs index 2966c56..0c5d05d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ extern crate alloc; pub mod decode; pub mod encode; mod error; +mod hex; // internal hex helpers pub mod validate; #[cfg(feature = "alloc")] diff --git a/src/validate.rs b/src/validate.rs index d4a6e0f..173bf70 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1,5 +1,6 @@ use crate::encode::is_unreserved; use crate::error::{Error, ErrorKind}; +use crate::hex::{has_lowercase_hex, is_hex}; /// Validation rule set (runtime adjustable apart from compile-time `strict` feature). #[derive(Debug, Clone, Copy, Default)] @@ -79,14 +80,3 @@ pub fn validate_dnp(input: &str, rules: &Rules) -> Result<(), Error> { } Ok(()) } - -#[inline] -const fn is_hex(b: u8) -> bool { - //matches!(b, b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F') - b.is_ascii_hexdigit() -} - -#[inline] -fn has_lowercase_hex(h1: u8, h2: u8) -> bool { - (b'a'..=b'f').contains(&h1) || (b'a'..=b'f').contains(&h2) -} From d32d791217076aa2296f1920989524347df970c0 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 16 Sep 2025 16:52:53 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=E2=9C=A8=20Enhance=20decoding=20logic=20wi?= =?UTF-8?q?th=20strict=20mode=20support=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated `decode.rs` to conditionally import hex utilities based on the "strict" feature flag. - Improved flexibility in decoding by allowing different hex validation methods. --- src/decode.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/decode.rs b/src/decode.rs index fa27741..f03b205 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -4,7 +4,10 @@ use crate::encode::is_unreserved; use crate::error::{Error, ErrorKind}; #[cfg(feature = "alloc")] use alloc::string::String; +#[cfg(feature = "strict")] use crate::hex::{hex_val, has_lowercase_hex}; +#[cfg(not(feature = "strict"))] +use crate::hex::hex_val; #[cfg(feature = "alloc")] pub fn decode(input: &str) -> Result { From 9bf40d3249b6ca8906e4de2403a0c3c2dbfa9e85 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 16 Sep 2025 16:55:06 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20Enhance=20decoding=20logic=20wi?= =?UTF-8?q?th=20strict=20mode=20support=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated `decode.rs` to conditionally import hex utilities based on the "strict" feature flag. - Improved flexibility in decoding by allowing different hex validation methods. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index df2d9c7..ff93310 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ documentation = "https://docs.rs/oi4-dnp-encoding" keywords = ["oi4","dnp","encoding","oec"] categories = ["encoding","parser-implementations"] readme = "README.md" -rust-version = "1.74" +rust-version = "1.80" # Keep published crate lean (examples & benches are small, keep them; exclude tooling & CI only): exclude = ["fuzz","tools",".github","target","dnp-encoder-rust.iml"] From d0d62371fd224fd0d0517abc7d0edc576eadbe7f Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 16 Sep 2025 16:57:18 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=E2=9C=A8=20Enhance=20decoding=20logic=20wi?= =?UTF-8?q?th=20strict=20mode=20support=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated `decode.rs` to conditionally import hex utilities based on the "strict" feature flag. - Improved flexibility in decoding by allowing different hex validation methods. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4f93ea..e85146f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: - toolchain: 1.74.0 + toolchain: 1.80.0 components: clippy, rustfmt override: true - name: Rust Cache @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: - toolchain: 1.74.0 + toolchain: 1.80.0 override: true - name: Rust Cache uses: Swatinem/rust-cache@v2 From 29b65bd38decce52eeb92dbc06e642ec91191da5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:06:46 +0000 Subject: [PATCH 7/8] Initial plan From f609b5b9673eb4b1c106baf1e7be7170d718abb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:12:49 +0000 Subject: [PATCH 8/8] Fix CI formatting failures and improve code quality Co-authored-by: Weinschenk <2759891+Weinschenk@users.noreply.github.com> --- src/decode.rs | 8 ++++---- src/hex.rs | 11 +++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/decode.rs b/src/decode.rs index f03b205..1812ec1 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -2,12 +2,12 @@ use crate::encode::is_unreserved; #[cfg(feature = "alloc")] use crate::error::{Error, ErrorKind}; -#[cfg(feature = "alloc")] -use alloc::string::String; -#[cfg(feature = "strict")] -use crate::hex::{hex_val, has_lowercase_hex}; #[cfg(not(feature = "strict"))] use crate::hex::hex_val; +#[cfg(feature = "strict")] +use crate::hex::{has_lowercase_hex, hex_val}; +#[cfg(feature = "alloc")] +use alloc::string::String; #[cfg(feature = "alloc")] pub fn decode(input: &str) -> Result { diff --git a/src/hex.rs b/src/hex.rs index 3a2b36c..b6a4203 100644 --- a/src/hex.rs +++ b/src/hex.rs @@ -13,7 +13,7 @@ pub(crate) const fn hex_val(b: u8) -> Option { #[inline] pub(crate) const fn is_hex(b: u8) -> bool { - matches!(b, b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F') + b.is_ascii_hexdigit() } #[inline] @@ -39,8 +39,12 @@ mod tests { #[test] fn test_is_hex() { - for b in b"0123456789ABCDEFabcdef" { assert!(is_hex(*b)); } - for b in b"GXYZ!" { assert!(!is_hex(*b)); } + for b in b"0123456789ABCDEFabcdef" { + assert!(is_hex(*b)); + } + for b in b"GXYZ!" { + assert!(!is_hex(*b)); + } } #[test] @@ -52,4 +56,3 @@ mod tests { assert!(!has_lowercase_hex(b'0', b'F')); } } -