feat(pairing): delegated approval (§8b-1) — approve an unpaired device from the console
ci / web (push) Failing after 40s
ci / rust (push) Successful in 1m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 1m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Successful in 16s

An identified-but-unpaired device that knocks on a pairing-required host is now
held as a pending request the operator approves from the web console — pairing it
with no PIN fetched out of band — instead of a flat reject.

- core: Hello gains an optional trailing device name (len u8 || UTF-8, ≤64,
  same trailing-back-compat pattern as compositor/gamepad/bitrate). client-rs
  --name sends it; the connector sends None (fingerprint-derived label).
- native_pairing: in-memory pending queue (note_pending dedups by fingerprint,
  evicts the least-recently-active past a 32 cap, 10-min TTL); approve_pending
  pins the fingerprint, deny drops it. Names are sanitized (strip control/ANSI/
  bidi — untrusted wire input); add()/remove() roll back in-memory on a persist
  failure; pairing clears any stale pending knock.
- m3: the require_pairing gate records the knock (sanitized label) before
  rejecting; anonymous (certless) clients record nothing.
- mgmt: GET /native/pending, POST /native/pending/{id}/approve (optional {name})
  and /deny; OpenAPI + tests; docs/api/openapi.json regenerated.
- web: a "Waiting for approval" section on the Pairing page (live-poll, Approve/
  Deny, error-surfaced via QueryState); en+de strings.
- Also completes an in-progress NativeClient Sync refactor (receivers behind
  per-plane mutexes) that was left half-applied in the tree.

Adversarially reviewed (4 lenses + 3-vote verify); the confirmed findings are
fixed here. Validated live on the GNOME box: knock (with a wire name, and a
malicious ANSI/bidi name that got neutralized) → pending → approve → the same
identity streams real video. Full workspace tests + clippy + fmt green; web tsc
clean. Roadmap §8b-1 marked done; §8b-2 (peer-push approval) is the client
follow-up. See docs-site pairing page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 19:14:05 +00:00
parent 9758751a4d
commit 99b4de32ee
14 changed files with 1250 additions and 109 deletions
+121 -6
View File
@@ -454,13 +454,34 @@ async fn serve_session(
punktfunk_core::ABI_VERSION
);
if opts.require_pairing {
let known = endpoint::peer_fingerprint(&conn)
.map(|fp| np.is_paired(&fingerprint_hex(&fp)))
let fp = endpoint::peer_fingerprint(&conn);
let known = fp
.as_ref()
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
.unwrap_or(false);
anyhow::ensure!(
known,
"unpaired client rejected (this host requires pairing — run the PIN ceremony first)"
);
if !known {
// Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending
// request the operator can approve from the console — no PIN fetched out of band.
// The label is the client's Hello name, else fingerprint-derived. An anonymous
// client (no certificate) has no identity to approve, so nothing is recorded.
if let Some(fp) = &fp {
let fp_hex = fingerprint_hex(fp);
// Sanitize the wire-supplied name before it reaches the log (untrusted: an
// unpaired device could embed terminal escapes / bidi overrides); note_pending
// stores the same sanitized form and derives a fingerprint label when empty.
let label = crate::native_pairing::sanitize_device_name(
hello.name.as_deref().unwrap_or(""),
&fp_hex,
);
tracing::info!(name = %label, fingerprint = %fp_hex,
"unpaired device knocked — held for approval in the console");
np.note_pending(&label, &fp_hex);
}
anyhow::bail!(
"unpaired client rejected (this host requires pairing — approve the device \
in the console, or run the PIN ceremony)"
);
}
}
crate::encode::validate_dimensions(
crate::encode::Codec::H265,
@@ -2206,6 +2227,100 @@ mod tests {
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
}
/// Delegated approval (§8b-1) end to end in-process: an identified-but-unpaired client's
/// knock on a pairing-required host is held as a pending request (fingerprint-derived label —
/// the connector sends no Hello name); approving it pairs the fingerprint, and the same
/// identity then gets a session with no PIN ceremony.
#[test]
fn delegated_approval_admits_after_knock() {
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
let store =
std::env::temp_dir().join(format!("pf-approval-test-{}.json", std::process::id()));
let _ = std::fs::remove_file(&store);
let np = Arc::new(NativePairing::load_with(Some(store.clone()), None, false).unwrap());
let np_host = np.clone();
let host = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
rt.block_on(serve(
M3Options {
port: 19779,
source: M3Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 2, // the knock + the post-approval session
max_concurrent: 1,
require_pairing: true,
allow_pairing: false,
pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle
},
np_host,
))
});
std::thread::sleep(std::time::Duration::from_millis(500));
let timeout = std::time::Duration::from_secs(10);
let (cert, key) = endpoint::generate_identity().unwrap();
let mode = punktfunk_core::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
// 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending.
assert!(
NativeClient::connect(
"127.0.0.1",
19779,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
None,
Some((cert.clone(), key.clone())),
timeout
)
.is_err(),
"unpaired knock must still be rejected"
);
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
let pend = np.pending();
assert_eq!(pend.len(), 1, "the knock must be held for approval");
assert_eq!(pend[0].fingerprint, expected_fp);
assert!(
pend[0].name.starts_with("device "),
"no Hello name → fingerprint-derived label, got {:?}",
pend[0].name
);
// 2: approve (with an operator label) → the same identity now gets a session, no PIN.
let approved = np
.approve_pending(pend[0].id, Some("Approved Device"))
.unwrap()
.expect("pending id must approve");
assert_eq!(approved.fingerprint, expected_fp);
let client = NativeClient::connect(
"127.0.0.1",
19779,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
None,
Some((cert, key)),
timeout,
)
.expect("approved identity gets a session");
drop(client);
let _ = std::fs::remove_file(&store);
host.join().unwrap().unwrap();
}
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
/// identity gets a session on a pairing-required host; an anonymous client does not.
+224
View File
@@ -145,6 +145,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(disarm_native_pairing))
.routes(routes!(list_native_clients))
.routes(routes!(unpair_native_client))
.routes(routes!(list_pending_devices))
.routes(routes!(approve_pending_device))
.routes(routes!(deny_pending_device))
.routes(routes!(stop_session))
.routes(routes!(request_idr)),
)
@@ -379,6 +382,29 @@ struct NativeClient {
fingerprint: String,
}
/// An unpaired device that tried to connect while the host requires pairing — awaiting
/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
#[derive(Serialize, ToSchema)]
struct PendingDevice {
/// Id to address approve/deny (per-process; entries expire after ~10 minutes).
id: u32,
/// Best-effort device label (the client's own name, else fingerprint-derived).
#[schema(example = "Enrico's MacBook")]
name: String,
/// Hex SHA-256 of the device's certificate — what approval pins.
fingerprint: String,
/// Seconds since the device last knocked.
age_secs: u64,
}
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
#[derive(Deserialize, ToSchema)]
struct ApprovePending {
/// Operator-chosen label for the device (defaults to the name it knocked with).
#[schema(example = "Living Room TV")]
name: Option<String>,
}
/// Error envelope for every non-2xx response.
#[derive(Serialize, Deserialize, ToSchema)]
struct ApiError {
@@ -885,6 +911,116 @@ async fn unpair_native_client(
}
}
/// List devices awaiting pairing approval
///
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
/// it without a PIN (delegated approval); entries expire after ~10 minutes.
#[utoipa::path(
get,
path = "/native/pending",
tag = "native",
operation_id = "listPendingDevices",
responses(
(status = OK, description = "Devices awaiting approval (empty when none, or when the \
native host is not enabled)", body = Vec<PendingDevice>),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn list_pending_devices(State(st): State<Arc<MgmtState>>) -> Json<Vec<PendingDevice>> {
let pending = st
.native
.as_ref()
.map(|np| np.pending())
.unwrap_or_default();
Json(
pending
.into_iter()
.map(|p| PendingDevice {
id: p.id,
name: p.name,
fingerprint: p.fingerprint,
age_secs: p.age_secs,
})
.collect(),
)
}
/// Approve a pending device
///
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
/// relabel it via the body; send `{}` to keep the name it knocked with.
#[utoipa::path(
post,
path = "/native/pending/{id}/approve",
tag = "native",
operation_id = "approvePendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
request_body = ApprovePending,
responses(
(status = OK, description = "Device paired", body = NativeClient),
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn approve_pending_device(
State(st): State<Arc<MgmtState>>,
Path(id): Path<u32>,
ApiJson(req): ApiJson<ApprovePending>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.approve_pending(id, req.name.as_deref()) {
Ok(Some(client)) => {
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
"management API: pending device approved (delegated pairing)");
Json(NativeClient {
name: client.name,
fingerprint: client.fingerprint,
})
.into_response()
}
Ok(None) => api_error(
StatusCode::NOT_FOUND,
"no pending request with that id (it may have expired — have the device retry)",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// Deny a pending device
///
/// Drops the request. Not a blocklist — the device's next attempt knocks again.
#[utoipa::path(
post,
path = "/native/pending/{id}/deny",
tag = "native",
operation_id = "denyPendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
responses(
(status = NO_CONTENT, description = "Request dropped"),
(status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn deny_pending_device(State(st): State<Arc<MgmtState>>, Path(id): Path<u32>) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
if np.deny_pending(id) {
tracing::info!(id, "management API: pending device denied");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(StatusCode::NOT_FOUND, "no pending request with that id")
}
}
/// Stop the active session
///
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
@@ -1344,6 +1480,77 @@ mod tests {
assert_eq!(b["armed"], false);
}
#[tokio::test]
async fn pending_devices_approve_and_deny() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-pending-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Empty queue.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Two devices knock (what the QUIC gate records); they appear in the list.
np.note_pending("Enrico's MacBook", "aa11");
np.note_pending("device bb22cc33", "bb22");
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 2);
assert_eq!(b[0]["name"], "Enrico's MacBook");
let approve_id = b[0]["id"].as_u64().unwrap();
let deny_id = b[1]["id"].as_u64().unwrap();
// Approve the first with an operator label → paired under that name, gone from pending.
let (s, b) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{approve_id}/approve"),
serde_json::json!({"name": "Office MacBook"}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["name"], "Office MacBook");
assert_eq!(b["fingerprint"], "aa11");
assert!(np.is_paired("AA11"), "approval pins the fingerprint");
// Deny the second → dropped, not paired; a re-deny is 404.
let deny = post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
);
assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT);
assert!(!np.is_paired("bb22"));
let (s, _) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
// Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name).
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 0);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/123/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn native_endpoints_report_disabled_without_native_host() {
let app = test_app(test_state(), None);
@@ -1357,5 +1564,22 @@ mod tests {
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
// Pending list reads as an empty array (like /native/clients), not a 503.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Approve/deny without a native host are 503.
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/deny", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
}
}
+307 -11
View File
@@ -47,10 +47,47 @@ struct Armed {
expires_at: Option<Instant>,
}
/// Shared native-pairing state: the arming PIN window + the persistent trust store.
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
/// again), and they expire after [`PENDING_TTL`].
struct Pending {
id: u32,
name: String,
fp_hex: String,
requested_at: Instant,
}
#[derive(Default)]
struct PendingState {
next_id: u32,
items: Vec<Pending>,
}
/// A pending-approval snapshot for the management API / web console.
pub struct PendingRequest {
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
pub id: u32,
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
pub name: String,
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
pub fingerprint: String,
/// Seconds since the (most recent) knock.
pub age_secs: u64,
}
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
/// approvable days later when the operator no longer remembers the context).
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
const PENDING_CAP: usize = 32;
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
/// pending-approval queue.
pub struct NativePairing {
arm: Mutex<Armed>,
paired: Mutex<PairedState>,
pending: Mutex<PendingState>,
}
/// A snapshot for the management API / web console.
@@ -92,6 +129,48 @@ fn random_pin() -> String {
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
}
/// Sanitize a client-supplied device name before it's stored, listed, or logged. The name comes
/// straight off the wire (the `Hello`/`PairRequest` of an *unpaired* device), so it's untrusted: a
/// hostile LAN device could embed terminal escapes / control characters (log + console injection) or
/// bidi overrides (`U+202E` etc.) to make a malicious device *look* like a trusted one in the
/// approval UI. Strip C0/C1 controls and Unicode bidi/format controls, collapse whitespace, trim, and
/// cap the length; an empty/all-control name falls back to a fingerprint-derived label.
pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| if c == '\t' || c == '\n' { ' ' } else { c })
.filter(|&c| {
!c.is_control()
// Bidi/format controls that could spoof or reorder the displayed name.
&& !('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF
&& !('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI
&& c != '\u{200E}' // LRM
&& c != '\u{200F}' // RLM
&& c != '\u{061C}' // ALM
&& c != '\u{FEFF}' // BOM / zero-width no-break space
})
.collect();
// Collapse internal whitespace runs, trim, cap at the wire limit.
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let mut trimmed = collapsed.as_str();
while trimmed.len() > NAME_MAX {
let mut cut = NAME_MAX;
while !trimmed.is_char_boundary(cut) {
cut -= 1;
}
trimmed = &trimmed[..cut];
}
let trimmed = trimmed.trim();
if trimmed.is_empty() {
format!("device {}", &fp_hex[..8.min(fp_hex.len())])
} else {
trimmed.to_string()
}
}
/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`).
const NAME_MAX: usize = 64;
impl NativePairing {
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
@@ -117,6 +196,7 @@ impl NativePairing {
Ok(NativePairing {
arm: Mutex::new(arm),
paired: Mutex::new(PairedState { path, clients }),
pending: Mutex::new(PendingState::default()),
})
}
@@ -172,15 +252,33 @@ impl NativePairing {
self.paired.lock().unwrap().clients.contains(fp_hex)
}
/// Record a successful pairing (re-pairing the same fingerprint just updates the name).
/// Record a successful pairing (re-pairing the same fingerprint just updates the name
/// matched case-insensitively, like every other fingerprint comparison here). The name is
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
/// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired).
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
let mut p = self.paired.lock().unwrap();
p.clients.clients.retain(|c| c.fingerprint != fp_hex);
p.clients.clients.push(PairedClient {
name: name.to_string(),
fingerprint: fp_hex.to_string(),
});
save(&p)
let name = sanitize_device_name(name, fp_hex);
{
let mut p = self.paired.lock().unwrap();
let snapshot = p.clients.clients.clone(); // restore on a failed save
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
p.clients.clients.push(PairedClient {
name,
fingerprint: fp_hex.to_string(),
});
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
// A device that knocked and is now paired shouldn't linger in the approval list.
let mut pending = self.pending.lock().unwrap();
pending
.items
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
Ok(())
}
/// The paired clients (for the management API's device list).
@@ -188,19 +286,122 @@ impl NativePairing {
self.paired.lock().unwrap().clients.clients.clone()
}
/// Remove a paired client by fingerprint. Returns whether one was removed.
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
/// failure the in-memory store is rolled back (it never diverges from disk).
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
let mut p = self.paired.lock().unwrap();
let before = p.clients.clients.len();
let snapshot = p.clients.clients.clone();
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
let removed = p.clients.clients.len() != before;
if removed {
save(&p)?;
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
Ok(removed)
}
// -- Delegated approval (roadmap §8b-1) --------------------------------
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]).
fn expire_pending(pending: &mut PendingState) {
pending
.items
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
}
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same
/// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam
/// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry
/// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]).
pub fn note_pending(&self, name: &str, fp_hex: &str) {
let name = sanitize_device_name(name, fp_hex);
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
{
p.requested_at = Instant::now();
p.name = name;
return;
}
if pending.items.len() >= PENDING_CAP {
// Evict the least-recently-active entry. NOT index 0: the in-place refresh above means
// Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly.
if let Some(at) = pending
.items
.iter()
.enumerate()
.min_by_key(|(_, p)| p.requested_at)
.map(|(i, _)| i)
{
pending.items.remove(at);
}
}
let id = pending.next_id;
pending.next_id = pending.next_id.wrapping_add(1);
pending.items.push(Pending {
id,
name,
fp_hex: fp_hex.to_string(),
requested_at: Instant::now(),
});
}
/// The devices currently awaiting approval (for the management API).
pub fn pending(&self) -> Vec<PendingRequest> {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.map(|p| PendingRequest {
id: p.id,
name: p.name.clone(),
fingerprint: p.fp_hex.clone(),
age_secs: p.requested_at.elapsed().as_secs(),
})
.collect()
}
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
/// (or expired) id.
pub fn approve_pending(
&self,
id: u32,
name_override: Option<&str>,
) -> Result<Option<PairedClient>> {
let entry = {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
let Some(at) = pending.items.iter().position(|p| p.id == id) else {
return Ok(None);
};
pending.items.remove(at)
}; // pending lock released — add() takes the paired lock
let name = name_override.unwrap_or(&entry.name);
self.add(name, &entry.fp_hex)?;
Ok(Some(PairedClient {
name: name.to_string(),
fingerprint: entry.fp_hex,
}))
}
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
/// re-creates an entry — deny is "not now", not a blocklist.
pub fn deny_pending(&self, id: u32) -> bool {
let mut pending = self.pending.lock().unwrap();
let before = pending.items.len();
pending.items.retain(|p| p.id != id);
pending.items.len() != before
}
}
#[cfg(test)]
@@ -250,6 +451,101 @@ mod tests {
let _ = std::fs::remove_file(&p);
}
#[test]
fn pending_knock_approve_and_deny() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
assert!(np.pending().is_empty());
// A knock appears; a re-knock from the same fingerprint refreshes (same id, new name)
// instead of duplicating.
np.note_pending("device aa11", "AA11");
np.note_pending("Bedroom TV", "aa11");
let pend = np.pending();
assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint");
assert_eq!(pend[0].name, "Bedroom TV");
let id = pend[0].id;
// Deny drops it without pairing; the next knock gets a fresh id.
assert!(np.deny_pending(id));
assert!(!np.deny_pending(id));
assert!(np.pending().is_empty());
assert!(!np.is_paired("aa11"));
// Approve pairs the fingerprint (operator label wins) and clears the entry.
np.note_pending("device bb22", "BB22");
let id = np.pending()[0].id;
assert!(
np.approve_pending(9999, None).unwrap().is_none(),
"unknown id"
);
let client = np
.approve_pending(id, Some("Living Room"))
.unwrap()
.unwrap();
assert_eq!(client.name, "Living Room");
assert!(np.is_paired("bb22"), "approval pins the fingerprint");
assert!(np.pending().is_empty());
assert_eq!(np.list()[0].name, "Living Room");
// The cap evicts the oldest knock.
for i in 0..(PENDING_CAP + 3) {
np.note_pending("flood", &format!("f{i:03}"));
}
let pend = np.pending();
assert_eq!(pend.len(), PENDING_CAP);
assert_eq!(pend[0].fingerprint, "f003", "oldest entries evicted first");
let _ = std::fs::remove_file(&p);
}
#[test]
fn sanitize_strips_control_and_bidi() {
// ANSI escape + newline + a bidi override that could spoof the displayed name.
let dirty = "\u{1b}]0;evil\u{07}Good\nDevice\u{202E}xfp";
let clean = sanitize_device_name(dirty, "deadbeef00");
assert!(!clean.contains('\u{1b}') && !clean.contains('\n') && !clean.contains('\u{202E}'));
// ESC dropped (']' survives), BEL dropped, '\n'→space (Good Device), RLO dropped (no space).
assert_eq!(clean, "]0;evilGood Devicexfp");
// All-control / empty → fingerprint-derived fallback.
assert_eq!(
sanitize_device_name("\u{1b}\u{07}", "deadbeef00"),
"device deadbeef"
);
assert_eq!(sanitize_device_name(" ", "abc"), "device abc");
// Over-long names cap at a char boundary.
assert!(sanitize_device_name(&"x".repeat(200), "ab").len() <= 64);
}
#[test]
fn pairing_clears_a_pending_knock() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.note_pending("Knocker", "cc44");
assert_eq!(np.pending().len(), 1);
// Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry.
np.add("Knocker", "CC44").unwrap();
assert!(
np.pending().is_empty(),
"a now-paired device must leave the approval list"
);
assert!(np.is_paired("cc44"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn add_replaces_case_insensitively() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.add("First", "AB12").unwrap();
np.add("Second", "ab12").unwrap(); // same device, different hex case
assert_eq!(np.list().len(), 1, "re-add must replace, not duplicate");
assert_eq!(np.list()[0].name, "Second");
let _ = std::fs::remove_file(&p);
}
#[test]
fn cli_flag_arms_with_no_expiry() {
let p = temp();