From 9e89d3a3423fcbead806f2604c5da9f60b908a6d Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:59:41 +0200 Subject: [PATCH] Add source-aware update policy --- README.md | 18 +++++- web/README.md | 33 +++++++++++ web/app.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++-- web/index.html | 6 ++ web/styles.css | 67 ++++++++++++++++++++- 5 files changed, 274 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4a2556c..88e5ef7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,23 @@ The target design is deny-by-default: - Games must be explicitly enabled before PatchDL checks or downloads updates. - Patch selection is capped to the highest update compatible with the current firmware. +- Install actions are source-aware: shadowmounted and unknown titles are never + installed by PatchDL. + +## Source Classification + +PatchDL treats the title source as part of the safety policy: + +```text +official check/download/install allowed +external check/download/install allowed when detected as a real install +shadowmount check/download allowed, install blocked +unknown check allowed, download/install blocked +``` + +The future ELF scanner should classify titles by app metadata plus mount table +inspection. Shadowmounts should be detected through `statfs()` / `getfsstat()` +and `nullfs` mount origins instead of trusting title IDs alone. ## Current Contents @@ -20,4 +37,3 @@ web/ Open `web/index.html` directly for the mock UI, or serve `web/` from a local HTTP server. - diff --git a/web/README.md b/web/README.md index b452759..7df5375 100644 --- a/web/README.md +++ b/web/README.md @@ -28,6 +28,12 @@ The UI assumes deny-by-default behavior: "download_dir": "/mnt/usb0/patches", "install_after_download": false, "delete_pkg_after_install": false, + "source_policy": { + "official": { "allow_check": true, "allow_download": true, "allow_install": true }, + "external": { "allow_check": true, "allow_download": true, "allow_install": true }, + "shadowmount": { "allow_check": true, "allow_download": true, "allow_install": false }, + "unknown": { "allow_check": true, "allow_download": false, "allow_install": false } + }, "cdn_allowlist": [ "sgst.prod.dl.playstation.net", "gst.prod.dl.playstation.net", @@ -45,3 +51,30 @@ latest_compatible pin check_only ``` + +Per-title source fields: + +```json +{ + "title_id": "PPSA90001_00", + "name": "Shadowmounted Test Title", + "source_type": "shadowmount", + "source_path": "/system_ex/app/PPSA90001_00", + "mount_from": "/mnt/usb0/itemzflow/Shadowmounted Test Title", + "enabled": true, + "mode": "download_only" +} +``` + +Supported `source_type` values: + +```text +official +external +shadowmount +unknown +``` + +The frontend treats `shadowmount` as download-only and `unknown` as blocked for +downloads and installs. Backend code should enforce the same policy even if a +client sends a forged request. diff --git a/web/app.js b/web/app.js index 44e8286..3f3fb90 100644 --- a/web/app.js +++ b/web/app.js @@ -6,6 +6,33 @@ const API = { action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`, }; +const SOURCE_TYPES = { + official: { + label: "Official", + className: "ok", + installAllowed: true, + description: "Echte Installation", + }, + external: { + label: "External", + className: "external", + installAllowed: true, + description: "Echte externe Installation", + }, + shadowmount: { + label: "Shadowmount", + className: "shadow", + installAllowed: false, + description: "Download only", + }, + unknown: { + label: "Unknown", + className: "blocked", + installAllowed: false, + description: "Blockiert", + }, +}; + const fallback = { status: { firmware: "11.60", @@ -20,6 +47,12 @@ const fallback = { download_dir: "/mnt/usb0/patches", install_after_download: false, delete_pkg_after_install: false, + source_policy: { + official: { allow_check: true, allow_download: true, allow_install: true }, + external: { allow_check: true, allow_download: true, allow_install: true }, + shadowmount: { allow_check: true, allow_download: true, allow_install: false }, + unknown: { allow_check: true, allow_download: false, allow_install: false }, + }, cdn_allowlist: [ "sgst.prod.dl.playstation.net", "gst.prod.dl.playstation.net", @@ -35,6 +68,9 @@ const fallback = { compatible_version: "01.041.000", latest_version: "01.041.000", latest_required_fw: "11.60", + source_type: "official", + source_path: "/system_ex/app/PPSA01628_00", + mount_from: "/dev/ssd0.system_ex", enabled: true, mode: "latest_compatible", queued: false, @@ -48,6 +84,9 @@ const fallback = { compatible_version: "01.004.000", latest_version: "01.004.000", latest_required_fw: "10.01", + source_type: "external", + source_path: "/system_data/priv/appmeta/external/PPSA01284_00", + mount_from: "/mnt/ext0/user/app/PPSA01284_00", enabled: true, mode: "pin", max_content_ver: "01.004.000", @@ -62,11 +101,30 @@ const fallback = { compatible_version: null, latest_version: "01.012.000", latest_required_fw: "12.50", + source_type: "unknown", + source_path: "/system_ex/app/PPSA08329_00", + mount_from: "", enabled: false, mode: "disabled", queued: false, status: "blocked", }, + { + title_id: "PPSA90001_00", + name: "Shadowmounted Test Title", + content_id: "UP0000-PPSA90001_00-SHADOWMOUNT0001", + installed_version: "01.000.000", + compatible_version: "01.006.000", + latest_version: "01.009.000", + latest_required_fw: "12.00", + source_type: "shadowmount", + source_path: "/system_ex/app/PPSA90001_00", + mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", + enabled: true, + mode: "download_only", + queued: false, + status: "available", + }, ], downloads: [ { @@ -116,6 +174,7 @@ function bindElements() { queueList: document.getElementById("queueList"), logOutput: document.getElementById("logOutput"), allowlistHosts: document.getElementById("allowlistHosts"), + sourcePolicyList: document.getElementById("sourcePolicyList"), searchInput: document.getElementById("searchInput"), defaultPolicy: document.getElementById("defaultPolicy"), downloadDir: document.getElementById("downloadDir"), @@ -207,6 +266,7 @@ function renderSettings() { chip.textContent = host; return chip; })); + renderSourcePolicies(); } function renderGames() { @@ -227,6 +287,7 @@ function renderGames() { function matchesFilter(game) { if (state.filter === "enabled") return game.enabled; if (state.filter === "available") return game.status === "available"; + if (state.filter === "shadowmount") return sourceType(game) === "shadowmount"; if (state.filter === "blocked") return game.status === "blocked"; if (state.filter === "queued") return game.queued || game.status === "queued"; return true; @@ -235,6 +296,7 @@ function matchesFilter(game) { function matchesQuery(game) { if (!state.query) return true; return [game.name, game.title_id, game.content_id] + .concat([game.source_type, game.source_path, game.mount_from]) .filter(Boolean) .some((value) => value.toLowerCase().includes(state.query)); } @@ -245,6 +307,7 @@ function createGameCard(game) { const title = document.createElement("div"); title.className = "game-title"; + const installBlocked = isInstallBlocked(game); title.innerHTML = `
${initials(game.name)}
@@ -255,8 +318,11 @@ function createGameCard(game) {
${statusPill(game)} + ${sourcePill(game)} + ${installBlocked ? `Install gesperrt` : `Install erlaubt`} ${game.enabled ? "Aktiv" : "Aus"}
+
${escapeHtml(sourceDetail(game))}
`; @@ -306,7 +372,7 @@ function createGameCard(game) { Pruefen - @@ -331,12 +397,39 @@ function versionBox(label, value) { } function statusPill(game) { - if (game.status === "blocked") return `FW blockiert`; + if (game.status === "blocked") return `${escapeHtml(blockedLabel(game))}`; if (game.status === "queued") return `In Queue`; if (game.status === "available") return `Update verfuegbar`; return `Aktuell`; } +function sourcePill(game) { + const info = sourceInfo(game); + return `${escapeHtml(info.label)}`; +} + +function sourceInfo(game) { + return SOURCE_TYPES[sourceType(game)] || SOURCE_TYPES.unknown; +} + +function sourceType(game) { + return game.source_type || "unknown"; +} + +function sourceDetail(game) { + const src = sourceType(game); + if (src === "shadowmount") return `Mount: ${game.mount_from || "unbekannte Quelle"}`; + if (src === "external") return `External: ${game.source_path || "externe App-Metadaten"}`; + if (src === "official") return game.source_path || "Offizielle Installation"; + return "Quelle nicht eindeutig erkannt"; +} + +function blockedLabel(game) { + if (sourceType(game) === "unknown") return "Quelle unbekannt"; + if (!game.compatible_version) return "FW blockiert"; + return "Blockiert"; +} + function renderQueue() { els.queueList.replaceChildren(); if (!state.downloads.length) { @@ -362,6 +455,22 @@ function renderQueue() { }); } +function renderSourcePolicies() { + const rows = Object.keys(SOURCE_TYPES).map((type) => { + const info = SOURCE_TYPES[type]; + const policy = sourcePolicyForType(type); + const row = document.createElement("div"); + row.className = "policy-row"; + row.innerHTML = ` + ${escapeHtml(info.label)} + ${policy.allow_install ? "Install erlaubt" : "Install gesperrt"} + ${policy.allow_download ? "Download erlaubt" : "Download gesperrt"} - ${escapeHtml(info.description)} + `; + return row; + }); + els.sourcePolicyList.replaceChildren(...rows); +} + function renderLogs() { els.logOutput.textContent = state.logs.join("\n"); } @@ -391,6 +500,11 @@ async function runTitleAction(titleId, action) { const game = state.titles.find((item) => item.title_id === titleId); if (!game) return; + if (action === "download" && !isDownloadAllowed(game)) { + showToast(downloadTitle(game)); + return; + } + try { await postJson(API.action(titleId, action), {}); } catch (error) { @@ -398,6 +512,9 @@ async function runTitleAction(titleId, action) { } if (action === "download" && game.compatible_version) { + const detail = isInstallBlocked(game) + ? "Download only - Install durch Source-Policy gesperrt" + : "Wartet auf Start"; game.queued = true; game.status = "queued"; if (!state.downloads.some((item) => item.title_id === titleId)) { @@ -406,10 +523,10 @@ async function runTitleAction(titleId, action) { name: game.name, version: game.compatible_version, progress: 0, - detail: "Wartet auf Start", + detail, }); } - state.logs.push(`[${timeNow()}] Queued ${game.title_id} ${game.compatible_version}`); + state.logs.push(`[${timeNow()}] Queued ${game.title_id} ${game.compatible_version} (${sourceType(game)}, install=${isInstallBlocked(game) ? "blocked" : "allowed"})`); showToast(`${game.title_id} wurde zur Queue hinzugefuegt.`); } else { state.logs.push(`[${timeNow()}] Check requested for ${game.title_id}`); @@ -424,6 +541,11 @@ async function runTitleAction(titleId, action) { function updateGame(titleId, patch) { const game = state.titles.find((item) => item.title_id === titleId); if (!game) return; + if (sourceType(game) === "unknown" && patch.mode && !["disabled", "check_only"].includes(patch.mode)) { + patch.mode = "check_only"; + patch.enabled = true; + showToast(`${titleId}: Quelle unbekannt, nur Check only erlaubt.`); + } Object.assign(game, patch); if (patch.mode === "disabled") game.enabled = false; state.logs.push(`[${timeNow()}] Policy updated for ${titleId}`); @@ -431,6 +553,33 @@ function updateGame(titleId, patch) { renderLogs(); } +function isDownloadAllowed(game) { + const policy = sourcePolicy(game); + return Boolean(game.enabled && game.compatible_version && policy.allow_download); +} + +function isInstallBlocked(game) { + return !sourcePolicy(game).allow_install; +} + +function sourcePolicy(game) { + return sourcePolicyForType(sourceType(game)); +} + +function sourcePolicyForType(type) { + const fallbackPolicy = fallback.config.source_policy[type] || fallback.config.source_policy.unknown; + const configuredPolicy = (state.config.source_policy || {})[type] || {}; + return { ...fallbackPolicy, ...configuredPolicy }; +} + +function downloadTitle(game) { + if (!game.enabled) return "Titel ist deaktiviert."; + if (!game.compatible_version) return "Keine kompatible Update-Version verfuegbar."; + if (!sourcePolicy(game).allow_download) return "Download ist fuer diese Quelle gesperrt."; + if (isInstallBlocked(game)) return "Download erlaubt, Installation bleibt fuer diese Quelle gesperrt."; + return "Kompatibles Update laden."; +} + async function postJson(url, body) { const response = await fetch(url, { method: "POST", diff --git a/web/index.html b/web/index.html index b7b80fa..9b2d9be 100644 --- a/web/index.html +++ b/web/index.html @@ -127,6 +127,7 @@ + @@ -189,6 +190,11 @@ Interne CDN Allowlist
+ +
+ Install Schutz +
+
diff --git a/web/styles.css b/web/styles.css index 4ee9c0f..bd6f369 100644 --- a/web/styles.css +++ b/web/styles.css @@ -377,12 +377,34 @@ h2 { color: #81500b; } +.pill.external { + border-color: #b7d8e8; + background: var(--blue-soft); + color: #1e547a; +} + +.pill.shadow { + border-color: #d0c4eb; + background: #eee8fb; + color: #584080; +} + .pill.blocked { border-color: #f0b8b8; background: var(--red-soft); color: var(--red); } +.source-path { + margin-top: 8px; + max-width: 100%; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + .version-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -521,7 +543,8 @@ h2 { } .field span, -.allowlist > span { +.allowlist > span, +.source-policy > span { color: var(--muted); font-size: 12px; font-weight: 700; @@ -576,12 +599,47 @@ h2 { margin-top: 14px; } +.source-policy { + display: grid; + gap: 8px; + margin-top: 14px; +} + .allowlist div { display: flex; flex-wrap: wrap; gap: 6px; } +.source-policy > div { + display: grid; + gap: 8px; +} + +.policy-row { + display: grid; + grid-template-columns: 110px minmax(100px, 0.8fr) minmax(0, 1fr); + gap: 8px; + align-items: center; + padding: 8px; + background: #f8f9f6; + border: 1px solid var(--border); + border-radius: 8px; +} + +.policy-row strong { + font-size: 13px; +} + +.policy-row em { + overflow: hidden; + color: var(--muted); + font-size: 12px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + .host-chip { display: inline-flex; align-items: center; @@ -701,10 +759,15 @@ pre { .toolbar, .split-band, .status-strip, - .version-grid { + .version-grid, + .policy-row { grid-template-columns: 1fr; } + .policy-row em { + white-space: normal; + } + .segmented { overflow-x: auto; }