//! User-curated custom store: CRUD (add/update/delete) over the persisted custom entries the web //! console manages, and their mapping onto the uniform `GameEntry`. Split out of the `library` facade (plan §W5). use super::*; /// A user-added title, persisted in the hardened host config dir's `library.json` (see /// [`custom_path`]). Same shape the API returns and the web console edits. #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct CustomEntry { /// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path). pub id: String, pub title: String, #[serde(default)] pub art: Artwork, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch: Option, /// Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each /// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub prep: Vec, /// The external provider owning this entry (RFC §8), set ONLY by the provider reconcile /// API — `None` = a manual entry, which no provider operation ever touches, and which the /// manual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned /// entries, so ownership is never ambiguous). #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, /// The provider's own stable key for this title — the reconcile diff key, so the /// host-assigned `id` stays stable across reconciles. Present iff `provider` is. #[serde(default, skip_serializing_if = "Option::is_none")] pub external_id: Option, } /// Request body to create or replace a custom entry (no `id` — the host owns it). #[derive(Clone, Debug, Deserialize, ToSchema)] pub struct CustomInput { pub title: String, #[serde(default)] pub art: Artwork, #[serde(default)] pub launch: Option, /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. #[serde(default)] pub prep: Vec, } /// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the /// provider's required stable key. #[derive(Clone, Debug, Deserialize, ToSchema)] pub struct ProviderEntryInput { /// The provider's stable id for this title (the reconcile diff key). pub external_id: String, pub title: String, #[serde(default)] pub art: Artwork, #[serde(default)] pub launch: Option, /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. #[serde(default)] pub prep: Vec, } impl From for GameEntry { fn from(c: CustomEntry) -> Self { GameEntry { id: format!("custom:{}", c.id), store: "custom".into(), title: c.title, art: c.art, launch: c.launch, provider: c.provider, } } } fn custom_path() -> PathBuf { // The shared, hardened host config dir (`%ProgramData%\punktfunk` / `~/.config/punktfunk`, with // the `PUNKTFUNK_CONFIG_DIR` override) — NOT a bespoke XDG/HOME resolver with a CWD-relative // fallback. This file drives operator `prep`/`launch` command execution, so it must live where the // rest of the privileged host config does and be DACL/0600-locked against a non-privileged local // user planting one (security-review 2026-07-17). Matches hooks.json / the mgmt token. pf_paths::config_dir().join("library.json") } /// Load the custom entries (empty + non-fatal if the file is absent or malformed). pub fn load_custom() -> Vec { match std::fs::read_to_string(custom_path()) { Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { tracing::warn!(error = %e, "library.json malformed — ignoring custom entries"); Vec::new() }), Err(_) => Vec::new(), } } /// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam /// branch of the art proxy (`GET /library/art/custom:/`). `id` is the bare custom id (the /// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of /// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches /// itself). Blocking IO — call off the async runtime. pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec, String)> { let entry = load_custom().into_iter().find(|e| e.id == id)?; let field = match kind { ArtKind::Portrait => entry.art.portrait, ArtKind::Hero => entry.art.hero, ArtKind::Logo => entry.art.logo, ArtKind::Header => entry.art.header, }?; is_local_art_path(&field) .then(|| local_art_bytes(&field)) .flatten() } fn save_custom(entries: &[CustomEntry]) -> Result<()> { let dir = pf_paths::config_dir(); // Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a // library.json whose `prep`/`launch` commands the host would later execute — the same trust // boundary hooks.json and the mgmt token already use. pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; let json = serde_json::to_string_pretty(entries)?; // Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives // the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them // to the final path. let tmp = custom_path().with_extension("json.tmp"); pf_paths::write_secret_file(&tmp, json.as_bytes()) .with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, custom_path()).context("rename library.json")?; Ok(()) } /// 12 hex chars from the title + wall-clock nanos — collision-free in practice, no uuid dep. fn new_id(title: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6]) } /// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists, /// but a provider owns it" (the mgmt layer maps the latter to 409, not 404). pub enum MutateOutcome { Done(T), NotFound, /// The entry belongs to this provider — mutate it through the provider reconcile API /// (or remove the whole provider set); manual edits would be clobbered at the next sync. ProviderOwned(String), } /// Create a custom (manual) entry, returning it with its assigned id. pub fn add_custom(input: CustomInput) -> Result { let mut entries = load_custom(); let entry = CustomEntry { id: new_id(&input.title), title: input.title, art: input.art, launch: input.launch, prep: input.prep, provider: None, external_id: None, }; entries.push(entry.clone()); save_custom(&entries)?; emit_changed("manual"); Ok(entry) } /// Replace a manual entry's fields (id preserved). Provider-owned entries are refused — /// their state belongs to the provider's reconcile (RFC §8 ownership rule). pub fn update_custom(id: &str, input: CustomInput) -> Result> { let mut entries = load_custom(); let Some(slot) = entries.iter_mut().find(|e| e.id == id) else { return Ok(MutateOutcome::NotFound); }; if let Some(provider) = &slot.provider { return Ok(MutateOutcome::ProviderOwned(provider.clone())); } slot.title = input.title; slot.art = input.art; slot.launch = input.launch; slot.prep = input.prep; let updated = slot.clone(); save_custom(&entries)?; emit_changed("manual"); Ok(MutateOutcome::Done(updated)) } /// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]). pub fn delete_custom(id: &str) -> Result> { let mut entries = load_custom(); let Some(entry) = entries.iter().find(|e| e.id == id) else { return Ok(MutateOutcome::NotFound); }; if let Some(provider) = &entry.provider { return Ok(MutateOutcome::ProviderOwned(provider.clone())); } entries.retain(|e| e.id != id); save_custom(&entries)?; emit_changed("manual"); Ok(MutateOutcome::Done(())) } // ------------------------------------------------------------------ providers (RFC §8) /// Provider ids are path segments, event sources, and console labels: keep them tame. /// `manual` is reserved (it is the no-provider sentinel in `library.changed`). pub fn validate_provider_name(provider: &str) -> Result<(), String> { if provider == "manual" { return Err("provider id `manual` is reserved".into()); } let ok = !provider.is_empty() && provider.len() <= 64 && provider .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.')) && provider.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()); if ok { Ok(()) } else { Err("provider id must be 1–64 chars of [a-z0-9._-], starting alphanumeric".into()) } } /// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the /// diff key — a duplicate would make ownership of the surviving entry ambiguous). pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> { let mut seen = std::collections::HashSet::new(); for (i, e) in inputs.iter().enumerate() { if e.external_id.trim().is_empty() { return Err(format!("entries[{i}]: `external_id` must not be empty")); } if e.title.trim().is_empty() { return Err(format!("entries[{i}]: `title` must not be empty")); } if !seen.insert(e.external_id.as_str()) { return Err(format!( "entries[{i}]: duplicate `external_id` \"{}\"", e.external_id )); } } Ok(()) } /// The pure reconcile (unit-tested without the filesystem): replace `provider`'s entry set /// with `inputs` inside `entries` — keeping each surviving title's host id stable (keyed on /// `external_id`), dropping the provider's orphans, and never touching manual entries or /// other providers'. Returns the provider's resulting entries, payload order. fn reconcile_entries( entries: &mut Vec, provider: &str, inputs: Vec, ) -> Vec { // The provider's current entries, keyed by its own stable id. let mut existing: std::collections::HashMap = entries .iter() .filter(|e| e.provider.as_deref() == Some(provider)) .filter_map(|e| e.external_id.clone().map(|x| (x, e.clone()))) .collect(); // Everything the provider does NOT own survives untouched. entries.retain(|e| e.provider.as_deref() != Some(provider)); let mut result = Vec::with_capacity(inputs.len()); for input in inputs { let id = existing .remove(&input.external_id) .map(|prev| prev.id) // same title as last sync → keep its host id .unwrap_or_else(|| new_id(&format!("{provider}:{}", input.external_id))); result.push(CustomEntry { id, title: input.title, art: input.art, launch: input.launch, prep: input.prep, provider: Some(provider.to_string()), external_id: Some(input.external_id), }); } // `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile). entries.extend(result.iter().cloned()); result } /// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`). /// The caller validates the name and payload first. Emits `library.changed` with the provider /// as the source. pub fn reconcile_provider( provider: &str, inputs: Vec, ) -> Result> { let mut entries = load_custom(); let result = reconcile_entries(&mut entries, provider, inputs); save_custom(&entries)?; emit_changed(provider); Ok(result) } /// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the /// clean-uninstall path). Returns how many were removed; no event when nothing was. pub fn delete_provider(provider: &str) -> Result { let mut entries = load_custom(); let before = entries.len(); entries.retain(|e| e.provider.as_deref() != Some(provider)); let removed = before - entries.len(); if removed > 0 { save_custom(&entries)?; emit_changed(provider); } Ok(removed) } /// The prep/undo steps for a library id — `custom:` entries only (the other stores have no /// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead). pub fn prep_for(library_id: &str) -> Vec { let Some(id) = library_id.strip_prefix("custom:") else { return Vec::new(); }; load_custom() .into_iter() .find(|e| e.id == id) .map(|e| e.prep) .unwrap_or_default() } /// Every library mutation announces itself (RFC §4): `source` is `"manual"` for the operator /// CRUD, the provider id for a reconcile/uninstall — hooks and the SDK filter on it. fn emit_changed(source: &str) { crate::events::emit(crate::events::EventKind::LibraryChanged { source: source.to_string(), }); } /// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it /// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax). /// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping /// ([`windows_launch_for`]). pub(crate) fn valid_steam_appid(value: &str) -> bool { !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) } #[cfg(test)] mod tests { use super::*; fn manual(id: &str, title: &str) -> CustomEntry { CustomEntry { id: id.into(), title: title.into(), art: Artwork::default(), launch: None, prep: Vec::new(), provider: None, external_id: None, } } fn input(external_id: &str, title: &str) -> ProviderEntryInput { ProviderEntryInput { external_id: external_id.into(), title: title.into(), art: Artwork::default(), launch: None, prep: Vec::new(), } } #[test] fn custom_entry_maps_to_game_entry_with_provider() { let g: GameEntry = manual("abc123", "My ROM").into(); assert_eq!(g.id, "custom:abc123"); assert_eq!(g.store, "custom"); assert_eq!(g.provider, None); let mut e = manual("def456", "Synced"); e.provider = Some("romm".into()); e.external_id = Some("rom-1".into()); let g: GameEntry = e.into(); assert_eq!(g.provider.as_deref(), Some("romm")); } /// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow, /// orphans drop, and neither manual entries nor other providers are ever touched. #[test] fn reconcile_is_declarative_with_stable_ids() { let mut entries = vec![manual("man1", "Hand-added")]; // Another provider's entry must survive every romm reconcile. let mut other = manual("oth1", "Other title"); other.provider = Some("itch".into()); other.external_id = Some("x1".into()); entries.push(other); // First sync: two titles appear. let r1 = reconcile_entries( &mut entries, "romm", vec![input("rom-a", "Game A"), input("rom-b", "Game B")], ); assert_eq!(r1.len(), 2); assert!(r1.iter().all(|e| e.provider.as_deref() == Some("romm"))); let id_a = r1[0].id.clone(); assert_eq!(entries.len(), 4); // Second sync: A renamed, B gone, C new — A's host id must be STABLE. let r2 = reconcile_entries( &mut entries, "romm", vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], ); assert_eq!(r2.len(), 2); assert_eq!(r2[0].id, id_a, "same external_id keeps its host id"); assert_eq!(r2[0].title, "Game A (v2)"); assert_ne!(r2[1].id, id_a); assert!( !entries .iter() .any(|e| e.external_id.as_deref() == Some("rom-b")), "orphan dropped" ); // Idempotence: an identical re-PUT changes nothing. let snapshot: Vec = entries.iter().map(|e| e.id.clone()).collect(); let r3 = reconcile_entries( &mut entries, "romm", vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], ); assert_eq!( r3.iter().map(|e| &e.id).collect::>(), r2.iter().map(|e| &e.id).collect::>() ); assert_eq!( entries.iter().map(|e| e.id.clone()).collect::>(), snapshot ); // The bystanders never moved. assert!(entries .iter() .any(|e| e.id == "man1" && e.provider.is_none())); assert!(entries .iter() .any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch"))); // Empty payload = remove everything the provider owns (same as DELETE). let r4 = reconcile_entries(&mut entries, "romm", Vec::new()); assert!(r4.is_empty()); assert_eq!( entries.len(), 2, "only the manual + other-provider entries remain" ); } #[test] fn provider_name_and_payload_validation() { assert!(validate_provider_name("romm").is_ok()); assert!(validate_provider_name("my-provider.v2").is_ok()); assert!(validate_provider_name("manual").is_err(), "reserved"); assert!(validate_provider_name("").is_err()); assert!(validate_provider_name("Bad/Name").is_err()); assert!(validate_provider_name("-lead").is_err()); assert!(validate_provider_name(&"x".repeat(65)).is_err()); assert!(validate_provider_payload(&[input("a", "A")]).is_ok()); assert!(validate_provider_payload(&[input("", "A")]).is_err()); assert!(validate_provider_payload(&[input("a", " ")]).is_err()); assert!( validate_provider_payload(&[input("a", "A"), input("a", "B")]).is_err(), "duplicate external_id" ); } }