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
-