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

This commit is contained in:
2026-08-11 22:57:13 +00:00
parent 9779e50ce9
commit 9e3b532a7e
21 changed files with 2086 additions and 78 deletions

View File

@@ -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

View 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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." }
]
}

View File

@@ -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",

View File

@@ -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]

View 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 }

View 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");
}
}

View 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());
}
}

View File

@@ -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"] }

View File

@@ -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
View 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
View 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.

View File

@@ -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

View File

@@ -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`.

File diff suppressed because it is too large Load Diff

View 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();
}
}

View File

@@ -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] = [

View File

@@ -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(())
} }