Harden dependency and supply-chain policy (#100)
Some checks failed
Native code generation / deterministic (push) Failing after 2m6s
Imaging and meshing gate / native (push) Failing after 2m48s
JPEG 2000 feature / linux (push) Successful in 2m43s
Release platform and feature matrix / audit (push) Successful in 35s
Native Rust workspace compile / compile (push) Failing after 57s
Skia feature / linux (push) Successful in 31m0s
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Some checks failed
Native code generation / deterministic (push) Failing after 2m6s
Imaging and meshing gate / native (push) Failing after 2m48s
JPEG 2000 feature / linux (push) Successful in 2m43s
Release platform and feature matrix / audit (push) Successful in 35s
Native Rust workspace compile / compile (push) Failing after 57s
Skia feature / linux (push) Successful in 31m0s
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
This commit is contained in:
@@ -58,7 +58,7 @@ jobs:
|
|||||||
- profile: linux-msrv-portable
|
- profile: linux-msrv-portable
|
||||||
toolchain: 1.96.0
|
toolchain: 1.96.0
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
native_dependencies: false
|
native_dependencies: true
|
||||||
- profile: linux-stable-default
|
- profile: linux-stable-default
|
||||||
toolchain: stable
|
toolchain: stable
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
|
|||||||
70
.gitea/workflows/supply-chain.yml
Normal file
70
.gitea/workflows/supply-chain.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Dependency and supply-chain audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- ".gitea/workflows/supply-chain.yml"
|
||||||
|
- "ci/dependency-policy.json"
|
||||||
|
- "deny.toml"
|
||||||
|
- "tools/ci-matrix/**"
|
||||||
|
- "docs/dependency-policy.md"
|
||||||
|
- "RUSTREWRITE.md"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "crates/**/Cargo.toml"
|
||||||
|
- "programs/Cargo.toml"
|
||||||
|
- "tests/**/Cargo.toml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".gitea/workflows/supply-chain.yml"
|
||||||
|
- "ci/dependency-policy.json"
|
||||||
|
- "deny.toml"
|
||||||
|
- "tools/ci-matrix/**"
|
||||||
|
- "docs/dependency-policy.md"
|
||||||
|
- "RUSTREWRITE.md"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "crates/**/Cargo.toml"
|
||||||
|
- "programs/Cargo.toml"
|
||||||
|
- "tests/**/Cargo.toml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_JOBS: 1
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
CARGO_PROFILE_DEV_DEBUG: 0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache immutable Cargo downloads and advisory database
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
~/.cargo/advisory-dbs
|
||||||
|
key: supply-chain-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'deny.toml') }}
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Install pinned audit tools
|
||||||
|
run: |
|
||||||
|
cargo install --locked cargo-deny --version 0.20.2
|
||||||
|
cargo install --locked cargo-machete --version 0.9.2
|
||||||
|
- name: Record and validate the reviewed graph
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cargo tree --locked --workspace --all-features --target all --duplicates > artifacts/dependency-duplicates.txt
|
||||||
|
cargo run --locked -p metacrate-ci-matrix -- dependency-audit --evidence artifacts/dependency-audit.json
|
||||||
|
- name: Reject advisories, licenses, duplicates, and sources outside policy
|
||||||
|
run: cargo deny check advisories licenses bans sources --hide-inclusion-graph
|
||||||
|
- name: Reject unused direct dependencies
|
||||||
|
run: cargo machete --with-metadata
|
||||||
|
- name: Upload dependency evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: supply-chain-evidence
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: error
|
||||||
29
Cargo.lock
generated
29
Cargo.lock
generated
@@ -194,25 +194,6 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "audiopus"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3743519567e9135cf6f9f1a509851cb0c8e4cb9d66feb286668afb1923bec458"
|
|
||||||
dependencies = [
|
|
||||||
"audiopus_sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "audiopus_sys"
|
|
||||||
version = "0.1.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "927791de46f70facea982dbfaf19719a41ce6064443403be631a85de6a58fff9"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"pkg-config",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -1596,6 +1577,14 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libremetaverse-opus"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libremetaverse-prim-mesher"
|
name = "libremetaverse-prim-mesher"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -1701,10 +1690,10 @@ dependencies = [
|
|||||||
name = "libremetaverse-voice-webrtc"
|
name = "libremetaverse-voice-webrtc"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"audiopus",
|
|
||||||
"cpal",
|
"cpal",
|
||||||
"hound",
|
"hound",
|
||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
|
"libremetaverse-opus",
|
||||||
"libremetaverse-structured-data",
|
"libremetaverse-structured-data",
|
||||||
"libremetaverse-types",
|
"libremetaverse-types",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ members = [
|
|||||||
"crates/libremetaverse-rlv",
|
"crates/libremetaverse-rlv",
|
||||||
"crates/libremetaverse-utilities",
|
"crates/libremetaverse-utilities",
|
||||||
"crates/libremetaverse-voice-vivox",
|
"crates/libremetaverse-voice-vivox",
|
||||||
|
"crates/libremetaverse-opus",
|
||||||
"crates/libremetaverse-voice-webrtc",
|
"crates/libremetaverse-voice-webrtc",
|
||||||
"programs",
|
"programs",
|
||||||
"tests/compat",
|
"tests/compat",
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ MSRV, features, and licenses at adoption time.
|
|||||||
| SkiaSharp 4.150.1 | [`skia-safe` 0.99.0](https://crates.io/crates/skia-safe/0.99.0) | Adopted behind the opt-in `skia` feature. Target-specific official binary-cache feature sets provide BMP/GIF/ICO/JPEG/PNG/WBMP/WebP decoding on macOS, Linux, and Windows; checked owned buffers keep Skia types out of the core image API. |
|
| SkiaSharp 4.150.1 | [`skia-safe` 0.99.0](https://crates.io/crates/skia-safe/0.99.0) | Adopted behind the opt-in `skia` feature. Target-specific official binary-cache feature sets provide BMP/GIF/ICO/JPEG/PNG/WBMP/WebP decoding on macOS, Linux, and Windows; checked owned buffers keep Skia types out of the core image API. |
|
||||||
| Pfim 0.11.4 | [`image` 0.25.10](https://crates.io/crates/image/0.25.10), [`ddsfile` 0.6.0](https://crates.io/crates/ddsfile/0.6.0) | `image` covers TGA and common DDS decoding; `ddsfile` exposes DDS container details. Golden files decide whether both are needed. |
|
| Pfim 0.11.4 | [`image` 0.25.10](https://crates.io/crates/image/0.25.10), [`ddsfile` 0.6.0](https://crates.io/crates/ddsfile/0.6.0) | `image` covers TGA and common DDS decoding; `ddsfile` exposes DDS container details. Golden files decide whether both are needed. |
|
||||||
| OggVorbisEncoder 1.2.2 | [`vorbis_rs` 0.5.6](https://crates.io/crates/vorbis_rs/0.5.6) | BSD-3-Clause, MSRV 1.82, backed by C libraries. Feature-gate native audio encoding. |
|
| OggVorbisEncoder 1.2.2 | [`vorbis_rs` 0.5.6](https://crates.io/crates/vorbis_rs/0.5.6) | BSD-3-Clause, MSRV 1.82, backed by C libraries. Feature-gate native audio encoding. |
|
||||||
| SIPSorcery 8.0.23 | [`webrtc` 0.20.0](https://crates.io/crates/webrtc/0.20.0), [`cpal` 0.18.1](https://crates.io/crates/cpal/0.18.1), [`opus` 0.3.1](https://crates.io/crates/opus/0.3.1) | Validate SDP, ICE, data-channel framing, audio formats, device hotplug, and native libopus deployment separately. Do not claim parity from successful compilation. |
|
| SIPSorcery 8.0.23 | [`str0m` 0.22.0](https://crates.io/crates/str0m/0.22.0), [`cpal` 0.18.1](https://crates.io/crates/cpal/0.18.1), system libopus 1.3+ through `libremetaverse-opus` | The private safe adapter avoids the unmaintained `audiopus_sys` binding. Validate SDP, ICE, data-channel framing, audio formats, device hotplug, and native libopus deployment separately. Do not claim parity from successful compilation. |
|
||||||
| LSL generated parser | [`lalrpop` 0.23.1](https://crates.io/crates/lalrpop/0.23.1) | Candidate only. Preserve grammar conflicts, recovery, token positions, and diagnostics before replacing the generated parser. |
|
| LSL generated parser | [`lalrpop` 0.23.1](https://crates.io/crates/lalrpop/0.23.1) | Candidate only. Preserve grammar conflicts, recovery, token positions, and diagnostics before replacing the generated parser. |
|
||||||
| NUnit/Moq | built-in test harness, [`mockall` 0.15.0](https://crates.io/crates/mockall/0.15.0), [`proptest` 1.11.0](https://crates.io/crates/proptest/1.11.0) | Prefer fakes and deterministic protocol fixtures; use mocks only for interaction contracts. Add properties after direct parity cases exist. |
|
| NUnit/Moq | built-in test harness, [`mockall` 0.15.0](https://crates.io/crates/mockall/0.15.0), [`proptest` 1.11.0](https://crates.io/crates/proptest/1.11.0) | Prefer fakes and deterministic protocol fixtures; use mocks only for interaction contracts. Add properties after direct parity cases exist. |
|
||||||
| NUnit benchmarks | [`criterion` 0.8.2](https://crates.io/crates/criterion/0.8.2) | Port benchmark-category methods to `benches/`; never make timing thresholds correctness tests. |
|
| NUnit benchmarks | [`criterion` 0.8.2](https://crates.io/crates/criterion/0.8.2) | Port benchmark-category methods to `benches/`; never make timing thresholds correctness tests. |
|
||||||
@@ -429,6 +429,29 @@ not select a crate merely because NuGet used one. In particular, begin with
|
|||||||
`Mutex<HashMap<...>>` rather than a concurrent-map dependency and add a pool
|
`Mutex<HashMap<...>>` rather than a concurrent-map dependency and add a pool
|
||||||
only after allocations appear in a profile.
|
only after allocations appear in a profile.
|
||||||
|
|
||||||
|
### 4.1 Adopted direct dependency inventory
|
||||||
|
|
||||||
|
The candidates above are not the release inventory. The machine-checked
|
||||||
|
inventory is [`ci/dependency-policy.json`](ci/dependency-policy.json); each
|
||||||
|
locked direct version must appear there with its implemented purpose,
|
||||||
|
maintenance state, transitive-cost classification, and native-code status.
|
||||||
|
The current adopted set is:
|
||||||
|
|
||||||
|
| Responsibility | Adopted direct crates |
|
||||||
|
|---|---|
|
||||||
|
| Async and HTTP | `tokio`, `futures-channel`, `futures-util`, `reqwest` |
|
||||||
|
| Wire data and parsing | `base64`, `serde`, `serde_json`, `roxmltree`, `regex`, `unicode-general-category` |
|
||||||
|
| Identity, hashing, and platform facts | `uuid`, `getrandom`, `md-5`, `sha1`, `sha2`, `mac_address2`, `os_info` |
|
||||||
|
| Archives, images, and audio | `flate2`, `tar`, `bcdec_rs`, `skia-safe`, `hound`, `vorbis_rs`, `str0m`, `cpal` |
|
||||||
|
| Code generation and program CLI | `syn`, `prettyplease`, `clap` |
|
||||||
|
| Build and compatibility-test support | `pkg-config`, `vcpkg`, `stats_alloc` |
|
||||||
|
|
||||||
|
System libopus is accessed only through the private `libremetaverse-opus`
|
||||||
|
safe adapter; it replaces the unmaintained `audiopus`/`audiopus_sys` stack.
|
||||||
|
The adapter uses `pkg-config` on Linux, macOS, and Windows GNU, and `vcpkg` on
|
||||||
|
Windows MSVC. The dependency audit fails closed for any new direct crate,
|
||||||
|
resolved version, duplicate-version set, wildcard, Git source, or registry.
|
||||||
|
|
||||||
## 5. Module-by-module implementation guidance
|
## 5. Module-by-module implementation guidance
|
||||||
|
|
||||||
### 5.1 `libremetaverse-types`
|
### 5.1 `libremetaverse-types`
|
||||||
|
|||||||
63
ci/dependency-policy.json
Normal file
63
ci/dependency-policy.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"msrv": "1.96.0",
|
||||||
|
"current": "stable",
|
||||||
|
"reviewed_on": "2026-08-11",
|
||||||
|
"review_by": "2026-11-11",
|
||||||
|
"direct": [
|
||||||
|
{ "name": "base64", "versions": ["0.22.1"], "purpose": "LLSD, login, asset, and protocol base64 encoding", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`base64`" },
|
||||||
|
{ "name": "bcdec_rs", "versions": ["0.2.0"], "purpose": "Pure Rust BC6H and BC7 texture block decoding", "maintenance": "stable", "transitive_cost": "low", "native": false, "rewrite_anchor": "`bcdec_rs`" },
|
||||||
|
{ "name": "clap", "versions": ["4.6.6"], "purpose": "Typed command-line parsing for shipped diagnostic programs", "maintenance": "active", "transitive_cost": "medium", "native": false, "rewrite_anchor": "`clap`" },
|
||||||
|
{ "name": "cpal", "versions": ["0.18.1"], "purpose": "Opt-in cross-platform physical audio device access", "maintenance": "monitored-native", "transitive_cost": "high", "native": true, "rewrite_anchor": "`cpal`" },
|
||||||
|
{ "name": "flate2", "versions": ["1.1.9"], "purpose": "Bounded gzip and deflate asset/archive decoding", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`flate2`" },
|
||||||
|
{ "name": "futures-channel", "versions": ["0.3.33"], "purpose": "One-shot compatibility callbacks without another runtime", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`futures-channel`" },
|
||||||
|
{ "name": "futures-util", "versions": ["0.3.33"], "purpose": "Stream adaptation for HTTP capability bodies", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`futures-util`" },
|
||||||
|
{ "name": "getrandom", "versions": ["0.4.3"], "purpose": "Operating-system entropy for protocol identifiers", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`getrandom`" },
|
||||||
|
{ "name": "hound", "versions": ["3.5.1"], "purpose": "Bounded PCM WAV parsing for WebRTC playback", "maintenance": "stable", "transitive_cost": "low", "native": false, "rewrite_anchor": "`hound`" },
|
||||||
|
{ "name": "mac_address2", "versions": ["2.0.2"], "purpose": "Cross-platform machine identity compatibility input", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`mac_address2`" },
|
||||||
|
{ "name": "md-5", "versions": ["0.10.6"], "purpose": "Legacy protocol checksum compatibility", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`md-5`" },
|
||||||
|
{ "name": "os_info", "versions": ["3.15.0"], "purpose": "Cross-platform login platform reporting", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`os_info`" },
|
||||||
|
{ "name": "pkg-config", "versions": ["0.3.33"], "purpose": "Unix and macOS native library discovery in reviewed adapters", "maintenance": "stable", "transitive_cost": "low", "native": false, "rewrite_anchor": "`pkg-config`" },
|
||||||
|
{ "name": "prettyplease", "versions": ["0.2.37"], "purpose": "Deterministic formatting of generated Rust sources", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`prettyplease`" },
|
||||||
|
{ "name": "regex", "versions": ["1.13.1"], "purpose": "LSL tokenization and bounded program input parsing", "maintenance": "active", "transitive_cost": "medium", "native": false, "rewrite_anchor": "`regex`" },
|
||||||
|
{ "name": "reqwest", "versions": ["0.13.4"], "purpose": "Rustls-backed login and capability HTTP transport", "maintenance": "active", "transitive_cost": "high", "native": false, "rewrite_anchor": "`reqwest`" },
|
||||||
|
{ "name": "roxmltree", "versions": ["0.21.1"], "purpose": "Read-only bounded LLSD and protocol XML parsing", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`roxmltree`" },
|
||||||
|
{ "name": "serde", "versions": ["1.0.229"], "purpose": "Typed JSON wire models and checked evidence manifests", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`serde`" },
|
||||||
|
{ "name": "serde_json", "versions": ["1.0.151"], "purpose": "JSON protocol, fixture, and audit evidence handling", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`serde_json`" },
|
||||||
|
{ "name": "sha1", "versions": ["0.10.7"], "purpose": "Legacy protocol hash compatibility", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`sha1`" },
|
||||||
|
{ "name": "sha2", "versions": ["0.11.0"], "purpose": "Manifest integrity and protocol hashing", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`sha2`" },
|
||||||
|
{ "name": "skia-safe", "versions": ["0.99.0"], "purpose": "Opt-in cross-platform Skia image decoding", "maintenance": "monitored-native", "transitive_cost": "high", "native": true, "rewrite_anchor": "`skia-safe`" },
|
||||||
|
{ "name": "stats_alloc", "versions": ["0.1.10"], "purpose": "Allocation-budget compatibility tests", "maintenance": "stable", "transitive_cost": "low", "native": false, "rewrite_anchor": "`stats_alloc`" },
|
||||||
|
{ "name": "str0m", "versions": ["0.22.0"], "purpose": "Native Rust ICE, DTLS, SRTP, RTP, and SCTP WebRTC transport", "maintenance": "active", "transitive_cost": "high", "native": false, "rewrite_anchor": "`str0m`" },
|
||||||
|
{ "name": "syn", "versions": ["2.0.119"], "purpose": "Syntax validation for generated Rust sources", "maintenance": "active", "transitive_cost": "medium", "native": false, "rewrite_anchor": "`syn`" },
|
||||||
|
{ "name": "tar", "versions": ["0.4.46"], "purpose": "Bounded OAR and asset archive traversal", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`tar`" },
|
||||||
|
{ "name": "tokio", "versions": ["1.53.1"], "purpose": "Shared asynchronous networking, timers, channels, and tasks", "maintenance": "active", "transitive_cost": "medium", "native": false, "rewrite_anchor": "`tokio`" },
|
||||||
|
{ "name": "unicode-general-category", "versions": ["1.1.0"], "purpose": "Unicode category matching in the LSL lexer", "maintenance": "stable", "transitive_cost": "low", "native": false, "rewrite_anchor": "`unicode-general-category`" },
|
||||||
|
{ "name": "uuid", "versions": ["1.24.0"], "purpose": "Random UUID generation behind protocol-compatible wrappers", "maintenance": "active", "transitive_cost": "low", "native": false, "rewrite_anchor": "`uuid`" },
|
||||||
|
{ "name": "vcpkg", "versions": ["0.2.15"], "purpose": "Windows MSVC native library discovery in reviewed adapters", "maintenance": "stable", "transitive_cost": "low", "native": false, "rewrite_anchor": "`vcpkg`" },
|
||||||
|
{ "name": "vorbis_rs", "versions": ["0.5.6"], "purpose": "Opt-in Ogg Vorbis asset encoding", "maintenance": "monitored-native", "transitive_cost": "medium", "native": true, "rewrite_anchor": "`vorbis_rs`" }
|
||||||
|
],
|
||||||
|
"approved_duplicates": [
|
||||||
|
{ "name": "block-buffer", "versions": ["0.10.4", "0.12.1"], "reason": "RustCrypto digest 0.10 and 0.11 consumers have not unified their block-buffer major version." },
|
||||||
|
{ "name": "cfg_aliases", "versions": ["0.1.1", "0.2.2"], "reason": "The target-specific native stacks use different nix-generation build helpers." },
|
||||||
|
{ "name": "chacha20", "versions": ["0.9.1", "0.10.1"], "reason": "The WebRTC crypto and current random-number stacks require different RustCrypto generations." },
|
||||||
|
{ "name": "const-oid", "versions": ["0.9.6", "0.10.2"], "reason": "DTLS certificate dependencies and the newer SHA-2 stack use different const-oid generations." },
|
||||||
|
{ "name": "cpufeatures", "versions": ["0.2.17", "0.3.0"], "reason": "RustCrypto digest 0.10 and 0.11 families have not unified their CPU feature helper." },
|
||||||
|
{ "name": "crypto-common", "versions": ["0.1.7", "0.2.2"], "reason": "Legacy protocol hashes and current SHA-2 intentionally span RustCrypto digest generations." },
|
||||||
|
{ "name": "digest", "versions": ["0.10.7", "0.11.3"], "reason": "MD5, SHA-1, and WebRTC use digest 0.10 while manifest SHA-2 uses the audited 0.11 API." },
|
||||||
|
{ "name": "getrandom", "versions": ["0.2.17", "0.3.4", "0.4.3"], "reason": "WebRTC crypto, transitive rand, and direct UUID entropy consumers span three compatible APIs." },
|
||||||
|
{ "name": "jni-sys", "versions": ["0.3.1", "0.4.1"], "reason": "CPAL Android NDK support and rustls Android certificate verification require different JNI bindings." },
|
||||||
|
{ "name": "nix", "versions": ["0.28.0", "0.31.3"], "reason": "Machine-address and operating-system reporting crates have not unified their Unix API generation." },
|
||||||
|
{ "name": "nom", "versions": ["7.1.3", "8.0.0"], "reason": "Skia bindgen tooling remains on nom 7 while the native WebRTC stack uses nom 8." },
|
||||||
|
{ "name": "r-efi", "versions": ["5.3.0", "6.0.0"], "reason": "The three resolved getrandom generations require two WASI EFI interface generations." },
|
||||||
|
{ "name": "rand", "versions": ["0.9.5", "0.10.2"], "reason": "WebRTC protocol crates use rand 0.9 while target-specific dependencies have adopted rand 0.10." },
|
||||||
|
{ "name": "rand_core", "versions": ["0.6.4", "0.9.5", "0.10.1"], "reason": "DTLS elliptic curves and the two resolved rand generations require distinct rand_core APIs." },
|
||||||
|
{ "name": "sha2", "versions": ["0.10.9", "0.11.0"], "reason": "WebRTC crypto still requires SHA-2 0.10 while MetaCrate integrity code directly uses 0.11." },
|
||||||
|
{ "name": "shlex", "versions": ["1.3.0", "2.0.1"], "reason": "Skia bindgen and current native C compilation helpers use different shell-token parsers." },
|
||||||
|
{ "name": "syn", "versions": ["2.0.119", "3.0.3"], "reason": "MetaCrate code generation uses syn 2 while current derive macro dependencies use syn 3." },
|
||||||
|
{ "name": "thiserror", "versions": ["1.0.69", "2.0.20"], "reason": "Machine-address compatibility remains on thiserror 1 while WebRTC and Vorbis use version 2." },
|
||||||
|
{ "name": "thiserror-impl", "versions": ["1.0.69", "2.0.20"], "reason": "The approved thiserror runtime duplication necessarily carries matching derive versions." },
|
||||||
|
{ "name": "untrusted", "versions": ["0.7.1", "0.9.0"], "reason": "Target-specific certificate backends and rustls webpki require different untrusted APIs." },
|
||||||
|
{ "name": "windows-sys", "versions": ["0.52.0", "0.61.2"], "reason": "Machine-address compatibility uses 0.52 while current Tokio, CLI, and archive stacks use 0.61." }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
"purpose": "Opt-in Skia image formats using the target-specific binary cache or source build"
|
"purpose": "Opt-in Skia image formats using the target-specific binary cache or source build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "audiopus-0.2",
|
"id": "libopus-1.3",
|
||||||
"version": "0.2",
|
"version": ">=1.3",
|
||||||
"purpose": "Native Opus codec used by WebRTC voice"
|
"purpose": "System Opus codec used through the reviewed safe WebRTC adapter"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "alsa-1.2",
|
"id": "alsa-1.2",
|
||||||
@@ -43,11 +43,15 @@
|
|||||||
"toolchain": "1.96.0",
|
"toolchain": "1.96.0",
|
||||||
"target": "x86_64-unknown-linux-gnu",
|
"target": "x86_64-unknown-linux-gnu",
|
||||||
"feature_sets": ["no-default-features"],
|
"feature_sets": ["no-default-features"],
|
||||||
"prerequisites": [],
|
"prerequisites": ["libopus-1.3"],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"label": "Compile the portable public crates on the declared MSRV",
|
"label": "Compile the portable public crates on the declared MSRV",
|
||||||
"args": ["check", "--locked", "-j", "1", "--all-targets", "--no-default-features", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-ci-matrix"]
|
"args": ["check", "--locked", "-j", "1", "--all-targets", "--no-default-features", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-ci-matrix"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Compile the safe native Opus boundary and WebRTC voice on the declared MSRV",
|
||||||
|
"args": ["check", "--locked", "-j", "1", "--all-targets", "--no-default-features", "-p", "libremetaverse-opus", "-p", "libremetaverse-voice-webrtc"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -59,7 +63,7 @@
|
|||||||
"toolchain": "stable",
|
"toolchain": "stable",
|
||||||
"target": "x86_64-unknown-linux-gnu",
|
"target": "x86_64-unknown-linux-gnu",
|
||||||
"feature_sets": ["default", "tests", "examples", "docs"],
|
"feature_sets": ["default", "tests", "examples", "docs"],
|
||||||
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "audiopus-0.2"],
|
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "libopus-1.3"],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"label": "Compile every default workspace target",
|
"label": "Compile every default workspace target",
|
||||||
@@ -103,7 +107,7 @@
|
|||||||
"toolchain": "stable",
|
"toolchain": "stable",
|
||||||
"target": "x86_64-unknown-linux-gnu",
|
"target": "x86_64-unknown-linux-gnu",
|
||||||
"feature_sets": ["all-features", "dds-bc67", "jpeg2000", "skia", "vorbis", "real-audio", "tests"],
|
"feature_sets": ["all-features", "dds-bc67", "jpeg2000", "skia", "vorbis", "real-audio", "tests"],
|
||||||
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "audiopus-0.2", "alsa-1.2", "vorbis-rs-0.5.6"],
|
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "libopus-1.3", "alsa-1.2", "vorbis-rs-0.5.6"],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"label": "Compile core with only pure Rust BC6H and BC7",
|
"label": "Compile core with only pure Rust BC6H and BC7",
|
||||||
@@ -139,7 +143,7 @@
|
|||||||
"toolchain": "stable",
|
"toolchain": "stable",
|
||||||
"target": "x86_64-unknown-linux-gnu",
|
"target": "x86_64-unknown-linux-gnu",
|
||||||
"feature_sets": ["default", "tests", "examples", "docs"],
|
"feature_sets": ["default", "tests", "examples", "docs"],
|
||||||
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "audiopus-0.2"],
|
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "libopus-1.3"],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"label": "Run all default documentation tests",
|
"label": "Run all default documentation tests",
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ build = "build.rs"
|
|||||||
pkg-config = "0.3"
|
pkg-config = "0.3"
|
||||||
vcpkg = "0.2"
|
vcpkg = "0.2"
|
||||||
|
|
||||||
|
# cargo-machete cannot associate target-conditional build.rs references with
|
||||||
|
# build dependencies. Both crates are invoked directly in build.rs.
|
||||||
|
[package.metadata.cargo-machete]
|
||||||
|
ignored = ["pkg-config", "vcpkg"]
|
||||||
|
|
||||||
# Unsafe code is permitted only in this workspace member: it is the reviewed
|
# Unsafe code is permitted only in this workspace member: it is the reviewed
|
||||||
# native ABI boundary. All callers use its safe, owned Rust API.
|
# native ABI boundary. All callers use its safe, owned Rust API.
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
|
|||||||
28
crates/libremetaverse-opus/Cargo.toml
Normal file
28
crates/libremetaverse-opus/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "libremetaverse-opus"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Safe system-libopus adapter for MetaCrate"
|
||||||
|
publish = false
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
pkg-config = "0.3"
|
||||||
|
vcpkg = "0.2"
|
||||||
|
|
||||||
|
# cargo-machete cannot associate target-conditional build.rs references with
|
||||||
|
# build dependencies. Both crates are invoked directly in build.rs.
|
||||||
|
[package.metadata.cargo-machete]
|
||||||
|
ignored = ["pkg-config", "vcpkg"]
|
||||||
|
|
||||||
|
# Unsafe code is permitted only inside this reviewed native ABI boundary.
|
||||||
|
# Every consumer receives an owned, validated safe Rust API.
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "allow"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
all = { level = "warn", priority = -1 }
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
16
crates/libremetaverse-opus/build.rs
Normal file
16
crates/libremetaverse-opus/build.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
let target = env::var("TARGET").expect("Cargo provides TARGET");
|
||||||
|
if target.contains("msvc") {
|
||||||
|
vcpkg::Config::new()
|
||||||
|
.find_package("opus")
|
||||||
|
.expect("WebRTC voice requires libopus 1.3 or newer from vcpkg");
|
||||||
|
} else {
|
||||||
|
pkg_config::Config::new()
|
||||||
|
.atleast_version("1.3")
|
||||||
|
.probe("opus")
|
||||||
|
.expect("WebRTC voice requires pkg-config and libopus 1.3 or newer");
|
||||||
|
}
|
||||||
|
}
|
||||||
313
crates/libremetaverse-opus/src/lib.rs
Normal file
313
crates/libremetaverse-opus/src/lib.rs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
//! Safe, narrowly scoped access to the system `libopus` codec.
|
||||||
|
//!
|
||||||
|
//! The foreign ABI, raw handles, and error pointers are contained in this
|
||||||
|
//! crate. Callers can move an encoder or decoder between threads, but all
|
||||||
|
//! codec operations require exclusive access because libopus mutates them.
|
||||||
|
|
||||||
|
#![allow(unsafe_code)]
|
||||||
|
|
||||||
|
use std::ffi::{CStr, c_char, c_int};
|
||||||
|
use std::fmt;
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
const OPUS_OK: c_int = 0;
|
||||||
|
const OPUS_BAD_ARG: c_int = -1;
|
||||||
|
const OPUS_ALLOC_FAIL: c_int = -7;
|
||||||
|
const OPUS_APPLICATION_VOIP: c_int = 2_048;
|
||||||
|
const MAX_PACKET_BYTES: usize = 1_275;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct OpusEncoder {
|
||||||
|
_private: [u8; 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct OpusDecoder {
|
||||||
|
_private: [u8; 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "opus")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn opus_encoder_create(
|
||||||
|
sample_rate: c_int,
|
||||||
|
channels: c_int,
|
||||||
|
application: c_int,
|
||||||
|
error: *mut c_int,
|
||||||
|
) -> *mut OpusEncoder;
|
||||||
|
fn opus_encoder_destroy(encoder: *mut OpusEncoder);
|
||||||
|
fn opus_encode(
|
||||||
|
encoder: *mut OpusEncoder,
|
||||||
|
pcm: *const i16,
|
||||||
|
frame_size: c_int,
|
||||||
|
output: *mut u8,
|
||||||
|
max_output_bytes: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
fn opus_decoder_create(
|
||||||
|
sample_rate: c_int,
|
||||||
|
channels: c_int,
|
||||||
|
error: *mut c_int,
|
||||||
|
) -> *mut OpusDecoder;
|
||||||
|
fn opus_decoder_destroy(decoder: *mut OpusDecoder);
|
||||||
|
fn opus_decode(
|
||||||
|
decoder: *mut OpusDecoder,
|
||||||
|
packet: *const u8,
|
||||||
|
packet_bytes: c_int,
|
||||||
|
pcm: *mut i16,
|
||||||
|
frame_size: c_int,
|
||||||
|
decode_fec: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
fn opus_strerror(error: c_int) -> *const c_char;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channel layouts accepted by the Opus multirate API.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(i32)]
|
||||||
|
pub enum Channels {
|
||||||
|
Mono = 1,
|
||||||
|
Stereo = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Channels {
|
||||||
|
const fn count(self) -> usize {
|
||||||
|
self as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable libopus error code returned by a codec operation.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Error(c_int);
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(self) -> i32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let message = unsafe {
|
||||||
|
let pointer = opus_strerror(self.0);
|
||||||
|
(!pointer.is_null()).then(|| CStr::from_ptr(pointer).to_string_lossy())
|
||||||
|
};
|
||||||
|
match message {
|
||||||
|
Some(message) => write!(formatter, "libopus error {}: {message}", self.0),
|
||||||
|
None => write!(formatter, "libopus error {}", self.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {}
|
||||||
|
|
||||||
|
/// Owned Opus `VoIP` encoder.
|
||||||
|
pub struct Encoder {
|
||||||
|
raw: NonNull<OpusEncoder>,
|
||||||
|
channels: Channels,
|
||||||
|
sample_rate: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// libopus codec handles have no thread affinity. Exclusive method access
|
||||||
|
// prevents concurrent calls after the owned handle is moved to another thread.
|
||||||
|
unsafe impl Send for Encoder {}
|
||||||
|
|
||||||
|
impl Encoder {
|
||||||
|
/// Creates a `VoIP` encoder for a supported Opus sample rate.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns the native libopus error when the configuration is unsupported
|
||||||
|
/// or the codec cannot be allocated.
|
||||||
|
pub fn voip(sample_rate: u32, channels: Channels) -> Result<Self, Error> {
|
||||||
|
let sample_rate = validate_sample_rate(sample_rate)?;
|
||||||
|
let sample_rate_usize = usize::try_from(sample_rate).map_err(|_| Error(OPUS_BAD_ARG))?;
|
||||||
|
let mut error = OPUS_OK;
|
||||||
|
let raw = unsafe {
|
||||||
|
opus_encoder_create(
|
||||||
|
sample_rate,
|
||||||
|
channels as c_int,
|
||||||
|
OPUS_APPLICATION_VOIP,
|
||||||
|
&raw mut error,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let raw = NonNull::new(raw).ok_or(Error(if error == OPUS_OK {
|
||||||
|
OPUS_ALLOC_FAIL
|
||||||
|
} else {
|
||||||
|
error
|
||||||
|
}))?;
|
||||||
|
if error != OPUS_OK {
|
||||||
|
unsafe { opus_encoder_destroy(raw.as_ptr()) };
|
||||||
|
return Err(Error(error));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
raw,
|
||||||
|
channels,
|
||||||
|
sample_rate: sample_rate_usize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one complete Opus frame into a caller-owned packet buffer.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Rejects malformed channel/frame lengths and oversized output buffers,
|
||||||
|
/// and returns any native codec failure.
|
||||||
|
pub fn encode(&mut self, pcm: &[i16], output: &mut [u8]) -> Result<usize, Error> {
|
||||||
|
let channels = self.channels.count();
|
||||||
|
if !pcm.len().is_multiple_of(channels) || output.is_empty() {
|
||||||
|
return Err(Error(OPUS_BAD_ARG));
|
||||||
|
}
|
||||||
|
let frame_size = pcm.len() / channels;
|
||||||
|
let base = self.sample_rate / 400;
|
||||||
|
if ![base, base * 2, base * 4, base * 8, base * 16, base * 24].contains(&frame_size) {
|
||||||
|
return Err(Error(OPUS_BAD_ARG));
|
||||||
|
}
|
||||||
|
let result = unsafe {
|
||||||
|
opus_encode(
|
||||||
|
self.raw.as_ptr(),
|
||||||
|
pcm.as_ptr(),
|
||||||
|
c_int::try_from(frame_size).map_err(|_| Error(OPUS_BAD_ARG))?,
|
||||||
|
output.as_mut_ptr(),
|
||||||
|
c_int::try_from(output.len()).map_err(|_| Error(OPUS_BAD_ARG))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
result_to_size(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Encoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { opus_encoder_destroy(self.raw.as_ptr()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owned Opus decoder.
|
||||||
|
pub struct Decoder {
|
||||||
|
raw: NonNull<OpusDecoder>,
|
||||||
|
channels: Channels,
|
||||||
|
sample_rate: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// See the Encoder safety argument above.
|
||||||
|
unsafe impl Send for Decoder {}
|
||||||
|
|
||||||
|
impl Decoder {
|
||||||
|
/// Creates a decoder for a supported Opus sample rate.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns the native libopus error when the configuration is unsupported
|
||||||
|
/// or the codec cannot be allocated.
|
||||||
|
pub fn new(sample_rate: u32, channels: Channels) -> Result<Self, Error> {
|
||||||
|
let sample_rate = validate_sample_rate(sample_rate)?;
|
||||||
|
let sample_rate_usize = usize::try_from(sample_rate).map_err(|_| Error(OPUS_BAD_ARG))?;
|
||||||
|
let mut error = OPUS_OK;
|
||||||
|
let raw = unsafe { opus_decoder_create(sample_rate, channels as c_int, &raw mut error) };
|
||||||
|
let raw = NonNull::new(raw).ok_or(Error(if error == OPUS_OK {
|
||||||
|
OPUS_ALLOC_FAIL
|
||||||
|
} else {
|
||||||
|
error
|
||||||
|
}))?;
|
||||||
|
if error != OPUS_OK {
|
||||||
|
unsafe { opus_decoder_destroy(raw.as_ptr()) };
|
||||||
|
return Err(Error(error));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
raw,
|
||||||
|
channels,
|
||||||
|
sample_rate: sample_rate_usize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one Opus packet, or performs packet-loss concealment for `None`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Rejects invalid output alignment/length and packets too large for the
|
||||||
|
/// libopus single-stream format, and returns any native codec failure.
|
||||||
|
pub fn decode(
|
||||||
|
&mut self,
|
||||||
|
packet: Option<&[u8]>,
|
||||||
|
pcm: &mut [i16],
|
||||||
|
decode_fec: bool,
|
||||||
|
) -> Result<usize, Error> {
|
||||||
|
let channels = self.channels.count();
|
||||||
|
if !pcm.len().is_multiple_of(channels)
|
||||||
|
|| pcm.is_empty()
|
||||||
|
|| pcm.len() / channels > self.sample_rate * 120 / 1_000
|
||||||
|
|| packet.is_some_and(|value| value.is_empty() || value.len() > MAX_PACKET_BYTES)
|
||||||
|
{
|
||||||
|
return Err(Error(OPUS_BAD_ARG));
|
||||||
|
}
|
||||||
|
let frame_size = pcm.len() / channels;
|
||||||
|
let (packet_pointer, packet_bytes) = packet.map_or((std::ptr::null(), 0), |value| {
|
||||||
|
(
|
||||||
|
value.as_ptr(),
|
||||||
|
c_int::try_from(value.len()).unwrap_or(c_int::MAX),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let result = unsafe {
|
||||||
|
opus_decode(
|
||||||
|
self.raw.as_ptr(),
|
||||||
|
packet_pointer,
|
||||||
|
packet_bytes,
|
||||||
|
pcm.as_mut_ptr(),
|
||||||
|
c_int::try_from(frame_size).map_err(|_| Error(OPUS_BAD_ARG))?,
|
||||||
|
c_int::from(decode_fec),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
result_to_size(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Decoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { opus_decoder_destroy(self.raw.as_ptr()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_sample_rate(sample_rate: u32) -> Result<c_int, Error> {
|
||||||
|
if matches!(sample_rate, 8_000 | 12_000 | 16_000 | 24_000 | 48_000) {
|
||||||
|
c_int::try_from(sample_rate).map_err(|_| Error(OPUS_BAD_ARG))
|
||||||
|
} else {
|
||||||
|
Err(Error(OPUS_BAD_ARG))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_to_size(result: c_int) -> Result<usize, Error> {
|
||||||
|
usize::try_from(result).map_err(|_| Error(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_libopus_encodes_and_decodes_a_real_frame() {
|
||||||
|
let mut encoder = Encoder::voip(48_000, Channels::Mono).unwrap();
|
||||||
|
let mut decoder = Decoder::new(48_000, Channels::Mono).unwrap();
|
||||||
|
let pcm = (0..960)
|
||||||
|
.map(|index| if index % 40 < 20 { 8_000 } else { -8_000 })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut packet = [0_u8; MAX_PACKET_BYTES];
|
||||||
|
let packet_bytes = encoder.encode(&pcm, &mut packet).unwrap();
|
||||||
|
assert!(packet_bytes > 0);
|
||||||
|
|
||||||
|
let mut output_pcm = [0_i16; 960];
|
||||||
|
assert_eq!(
|
||||||
|
decoder
|
||||||
|
.decode(Some(&packet[..packet_bytes]), &mut output_pcm, false)
|
||||||
|
.unwrap(),
|
||||||
|
960
|
||||||
|
);
|
||||||
|
assert!(output_pcm.iter().any(|sample| *sample != 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_boundary_rejects_invalid_shapes() {
|
||||||
|
assert!(Encoder::voip(44_100, Channels::Mono).is_err());
|
||||||
|
let mut encoder = Encoder::voip(48_000, Channels::Mono).unwrap();
|
||||||
|
assert!(encoder.encode(&[0; 100], &mut [0; 100]).is_err());
|
||||||
|
let mut decoder = Decoder::new(48_000, Channels::Stereo).unwrap();
|
||||||
|
assert!(decoder.decode(Some(&[1]), &mut [0; 3], false).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ repository.workspace = true
|
|||||||
description = "WebRTC voice shims for the MetaCrate LibreMetaverse rewrite"
|
description = "WebRTC voice shims for the MetaCrate LibreMetaverse rewrite"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
audiopus = "0.2"
|
|
||||||
hound = "3.5"
|
hound = "3.5"
|
||||||
libremetaverse = { path = "../libremetaverse" }
|
libremetaverse = { path = "../libremetaverse" }
|
||||||
|
libremetaverse-opus = { path = "../libremetaverse-opus" }
|
||||||
libremetaverse-structured-data = { path = "../libremetaverse-structured-data" }
|
libremetaverse-structured-data = { path = "../libremetaverse-structured-data" }
|
||||||
libremetaverse-types = { path = "../libremetaverse-types" }
|
libremetaverse-types = { path = "../libremetaverse-types" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
#![allow(clippy::too_many_arguments)] // The RTC loop receives independent owned I/O resources.
|
#![allow(clippy::too_many_arguments)] // The RTC loop receives independent owned I/O resources.
|
||||||
#![allow(clippy::too_many_lines)] // Linear loops preserve the WebRTC mutation/drain ordering.
|
#![allow(clippy::too_many_lines)] // Linear loops preserve the WebRTC mutation/drain ordering.
|
||||||
|
|
||||||
use audiopus::coder::{Decoder, Encoder};
|
use libremetaverse_opus::{Channels, Decoder, Encoder};
|
||||||
use audiopus::{Application, Channels, SampleRate};
|
|
||||||
use libremetaverse_types::UUID;
|
use libremetaverse_types::UUID;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
@@ -262,7 +261,7 @@ pub struct OpusFrame(pub Vec<u8>);
|
|||||||
|
|
||||||
/// Encodes PCM into WebRTC Opus frames using native libopus.
|
/// Encodes PCM into WebRTC Opus frames using native libopus.
|
||||||
pub fn encode_pcm_48k_mono(samples: &[i16]) -> Result<Vec<OpusFrame>, WebRtcError> {
|
pub fn encode_pcm_48k_mono(samples: &[i16]) -> Result<Vec<OpusFrame>, WebRtcError> {
|
||||||
let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
let mut encoder = Encoder::voip(SAMPLE_RATE, Channels::Mono)
|
||||||
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
|
.map_err(|error| WebRtcError::Audio(error.to_string()))?;
|
||||||
let mut padded = samples.to_vec();
|
let mut padded = samples.to_vec();
|
||||||
let remainder = padded.len() % FRAME_SAMPLES;
|
let remainder = padded.len() % FRAME_SAMPLES;
|
||||||
@@ -783,8 +782,8 @@ async fn run_client(
|
|||||||
) {
|
) {
|
||||||
let mut buffer = vec![0_u8; 65_536];
|
let mut buffer = vec![0_u8; 65_536];
|
||||||
let mut playback: Option<Playback> = None;
|
let mut playback: Option<Playback> = None;
|
||||||
let mut decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono).ok();
|
let mut decoder = Decoder::new(SAMPLE_RATE, Channels::Mono).ok();
|
||||||
let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip).ok();
|
let mut encoder = Encoder::voip(SAMPLE_RATE, Channels::Mono).ok();
|
||||||
let mut captured_elapsed = Duration::ZERO;
|
let mut captured_elapsed = Duration::ZERO;
|
||||||
let mut running = true;
|
let mut running = true;
|
||||||
while running {
|
while running {
|
||||||
@@ -833,7 +832,7 @@ async fn run_client(
|
|||||||
None => running = false,
|
None => running = false,
|
||||||
},
|
},
|
||||||
captured = capture.recv() => {
|
captured = capture.recv() => {
|
||||||
if let (Some(pcm), Some(codec)) = (captured, encoder.as_ref())
|
if let (Some(pcm), Some(codec)) = (captured, encoder.as_mut())
|
||||||
&& pcm.len() == FRAME_SAMPLES
|
&& pcm.len() == FRAME_SAMPLES
|
||||||
{
|
{
|
||||||
let mut packet = vec![0_u8; 4_000];
|
let mut packet = vec![0_u8; 4_000];
|
||||||
@@ -1731,7 +1730,7 @@ mod tests {
|
|||||||
fn wav_is_resampled_and_encoded_as_valid_opus() {
|
fn wav_is_resampled_and_encoded_as_valid_opus() {
|
||||||
let frames = encode_wav_bytes(&wav_fixture()).unwrap();
|
let frames = encode_wav_bytes(&wav_fixture()).unwrap();
|
||||||
assert_eq!(frames.len(), 5);
|
assert_eq!(frames.len(), 5);
|
||||||
let mut decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono).unwrap();
|
let mut decoder = Decoder::new(SAMPLE_RATE, Channels::Mono).unwrap();
|
||||||
let mut samples = vec![0_i16; FRAME_SAMPLES * 6];
|
let mut samples = vec![0_i16; FRAME_SAMPLES * 6];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decoder
|
decoder
|
||||||
|
|||||||
72
deny.toml
Normal file
72
deny.toml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
[graph]
|
||||||
|
targets = [
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"x86_64-pc-windows-gnu",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
|
]
|
||||||
|
all-features = true
|
||||||
|
|
||||||
|
[advisories]
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
allow = [
|
||||||
|
"Apache-2.0",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"MIT",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Zlib",
|
||||||
|
]
|
||||||
|
confidence-threshold = 0.93
|
||||||
|
exceptions = []
|
||||||
|
|
||||||
|
[licenses.private]
|
||||||
|
ignore = false
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
multiple-versions = "deny"
|
||||||
|
# Path-only workspace dependencies resolve as `*` in Cargo metadata. The Rust
|
||||||
|
# dependency-policy audit below rejects wildcard registry dependencies while
|
||||||
|
# permitting those local, unpublished path edges.
|
||||||
|
wildcards = "allow"
|
||||||
|
highlight = "all"
|
||||||
|
workspace-default-features = "allow"
|
||||||
|
external-default-features = "allow"
|
||||||
|
allow = []
|
||||||
|
allow-workspace = true
|
||||||
|
deny = []
|
||||||
|
skip = [
|
||||||
|
{ crate = "block-buffer@0.10.4", reason = "Approved RustCrypto digest 0.10 compatibility line" },
|
||||||
|
{ crate = "cfg_aliases@0.1.1", reason = "Approved older nix build-helper line" },
|
||||||
|
{ crate = "const-oid@0.9.6", reason = "Approved DTLS certificate compatibility line" },
|
||||||
|
{ crate = "cpufeatures@0.2.17", reason = "Approved RustCrypto digest 0.10 compatibility line" },
|
||||||
|
{ crate = "crypto-common@0.1.7", reason = "Approved RustCrypto digest 0.10 compatibility line" },
|
||||||
|
{ crate = "digest@0.10.7", reason = "Approved legacy hash and WebRTC compatibility line" },
|
||||||
|
{ crate = "getrandom@0.2.17", reason = "Approved elliptic-curve entropy compatibility line" },
|
||||||
|
{ crate = "getrandom@0.3.4", reason = "Approved rand 0.9 entropy compatibility line" },
|
||||||
|
{ crate = "nix@0.28.0", reason = "Approved machine-address Unix compatibility line" },
|
||||||
|
{ crate = "nom@7.1.3", reason = "Approved Skia bindgen parser compatibility line" },
|
||||||
|
{ crate = "rand_core@0.6.4", reason = "Approved elliptic-curve random compatibility line" },
|
||||||
|
{ crate = "rand_core@0.9.5", reason = "Approved rand 0.9 compatibility line" },
|
||||||
|
{ crate = "sha2@0.10.9", reason = "Approved WebRTC RustCrypto compatibility line" },
|
||||||
|
{ crate = "shlex@1.3.0", reason = "Approved Skia bindgen shell parser compatibility line" },
|
||||||
|
{ crate = "syn@2.0.119", reason = "Approved MetaCrate code generator syntax line" },
|
||||||
|
{ crate = "thiserror@1.0.69", reason = "Approved machine-address error compatibility line" },
|
||||||
|
{ crate = "thiserror-impl@1.0.69", reason = "Approved machine-address derive compatibility line" },
|
||||||
|
{ crate = "windows-sys@0.52.0", reason = "Approved machine-address Windows compatibility line" },
|
||||||
|
]
|
||||||
|
skip-tree = []
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
|
allow-git = []
|
||||||
|
|
||||||
|
[sources.allow-org]
|
||||||
|
github = []
|
||||||
|
gitlab = []
|
||||||
|
bitbucket = []
|
||||||
81
docs/dependency-policy.md
Normal file
81
docs/dependency-policy.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Dependency and supply-chain policy
|
||||||
|
|
||||||
|
MetaCrate treats the locked dependency graph as reviewed release input. The
|
||||||
|
canonical direct-dependency and duplicate-version inventory is
|
||||||
|
[`ci/dependency-policy.json`](../ci/dependency-policy.json), and the native Rust
|
||||||
|
`metacrate-ci-matrix dependency-audit` command compares that inventory with
|
||||||
|
`cargo metadata --locked --all-features`.
|
||||||
|
|
||||||
|
The audit fails when a direct crate or resolved direct version is added or
|
||||||
|
removed without review, duplicate versions drift, a registry dependency uses a
|
||||||
|
wildcard, a package comes from Git or an unapproved registry, rationale is
|
||||||
|
missing, or the dependency lacks an implemented responsibility in
|
||||||
|
[`RUSTREWRITE.md`](../RUSTREWRITE.md). Its JSON evidence records every direct
|
||||||
|
consumer and dependency scope, all approved duplicate sets, native direct
|
||||||
|
dependencies, review dates, and the resolved external package count.
|
||||||
|
|
||||||
|
The policy intentionally distinguishes Rust 1.96.0, the minimum supported Rust
|
||||||
|
version, from current stable Rust used for development and release validation.
|
||||||
|
The release matrix checks both. Raising the MSRV requires an explicit policy,
|
||||||
|
matrix, documentation, and changelog review; ordinary dependency updates may
|
||||||
|
not raise it accidentally.
|
||||||
|
|
||||||
|
## Automated gates
|
||||||
|
|
||||||
|
The Ubuntu-only `supply-chain` Gitea workflow installs pinned versions of
|
||||||
|
`cargo-deny` and `cargo-machete`, then runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --locked -p metacrate-ci-matrix -- \
|
||||||
|
dependency-audit --evidence artifacts/dependency-audit.json
|
||||||
|
cargo deny check advisories licenses bans sources --hide-inclusion-graph
|
||||||
|
cargo machete --with-metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo-deny` accepts only crates.io sources and the reviewed permissive license
|
||||||
|
set in `deny.toml`. Advisory exceptions and license exceptions are empty.
|
||||||
|
Duplicate versions are denied unless one exact version is listed with a reason;
|
||||||
|
the Rust audit independently verifies the complete exact duplicate set across
|
||||||
|
the lockfile, including target-specific packages. `cargo-machete` rejects
|
||||||
|
unused direct dependencies. The only metadata exclusions are `pkg-config` and
|
||||||
|
`vcpkg` in the two native adapter crates because their target-conditional use
|
||||||
|
is directly visible in each `build.rs`.
|
||||||
|
|
||||||
|
## Native and platform requirements
|
||||||
|
|
||||||
|
All native boundaries have Linux, Windows, and macOS strategies:
|
||||||
|
|
||||||
|
| Boundary | Linux and macOS | Windows | Feature scope |
|
||||||
|
|---|---|---|---|
|
||||||
|
| libopus 1.3+ | `pkg-config` package `opus` | vcpkg `opus` for MSVC; `pkg-config` for GNU | WebRTC voice crate |
|
||||||
|
| OpenJPEG 2.5.4+ | `pkg-config` package `libopenjp2` | vcpkg `openjpeg` for MSVC; `pkg-config` for GNU | opt-in `jpeg2000` |
|
||||||
|
| Skia 0.99.0 | target-specific official binary cache or source build | same target-specific strategy | opt-in `skia` |
|
||||||
|
| Vorbis 0.5.6 stack | `vorbis_rs` builds its reviewed C codec stack | same crate strategy | opt-in `vorbis` |
|
||||||
|
| Physical audio | ALSA development files on Linux; CoreAudio is system-provided on macOS | WASAPI is system-provided | opt-in `real-audio` |
|
||||||
|
|
||||||
|
`pkg-config` and `vcpkg` only discover libraries; they are not runtime
|
||||||
|
dependencies. `libremetaverse-opus` and `libremetaverse-openjpeg` are the only
|
||||||
|
workspace crates allowed to contain unsafe ABI calls. They expose owned,
|
||||||
|
validated safe Rust APIs and require exclusive mutable access to native codec
|
||||||
|
state. No macOS-only API is used without Linux and Windows equivalents.
|
||||||
|
|
||||||
|
## Review and update cadence
|
||||||
|
|
||||||
|
The graph is reviewed at least quarterly using `reviewed_on` and `review_by`.
|
||||||
|
RustSec advisories are reviewed immediately. For every update:
|
||||||
|
|
||||||
|
1. identify the implemented caller and confirm the dependency remains needed;
|
||||||
|
2. inspect release notes, maintenance state, license, MSRV, enabled features,
|
||||||
|
native code, and target-specific build behavior;
|
||||||
|
3. update one crate deliberately with `cargo update -p NAME --precise VERSION`;
|
||||||
|
4. rerun the dependency audit, `cargo-deny`, `cargo-machete`, and the affected
|
||||||
|
release-matrix profiles from clean target directories;
|
||||||
|
5. update the exact policy versions and duplicate reasons only after reviewing
|
||||||
|
the resulting transitive graph.
|
||||||
|
|
||||||
|
High-cost or native dependencies require isolated feature testing and all-
|
||||||
|
features unification. A successful compile does not replace the real codec,
|
||||||
|
secure WebRTC loopback, device, or live-grid gates applicable to that boundary.
|
||||||
|
The abandoned `audiopus`/`audiopus_sys` stack is prohibited by absence from the
|
||||||
|
direct inventory and by RustSec; MetaCrate instead binds the maintained system
|
||||||
|
libopus ABI in its private adapter.
|
||||||
@@ -42,6 +42,11 @@ The checked profiles cover:
|
|||||||
- portable Windows GNU and macOS cross-target compilation;
|
- portable Windows GNU and macOS cross-target compilation;
|
||||||
- exact OpenJPEG, Skia, Opus, ALSA, and Vorbis prerequisite declarations.
|
- exact OpenJPEG, Skia, Opus, ALSA, and Vorbis prerequisite declarations.
|
||||||
|
|
||||||
|
Dependency purpose, maintenance, license, advisory, source, and duplicate
|
||||||
|
review is the separate supply-chain gate documented in
|
||||||
|
[`dependency-policy.md`](dependency-policy.md). Changes to manifests or the
|
||||||
|
lockfile trigger both gates.
|
||||||
|
|
||||||
## Clean-build evidence
|
## Clean-build evidence
|
||||||
|
|
||||||
Each profile uses `target/ci/<profile>` and refuses to start if that directory
|
Each profile uses `target/ci/<profile>` and refuses to start if that directory
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ time. Typical packages are:
|
|||||||
|
|
||||||
- Ubuntu/Debian: `libopus-dev` (and `pkg-config`).
|
- Ubuntu/Debian: `libopus-dev` (and `pkg-config`).
|
||||||
- Fedora: `opus-devel`.
|
- Fedora: `opus-devel`.
|
||||||
- Windows MSVC: `audiopus` supplies supported prebuilt Opus libraries; a custom
|
- Windows MSVC: install `opus` through vcpkg and set `VCPKG_ROOT`; the adapter
|
||||||
libopus can be selected with `OPUS_LIB_DIR`/`LIBOPUS_LIB_DIR`.
|
uses vcpkg's target-aware library discovery. Windows GNU uses `pkg-config`.
|
||||||
- macOS: install `opus` with the system package manager when it is not already
|
- macOS: install `opus` with the system package manager when it is not already
|
||||||
discoverable by `pkg-config`.
|
discoverable by `pkg-config`.
|
||||||
|
|
||||||
|
|||||||
1015
tests/api-compile/Cargo.lock
generated
1015
tests/api-compile/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
375
tools/ci-matrix/src/dependency.rs
Normal file
375
tools/ci-matrix/src/dependency.rs
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
use super::{MatrixError, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const POLICY_PATH: &str = "ci/dependency-policy.json";
|
||||||
|
const REWRITE_PATH: &str = "RUSTREWRITE.md";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DependencyPolicy {
|
||||||
|
schema: u32,
|
||||||
|
msrv: String,
|
||||||
|
current: String,
|
||||||
|
reviewed_on: String,
|
||||||
|
review_by: String,
|
||||||
|
direct: Vec<DirectPolicy>,
|
||||||
|
approved_duplicates: Vec<DuplicatePolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DirectPolicy {
|
||||||
|
name: String,
|
||||||
|
versions: Vec<String>,
|
||||||
|
purpose: String,
|
||||||
|
maintenance: String,
|
||||||
|
transitive_cost: String,
|
||||||
|
native: bool,
|
||||||
|
rewrite_anchor: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DuplicatePolicy {
|
||||||
|
name: String,
|
||||||
|
versions: Vec<String>,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize)]
|
||||||
|
struct ObservedDirect {
|
||||||
|
versions: BTreeSet<String>,
|
||||||
|
consumers: BTreeSet<String>,
|
||||||
|
scopes: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ObservedGraph {
|
||||||
|
direct: BTreeMap<String, ObservedDirect>,
|
||||||
|
resolved_external_packages: usize,
|
||||||
|
duplicates: BTreeMap<String, BTreeSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DependencyEvidence<'a> {
|
||||||
|
schema: u32,
|
||||||
|
msrv: &'a str,
|
||||||
|
current: &'a str,
|
||||||
|
reviewed_on: &'a str,
|
||||||
|
review_by: &'a str,
|
||||||
|
recorded_unix_seconds: u64,
|
||||||
|
direct_dependencies: &'a BTreeMap<String, ObservedDirect>,
|
||||||
|
direct_dependency_count: usize,
|
||||||
|
resolved_external_package_count: usize,
|
||||||
|
approved_duplicates: &'a BTreeMap<String, BTreeSet<String>>,
|
||||||
|
native_dependencies: Vec<&'a str>,
|
||||||
|
status: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audits the complete resolved dependency graph against its reviewed policy.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for unreviewed direct crates or versions, stale duplicate
|
||||||
|
/// approvals, wildcard or non-registry dependencies, incomplete rationale, or
|
||||||
|
/// a metadata/evidence I/O failure.
|
||||||
|
pub fn audit_dependencies(root: &Path, evidence: &Path) -> Result<()> {
|
||||||
|
if evidence.exists() {
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"{} already exists; preserve or remove it before rerunning the audit",
|
||||||
|
evidence.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let policy: DependencyPolicy = serde_json::from_slice(&fs::read(root.join(POLICY_PATH))?)?;
|
||||||
|
validate_policy(&policy, &fs::read_to_string(root.join(REWRITE_PATH))?)?;
|
||||||
|
let metadata = cargo_metadata(root)?;
|
||||||
|
let observed = observe(&metadata)?;
|
||||||
|
compare_direct(&policy.direct, &observed.direct)?;
|
||||||
|
compare_duplicates(&policy.approved_duplicates, &observed.duplicates)?;
|
||||||
|
|
||||||
|
let native_dependencies = policy
|
||||||
|
.direct
|
||||||
|
.iter()
|
||||||
|
.filter(|dependency| dependency.native)
|
||||||
|
.map(|dependency| dependency.name.as_str())
|
||||||
|
.collect();
|
||||||
|
let evidence_record = DependencyEvidence {
|
||||||
|
schema: 1,
|
||||||
|
msrv: &policy.msrv,
|
||||||
|
current: &policy.current,
|
||||||
|
reviewed_on: &policy.reviewed_on,
|
||||||
|
review_by: &policy.review_by,
|
||||||
|
recorded_unix_seconds: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| MatrixError::new("system clock predates Unix epoch"))?
|
||||||
|
.as_secs(),
|
||||||
|
direct_dependencies: &observed.direct,
|
||||||
|
direct_dependency_count: observed.direct.len(),
|
||||||
|
resolved_external_package_count: observed.resolved_external_packages,
|
||||||
|
approved_duplicates: &observed.duplicates,
|
||||||
|
native_dependencies,
|
||||||
|
status: "ok",
|
||||||
|
};
|
||||||
|
if let Some(parent) = evidence.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(evidence)?;
|
||||||
|
serde_json::to_writer_pretty(&mut file, &evidence_record)?;
|
||||||
|
file.write_all(b"\n")?;
|
||||||
|
file.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_policy(policy: &DependencyPolicy, rewrite: &str) -> Result<()> {
|
||||||
|
if policy.schema != 1 || policy.msrv != "1.96.0" || policy.current != "stable" {
|
||||||
|
return Err(MatrixError::new(
|
||||||
|
"dependency policy must use schema 1, MSRV 1.96.0, and current stable",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !date_shape(&policy.reviewed_on) || !date_shape(&policy.review_by) {
|
||||||
|
return Err(MatrixError::new(
|
||||||
|
"dependency review dates must use YYYY-MM-DD",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
for dependency in &policy.direct {
|
||||||
|
if dependency.name.trim().is_empty()
|
||||||
|
|| !names.insert(dependency.name.as_str())
|
||||||
|
|| dependency.versions.is_empty()
|
||||||
|
|| dependency.purpose.trim().is_empty()
|
||||||
|
|| dependency.rewrite_anchor.trim().is_empty()
|
||||||
|
|| !rewrite.contains(&dependency.rewrite_anchor)
|
||||||
|
|| !matches!(
|
||||||
|
dependency.maintenance.as_str(),
|
||||||
|
"active" | "stable" | "monitored-native"
|
||||||
|
)
|
||||||
|
|| !matches!(
|
||||||
|
dependency.transitive_cost.as_str(),
|
||||||
|
"low" | "medium" | "high"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"dependency {} has incomplete or invalid review metadata",
|
||||||
|
dependency.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
unique_versions(&dependency.name, &dependency.versions)?;
|
||||||
|
}
|
||||||
|
let mut duplicate_names = BTreeSet::new();
|
||||||
|
for duplicate in &policy.approved_duplicates {
|
||||||
|
if duplicate.name.trim().is_empty()
|
||||||
|
|| !duplicate_names.insert(duplicate.name.as_str())
|
||||||
|
|| duplicate.versions.len() < 2
|
||||||
|
|| duplicate.reason.trim().len() < 20
|
||||||
|
{
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"duplicate {} needs unique versions and a substantive reason",
|
||||||
|
duplicate.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
unique_versions(&duplicate.name, &duplicate.versions)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_shape(value: &str) -> bool {
|
||||||
|
value.len() == 10
|
||||||
|
&& value.as_bytes()[4] == b'-'
|
||||||
|
&& value.as_bytes()[7] == b'-'
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.enumerate()
|
||||||
|
.all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_versions(name: &str, versions: &[String]) -> Result<()> {
|
||||||
|
let unique = versions.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||||
|
if unique.len() != versions.len() || unique.iter().any(|version| version.trim().is_empty()) {
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"dependency {name} versions must be nonempty and unique"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cargo_metadata(root: &Path) -> Result<Value> {
|
||||||
|
let output = Command::new(super::cargo_program())
|
||||||
|
.args([
|
||||||
|
"metadata",
|
||||||
|
"--locked",
|
||||||
|
"--all-features",
|
||||||
|
"--format-version",
|
||||||
|
"1",
|
||||||
|
])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"cargo metadata failed during dependency audit: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_slice(&output.stdout)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe(metadata: &Value) -> Result<ObservedGraph> {
|
||||||
|
let packages = metadata["packages"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| MatrixError::new("cargo metadata has no package array"))?;
|
||||||
|
let mut package_by_id = BTreeMap::new();
|
||||||
|
let mut all_external = BTreeMap::<String, BTreeSet<String>>::new();
|
||||||
|
for package in packages {
|
||||||
|
let id = string(package, "id")?;
|
||||||
|
let name = string(package, "name")?;
|
||||||
|
let version = string(package, "version")?;
|
||||||
|
let source = package["source"].as_str();
|
||||||
|
if let Some(source) = source {
|
||||||
|
if !source.starts_with("registry+https://github.com/rust-lang/crates.io-index") {
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"package {name} {version} uses unapproved source {source}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
all_external
|
||||||
|
.entry(name.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.insert(version.to_owned());
|
||||||
|
}
|
||||||
|
if package["dependencies"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|dependencies| {
|
||||||
|
dependencies.iter().any(|dependency| {
|
||||||
|
dependency["source"].is_string() && dependency["req"].as_str() == Some("*")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"package {name} contains a wildcard dependency"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
package_by_id.insert(
|
||||||
|
id.to_owned(),
|
||||||
|
(name.to_owned(), version.to_owned(), source.is_some()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes = metadata["resolve"]["nodes"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| MatrixError::new("cargo metadata has no resolve nodes"))?;
|
||||||
|
let mut direct = BTreeMap::<String, ObservedDirect>::new();
|
||||||
|
for node in nodes {
|
||||||
|
let id = string(node, "id")?;
|
||||||
|
let Some((consumer, _, false)) = package_by_id.get(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let dependencies = node["deps"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| MatrixError::new("cargo metadata node has no dependencies"))?;
|
||||||
|
for dependency in dependencies {
|
||||||
|
let package_id = string(dependency, "pkg")?;
|
||||||
|
let Some((name, version, true)) = package_by_id.get(package_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let observed = direct.entry(name.clone()).or_default();
|
||||||
|
observed.versions.insert(version.clone());
|
||||||
|
observed.consumers.insert(consumer.clone());
|
||||||
|
for kind in dependency["dep_kinds"].as_array().into_iter().flatten() {
|
||||||
|
observed
|
||||||
|
.scopes
|
||||||
|
.insert(kind["kind"].as_str().unwrap_or("normal").to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let duplicates = all_external
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, versions)| versions.len() > 1)
|
||||||
|
.map(|(name, versions)| (name.clone(), versions.clone()))
|
||||||
|
.collect();
|
||||||
|
Ok(ObservedGraph {
|
||||||
|
direct,
|
||||||
|
resolved_external_packages: all_external.values().map(BTreeSet::len).sum(),
|
||||||
|
duplicates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
|
||||||
|
value[key]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| MatrixError::new(format!("cargo metadata field {key} is not a string")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_direct(
|
||||||
|
policy: &[DirectPolicy],
|
||||||
|
observed: &BTreeMap<String, ObservedDirect>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let expected = policy
|
||||||
|
.iter()
|
||||||
|
.map(|dependency| {
|
||||||
|
(
|
||||||
|
dependency.name.as_str(),
|
||||||
|
dependency.versions.iter().cloned().collect::<BTreeSet<_>>(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let expected_names = expected.keys().copied().collect::<BTreeSet<_>>();
|
||||||
|
let observed_names = observed.keys().map(String::as_str).collect::<BTreeSet<_>>();
|
||||||
|
if expected_names != observed_names {
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"direct dependency policy mismatch; expected {expected_names:?}, observed {observed_names:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for (name, versions) in expected {
|
||||||
|
if observed[name].versions != versions {
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"direct dependency {name} version mismatch; expected {versions:?}, observed {:?}",
|
||||||
|
observed[name].versions
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_duplicates(
|
||||||
|
policy: &[DuplicatePolicy],
|
||||||
|
observed: &BTreeMap<String, BTreeSet<String>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let expected = policy
|
||||||
|
.iter()
|
||||||
|
.map(|duplicate| {
|
||||||
|
(
|
||||||
|
duplicate.name.clone(),
|
||||||
|
duplicate.versions.iter().cloned().collect::<BTreeSet<_>>(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
if expected != *observed {
|
||||||
|
return Err(MatrixError::new(format!(
|
||||||
|
"resolved duplicate policy mismatch; expected {expected:?}, observed {observed:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_in_dependency_policy_matches_the_locked_graph() {
|
||||||
|
let root = super::super::workspace_root(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
|
||||||
|
let evidence = std::env::temp_dir().join(format!(
|
||||||
|
"metacrate-dependency-audit-{}.json",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_file(&evidence);
|
||||||
|
audit_dependencies(&root, &evidence).unwrap();
|
||||||
|
let contents = fs::read_to_string(&evidence).unwrap();
|
||||||
|
assert!(contents.contains("\"status\": \"ok\""));
|
||||||
|
fs::remove_file(evidence).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Command, ExitStatus, Stdio};
|
use std::process::{Command, ExitStatus, Stdio};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
mod dependency;
|
||||||
|
|
||||||
|
pub use dependency::audit_dependencies;
|
||||||
|
|
||||||
pub const MATRIX_PATH: &str = "ci/release-matrix.json";
|
pub const MATRIX_PATH: &str = "ci/release-matrix.json";
|
||||||
const WORKFLOW_PATH: &str = ".gitea/workflows/release-matrix.yml";
|
const WORKFLOW_PATH: &str = ".gitea/workflows/release-matrix.yml";
|
||||||
const REQUIRED_PROFILES: [&str; 7] = [
|
const REQUIRED_PROFILES: [&str; 7] = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use metacrate_ci_matrix::{audit, load, run, workspace_root};
|
use metacrate_ci_matrix::{audit, audit_dependencies, load, run, workspace_root};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -32,7 +32,26 @@ fn execute() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
run(&root, &matrix, &profile, &evidence)?;
|
run(&root, &matrix, &profile, &evidence)?;
|
||||||
println!("release CI profile {profile}: ok ({})", evidence.display());
|
println!("release CI profile {profile}: ok ({})", evidence.display());
|
||||||
}
|
}
|
||||||
_ => return Err("usage: ci-matrix audit | run PROFILE --evidence FILE".into()),
|
Some("dependency-audit") => {
|
||||||
|
let flag = arguments
|
||||||
|
.next()
|
||||||
|
.ok_or("dependency-audit requires --evidence FILE")?;
|
||||||
|
let evidence = arguments
|
||||||
|
.next()
|
||||||
|
.ok_or("dependency-audit requires --evidence FILE")?;
|
||||||
|
if flag != "--evidence" || arguments.next().is_some() {
|
||||||
|
return Err("usage: ci-matrix dependency-audit --evidence FILE".into());
|
||||||
|
}
|
||||||
|
let evidence = absolute_or_rooted(&root, &evidence);
|
||||||
|
audit_dependencies(&root, &evidence)?;
|
||||||
|
println!("dependency policy: ok ({})", evidence.display());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(
|
||||||
|
"usage: ci-matrix audit | run PROFILE --evidence FILE | dependency-audit --evidence FILE"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user