forked from unom/punktfunk
Second de-coupling for the host crate carve (plan §W6.1 leaf). config_dir / create_private_dir / write_secret_file (+ the Windows DACL helpers) were pub(crate) in the gamestream junk drawer, yet consumed by vdisplay, stats, gpu, library, mgmt_token, native_pairing and the Windows service — many of which become pf-media / pf-vdisplay, for which crate::gamestream would be an illegal upward edge. New leaf crate pf-paths (pure std + tracing) owns them; ~40 call sites across 14 files repoint to pf_paths::. gamestream keeps only its own concerns. Verified: Linux (home-worker-5) clippy -p pf-paths -p punktfunk-host --all-targets -D warnings + tests (347 pass, incl. secrets_are_written_owner_only); Windows (192.168.1.158) clippy --features nvenc,amf-qsv --all-targets green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
5.1 KiB
Rust
138 lines
5.1 KiB
Rust
//! The persistent native-pairing trust store: `~/.config/punktfunk/punktfunk1-paired.json`
|
|
//! (plan §W5 — carved out of the [`super`] facade). Owns the paired-clients [`Mutex`] and the
|
|
//! atomic-replace persistence; the pending-approval side of a pairing lives in [`super::approval`].
|
|
|
|
use anyhow::Result;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
|
|
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
|
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
|
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
|
pub struct PairedClients {
|
|
pub clients: Vec<PairedClient>,
|
|
}
|
|
|
|
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct PairedClient {
|
|
pub name: String,
|
|
/// Hex SHA-256 of the client's certificate.
|
|
pub fingerprint: String,
|
|
}
|
|
|
|
impl PairedClients {
|
|
fn contains(&self, fp_hex: &str) -> bool {
|
|
self.clients
|
|
.iter()
|
|
.any(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
|
|
}
|
|
}
|
|
|
|
struct PairedState {
|
|
path: PathBuf,
|
|
clients: PairedClients,
|
|
}
|
|
|
|
fn default_path() -> Result<PathBuf> {
|
|
// `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the
|
|
// native paired-store works without a HOME env var (which a Windows service/task doesn't set).
|
|
Ok(pf_paths::config_dir().join("punktfunk1-paired.json"))
|
|
}
|
|
|
|
fn load(path: &Path) -> PairedClients {
|
|
std::fs::read(path)
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn save(state: &PairedState) -> Result<()> {
|
|
if let Some(dir) = state.path.parent() {
|
|
pf_paths::create_private_dir(dir)?;
|
|
}
|
|
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would
|
|
// silently lock out every paired client on a --require-pairing host). Temp + rename. The temp is
|
|
// written owner-only so a local user can't inject a fingerprint to pair themselves.
|
|
let tmp = state.path.with_extension("json.tmp");
|
|
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?;
|
|
std::fs::rename(&tmp, &state.path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The persistent trust store — the paired-clients set behind a [`Mutex`], backed by an
|
|
/// atomic-replace JSON file.
|
|
pub(super) struct TrustStore {
|
|
paired: Mutex<PairedState>,
|
|
}
|
|
|
|
impl TrustStore {
|
|
/// Open (load) the trust store. `store_path = None` uses the default config path.
|
|
pub(super) fn open(store_path: Option<PathBuf>) -> Result<TrustStore> {
|
|
let path = match store_path {
|
|
Some(p) => p,
|
|
None => default_path()?,
|
|
};
|
|
let clients = load(&path);
|
|
Ok(TrustStore {
|
|
paired: Mutex::new(PairedState { path, clients }),
|
|
})
|
|
}
|
|
|
|
/// Is this client (hex SHA-256 fingerprint) in the paired set?
|
|
pub(super) fn is_paired(&self, fp_hex: &str) -> bool {
|
|
self.paired.lock().unwrap().clients.contains(fp_hex)
|
|
}
|
|
|
|
/// 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. (Clearing any pending knock for this fingerprint is the caller's job —
|
|
/// see [`super::approval::ApprovalQueue::admit_and_clear`].)
|
|
pub(super) fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
|
|
let name = super::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);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The paired clients (for the management API's device list).
|
|
pub(super) fn list(&self) -> Vec<PairedClient> {
|
|
self.paired.lock().unwrap().clients.clients.clone()
|
|
}
|
|
|
|
/// 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(super) 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 {
|
|
if let Err(e) = save(&p) {
|
|
p.clients.clients = snapshot;
|
|
return Err(e);
|
|
}
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
/// The number of paired clients (for the status snapshot).
|
|
pub(super) fn count(&self) -> u32 {
|
|
self.paired.lock().unwrap().clients.clients.len() as u32
|
|
}
|
|
}
|