Closes the last blocking finding: a pid is only a number, and the same number is a different process in a different pid namespace. Repair running inside a container that can reach the host's Pulse socket saw a live host's modules, asked about that pid in its own namespace, was told nothing existed, and unloaded a running host's audio. No negative signal closes that — `NSpid == 1` does not prove the initial namespace, since its leftmost value is relative to whichever procfs was mounted. So the module now carries the answer with it. Every module a host loads gets `pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only asks about a pid when all three identities match its own. Anything else is reported and left alone, and its pid is never even looked up — asking is the bug, because the answer would be meaningless. **Untagged modules are refused by default.** Everything loaded before tokens existed is unattributable, so `--repair` now lists those and does nothing, with `--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the candidates. That is a deliberate loss of reach: the failure being optimised against is a false-positive destructive repair, and leaving an old orphan behind is recoverable where destroying live routing is not. A foreign token is refused even with the flag, since the flag speaks to missing evidence, not wrong evidence. The vehicle was verified on the live server before anything was built on it: all three shapes accept a property-list argument (`sink_properties`, `sink_input_properties`, `source_output_properties`), the recorded argument comes back byte-identical — so exact-form matching still holds — and the property really lands on the resulting sink, sink-input and source-output. **Audit gate passed, with the variable isolated.** The token rides on real graph objects that phases 2/3 observe, so the partition had to be re-measured. Running the same fixture with and without tokens gives an identical partition: 2 eligible (FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the same six-entry taint set. Everything that differs from the empty-graph baseline is the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink into the real default sink — and none of it is the property's. Attributing that to the token without the untokened control would have been the mistake. A side benefit: the per-load nonce narrows the ABA window I previously documented as unclosable. Two loads by the same pid no longer render byte-identical arguments, so a fingerprint taken from one no longer matches the other. Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns` dropped from the comparison, and untagged treated as judgeable regardless of policy — each killed by its own test. ⚠️ The third "survived" on first run because my mutation script's indentation did not match and the edit silently did nothing; the re-run asserts the file actually changed. A mutation that was never applied proves the same amount as no mutation at all. Field-verified live, three fixtures for one dead pid in one run: tokened with this machine's identity is cleaned, tokened with a foreign pid namespace is left alone and reported (and the legacy flag does not override it), and untagged is refused then cleaned only when asked. The two older field fixtures were tokenised too — without that the A/B test would have failed and the reference-gate test would have passed for the wrong reason, which is a vacuous gate in the harness rather than the code. 253 tests, clippy clean, fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
3.3 KiB
Rust
108 lines
3.3 KiB
Rust
mod cli;
|
|
mod common;
|
|
mod doctor;
|
|
#[cfg(feature = "gui")]
|
|
mod gui;
|
|
mod host;
|
|
mod interactive;
|
|
mod repair;
|
|
mod viewer;
|
|
|
|
use anyhow::Result;
|
|
use clap::Parser;
|
|
use cli::Cli;
|
|
use iroh_tickets::endpoint::EndpointTicket;
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
init_tracing(cli.verbose);
|
|
|
|
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
|
|
common::output::set_json(true);
|
|
}
|
|
|
|
if cli.gui {
|
|
#[cfg(feature = "gui")]
|
|
{
|
|
return gui::run(cli.relay);
|
|
}
|
|
#[cfg(not(feature = "gui"))]
|
|
{
|
|
anyhow::bail!(
|
|
"this binary was built without GUI support. Rebuild with \
|
|
`cargo build --release --features gui` to use --gui."
|
|
);
|
|
}
|
|
}
|
|
|
|
// Diagnostics run before pipewire::init() (they don't need it) and work
|
|
// regardless of the `gui` feature, so a headless tester can probe their box.
|
|
if cli.doctor {
|
|
let relay = common::endpoint::relay_override(cli.relay.as_deref());
|
|
return doctor::run(relay).await;
|
|
}
|
|
|
|
// libpipewire requires global init before any pw_* call. Idempotent;
|
|
// safe to call even when the per-app audio thread never spawns.
|
|
pipewire::init();
|
|
|
|
if cli.repair {
|
|
return repair::run(cli.repair_legacy_untagged).await;
|
|
}
|
|
|
|
// Read-only diagnostic: observe the graph, report what the audio-exclusion
|
|
// engine concludes, create nothing. Placed before the host/viewer dispatch
|
|
// because it is neither — it shares no screen and connects to no peer.
|
|
if cli.audit_audio {
|
|
return host::audit::run::run_standalone().await;
|
|
}
|
|
|
|
if cli.reconfigure {
|
|
return interactive::run_reconfigure().await;
|
|
}
|
|
|
|
if cli.host {
|
|
if cli.ticket.is_some() {
|
|
anyhow::bail!(
|
|
"--host and a ticket argument are mutually exclusive: --host shares your \
|
|
screen, a ticket views someone else's."
|
|
);
|
|
}
|
|
return host::run(cli.into_host_opts(false)).await;
|
|
}
|
|
|
|
match cli.ticket.as_deref() {
|
|
Some(s) => {
|
|
let ticket: EndpointTicket = s.parse().map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"argument doesn't look like a pixelpass ticket ({e}).\n\
|
|
Run with no arguments for the interactive menu, or pass a ticket to view."
|
|
)
|
|
})?;
|
|
viewer::run(ticket, cli.into_viewer_opts(false)).await
|
|
}
|
|
None => interactive::run(cli).await,
|
|
}
|
|
}
|
|
|
|
fn init_tracing(verbose: bool) {
|
|
let default = if verbose {
|
|
"pixelpass=trace,iroh=info"
|
|
} else {
|
|
"pixelpass=info,iroh=warn"
|
|
};
|
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
|
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
|
|
// writer to stdout, but with `--output json` stdout carries the JSON event
|
|
// stream the `--gui` front-end parses (see `common::output`) — logging there
|
|
// interleaves log lines into that stream (corrupting events and starving the
|
|
// GUI's stderr-tail diagnostics). Pin it to stderr to honor that contract.
|
|
tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.with_env_filter(filter)
|
|
.with_target(false)
|
|
.init();
|
|
}
|