feat(store): console plugin store, index repo, and the fixes on-glass found
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s
Console: a Plugins section (Browse / Installed / Sources) on a static nav entry, with install friction proportional to trust — a plain confirm for a verified entry, a warning naming the curator for an external one, and a danger dialog that makes you retype the spec for a raw package. Tier badges are permanent and follow the plugin onto its own UI page. Index: unom/punktfunk-plugin-index published and served from Gitea's raw endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is publish, which resolves the design's open hosting question. Four things only running it could find: - runner discovery matched @punktfunk/plugin-* only, so a third-party scoped plugin (which D8 requires) would install and never run - ...and that convention also matches @punktfunk/plugin-kit, a plugin's own framework: it listed as installed and would have been imported as a unit. Both now key off the plugins dir's top-level dependencies, with an emptied dependency list meaning 'nothing installed' rather than falling back to the naming convention - the store must not pass new flags to the runner: the scripting package ships separately and an older one reads an unknown flag's value as a package name. The host writes the bunfig scope mapping itself - ureq reports only >= 400 as Err, so a conditional request's 304 arrives as Ok with an empty body — handled as an error it made every refresh after the first verify a signature over zero bytes and sit stale Also: the console's first Tabs use exposed an @unom/ui theme gap that rendered inactive tabs invisible (caught in a browser pass, not by types). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -434,6 +434,46 @@ fn plugin_allowlist_excludes_escalation_routes() {
|
||||
&Method::GET,
|
||||
"/api/v1/plugins/x/ui-credential"
|
||||
));
|
||||
|
||||
// The plugin STORE, wholesale. Installing a plugin runs new code with operator privileges, so a
|
||||
// plugin able to do it could install a helper that isn't constrained the way it is — and
|
||||
// `POST /store/runtime` would let it switch its own supervisor. Denied by whole-prefix so a
|
||||
// route added here later is denied by default rather than by remembering to list it.
|
||||
for path in [
|
||||
"/api/v1/store/catalog",
|
||||
"/api/v1/store/installed",
|
||||
"/api/v1/store/sources",
|
||||
"/api/v1/store/jobs",
|
||||
"/api/v1/store/jobs/job-1",
|
||||
"/api/v1/store/runtime",
|
||||
"/api/v1/store/some-route-that-does-not-exist-yet",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::GET, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/api/v1/store/install",
|
||||
"/api/v1/store/uninstall",
|
||||
"/api/v1/store/refresh",
|
||||
"/api/v1/store/runtime",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::POST, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::PUT,
|
||||
"/api/v1/store/sources/evil"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::DELETE,
|
||||
"/api/v1/store/sources/unom"
|
||||
));
|
||||
// …but a route that merely starts with the same letters is unaffected.
|
||||
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
|
||||
}
|
||||
|
||||
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
|
||||
@@ -484,6 +524,12 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
|
||||
(Method::GET, "/api/v1/native/pending"),
|
||||
(Method::DELETE, "/api/v1/clients/aabbcc"),
|
||||
(Method::GET, "/api/v1/plugins/x/ui-credential"),
|
||||
// The plugin store: a plugin must not be able to install plugins or switch its own runner.
|
||||
(Method::GET, "/api/v1/store/catalog"),
|
||||
(Method::POST, "/api/v1/store/install"),
|
||||
(Method::POST, "/api/v1/store/uninstall"),
|
||||
(Method::POST, "/api/v1/store/runtime"),
|
||||
(Method::PUT, "/api/v1/store/sources/evil"),
|
||||
] {
|
||||
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(crate) mod jobs;
|
||||
pub(crate) mod manifest;
|
||||
pub(crate) mod sources;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use index::{Advisory, Entry, Index};
|
||||
use sources::Source;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -50,6 +50,30 @@ pub(crate) struct InstalledPkg {
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// The packages the operator actually asked for: the `dependencies` of the plugins dir's own
|
||||
/// `package.json`, which `bun add` maintains. `None` only when there is no readable `package.json`
|
||||
/// at all.
|
||||
///
|
||||
/// This is what separates a plugin from a plugin's *library*. `@punktfunk/plugin-kit` is the
|
||||
/// framework every kit-built plugin depends on — it matches the `plugin-*` naming convention
|
||||
/// exactly, lands in `node_modules` as a transitive dependency, and is emphatically not something
|
||||
/// the operator installed or can meaningfully uninstall. The convention alone cannot tell the two
|
||||
/// apart; the top-level dependency list can.
|
||||
///
|
||||
/// A `package.json` with **no** `dependencies` key returns an empty list, not `None`: `bun remove`
|
||||
/// drops the key entirely when the last plugin goes, and orphaned transitive packages can outlive
|
||||
/// it in `node_modules`. Falling back to the naming convention there resurrects a plugin's library
|
||||
/// as an installed plugin the moment you uninstall the last real one (seen on-glass). If bun is
|
||||
/// managing this tree at all, its answer is the answer — including when the answer is "nothing".
|
||||
fn top_level_deps(dir: &Path) -> Option<Vec<String>> {
|
||||
let bytes = std::fs::read(dir.join("package.json")).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(match v.get("dependencies").and_then(|d| d.as_object()) {
|
||||
Some(deps) => deps.keys().cloned().collect(),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enumerate installed plugin packages under `<dir>/node_modules`.
|
||||
///
|
||||
/// Mirrors the runner's own discovery so the store never claims something is installed that the
|
||||
@@ -57,8 +81,14 @@ pub(crate) struct InstalledPkg {
|
||||
/// `plugin-*` (`@punktfunk/plugin-rom-manager`, `@retro-hub/plugin-x`). Scoped-any is what makes a
|
||||
/// third-party catalog entry work at all — a scoped name is required for the registry mapping
|
||||
/// (D8), so discovery must not be limited to the first-party scope.
|
||||
///
|
||||
/// Then narrowed to [`top_level_deps`] when a dependency list exists, so a plugin's *dependencies*
|
||||
/// (notably `@punktfunk/plugin-kit`) aren't reported as installed plugins. A tree with no readable
|
||||
/// `package.json` — hand-assembled, or an older layout — falls back to the convention alone rather
|
||||
/// than reporting nothing.
|
||||
pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
|
||||
let modules = dir.join("node_modules");
|
||||
let top_level = top_level_deps(dir);
|
||||
let mut out = Vec::new();
|
||||
let version_of = |pkg_dir: &Path| -> Option<String> {
|
||||
let bytes = std::fs::read(pkg_dir.join("package.json")).ok()?;
|
||||
@@ -101,9 +131,74 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(top) = top_level {
|
||||
out.retain(|p| top.iter().any(|d| d == &p.pkg));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
|
||||
///
|
||||
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
|
||||
/// that: `runner_command()` resolves whatever scripting package is installed on the box, which can
|
||||
/// predate the host binary (the packaged runner and the host ship separately). An older runner
|
||||
/// treats an unknown flag's *value* as a package name and the install dies with
|
||||
/// "unrecognised dependency format" — found on-glass. Writing the mapping here keeps a
|
||||
/// catalog-driven install working against every runner that has ever shipped.
|
||||
///
|
||||
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
|
||||
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
|
||||
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
|
||||
// The scope and URL both come from a signature-verified, field-validated index entry
|
||||
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
|
||||
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
|
||||
bail!("refusing to map scope `{scope}` to `{url}`");
|
||||
}
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let path = dir.join("bunfig.toml");
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let wanted = format!("\"{scope}\" = \"{url}\"");
|
||||
|
||||
let is_mapping_for_scope = |line: &str| {
|
||||
let t = line.trim_start();
|
||||
t.starts_with(&format!("\"{scope}\"")) || t.starts_with(&format!("{scope} "))
|
||||
};
|
||||
if existing.lines().any(|l| l.trim() == wanted) {
|
||||
return Ok(()); // already correct
|
||||
}
|
||||
let updated = if existing.lines().any(is_mapping_for_scope) {
|
||||
existing
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if is_mapping_for_scope(l) {
|
||||
wanted.clone()
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n"
|
||||
} else if let Some(pos) = existing
|
||||
.lines()
|
||||
.position(|l| l.trim() == "[install.scopes]")
|
||||
{
|
||||
let mut lines: Vec<String> = existing.lines().map(str::to_string).collect();
|
||||
lines.insert(pos + 1, wanted);
|
||||
lines.join("\n") + "\n"
|
||||
} else if existing.trim().is_empty() {
|
||||
format!("[install.scopes]\n{wanted}\n")
|
||||
} else {
|
||||
format!(
|
||||
"{}{}\n[install.scopes]\n{wanted}\n",
|
||||
existing,
|
||||
if existing.ends_with('\n') { "" } else { "\n" }
|
||||
)
|
||||
};
|
||||
std::fs::write(&path, updated).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `pkg` a name the runner would supervise? Guards the uninstall route so a stray
|
||||
/// `POST /store/uninstall {"pkg": "effect"}` can't rip a shared dependency out of the tree.
|
||||
pub(crate) fn valid_installed_pkg(pkg: &str) -> Result<()> {
|
||||
@@ -366,6 +461,62 @@ mod tests {
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
/// A plugin's LIBRARY is not an installed plugin.
|
||||
///
|
||||
/// Regression from a live install: `@punktfunk/plugin-kit` is the framework every kit-built
|
||||
/// plugin depends on. It matches the `plugin-*` convention exactly and lands in `node_modules`
|
||||
/// transitively, so a convention-only scan reported the framework as an installed plugin the
|
||||
/// operator could uninstall. The plugins dir's own `dependencies` is the authority.
|
||||
#[test]
|
||||
fn transitive_plugin_named_dependencies_are_not_installed_plugins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // a dependency of the above
|
||||
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
|
||||
std::fs::write(
|
||||
dir.path().join("package.json"),
|
||||
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"^0.3.1"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let found = installed_packages(dir.path());
|
||||
assert_eq!(
|
||||
found.iter().map(|p| p.pkg.as_str()).collect::<Vec<_>>(),
|
||||
vec!["@punktfunk/plugin-rom-manager"],
|
||||
"only the top-level install counts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_with_no_package_json_falls_back_to_the_convention() {
|
||||
// Hand-assembled or older layouts must still be discovered, not silently reported empty.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
|
||||
assert_eq!(installed_packages(dir.path()).len(), 1);
|
||||
}
|
||||
|
||||
/// Uninstalling the last plugin must not resurrect its library as an installed plugin.
|
||||
///
|
||||
/// `bun remove` drops the `dependencies` key entirely once it empties, while orphaned
|
||||
/// transitive packages can linger in `node_modules`. Treating "package.json exists but has no
|
||||
/// dependencies" as "no authority, fall back to the naming convention" made
|
||||
/// `@punktfunk/plugin-kit` pop back into the installed list right after the operator removed
|
||||
/// the only real plugin — seen on-glass.
|
||||
#[test]
|
||||
fn an_emptied_dependency_list_means_nothing_is_installed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // orphan left behind
|
||||
std::fs::write(dir.path().join("package.json"), r#"{"name":"plugins"}"#).unwrap();
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
|
||||
std::fs::write(
|
||||
dir.path().join("package.json"),
|
||||
r#"{"name":"plugins","dependencies":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_target_must_be_a_plugin_package() {
|
||||
assert!(valid_installed_pkg("@punktfunk/plugin-rom-manager").is_ok());
|
||||
@@ -378,6 +529,48 @@ mod tests {
|
||||
assert!(valid_installed_pkg("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bunfig_scope_mapping_is_idempotent_and_preserves_other_scopes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let read = || std::fs::read_to_string(dir.path().join("bunfig.toml")).unwrap();
|
||||
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
|
||||
assert!(read().contains("[install.scopes]"));
|
||||
assert!(read().contains("\"@retro-hub\" = \"https://retro.example/npm/\""));
|
||||
|
||||
// Idempotent — no duplicate line.
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
|
||||
assert_eq!(read().matches("@retro-hub").count(), 1);
|
||||
|
||||
// A second scope joins the same table.
|
||||
ensure_bunfig_scope(
|
||||
dir.path(),
|
||||
"@punktfunk",
|
||||
"https://git.unom.io/api/packages/unom/npm/",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read().contains("@punktfunk"));
|
||||
assert!(read().contains("@retro-hub"));
|
||||
|
||||
// A changed registry rewrites in place rather than duplicating.
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://new.example/npm/").unwrap();
|
||||
assert_eq!(read().matches("@retro-hub").count(), 1);
|
||||
assert!(read().contains("https://new.example/npm/"));
|
||||
assert!(!read().contains("retro.example"));
|
||||
assert!(read().contains("@punktfunk"), "unrelated scope survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bunfig_scope_mapping_refuses_junk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Only https, only a well-formed scope — both already guaranteed by index validation, held
|
||||
// here as the second line of defence for the one place we format TOML by hand.
|
||||
assert!(ensure_bunfig_scope(dir.path(), "@x", "http://insecure/").is_err());
|
||||
assert!(ensure_bunfig_scope(dir.path(), "no-at-sign", "https://e/").is_err());
|
||||
assert!(ensure_bunfig_scope(dir.path(), "@bad\"quote", "https://e/").is_err());
|
||||
assert!(!dir.path().join("bunfig.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_id_derivation() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -54,9 +54,13 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
|
||||
req = req.set("If-None-Match", tag);
|
||||
}
|
||||
let resp = match req.call() {
|
||||
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
|
||||
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
|
||||
// (the intuitive reading) means every refresh after the first one verifies a signature
|
||||
// over zero bytes and "fails"; the catalog then sits permanently stale, serving cache and
|
||||
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
|
||||
Ok(r) if r.status() == 304 => return Fetched::NotModified,
|
||||
Ok(r) => r,
|
||||
// 304 is the happy "nothing changed" path, not a failure.
|
||||
Err(ureq::Error::Status(304, _)) => return Fetched::NotModified,
|
||||
Err(ureq::Error::Status(code, _)) => {
|
||||
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
|
||||
}
|
||||
@@ -243,6 +247,37 @@ mod tests {
|
||||
assert!(!meta_path(dir.path(), "s").exists());
|
||||
}
|
||||
|
||||
/// Pins the HTTP-client assumption that [`fetch`]'s conditional-request handling rests on.
|
||||
///
|
||||
/// `ureq` converts only status >= 400 into `Err(Status)`. A 304 — which is exactly what a
|
||||
/// successful `If-None-Match` produces, i.e. the *steady state* of a healthy catalog — comes
|
||||
/// back as `Ok` with an empty body. Treating it as an error arm compiles, looks right, and
|
||||
/// silently breaks every refresh after the first: the empty body fails the signature check and
|
||||
/// the source sits stale forever. If a `ureq` upgrade ever changes this, fail here rather than
|
||||
/// on someone's host.
|
||||
#[test]
|
||||
fn ureq_returns_304_as_ok() {
|
||||
use std::io::Write as _;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
if let Ok((mut sock, _)) = listener.accept() {
|
||||
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
|
||||
let _ = sock.flush();
|
||||
}
|
||||
});
|
||||
|
||||
let resp = ureq::get(&format!("http://{addr}/index.json"))
|
||||
.set("If-None-Match", "\"x\"")
|
||||
.call();
|
||||
let _ = server.join();
|
||||
|
||||
match resp {
|
||||
Ok(r) => assert_eq!(r.status(), 304, "304 must arrive as Ok, and be checked for"),
|
||||
Err(e) => panic!("ureq now reports 304 as an error ({e}) — fetch() must be updated"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_https_source_never_reaches_the_network() {
|
||||
let s = Source {
|
||||
|
||||
@@ -561,6 +561,56 @@ mod tests {
|
||||
assert!(verify_signature(body, &sig, &[]).is_err());
|
||||
}
|
||||
|
||||
/// The real published index must parse here, field for field.
|
||||
///
|
||||
/// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index
|
||||
/// repo's validator reimplements this module's rules in TypeScript (it has to — it gates PRs
|
||||
/// before anything is signed), and two hand-written parsers of the same grammar drift. This
|
||||
/// test is the seam that catches the drift from our side: if a document the publisher accepts
|
||||
/// stops being one we accept, an entry silently disappears from every operator's store.
|
||||
#[test]
|
||||
fn the_published_seed_index_parses() {
|
||||
let bytes = include_bytes!("testdata/seed-index.json");
|
||||
let idx = Index::parse(bytes).expect("the published index must parse");
|
||||
assert_eq!(idx.plugins.len(), 2, "no entry may be silently dropped");
|
||||
|
||||
let rom = idx
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|e| e.id == "rom-manager")
|
||||
.expect("rom-manager entry");
|
||||
assert_eq!(rom.pkg, "@punktfunk/plugin-rom-manager");
|
||||
assert!(rom.integrity.starts_with("sha512-"));
|
||||
assert!(
|
||||
semver::Version::parse(&rom.version).is_ok(),
|
||||
"exact version"
|
||||
);
|
||||
assert_eq!(
|
||||
rom.verification.as_ref().map(|v| v.reviewed_at.as_str()),
|
||||
Some("2026-07-20"),
|
||||
"camelCase `reviewedAt` must decode"
|
||||
);
|
||||
assert_eq!(
|
||||
rom.min_host.as_deref(),
|
||||
Some("0.15.0"),
|
||||
"camelCase `minHost`"
|
||||
);
|
||||
assert_eq!(scope_of(&rom.pkg).unwrap(), "@punktfunk");
|
||||
|
||||
// A windows-only entry is listed everywhere but only *installable* where it can run.
|
||||
let playnite = idx
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|e| e.id == "playnite")
|
||||
.expect("playnite entry");
|
||||
assert_eq!(playnite.platforms, vec!["windows"]);
|
||||
if HOST_PLATFORM == "windows" {
|
||||
assert!(playnite.incompatible_reason().is_none());
|
||||
} else {
|
||||
assert!(playnite.incompatible_reason().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_parsing_rejects_junk() {
|
||||
assert!(PublicKey::parse("nope").is_err());
|
||||
|
||||
@@ -354,16 +354,21 @@ fn run_install(id: &str, plan: Plan) -> Result<()> {
|
||||
// ---- install ------------------------------------------------------------------------------
|
||||
set_phase(id, "installing");
|
||||
let before = super::installed_packages(&dir);
|
||||
// Map the entry's scope to its registry ourselves rather than through a runner flag: the
|
||||
// installed scripting package can be older than this binary, and an older runner would read an
|
||||
// unknown flag's value as a package name (see `ensure_bunfig_scope`).
|
||||
if let Some((scope, url)) = &plan.registry {
|
||||
super::ensure_bunfig_scope(&dir, scope, url)
|
||||
.with_context(|| format!("map {scope} to {url}"))?;
|
||||
}
|
||||
let mut args = vec!["add".to_string(), plan.spec.clone()];
|
||||
if plan.version.is_some() {
|
||||
// Pin the dependency range too, so a later `bun install` in this tree can't drift off the
|
||||
// reviewed version.
|
||||
// reviewed version. Safely ignored by a runner too old to know it (an unknown `-`-prefixed
|
||||
// flag is skipped, not misread) — the version we install is exact either way, so the worst
|
||||
// case is a caret range recorded in package.json.
|
||||
args.push("--exact".into());
|
||||
}
|
||||
if let Some((scope, url)) = &plan.registry {
|
||||
args.push("--registry".into());
|
||||
args.push(format!("{scope}={url}"));
|
||||
}
|
||||
if !plan.spec.starts_with("@punktfunk/") {
|
||||
// The runner CLI's supply-chain gate refuses anything resolving off Punktfunk's own
|
||||
// registry unless told otherwise. Here the operator already made that decision — either by
|
||||
|
||||
@@ -193,7 +193,7 @@ mod tests {
|
||||
assert_eq!(r.tier, Tier::Verified);
|
||||
assert_eq!(r.version.as_deref(), Some("0.2.1"));
|
||||
// A package we never recorded has no entry — the caller reports it as CLI-installed.
|
||||
assert!(m.get("punktfunk-plugin-other").is_none());
|
||||
assert!(!m.contains_key("punktfunk-plugin-other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -211,8 +211,8 @@ mod tests {
|
||||
record(dir.path(), "@x/z", rec(Tier::External)).unwrap();
|
||||
forget(dir.path(), "@x/y").unwrap();
|
||||
let m = load(dir.path());
|
||||
assert!(m.get("@x/y").is_none());
|
||||
assert!(m.get("@x/z").is_some());
|
||||
assert!(!m.contains_key("@x/y"));
|
||||
assert!(m.contains_key("@x/z"));
|
||||
forget(dir.path(), "@not/here").unwrap(); // no-op, no error
|
||||
}
|
||||
|
||||
@@ -241,6 +241,6 @@ mod tests {
|
||||
assert_eq!(&s[4..5], "-");
|
||||
assert_eq!(&s[10..11], "T");
|
||||
// A known epoch value, to catch the civil-date arithmetic drifting.
|
||||
assert!(s > "2026-01-01T00:00:00Z".to_string(), "{s}");
|
||||
assert!(s.as_str() > "2026-01-01T00:00:00Z", "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,17 @@ use serde::{Deserialize, Serialize};
|
||||
pub(crate) const OFFICIAL_NAME: &str = "unom";
|
||||
|
||||
/// The built-in source's index URL.
|
||||
pub(crate) const OFFICIAL_URL: &str = "https://plugins.punktfunk.unom.io/v1/index.json";
|
||||
///
|
||||
/// Served straight out of the index repo over Gitea's anonymous raw endpoint: real HTTPS with a
|
||||
/// real certificate, no new vhost to stand up, and "merged to main" *is* "published". The document
|
||||
/// is signed, so the transport is not what we're trusting — swapping this for a dedicated static
|
||||
/// host later is a one-constant change with no protocol impact.
|
||||
///
|
||||
/// One consequence worth knowing: CI signs *after* the merge, so between a merge and the signature
|
||||
/// commit the index is newer than its `.sig`. That window fails **closed** — the signature check
|
||||
/// rejects the document and hosts keep serving their last good copy, marked stale.
|
||||
pub(crate) const OFFICIAL_URL: &str =
|
||||
"https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json";
|
||||
|
||||
/// Pinned signing keys for the built-in source. **Two slots** so a key rotation is "sign with the
|
||||
/// new key, ship a host that trusts both, retire the old one" instead of a flag day where old
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "unom official",
|
||||
"generated": "2026-07-20T18:00:00Z",
|
||||
"plugins": [
|
||||
{
|
||||
"id": "rom-manager",
|
||||
"pkg": "@punktfunk/plugin-rom-manager",
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/",
|
||||
"title": "ROM Manager",
|
||||
"description": "Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider. Includes a console-hosted web UI for mapping and cleanup.",
|
||||
"icon": "gamepad-2",
|
||||
"author": "unom",
|
||||
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"version": "0.3.1",
|
||||
"integrity": "sha512-SGqMriqQPOQobXMYiT20w0rcTMNdYfZc8No3fPu57njMN+2eTVBVlQjyn1t3KoRFxfoRYGwuumN4X3aNmS0Tpw==",
|
||||
"verification": {
|
||||
"reviewedAt": "2026-07-20"
|
||||
},
|
||||
"minHost": "0.15.0",
|
||||
"platforms": [
|
||||
"linux",
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "playnite",
|
||||
"pkg": "@punktfunk/plugin-playnite",
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/",
|
||||
"title": "Playnite",
|
||||
"description": "Bridges your Playnite library on Windows into the host game library, keeping installed games, metadata, and launch commands in sync as a library provider.",
|
||||
"icon": "library",
|
||||
"author": "unom",
|
||||
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"version": "0.1.1",
|
||||
"integrity": "sha512-H40QEcUN7g0wHTy5CFCOTA4+j/FypbVzWSZ0OlW5Ej5+FnnT1fMUDVOx8KEH34mavTy70fd1zx3zDucu9hNQuQ==",
|
||||
"verification": {
|
||||
"reviewedAt": "2026-07-20"
|
||||
},
|
||||
"minHost": "0.15.0",
|
||||
"platforms": [
|
||||
"windows"
|
||||
]
|
||||
}
|
||||
],
|
||||
"security": []
|
||||
}
|
||||
Reference in New Issue
Block a user