Files
pixelpass/src/main.rs
T
molluskandClaude Opus 5 d9ef38c1c5 repair: a pid is not an owner — modules carry a machine/boot/namespace token
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>
2026-07-27 01:57:55 -04:00

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