Merge phase 0a: object.serial u32→u64
Impl-plan §2/0a. Exit gate (boundary parse tests) met; reviewed by Codex (gpt-5.6-sol xhigh) round 1 — APPROVE-WITH-NITS, one P3 fixed and its mutant verified. Unblocks phase 2 (pure taint engine). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+114
-5
@@ -365,6 +365,9 @@ fn load_module(args: &[&str]) -> Result<u32> {
|
|||||||
.context("pactl returned non-UTF-8")?
|
.context("pactl returned non-UTF-8")?
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
|
||||||
|
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
|
||||||
|
// back verbatim. Do not widen it.
|
||||||
id_str
|
id_str
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
||||||
@@ -532,14 +535,22 @@ fn run_router(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||||
if let Some(serial) = props
|
match props.get("object.serial").and_then(parse_object_serial) {
|
||||||
.get("object.serial")
|
Some(serial) => {
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
{
|
|
||||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||||
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
||||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||||
}
|
}
|
||||||
|
// Never silently: without a serial `try_flush` can
|
||||||
|
// never route anything, so the whole app-filter mode
|
||||||
|
// is dead and the only symptom is missing audio.
|
||||||
|
None => tracing::warn!(
|
||||||
|
node_id = obj.id,
|
||||||
|
serial = props.get("object.serial").unwrap_or("<absent>"),
|
||||||
|
"audio routing: pixelpass sink has no usable object.serial; \
|
||||||
|
stream rerouting disabled"
|
||||||
|
),
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||||
@@ -591,8 +602,30 @@ fn run_router(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a PipeWire `object.serial` property value.
|
||||||
|
///
|
||||||
|
/// `object.serial` is a **64-bit** monotonically-increasing counter
|
||||||
|
/// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id
|
||||||
|
/// (those are `u32` and get recycled — the serial exists precisely so
|
||||||
|
/// that recycled ids can be disambiguated). Parsing it as `u32` silently
|
||||||
|
/// yields `None` past `u32::MAX`, which on a long-lived daemon means the
|
||||||
|
/// sink is never registered and no stream is ever routed.
|
||||||
|
///
|
||||||
|
/// Strict on purpose: PipeWire emits a bare decimal, so anything else
|
||||||
|
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
|
||||||
|
/// property we do not understand and must not guess at. Leading zeroes
|
||||||
|
/// are accepted — they are unambiguous and parse to the same value.
|
||||||
|
fn parse_object_serial(raw: &str) -> Option<u64> {
|
||||||
|
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
raw.parse::<u64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
struct RouterState {
|
struct RouterState {
|
||||||
sink_serial: Option<u32>,
|
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
|
||||||
|
/// the `u32` node ids in `routed_node_ids` / `pending`.
|
||||||
|
sink_serial: Option<u64>,
|
||||||
default_metadata: Option<pipewire::metadata::Metadata>,
|
default_metadata: Option<pipewire::metadata::Metadata>,
|
||||||
routed_node_ids: Vec<u32>,
|
routed_node_ids: Vec<u32>,
|
||||||
pending: Vec<u32>,
|
pending: Vec<u32>,
|
||||||
@@ -654,3 +687,79 @@ fn try_flush(
|
|||||||
let _ = event_tx.send(Event::FirstRoutedStream);
|
let _ = event_tx.send(Event::FirstRoutedStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_parses_past_u32() {
|
||||||
|
// The regression this fix exists for: a serial one past `u32::MAX`
|
||||||
|
// used to parse as `None` and silently disable rerouting.
|
||||||
|
let beyond = u64::from(u32::MAX) + 1;
|
||||||
|
assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond));
|
||||||
|
assert_eq!(
|
||||||
|
parse_object_serial(&u64::MAX.to_string()),
|
||||||
|
Some(u64::MAX),
|
||||||
|
"the full 64-bit range must round-trip"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_accepts_ordinary_serials() {
|
||||||
|
// Without this the valid cases are only 1, 10 and 20 digits long, and
|
||||||
|
// a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`)
|
||||||
|
// survives the whole suite while rejecting every serial a freshly
|
||||||
|
// started daemon actually hands out. (Codex, round 1.)
|
||||||
|
for serial in 0_u64..=1024 {
|
||||||
|
assert_eq!(parse_object_serial(&serial.to_string()), Some(serial));
|
||||||
|
}
|
||||||
|
assert_eq!(parse_object_serial("123456789"), Some(123_456_789));
|
||||||
|
assert_eq!(
|
||||||
|
parse_object_serial("007"),
|
||||||
|
Some(7),
|
||||||
|
"leading zeroes are fine"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_boundary_values() {
|
||||||
|
assert_eq!(parse_object_serial("0"), Some(0));
|
||||||
|
assert_eq!(parse_object_serial("1"), Some(1));
|
||||||
|
let max32 = u64::from(u32::MAX);
|
||||||
|
assert_eq!(parse_object_serial(&max32.to_string()), Some(max32));
|
||||||
|
assert_eq!(
|
||||||
|
parse_object_serial(&(max32 - 1).to_string()),
|
||||||
|
Some(max32 - 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_round_trips_through_the_metadata_string() {
|
||||||
|
// `try_flush` writes the serial back out as a decimal string for
|
||||||
|
// `target.object`; widening must not introduce a formatting change.
|
||||||
|
for raw in ["0", "4294967296", "18446744073709551615"] {
|
||||||
|
let parsed = parse_object_serial(raw).expect("valid serial");
|
||||||
|
assert_eq!(parsed.to_string(), raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_rejects_malformed() {
|
||||||
|
for raw in [
|
||||||
|
"",
|
||||||
|
" 12",
|
||||||
|
"12 ",
|
||||||
|
"+12",
|
||||||
|
"-1",
|
||||||
|
"1.0",
|
||||||
|
"0x10",
|
||||||
|
"12a",
|
||||||
|
"abc",
|
||||||
|
// u64::MAX + 1 — overflow must be rejected, not wrapped.
|
||||||
|
"18446744073709551616",
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user