feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`.
|
||||
//!
|
||||
//! The CSP is only enforced on assets Tauri itself serves, so neither
|
||||
//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under
|
||||
//! `vite preview`) can catch a policy that breaks the app. These tests pin the
|
||||
//! non-obvious sources the frontend actually needs, so a future tightening
|
||||
//! fails here instead of in a signed build.
|
||||
//!
|
||||
//! Kept as an integration test so the policy can be checked without the app
|
||||
//! crate having to declare a test-only module.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
const TAURI_CONF: &str = include_str!("../tauri.conf.json");
|
||||
|
||||
fn csp_directives() -> HashMap<String, Vec<String>> {
|
||||
let conf: serde_json::Value =
|
||||
serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON");
|
||||
let csp = conf["app"]["security"]["csp"]
|
||||
.as_str()
|
||||
.expect("app.security.csp is set as a policy string");
|
||||
|
||||
csp.split(';')
|
||||
.filter_map(|directive| {
|
||||
let mut parts = directive.split_whitespace();
|
||||
let name = parts.next()?;
|
||||
Some((name.to_owned(), parts.map(str::to_owned).collect()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sources(directive: &str) -> Vec<String> {
|
||||
csp_directives()
|
||||
.remove(directive)
|
||||
.unwrap_or_else(|| panic!("csp is missing the {directive} directive"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_src_allows_wasm_instantiation() {
|
||||
// Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for
|
||||
// every code block; MediaPipe selfie segmentation does the same. Without
|
||||
// this token both silently degrade — highlighting drops to plain text and
|
||||
// animated avatars keep their background.
|
||||
assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned()));
|
||||
}
|
||||
|
||||
/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`.
|
||||
fn mediapipe_wasm_base() -> String {
|
||||
const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts");
|
||||
|
||||
let after = CAPTURE
|
||||
.split_once("const MEDIAPIPE_WASM_BASE =")
|
||||
.expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE")
|
||||
.1;
|
||||
let url = after
|
||||
.split_once('"')
|
||||
.expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal")
|
||||
.1;
|
||||
url.split_once('"')
|
||||
.expect("MEDIAPIPE_WASM_BASE literal is terminated")
|
||||
.0
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// The npm scope the MediaPipe loader must come from. A CSP source ending in
|
||||
/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any
|
||||
/// `@mediapipe` package while excluding the rest of what jsDelivr serves.
|
||||
const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/";
|
||||
|
||||
#[test]
|
||||
fn script_src_scopes_the_mediapipe_loader() {
|
||||
// `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js`
|
||||
// via a `<script>` tag (which of the two depends on a runtime SIMD probe),
|
||||
// so the loader URL the frontend builds has to fall inside the allowlisted
|
||||
// prefix — checked here rather than discovered in a signed build.
|
||||
let allowed = sources("script-src");
|
||||
assert!(
|
||||
allowed.contains(&MEDIAPIPE_SCOPE.to_owned()),
|
||||
"script-src must allow {MEDIAPIPE_SCOPE}"
|
||||
);
|
||||
|
||||
let base = mediapipe_wasm_base();
|
||||
assert!(
|
||||
base.starts_with(MEDIAPIPE_SCOPE),
|
||||
"MEDIAPIPE_WASM_BASE ({base}) must sit under the allowlisted {MEDIAPIPE_SCOPE}"
|
||||
);
|
||||
}
|
||||
|
||||
/// How many path segments a source narrows to past its origin.
|
||||
fn path_depth(source: &str) -> usize {
|
||||
let Some((_scheme, authority)) = source.split_once("://") else {
|
||||
return 0;
|
||||
};
|
||||
match authority.split_once('/') {
|
||||
Some((_host, path)) => path.split('/').filter(|part| !part.is_empty()).count(),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `script-src` source is narrow enough to be worth allowing. CSP
|
||||
/// keywords (`'self'`, `'wasm-unsafe-eval'`) pass. A remote source must name a
|
||||
/// non-wildcard host *and* a path reaching at least a publisher scope — a bare
|
||||
/// origin, a scheme, or a registry root would put arbitrary third-party code
|
||||
/// one injected `<script>` away.
|
||||
fn is_pinned_script_source(source: &str) -> bool {
|
||||
if source.starts_with('\'') {
|
||||
return true;
|
||||
}
|
||||
!source.contains('*') && path_depth(source) >= 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_src_trusts_no_bare_origins() {
|
||||
for source in sources("script-src") {
|
||||
assert!(
|
||||
is_pinned_script_source(&source),
|
||||
"script-src must stay scoped, found broad source `{source}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_script_source_rejects_broad_sources() {
|
||||
// Guards the guard: the check above is only worth having if it fails on the
|
||||
// shapes that reopen the allowlist.
|
||||
for allowed in [
|
||||
"'self'",
|
||||
"'wasm-unsafe-eval'",
|
||||
MEDIAPIPE_SCOPE,
|
||||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm/vision_wasm_internal.js",
|
||||
] {
|
||||
assert!(is_pinned_script_source(allowed), "{allowed} should pass");
|
||||
}
|
||||
for rejected in [
|
||||
"https://cdn.jsdelivr.net",
|
||||
"https://cdn.jsdelivr.net/",
|
||||
"https://cdn.jsdelivr.net/npm/",
|
||||
"https://cdn.jsdelivr.net/gh/",
|
||||
"https://*.jsdelivr.net/npm/@mediapipe/",
|
||||
"https:",
|
||||
"*",
|
||||
] {
|
||||
assert!(
|
||||
!is_pinned_script_source(rejected),
|
||||
"{rejected} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_directives_allow_the_buzz_media_scheme() {
|
||||
// `rewriteRelayUrl` emits `buzz-media://localhost/...` until the loopback
|
||||
// proxy port resolves, so cold-start media renders through the custom
|
||||
// scheme (mapped to `http://buzz-media.localhost` on Windows).
|
||||
for directive in ["img-src", "media-src", "connect-src"] {
|
||||
let allowed = sources(directive);
|
||||
assert!(
|
||||
allowed.contains(&"buzz-media:".to_owned()),
|
||||
"{directive} must allow buzz-media:"
|
||||
);
|
||||
assert!(
|
||||
allowed.contains(&"http://buzz-media.localhost".to_owned()),
|
||||
"{directive} must allow http://buzz-media.localhost"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_src_allows_ipc_and_cleartext_relays() {
|
||||
// `ipc:` / `http://ipc.localhost` carry every Tauri command. Cleartext
|
||||
// `http:`/`ws:` stay allowed because a relay URL is user-supplied and the
|
||||
// app accepts plain `ws://` on any host (`communityStorage::normalizeRelayUrl`,
|
||||
// the community edit form): `relayProbe` opens a browser WebSocket to it,
|
||||
// so narrowing this to loopback would report reachable relays as dead —
|
||||
// while the real connection, which runs through tauri-plugin-websocket in
|
||||
// Rust, is not governed by CSP at all. Blanket `https:` is already allowed,
|
||||
// so restricting the cleartext schemes would close no exfiltration path.
|
||||
let allowed = sources("connect-src");
|
||||
for source in [
|
||||
"ipc:",
|
||||
"http://ipc.localhost",
|
||||
"https:",
|
||||
"http:",
|
||||
"wss:",
|
||||
"ws:",
|
||||
] {
|
||||
assert!(
|
||||
allowed.contains(&source.to_owned()),
|
||||
"connect-src must allow {source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_src_stays_free_of_unsafe_inline_and_eval() {
|
||||
let allowed = sources("script-src");
|
||||
// The inline boot script in index.html is covered by Tauri's build-time
|
||||
// SHA-256 hashing (scripts only — Tauri nonces inline <style> elements via
|
||||
// a different path), so neither escape hatch is ever needed here. The boot
|
||||
// background style was moved to public/boot.css to avoid the nonce path for
|
||||
// style-src; see boot.css for the full rationale.
|
||||
assert!(!allowed.contains(&"'unsafe-inline'".to_owned()));
|
||||
assert!(!allowed.contains(&"'unsafe-eval'".to_owned()));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Diagnostic regression test: rodio's `Player` is a single-queue FIFO, not a summing mixer.
|
||||
//!
|
||||
//! This pins the failure mode we hit at 3+ simultaneous speakers in `huddle`:
|
||||
//! `Player::connect_new(&device_mixer)` calls `device_mixer.add(queue_source)` exactly once,
|
||||
//! and every `player.append(SamplesBuffer)` enqueues onto that single queue. Multiple peers'
|
||||
//! 20 ms frames therefore serialize — peer B plays after peer A finishes, etc. — instead of
|
||||
//! mixing.
|
||||
//!
|
||||
//! The fix is per-peer `Player` (each peer gets its own queue added to the device mixer), so
|
||||
//! the device mixer actually sums across queues. This test asserts both halves of the
|
||||
//! diagnosis with a deterministic, device-free harness:
|
||||
//!
|
||||
//! * A single queue receiving two sources → samples arrive **serially**.
|
||||
//! * A summing mixer fed by two sources → samples arrive **concurrently and summed**.
|
||||
//!
|
||||
//! If rodio ever changes either invariant under us we want CI to scream, not the next
|
||||
//! 3-person huddle.
|
||||
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
use rodio::mixer;
|
||||
use rodio::queue;
|
||||
use rodio::source::Source;
|
||||
use std::num::NonZero;
|
||||
|
||||
const SR: u32 = 48_000;
|
||||
const CH: u16 = 1;
|
||||
/// 20 ms at 48 kHz mono — one Opus frame's worth of decoded samples.
|
||||
const FRAME_SAMPLES: usize = 960;
|
||||
|
||||
fn channels() -> NonZero<u16> {
|
||||
NonZero::new(CH).unwrap()
|
||||
}
|
||||
|
||||
fn sample_rate() -> NonZero<u32> {
|
||||
NonZero::new(SR).unwrap()
|
||||
}
|
||||
|
||||
/// Build a `SamplesBuffer` of length `n` samples filled with `value`.
|
||||
fn buf(value: f32, n: usize) -> SamplesBuffer {
|
||||
SamplesBuffer::new(channels(), sample_rate(), vec![value; n])
|
||||
}
|
||||
|
||||
/// Drain at most `limit` samples from any `Source` and return them.
|
||||
fn drain<S: Source<Item = f32>>(src: S, limit: usize) -> Vec<f32> {
|
||||
src.take(limit).collect()
|
||||
}
|
||||
|
||||
/// A single `queue` plays sources **one after the other** (FIFO).
|
||||
///
|
||||
/// This is the shape `rodio::Player::connect_new` produces: one queue is added to the
|
||||
/// device mixer at start, and every `Player::append` enqueues onto that one queue.
|
||||
/// In the huddle path, every peer's decoded frame was appended here — serializing them.
|
||||
#[test]
|
||||
fn single_queue_serializes_sources() {
|
||||
let (input, output) = queue::queue(false /* don't keep alive when empty */);
|
||||
|
||||
// Two "peers": A produces all 1.0s, B produces all -1.0s. If they were summed, the
|
||||
// overlap would be ~0.0; if they're serialized we see a clean 1.0 → -1.0 transition.
|
||||
input.append(buf(1.0, FRAME_SAMPLES));
|
||||
input.append(buf(-1.0, FRAME_SAMPLES));
|
||||
|
||||
let samples = drain(output, FRAME_SAMPLES * 4);
|
||||
|
||||
// Total samples produced = sum of inputs (serial). If it were summed we'd only get
|
||||
// FRAME_SAMPLES of output (and at amplitude 0.0).
|
||||
assert_eq!(
|
||||
samples.len(),
|
||||
FRAME_SAMPLES * 2,
|
||||
"single queue should drain both sources back-to-back (serial), not mix them",
|
||||
);
|
||||
|
||||
// The first source's samples come first, then the second's. No interleaving, no summing.
|
||||
assert!(
|
||||
samples[..FRAME_SAMPLES].iter().all(|&s| s == 1.0),
|
||||
"first half of queue output should be source A's samples (1.0), not a mix",
|
||||
);
|
||||
assert!(
|
||||
samples[FRAME_SAMPLES..].iter().all(|&s| s == -1.0),
|
||||
"second half of queue output should be source B's samples (-1.0)",
|
||||
);
|
||||
}
|
||||
|
||||
/// A `mixer` plays sources **concurrently** and **sums** overlapping samples.
|
||||
///
|
||||
/// This is the shape we want for huddle playout: each peer gets its own source added to
|
||||
/// the device mixer, so simultaneous speakers actually mix instead of serializing.
|
||||
#[test]
|
||||
fn mixer_sums_overlapping_sources() {
|
||||
let (controller, mixer_source) = mixer::mixer(channels(), sample_rate());
|
||||
|
||||
// Same two "peers" — 1.0 and -1.0. If the mixer sums correctly, overlap == 0.0.
|
||||
controller.add(buf(1.0, FRAME_SAMPLES));
|
||||
controller.add(buf(-1.0, FRAME_SAMPLES));
|
||||
|
||||
// Take exactly one frame's worth — both sources should be active across that window.
|
||||
let samples = drain(mixer_source, FRAME_SAMPLES);
|
||||
|
||||
assert_eq!(
|
||||
samples.len(),
|
||||
FRAME_SAMPLES,
|
||||
"mixer should produce one frame's worth of mixed output, not two frames serialized",
|
||||
);
|
||||
|
||||
// Both sources are active over this window. Their sum is 0.0 at every sample.
|
||||
// Allow for f32 rounding (rodio's UniformSourceIterator + sum can introduce a few ULPs).
|
||||
let max_abs = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||
assert!(
|
||||
max_abs < 1e-5,
|
||||
"mixer should sum 1.0 + -1.0 ≈ 0.0 per sample; saw max |sample| = {max_abs} \
|
||||
which means the sources weren't actually mixed concurrently",
|
||||
);
|
||||
}
|
||||
|
||||
/// One mixer fed by **two distinct queues** (= two distinct `Player`s on one device sink)
|
||||
/// behaves the same way: the queues drain concurrently, the mixer sums them.
|
||||
///
|
||||
/// This is the exact shape the huddle fix moves to: `HashMap<peer_index, Player>`, each
|
||||
/// added to `MixerDeviceSink::mixer()`. The test pins that this composes correctly.
|
||||
#[test]
|
||||
fn mixer_of_queues_mixes_per_peer_streams() {
|
||||
let (mixer_in, mixer_source) = mixer::mixer(channels(), sample_rate());
|
||||
|
||||
// Two queues — one per "peer".
|
||||
let (peer_a_in, peer_a_out) =
|
||||
queue::queue(true /* keep alive — players outlive frames */);
|
||||
let (peer_b_in, peer_b_out) = queue::queue(true);
|
||||
mixer_in.add(peer_a_out);
|
||||
mixer_in.add(peer_b_out);
|
||||
|
||||
// Each peer pushes one 20 ms frame at the same wall-clock moment.
|
||||
peer_a_in.append(buf(1.0, FRAME_SAMPLES));
|
||||
peer_b_in.append(buf(-1.0, FRAME_SAMPLES));
|
||||
|
||||
let samples = drain(mixer_source, FRAME_SAMPLES);
|
||||
assert_eq!(samples.len(), FRAME_SAMPLES);
|
||||
|
||||
let max_abs = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||
assert!(
|
||||
max_abs < 1e-5,
|
||||
"per-peer queues into one mixer should sum concurrently; saw max |sample| = {max_abs}",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user