audio/ownership: validate inherited SPA-JSON with libspa, not a scanner

Round 11 review, findings 2, 3 and 4.

The round-10 fix replaced a brace check with a hand-written scanner. That was
the wrong shape: a second implementation of someone else's grammar drifts in
both directions at once, and measured against `spa-json-dump -s` on this host
it did.

It ACCEPTED `{ "foo" = { garbage } }` (only brackets were balanced, contents
never validated), `{ "a" = "\é" }`, `{ "a" = é }` and `{ "a" = foo\bar }`.
Merging into those put an invalid pair before our carriers, so the daemon
stops at it and drops both -- recreating the exact fail-open the round-10 fix
existed to close. Its own test even pinned `"\é"` as a valid token.

It REJECTED `{ target.object, "my-sink" }`, `{ key == "value" }` and
CR-terminated comments, all valid -- so a user with one of those in their
environment silently lost their routing policy to an overwrite. That half
affects a running Linux user.

Now libspa's own parser validates, and the merge splices into the validated
text instead of re-emitting parsed pairs. Splicing preserves the user's bytes
exactly, which also answers the review's point that re-quoting a bare key can
invent a different one (`foo\bar` -> a string with a \b escape). Three
measured properties make the splice safe -- the last `}` is the object's, a
validated object's brace is never mid-comment, and commas are pure separators
-- and the result is validated again before it is returned.

Mutation testing then deleted the rest: every pairing and recursion check I
had written turned out to be redundant, because spa_json_next already errors
on `{ garbage }` and on nested garbage, and skips containers rather than
descending. ~60 lines of my own grammar logic removed. What remains is gated
by a new differential test against `spa-json-dump -s` over a 27-value corpus
-- the check whose absence caused this round. It found a real disagreement on
its first run (a bare document, which we reject by design, not by accident).

One mutation HUNG rather than failed: dropping the `length < 0` check makes
libspa report the same error without advancing, spinning forever. Kept, now
labelled load-bearing for termination, with a token-count bound beside it.

Finding 4: the ordering test took the first textual match of `fn main`, so a
raw-string decoy above the real function satisfied it while the real one
spawned a thread first. Now requires each of the three anchors to be unique.
Mutation-verified with the review's own decoy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 01:08:17 -04:00
co-authored by Claude Opus 5
parent 45b1b97dd8
commit 21ba633825
+326 -363
View File
@@ -288,212 +288,6 @@ fn merge_pulse_prop(inherited: Option<&str>, node_name: &str) -> String {
}
}
/// One top-level pair of an inherited SPA-JSON object.
#[derive(Debug, PartialEq, Eq)]
struct SpaPair {
/// Exactly as the user wrote it: quoted if they quoted it, bare if not.
key: String,
/// Verbatim source text — a quoted string, a bare token, or a whole
/// nested container. Never re-encoded, so nothing is lost in a round trip.
value: String,
}
/// Skip whitespace and `#`-to-end-of-line comments.
fn skip_blanks(bytes: &[u8], i: &mut usize) {
while let Some(&byte) = bytes.get(*i) {
if byte.is_ascii_whitespace() {
*i += 1;
} else if byte == b'#' {
while !matches!(bytes.get(*i), None | Some(b'\n')) {
*i += 1;
}
} else {
return;
}
}
}
/// Scan one SPA-JSON token — a quoted string, a balanced container, or a bare
/// word — advancing `i` past it. `None` means the token is unterminated, which
/// is exactly the class of malformation the brace check used to miss.
///
/// Byte indices are only ever *returned* at ASCII boundaries (a quote, a
/// bracket, or a separator), so slicing `text` with them cannot split a
/// multi-byte character even though the scan steps byte by byte.
fn scan_token<'a>(text: &'a str, i: &mut usize) -> Option<&'a str> {
let bytes = text.as_bytes();
let start = *i;
match *bytes.get(start)? {
b'"' => {
let mut j = start + 1;
loop {
match *bytes.get(j)? {
b'\\' => j += 2,
b'"' => {
j += 1;
break;
}
_ => j += 1,
}
}
*i = j;
}
open @ (b'{' | b'[') => {
let mut stack = vec![if open == b'{' { b'}' } else { b']' }];
let mut j = start + 1;
while let Some(&byte) = bytes.get(j) {
match byte {
// A bracket inside a string is not structure.
b'"' => {
let mut inner = j;
scan_token(text, &mut inner)?;
j = inner;
}
b'#' => skip_blanks(bytes, &mut j),
b'{' => {
stack.push(b'}');
j += 1;
}
b'[' => {
stack.push(b']');
j += 1;
}
b'}' | b']' => {
if stack.pop()? != byte {
return None;
}
j += 1;
if stack.is_empty() {
break;
}
}
_ => j += 1,
}
}
if !stack.is_empty() {
return None;
}
*i = j;
}
_ => {
let mut j = start;
while let Some(&byte) = bytes.get(j) {
if byte.is_ascii_whitespace()
|| matches!(
byte,
b',' | b':' | b'=' | b'{' | b'}' | b'[' | b']' | b'#' | b'"'
)
{
break;
}
j += 1;
}
if j == start {
// A stray separator where a token belongs.
return None;
}
*i = j;
}
}
Some(&text[start..*i])
}
/// Parse a complete SPA-JSON **object** into its top-level pairs, or `None` if
/// it is not one.
///
/// ⚠️ **This exists because checking the outer braces is not a validity
/// check, and the gap is a live fail-open** (round 10 review, finding 3).
/// Measured 2026-07-25 on this host: with `PIPEWIRE_ALSA` set to what the old
/// brace-splicing merge produced from an inherited `{ garbage }` — that is,
/// `{ garbage, "peerspeak.owned" = "1", "node.name" = "…" }` — a real `aplay`
/// node came up as `node.name=alsa_playback.aplay` with **no**
/// `peerspeak.owned` and a junk property `garbage = "peerspeak.owned"`. The
/// daemon's parser is lenient: it paired our key name as *their* value and
/// then stopped, so **both carriers were lost** on a live
/// `Stream/Output/Audio` node. `spa-json-dump -s` reports the same thing as
/// `Expected object key`.
///
/// The merge therefore parses and **rebuilds** rather than splicing. Rebuilding
/// is what makes the result independent of the input's formatting — a value
/// ending in a `#` comment, for instance, would otherwise swallow the pairs
/// appended after it.
///
/// Deliberately stricter than the daemon in one direction only: a pair the
/// parser cannot make sense of fails the whole object, which costs the user
/// their routing policy and keeps our tag. The reverse trade — guessing, and
/// silently emitting a value the daemon truncates — is the one that echoes.
fn parse_spa_object(text: &str) -> Option<Vec<SpaPair>> {
let bytes = text.as_bytes();
let mut i = 0;
skip_blanks(bytes, &mut i);
if bytes.get(i) != Some(&b'{') {
return None;
}
i += 1;
let mut pairs = Vec::new();
loop {
skip_blanks(bytes, &mut i);
// Separators between pairs are optional in SPA-JSON and repeats are
// tolerated here; a rebuilt object is well-formed either way.
while bytes.get(i) == Some(&b',') {
i += 1;
skip_blanks(bytes, &mut i);
}
match bytes.get(i) {
// Ran out of input before the closing brace.
None => return None,
Some(b'}') => {
i += 1;
break;
}
_ => {}
}
let key = scan_token(text, &mut i)?;
if key.starts_with('{') || key.starts_with('[') {
return None;
}
skip_blanks(bytes, &mut i);
if matches!(bytes.get(i), Some(b'=' | b':')) {
i += 1;
skip_blanks(bytes, &mut i);
}
// The `{ garbage }` case — a key with no value — needs no guard of its
// own: whatever follows is a separator or the closing brace, and
// `scan_token` refuses both. Verified by mutation; an explicit check
// here was unreachable, and unreachable defence rots.
let value = scan_token(text, &mut i)?;
pairs.push(SpaPair {
key: key.to_string(),
value: value.to_string(),
});
}
skip_blanks(bytes, &mut i);
// Trailing text after the object: two concatenated objects, or debris.
if i != bytes.len() {
return None;
}
Some(pairs)
}
/// Re-emit `pairs` as a SPA-JSON object with our two carriers appended last,
/// where last wins (measured — see [`tag_child`]).
fn spa_object_with_our_pairs(pairs: &[SpaPair], node_name: &str) -> String {
let mut out = String::from("{");
for pair in pairs {
// Bare keys are re-quoted; already-quoted keys are passed through
// verbatim so any escape the user wrote survives untouched.
if pair.key.starts_with('"') {
out.push_str(&format!(" {} = {},", pair.key, pair.value));
} else {
out.push_str(&format!(" \"{}\" = {},", pair.key, pair.value));
}
}
out.push_str(&format!(
" \"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\", \"node.name\" = \"{node_name}\" }}"
));
out
}
/// The diagnostic for an inherited value we refuse to merge into.
///
/// A pure function so a test can assert the variable name is the one that was
@@ -507,31 +301,192 @@ fn malformed_object_warning(var: &str, existing: &str) -> String {
)
}
/// Validate a SPA-JSON **object** with **libspa's own parser** — the same code
/// the daemon runs — and return how many tokens it holds.
///
/// The count is only ever compared against zero, to tell an empty object from
/// one with something worth preserving, so tokens rather than pairs is fine.
///
/// ⚠️ **This was a hand-written scanner for one round, and that is worth
/// recording** (round 11 review, findings 2 and 3). Checking the outer braces
/// was not a validity check; neither is checking that brackets balance.
/// Measured against `spa-json-dump -s` on this host, the hand-written version
/// disagreed with the real grammar in **both** directions:
///
/// - It **accepted** `{ "foo" = { garbage } }`, `{ "a" = "\é" }` (invalid
/// escape), `{ "a" = é }` (non-ASCII bare token) and `{ "a" = foo\bar }`.
/// Merging into those put an invalid pair *before* our carriers, so the
/// daemon would stop at it and drop both — recreating the exact fail-open
/// the fix existed to close.
/// - It **rejected** `{ target.object, "my-sink" }` (a comma between key and
/// value is ignored by SPA-JSON), `{ key == "value" }`, and CR-terminated
/// comments, all of which are valid — so a user with one of those in their
/// environment silently lost their routing policy to an overwrite.
///
/// A second implementation of someone else's grammar is a liability in both
/// directions at once. Using theirs makes the disagreement impossible.
///
/// `None` for anything that is not one complete, well-formed object, including
/// trailing text after it.
#[cfg(target_os = "linux")]
fn spa_object_tokens(text: &str) -> Option<usize> {
use pipewire::spa::sys as spa;
// SAFETY: the iterators borrow `text` and never outlive this call, and
// every pointer libspa hands back points into `text`. No slice is taken —
// only counts and libspa's own predicates — so there is no span
// arithmetic to get wrong.
unsafe {
let mut outer = std::mem::zeroed::<spa::spa_json>();
spa::spa_json_init(
&mut outer,
text.as_ptr().cast::<std::os::raw::c_char>(),
text.len(),
);
// The whole input must be exactly one object.
let mut token: *const std::os::raw::c_char = std::ptr::null();
let length = spa::spa_json_next(&mut outer, &mut token);
if length <= 0 || spa::spa_json_is_object(token, length) == 0 {
return None;
}
// ⚠️ **Nothing here re-implements the grammar, and that is the whole
// point.** An earlier cut walked the object itself, checking key/value
// pairing and recursing into nested containers. Mutation testing then
// showed every one of those checks was redundant: `spa_json_next`
// already returns an error for `{ garbage }`, for
// `{ "foo" = { garbage } }`, and for an unterminated string, and it
// *skips* a nested container rather than descending into it — so a
// flat token walk is both correct and complete. Deleting the
// hand-written half is what makes "use their grammar, not ours"
// actually true.
let mut object = std::mem::zeroed::<spa::spa_json>();
spa::spa_json_enter(&mut outer, &mut object);
let mut tokens = 0;
loop {
let mut token: *const std::os::raw::c_char = std::ptr::null();
let length = spa::spa_json_next(&mut object, &mut token);
if length == 0 {
break;
}
// ⚠️ **Load-bearing for termination, not just correctness.**
// Mutation-tested: with this removed, libspa keeps reporting the
// same error without advancing the cursor and the loop spins
// forever — the test run hung rather than failed, which is a much
// worse way to find out.
if length < 0 {
return None;
}
tokens += 1;
// Belt and braces on the same hazard: every token consumes at
// least one byte, so a token count past the input length means the
// iterator is not advancing and we must not trust it.
if tokens > text.len() {
return None;
}
}
// Nothing may follow the object.
let mut trailing: *const std::os::raw::c_char = std::ptr::null();
if spa::spa_json_next(&mut outer, &mut trailing) != 0 {
return None;
}
Some(tokens)
}
}
/// Off Linux there is nothing to merge into: `PIPEWIRE_PROPS` and
/// `PIPEWIRE_ALSA` are PipeWire's own and libspa is a Linux-only dependency,
/// so refusing to validate makes [`merge_pipewire_props`] overwrite — the
/// right answer on a platform where the variable has no reader.
///
/// The module stays platform-neutral on purpose (see `audio::mod`): the
/// literals are a wire contract and their test must run everywhere.
#[cfg(not(target_os = "linux"))]
fn spa_object_tokens(_text: &str) -> Option<usize> {
None
}
/// `PIPEWIRE_PROPS` and `PIPEWIRE_ALSA` are SPA-JSON objects, so merging is
/// parsing the inherited object and re-emitting it with our pairs last.
/// inserting our two pairs before the closing brace of a **validated** object.
///
/// Anything that is not a *complete, well-formed* object — a bare
/// `key = value` list, a truncated object, an unterminated string, a key with
/// no value — is logged and overwritten rather than guessed at. See
/// [`parse_spa_object`] for why the old outer-brace check was not enough.
/// Anything that is not a complete, well-formed object — a bare `key = value`
/// list, a truncated object, an unterminated string, a key with no value, an
/// invalid escape — is logged and overwritten rather than guessed at.
///
/// `var` names the variable being merged, and it is a parameter rather than a
/// Three properties make the splice safe, and all three depend on the object
/// having been validated first:
///
/// 1. **The last `}` is the object's own.** Nothing may follow the object, so
/// the final brace closes it.
/// 2. **Our pairs cannot land inside a comment.** A `#` comment runs to the
/// end of its line, so a validated object's closing brace is always
/// reachable, and the text immediately before it is never mid-comment.
/// Only spaces and tabs are trimmed before appending — never a newline —
/// so a value ending in `# note\n ` keeps its line break.
/// 3. **A leading comma is always legal.** Measured: SPA-JSON treats commas
/// as pure separators, accepting `{ "a" = "b",, "c" = "d" }` and even
/// `{ target.object, "my-sink" }`.
///
/// And then the result is **validated again before it is returned**. If the
/// splice ever produces something libspa will not parse, we overwrite instead:
/// a lost routing preference is recoverable, a lost carrier is an echo.
///
/// ⚠️ Honest note on that last check and on `spa_object_tokens`'s
/// "must be an object" test: **mutation testing cannot currently distinguish
/// either of them.** Removing the re-validation breaks nothing because the
/// first validation already rejects every input we have; removing the
/// object test breaks nothing because a non-object has no `}` to splice
/// before. They are deliberate belt-and-braces on the one code path where
/// being wrong is a silent echo, not load-bearing logic — and saying so is
/// better than leaving a future reader to assume a test is watching them.
///
/// `var` names the variable being merged, and is a parameter rather than a
/// constant because both variables take this grammar: the warning used to say
/// `PIPEWIRE_PROPS` even when it was `PIPEWIRE_ALSA` that was malformed.
/// `PIPEWIRE_PROPS` even when `PIPEWIRE_ALSA` was the malformed one.
fn merge_pipewire_props(inherited: Option<&str>, node_name: &str, var: &str) -> String {
let ours = pipewire_props_value(node_name);
let Some(existing) = inherited else {
return ours;
};
match parse_spa_object(existing) {
// An empty object carries nothing to preserve.
Some(pairs) if pairs.is_empty() => ours,
Some(pairs) => spa_object_with_our_pairs(&pairs, node_name),
None => {
let overwrite = || {
crate::log_msg(&malformed_object_warning(var, existing));
ours
ours.clone()
};
// An empty object carries nothing worth preserving.
let Some(tokens) = spa_object_tokens(existing) else {
return overwrite();
};
if tokens == 0 {
return ours;
}
let trimmed = existing.trim_end();
let Some(closer) = trimmed.rfind('}') else {
return overwrite();
};
// A trailing separator of the user's would make ours a double comma. That
// is legal SPA-JSON (measured) but it reads like a bug, so drop it. It
// cannot be a comma inside a comment: a comment runs to end of line, so an
// object whose closing brace followed one on the same line would not have
// validated.
let kept = trimmed[..closer]
.trim_end_matches([' ', '\t'])
.trim_end_matches(',');
let merged = format!(
"{kept}, \"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\", \
\"node.name\" = \"{node_name}\" }}"
);
// Never emit a value we cannot parse back.
if spa_object_tokens(&merged).is_none() {
return overwrite();
}
merged
}
/// Tag the audio this process plays **through ALSA** — in practice everything
@@ -915,6 +870,30 @@ mod tests {
call < gui,
"the tag must be set before the GUI starts any thread"
);
// ⚠️ **Every search above takes the FIRST textual match, so the whole
// check is only as good as there being one candidate** (round 11
// review, finding 4). A raw-string decoy above the real function —
// `const DECOY: &str = r#" fn main() { unsafe { …tag…(); run_gui(); } "#;`
// — would satisfy all three searches while the real `main` spawned a
// thread before the tag. Requiring uniqueness kills that class without
// pulling in a parser: if a second `fn main` or a second call ever
// appears, this fails and asks a human to look.
assert_eq!(
code.matches("fn main").count(),
1,
"exactly one `fn main` in main.rs; the ordering check reads the first match"
);
assert_eq!(
code.matches("tag_this_process_alsa_audio").count(),
1,
"exactly one tag call; a second would make the ordering check ambiguous"
);
assert_eq!(
code.matches("run_gui").count(),
1,
"exactly one run_gui; a second would make the ordering check ambiguous"
);
}
/// R10-5: a user's inherited `PULSE_PROP` survives, and our pairs go last
@@ -1052,6 +1031,17 @@ mod tests {
"{ \"a\" = 1 } { \"b\" = 2 }",
// A dangling assignment.
"{ \"media.role\" = }",
// ⚠️ Round 11 finding 2's decisive case: garbage *inside* a nested
// container. The hand-written scanner only balanced brackets, so
// it accepted these and spliced our carriers in after an invalid
// pair — which the daemon stops at, dropping both.
"{ \"foo\" = { garbage } }",
"{ \"foo\" = [ { garbage } ] }",
"{ \"foo\" = { \"bar\" = { garbage } } }",
// Invalid escapes and non-ASCII bare tokens, also accepted before.
"{ \"a\" = \"\\é\" }",
"{ \"a\" = é }",
"{ \"a\" = foo\\bar }",
];
for value in malformed {
assert_eq!(
@@ -1062,30 +1052,43 @@ mod tests {
}
}
/// The other half of finding 3: values that *are* well-formed must still
/// round-trip, or the fix would be "overwrite everything" wearing a parser
/// as a disguise. Rebuilding — rather than splicing — is what makes the
/// result independent of the input's formatting.
/// The other half of finding 3: values that *are* well-formed must survive,
/// or the fix would be "overwrite everything" wearing a parser as a
/// disguise.
///
/// ⚠️ **The user's text is preserved byte-for-byte, not re-encoded.** An
/// earlier version parsed into pairs and re-emitted them, which re-quoted
/// bare keys — and round 11 pointed out that re-quoting a token the parser
/// wrongly accepted can invent a *different* key (`foo\bar` becoming a
/// string with a `\b` escape). Splicing into validated text cannot change
/// the meaning of anything it does not touch.
///
/// Every input here is one `spa-json-dump -s` accepts; the last three were
/// specifically rejected by the hand-written scanner this replaced.
#[test]
fn a_well_formed_pipewire_props_survives_the_rebuild() {
fn a_well_formed_pipewire_props_survives_the_merge() {
let name = owned_node_name("vlc");
let cases = [
// Bare key, bare value.
("{ media.role = music }", "\"media.role\" = music"),
// Bare key, bare value — kept bare, exactly as written.
("{ media.role = music }", "media.role = music"),
// Colon separator.
(
"{ \"media.role\": \"music\" }",
"\"media.role\" = \"music\"",
),
("{ \"media.role\": \"music\" }", "\"media.role\": \"music\""),
// No separator at all — legal SPA-JSON.
("{ \"media.role\" \"music\" }", "\"media.role\" = \"music\""),
// Nested array, preserved verbatim.
("{ \"media.role\" \"music\" }", "\"media.role\" \"music\""),
// Nested array and object.
(
"{ \"audio.position\" = [ FL, FR ] }",
"\"audio.position\" = [ FL, FR ]",
),
// Nested object, preserved verbatim.
("{ \"props\" = { \"a\" = 1 } }", "\"props\" = { \"a\" = 1 }"),
// ⚠️ An ODD number of array elements. This is the case that
// distinguishes recursive validation from a flat token walk: a
// flat walk pairs FL with FR, then finds RC with no partner and
// wrongly rejects a valid object. Found by mutation.
(
"{ \"audio.position\" = [ FL, FR, RC ] }",
"\"audio.position\" = [ FL, FR, RC ]",
),
// A brace inside a string is not structure.
(
"{ \"node.name\" = \"weird}name\" }",
@@ -1096,6 +1099,14 @@ mod tests {
"# mine\n{ \"media.role\" = \"music\" }",
"\"media.role\" = \"music\"",
),
// ⚠️ Round 11 finding 3 — the hand-written scanner REJECTED these
// three valid objects, overwriting the user's routing policy.
(
"{ target.object, \"my-sink\" }",
"target.object, \"my-sink\"",
),
("{ key == \"value\" }", "key == \"value\""),
("{ \"a\" = \"b\" # note\r }", "\"a\" = \"b\""),
];
for (input, preserved) in cases {
let merged = merge_pipewire_props(Some(input), &name, PIPEWIRE_PROPS_ENV);
@@ -1120,11 +1131,13 @@ mod tests {
}
/// ✅ **The exact bytes below were verified against the live daemon**
/// 2026-07-25: each was set as `PIPEWIRE_ALSA` for a real `aplay`, and
/// `pw-dump` showed a `Stream/Output/Audio` node carrying `peerspeak.owned
/// = 1`, the expected `node.name`, **and** the user's own properties
/// (`media.role = music`, `audio.position = [ FL, FR ]`). `spa-json-dump
/// -s` accepts all three.
/// (2026-07-25, and the third re-verified 2026-07-26 after round 11
/// changed what it emits): each was set as `PIPEWIRE_ALSA` for a real
/// `aplay`, and `pw-dump` showed a `Stream/Output/Audio` node carrying
/// `peerspeak.owned = 1`, the expected `node.name`, **and** the user's own
/// properties (`media.role = music`, `audio.position = [ FL, FR ]`).
/// `spa-json-dump -s` accepts all three. A comma-between-key-and-value and
/// a nested-object case were confirmed live in the same run.
///
/// Pinned byte-for-byte rather than asserted with `contains`, because
/// what the live run establishes is that *this string* works — a
@@ -1151,147 +1164,97 @@ mod tests {
),
"{ \"peerspeak.owned\" = \"1\", \"node.name\" = \"f3_overwritten_probe\" }"
);
// A bare key, a bare value and a nested array all survive the rebuild
// verbatim — the array in particular is passed through as written.
// A bare key, a bare value and a nested array all survive verbatim —
// the bare key stays bare, which is the round-11 correction.
assert_eq!(
merge_pipewire_props(
Some("{ media.role = music, \"audio.position\" = [ FL, FR ] }"),
"f3_exotic_probe",
PIPEWIRE_ALSA_ENV
),
"{ \"media.role\" = music, \"audio.position\" = [ FL, FR ], \
"{ media.role = music, \"audio.position\" = [ FL, FR ], \
\"peerspeak.owned\" = \"1\", \"node.name\" = \"f3_exotic_probe\" }"
);
}
/// [`scan_token`] is the seam the whole SPA-JSON check rests on, so it is
/// gated on its own postcondition — "a token I return is complete" —
/// rather than only through the merge.
/// **Differential: our acceptance set must equal `spa-json-dump -s`'s.**
///
/// ⚠️ Not redundant with the merge tests, and mutation says so: at the
/// object level an unterminated string or an unbalanced bracket is *also*
/// caught by "the object never closed", so both implementations agree
/// there. They disagree here, which is where the property actually lives.
#[test]
fn scan_token_returns_only_complete_tokens() {
let complete = [
("\"music\"", "\"music\""),
// Multi-byte characters: the scan steps by bytes, so a token
// boundary landing inside one would panic the slice. It cannot —
// UTF-8 continuation bytes are never ASCII — and this pins it.
("\"café ☕\" rest", "\"café ☕\""),
("café", "café"),
("\"\\é\" rest", "\"\\é\""),
("[ \"ünïcode\" ] rest", "[ \"ünïcode\" ]"),
// An escaped quote does not end the string.
("\"we\\\"ird\" rest", "\"we\\\"ird\""),
// Brackets inside a string are not structure.
("\"a}b]c\"", "\"a}b]c\""),
("[ 1, 2 ] rest", "[ 1, 2 ]"),
(
"{ \"a\" = { \"b\" = 1 } } rest",
"{ \"a\" = { \"b\" = 1 } }",
),
// A container closing inside a string does not close it.
("[ \"]\" ] rest", "[ \"]\" ]"),
("bare-token, rest", "bare-token"),
("music}", "music"),
];
for (input, expected) in complete {
let mut i = 0;
assert_eq!(scan_token(input, &mut i), Some(expected), "{input:?}");
assert_eq!(&input[..i], expected, "{input:?} advanced past its token");
}
let incomplete = [
// Unterminated string, plain and inside a container.
"\"music",
"\"music\\\"",
"[ \"music ]",
// Unbalanced containers.
"[ 1, 2",
"{ \"a\" = 1",
"[ { } ",
// Mismatched closer.
"[ 1, 2 }",
"{ \"a\" = 1 ]",
// A stray separator where a token belongs.
",",
"=",
"}",
];
for input in incomplete {
let mut i = 0;
assert_eq!(scan_token(input, &mut i), None, "{input:?} is not complete");
}
}
/// **The invariant the whole finding is about: no inherited value, however
/// hostile, may cost us a carrier.**
/// This is the check whose absence caused round 11. The previous validator
/// was a hand-written scanner, and nothing compared it to the real grammar
/// — so it drifted in both directions at once and neither the unit tests
/// nor the 20,000-input property test noticed, because both used *it* as
/// their oracle.
///
/// The merge has two outcomes — parse and rebuild, or overwrite — and both
/// are supposed to end with our two pairs present in a well-formed object.
/// A deterministic walk over an alphabet of the exact characters that
/// break SPA-JSON (braces, brackets, quotes, separators, comment marks,
/// escapes, newlines, multi-byte characters) checks that directly, and
/// checks the result parses — an output our own parser rejects would be an
/// output the daemon may well truncate, which is the live failure this
/// finding began with.
/// Now that the validator is libspa itself this should be true by
/// construction, which is exactly why it is worth asserting: if it ever
/// fails, the construction is not what we think it is.
///
/// `#[ignore]` because it shells out to a PipeWire tool that CI need not
/// have; it is part of the phase 5 matrix procedure.
#[test]
fn no_inherited_value_can_cost_us_a_carrier() {
let name = owned_node_name(CLIP_ROLE);
let alphabet = [
"{",
"}",
"[",
"]",
"\"",
"=",
":",
",",
"#",
"\\",
" ",
"\n",
"\t",
"a",
"1",
"é",
"",
"peerspeak.owned",
"node.name",
"media.role",
"true",
#[ignore = "needs spa-json-dump"]
fn our_validator_agrees_with_spa_json_dump() {
let corpus = [
// Valid.
"{ \"a\" = \"b\" }",
"{ }",
"{ media.role = music }",
"{ \"media.role\": \"music\" }",
"{ \"media.role\" \"music\" }",
"{ target.object, \"my-sink\" }",
"{ key == \"value\" }",
"{ \"a\" = \"b\",, \"c\" = \"d\" }",
"{ \"a\" = \"b\", }",
"{ \"audio.position\" = [ FL, FR ] }",
"{ \"props\" = { \"a\" = 1 } }",
"{ \"node.name\" = \"weird}name\" }",
"# mine\n{ \"media.role\" = \"music\" }",
"{ \"a\" = \"b\" # note\r }",
// Invalid.
"{ garbage }",
"{ garbage, \"media.role\" = \"music\" }",
"{ \"media.role\" = \"music }",
"{ \"channels\" = [ 1, 2 }",
"{ \"channels\" = [ 1, 2 } ]",
"{ \"a\" = { \"b\" = 1 }",
"{ \"media.role\" = \"music\" # note }",
"{ \"a\" = 1 } { \"b\" = 2 }",
"{ \"media.role\" = }",
"{ \"foo\" = { garbage } }",
"{ \"a\" = \"\\é\" }",
"{ \"a\" = é }",
"{ \"a\" = foo\\bar }",
];
// A fixed seed: a failure here must be reproducible, not a flake.
let mut state: u64 = 0x2545_f491_4f6c_dd1d;
let mut next = move || {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(state >> 33) as usize
};
// ⚠️ The corpus is deliberately all *objects*. `spa-json-dump` accepts
// any SPA-JSON document, so a bare `media.role = music` passes there
// and is rejected here — not a disagreement about the grammar but our
// own additional requirement, since there is no object to splice into.
// The first run of this test flagged exactly that, which is the point
// of having it. `an_empty_or_malformed_pipewire_props_yields_our_value_alone`
// pins the bare-list case.
for _ in 0..20_000 {
let length = next() % 14;
let mut inherited = String::new();
for _ in 0..length {
inherited.push_str(alphabet[next() % alphabet.len()]);
for value in corpus {
let mut child = std::process::Command::new("spa-json-dump")
.arg("-s")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spa-json-dump must be installed for this test");
{
use std::io::Write;
child
.stdin
.as_mut()
.expect("piped stdin")
.write_all(value.as_bytes())
.expect("write to spa-json-dump");
}
let merged = merge_pipewire_props(Some(&inherited), &name, PIPEWIRE_ALSA_ENV);
assert!(
merged.contains(&format!("\"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\"")),
"carrier 1 lost for inherited {inherited:?}: {merged}"
);
assert!(
merged.contains(&format!("\"node.name\" = \"{name}\"")),
"carrier 2 lost for inherited {inherited:?}: {merged}"
);
// And whatever we emit must be an object we can read back.
assert!(
parse_spa_object(&merged).is_some(),
"emitted an object we cannot parse for inherited {inherited:?}: {merged}"
let theirs = child.wait().expect("spa-json-dump exits").success();
let ours = spa_object_tokens(value).is_some();
assert_eq!(
ours, theirs,
"disagreement on {value:?}: ours={ours}, spa-json-dump={theirs}"
);
}
}