From 808780f2e36fc2fc8101fe71f3151ea619cc3cd6 Mon Sep 17 00:00:00 2001 From: phantomptr Date: Mon, 28 Sep 2026 00:29:19 -0700 Subject: [PATCH] fix(viewer): cache sees folder PARAM and split-part edits; safer reads and labels - Cache key covers a folder's PARAM files and a package's split parts, and a hit keeps its token alive. - Content-id slicing can't panic; text read from an image or folder is capped; a failing OS random source is an error, never a guessable token. - SFO counts and levels show in decimal (flags and versions stay hex). - "Entries" for a package, "Parental level" for PS4; Copy uses the shared clipboard helper; a reply for an earlier path or console is ignored. Co-Authored-By: Claude Opus 5.5 --- client/src/components/PackagePanel.test.tsx | 16 ++ client/src/components/PackagePanel.tsx | 37 ++-- client/src/i18n/locales/ar.ts | 2 + client/src/i18n/locales/bn.ts | 2 + client/src/i18n/locales/de.ts | 2 + client/src/i18n/locales/en.ts | 2 + client/src/i18n/locales/es.ts | 2 + client/src/i18n/locales/fr.ts | 2 + client/src/i18n/locales/hi.ts | 2 + client/src/i18n/locales/hu.ts | 2 + client/src/i18n/locales/id.ts | 2 + client/src/i18n/locales/it.ts | 2 + client/src/i18n/locales/ja.ts | 2 + client/src/i18n/locales/ko.ts | 2 + client/src/i18n/locales/pl.ts | 2 + client/src/i18n/locales/pt-BR.ts | 2 + client/src/i18n/locales/ru.ts | 2 + client/src/i18n/locales/th.ts | 2 + client/src/i18n/locales/tr.ts | 2 + client/src/i18n/locales/vi.ts | 2 + client/src/i18n/locales/zh-CN.ts | 2 + client/src/i18n/locales/zh-TW.ts | 2 + engine/crates/ps5upload-engine/src/inspect.rs | 166 +++++++++++++++--- 23 files changed, 225 insertions(+), 34 deletions(-) diff --git a/client/src/components/PackagePanel.test.tsx b/client/src/components/PackagePanel.test.tsx index 2cbbddae..a0ff456d 100644 --- a/client/src/components/PackagePanel.test.tsx +++ b/client/src/components/PackagePanel.test.tsx @@ -99,6 +99,22 @@ describe("PackagePanelView", () => { expect(html).toContain("encrypted or missing"); }); + it("names a package's entries and a PS4 parental level for what they are", () => { + const html = view(); + expect(html).toContain("Entries"); + expect(html).toContain("Parental level"); + const folder = view({ + inspection: { + ...g, + source: { ...g.source, format: "folder", parts: [] }, + identity: { ...g.identity, platform: "ps5" }, + }, + }); + expect(folder).toContain("Files"); + expect(folder).toContain("Age rating"); + expect(folder).not.toContain("Parental level"); + }); + it("details lists PARAM keys behind Show all", () => { const html = view({ tab: "details" }); expect(html).toContain("TITLE"); diff --git a/client/src/components/PackagePanel.tsx b/client/src/components/PackagePanel.tsx index 9951475f..34c947d8 100644 --- a/client/src/components/PackagePanel.tsx +++ b/client/src/components/PackagePanel.tsx @@ -26,6 +26,7 @@ import { } from "../lib/packageChecks"; import { useConnectionStore } from "../state/connection"; import { useTr } from "../state/lang"; +import { writeClipboard } from "../lib/clipboard"; export interface PanelAction { label: string; @@ -183,10 +184,22 @@ export function PackagePanelView({ [tr("viewer_field_sdk", undefined, "SDK"), g.specs.sdk_ver], [tr("viewer_field_built", undefined, "Built"), g.specs.build_date], [tr("viewer_field_drm", undefined, "DRM"), g.specs.drm], - [tr("viewer_field_age", undefined, "Age rating"), g.specs.age_rating], + // PS4's PARENTAL_LEVEL is a 1–11 level, not an age. + [ + g.identity.platform === "ps4" + ? tr("viewer_field_parental", undefined, "Parental level") + : tr("viewer_field_age", undefined, "Age rating"), + g.specs.age_rating, + ], [tr("viewer_field_languages", undefined, "Languages"), g.specs.languages.length ? g.specs.languages.join(", ") : null], [tr("viewer_field_size", undefined, "Size"), formatBytes(g.source.size)], - [tr("viewer_field_files", undefined, "Files"), g.specs.file_count != null ? String(g.specs.file_count) : null], + [ + // A package counts its entry table, not files. + g.source.format === "pkg" || g.source.format === "split-pkg" + ? tr("viewer_field_entries", undefined, "Entries") + : tr("viewer_field_files", undefined, "Files"), + g.specs.file_count != null ? String(g.specs.file_count) : null, + ], ]; const params = showAll ? g.params : g.params.filter((p) => CURATED_KEYS.has(p.key)); @@ -317,7 +330,7 @@ export function PackagePanelView({ size="sm" variant="ghost" leftIcon={} - onClick={() => void navigator.clipboard?.writeText(copyText).catch(() => {})} + onClick={() => void writeClipboard(copyText)} > {tr("viewer_copy", undefined, "Copy details")} @@ -356,13 +369,15 @@ export function PackagePanel({ const [tab, setTab] = useState("overview"); const [installed, setInstalled] = useState(null); const [free, setFree] = useState(null); - const latest = useRef(null); + // Bumped per load: a reply for an earlier path or console is ignored. + const request = useRef(0); const kernel = useConnectionStore((s) => host ? (s.runtimeByHost[hostOf(host)]?.ps5Kernel ?? null) : null, ); useEffect(() => { - latest.current = path; + const req = ++request.current; + const current = () => request.current === req; setData(null); setError(null); setCover(null); @@ -373,18 +388,18 @@ export function PackagePanel({ if (!path) return; void gameInspect(path) .then(async (r) => { - if (latest.current !== path) return; + if (!current()) return; setData(r); const names = new Set(r.inspection.images.map((i) => i.name)); if (names.has("icon0.png")) { void gameInspectImageUrl(r.token, "icon0.png") - .then((u) => latest.current === path && setCover(u)) + .then((u) => current() && setCover(u)) .catch(() => {}); } const bg = names.has("pic0.png") ? "pic0.png" : names.has("pic1.png") ? "pic1.png" : null; if (bg) { void gameInspectImageUrl(r.token, bg) - .then((u) => latest.current === path && setBackdrop(u)) + .then((u) => current() && setBackdrop(u)) .catch(() => {}); } if (host) { @@ -394,18 +409,18 @@ export function PackagePanel({ packageType: pkgTypeForCategory(g.identity.category, g.identity.platform), }) .then((pre) => { - if (latest.current !== path) return; + if (!current()) return; setInstalled(installedFromPreflight(g.specs.app_ver, pre)); }) .catch(() => {}); } void listVolumes(transferAddr(host)) - .then((vols) => latest.current === path && setFree(largestFreeBytes(vols))) + .then((vols) => current() && setFree(largestFreeBytes(vols))) .catch(() => {}); } }) .catch((e) => { - if (latest.current === path) setError(e instanceof Error ? e.message : String(e)); + if (current()) setError(e instanceof Error ? e.message : String(e)); }); }, [path, host]); diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts index 2dad65a1..27f70b6b 100644 --- a/client/src/i18n/locales/ar.ts +++ b/client/src/i18n/locales/ar.ts @@ -342,6 +342,8 @@ queue_title: "قائمة الانتظار", "viewer_field_languages": "اللغات", "viewer_field_size": "الحجم", "viewer_field_files": "الملفات", +"viewer_field_entries": "الإدخالات", +"viewer_field_parental": "مستوى الرقابة الأبوية", "viewer_field_parts": "الأجزاء", "queue_install_from_pc": "تثبيت بالبث من هذا الكمبيوتر", "queue_install_from_server": "تثبيت بالبث من خادم محفوظ", diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts index 41c84f36..0d6a5533 100644 --- a/client/src/i18n/locales/bn.ts +++ b/client/src/i18n/locales/bn.ts @@ -342,6 +342,8 @@ queue_title: "সারি", "viewer_field_languages": "ভাষা", "viewer_field_size": "আকার", "viewer_field_files": "ফাইল", +"viewer_field_entries": "এন্ট্রি", +"viewer_field_parental": "প্যারেন্টাল স্তর", "viewer_field_parts": "অংশ", "queue_install_from_pc": "এই কম্পিউটার থেকে স্ট্রিম ইনস্টল", "queue_install_from_server": "সংরক্ষিত সার্ভার থেকে স্ট্রিম ইনস্টল", diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts index b17cac73..e4e2d68d 100644 --- a/client/src/i18n/locales/de.ts +++ b/client/src/i18n/locales/de.ts @@ -343,6 +343,8 @@ queue_title: "Warteschlange", "viewer_field_languages": "Sprachen", "viewer_field_size": "Größe", "viewer_field_files": "Dateien", +"viewer_field_entries": "Einträge", +"viewer_field_parental": "Jugendschutzstufe", "viewer_field_parts": "Teile", "queue_install_from_pc": "Stream-Installation von diesem Computer", "queue_install_from_server": "Stream-Installation von einem gespeicherten Server", diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 4bddea8b..a620e8af 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -604,6 +604,8 @@ queue_title: "Queue", "viewer_field_languages": "Languages", "viewer_field_size": "Size", "viewer_field_files": "Files", +"viewer_field_entries": "Entries", +"viewer_field_parental": "Parental level", "viewer_field_parts": "Parts", "queue_install_from_pc": "Stream install from this computer", "queue_install_from_server": "Stream install from a saved server", diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts index 7a5a63d9..7b7150b7 100644 --- a/client/src/i18n/locales/es.ts +++ b/client/src/i18n/locales/es.ts @@ -342,6 +342,8 @@ queue_title: "Cola", "viewer_field_languages": "Idiomas", "viewer_field_size": "Tamaño", "viewer_field_files": "Archivos", +"viewer_field_entries": "Entradas", +"viewer_field_parental": "Nivel de control parental", "viewer_field_parts": "Partes", "queue_install_from_pc": "Instalación en streaming desde este equipo", "queue_install_from_server": "Instalación en streaming desde un servidor guardado", diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts index e67f7c0d..0dbbc4fa 100644 --- a/client/src/i18n/locales/fr.ts +++ b/client/src/i18n/locales/fr.ts @@ -342,6 +342,8 @@ queue_title: "File d’attente", "viewer_field_languages": "Langues", "viewer_field_size": "Taille", "viewer_field_files": "Fichiers", +"viewer_field_entries": "Entrées", +"viewer_field_parental": "Niveau de contrôle parental", "viewer_field_parts": "Parties", "queue_install_from_pc": "Installation en streaming depuis cet ordinateur", "queue_install_from_server": "Installation en streaming depuis un serveur enregistré", diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts index 90405664..a2c5558c 100644 --- a/client/src/i18n/locales/hi.ts +++ b/client/src/i18n/locales/hi.ts @@ -342,6 +342,8 @@ queue_title: "कतार", "viewer_field_languages": "भाषाएँ", "viewer_field_size": "आकार", "viewer_field_files": "फ़ाइलें", +"viewer_field_entries": "प्रविष्टियाँ", +"viewer_field_parental": "पैरेंटल स्तर", "viewer_field_parts": "भाग", "queue_install_from_pc": "इस कंप्यूटर से स्ट्रीम इंस्टॉल", "queue_install_from_server": "सहेजे गए सर्वर से स्ट्रीम इंस्टॉल", diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts index cc1fbecf..c3107a05 100644 --- a/client/src/i18n/locales/hu.ts +++ b/client/src/i18n/locales/hu.ts @@ -601,6 +601,8 @@ queue_title: "Sor", "viewer_field_languages": "Nyelvek", "viewer_field_size": "Méret", "viewer_field_files": "Fájlok", +"viewer_field_entries": "Bejegyzések", +"viewer_field_parental": "Szülői felügyeleti szint", "viewer_field_parts": "Részek", "queue_install_from_pc": "Streamelt telepítés erről a számítógépről", "queue_install_from_server": "Streamelt telepítés mentett szerverről", diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts index 27d28275..bab79f27 100644 --- a/client/src/i18n/locales/id.ts +++ b/client/src/i18n/locales/id.ts @@ -342,6 +342,8 @@ queue_title: "Antrean", "viewer_field_languages": "Bahasa", "viewer_field_size": "Ukuran", "viewer_field_files": "File", +"viewer_field_entries": "Entri", +"viewer_field_parental": "Tingkat kontrol orang tua", "viewer_field_parts": "Bagian", "queue_install_from_pc": "Instal stream dari komputer ini", "queue_install_from_server": "Instal stream dari server tersimpan", diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts index 4c494053..e2cdc53f 100644 --- a/client/src/i18n/locales/it.ts +++ b/client/src/i18n/locales/it.ts @@ -342,6 +342,8 @@ queue_title: "Coda", "viewer_field_languages": "Lingue", "viewer_field_size": "Dimensione", "viewer_field_files": "File", +"viewer_field_entries": "Voci", +"viewer_field_parental": "Livello di controllo genitori", "viewer_field_parts": "Parti", "queue_install_from_pc": "Installazione in streaming da questo computer", "queue_install_from_server": "Installazione in streaming da un server salvato", diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts index 2de85eeb..9748aac7 100644 --- a/client/src/i18n/locales/ja.ts +++ b/client/src/i18n/locales/ja.ts @@ -343,6 +343,8 @@ queue_title: "キュー", "viewer_field_languages": "言語", "viewer_field_size": "サイズ", "viewer_field_files": "ファイル", +"viewer_field_entries": "エントリ", +"viewer_field_parental": "ペアレンタルレベル", "viewer_field_parts": "パーツ", "queue_install_from_pc": "このコンピューターからストリームインストール", "queue_install_from_server": "保存済みサーバーからストリームインストール", diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts index 22ed551f..3ada6d5a 100644 --- a/client/src/i18n/locales/ko.ts +++ b/client/src/i18n/locales/ko.ts @@ -343,6 +343,8 @@ queue_title: "대기열", "viewer_field_languages": "언어", "viewer_field_size": "크기", "viewer_field_files": "파일", +"viewer_field_entries": "항목", +"viewer_field_parental": "자녀 보호 등급", "viewer_field_parts": "파트", "queue_install_from_pc": "이 컴퓨터에서 스트림 설치", "queue_install_from_server": "저장된 서버에서 스트림 설치", diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts index 565e196a..8f64500e 100644 --- a/client/src/i18n/locales/pl.ts +++ b/client/src/i18n/locales/pl.ts @@ -582,6 +582,8 @@ queue_title: "Kolejka", "viewer_field_languages": "Języki", "viewer_field_size": "Rozmiar", "viewer_field_files": "Pliki", +"viewer_field_entries": "Wpisy", +"viewer_field_parental": "Poziom kontroli rodzicielskiej", "viewer_field_parts": "Części", "queue_install_from_pc": "Instalacja strumieniowa z tego komputera", "queue_install_from_server": "Instalacja strumieniowa z zapisanego serwera", diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts index 8392bbbd..3e75e2ef 100644 --- a/client/src/i18n/locales/pt-BR.ts +++ b/client/src/i18n/locales/pt-BR.ts @@ -342,6 +342,8 @@ queue_title: "Fila", "viewer_field_languages": "Idiomas", "viewer_field_size": "Tamanho", "viewer_field_files": "Arquivos", +"viewer_field_entries": "Entradas", +"viewer_field_parental": "Nível de controle parental", "viewer_field_parts": "Partes", "queue_install_from_pc": "Instalação por stream deste computador", "queue_install_from_server": "Instalação por stream de um servidor salvo", diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts index c68b6c04..e0bdf7d4 100644 --- a/client/src/i18n/locales/ru.ts +++ b/client/src/i18n/locales/ru.ts @@ -342,6 +342,8 @@ queue_title: "Очередь", "viewer_field_languages": "Языки", "viewer_field_size": "Размер", "viewer_field_files": "Файлы", +"viewer_field_entries": "Записи", +"viewer_field_parental": "Уровень родительского контроля", "viewer_field_parts": "Части", "queue_install_from_pc": "Потоковая установка с этого компьютера", "queue_install_from_server": "Потоковая установка с сохранённого сервера", diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts index c8879576..ac6252b9 100644 --- a/client/src/i18n/locales/th.ts +++ b/client/src/i18n/locales/th.ts @@ -342,6 +342,8 @@ queue_title: "คิว", "viewer_field_languages": "ภาษา", "viewer_field_size": "ขนาด", "viewer_field_files": "ไฟล์", +"viewer_field_entries": "รายการ", +"viewer_field_parental": "ระดับการควบคุมโดยผู้ปกครอง", "viewer_field_parts": "ส่วน", "queue_install_from_pc": "ติดตั้งแบบสตรีมจากคอมพิวเตอร์เครื่องนี้", "queue_install_from_server": "ติดตั้งแบบสตรีมจากเซิร์ฟเวอร์ที่บันทึกไว้", diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts index 676353d4..d84eed31 100644 --- a/client/src/i18n/locales/tr.ts +++ b/client/src/i18n/locales/tr.ts @@ -342,6 +342,8 @@ queue_title: "Kuyruk", "viewer_field_languages": "Diller", "viewer_field_size": "Boyut", "viewer_field_files": "Dosyalar", +"viewer_field_entries": "Girdiler", +"viewer_field_parental": "Ebeveyn denetimi düzeyi", "viewer_field_parts": "Parçalar", "queue_install_from_pc": "Bu bilgisayardan akışla kurulum", "queue_install_from_server": "Kayıtlı bir sunucudan akışla kurulum", diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts index 4f19b5aa..ff8aae0b 100644 --- a/client/src/i18n/locales/vi.ts +++ b/client/src/i18n/locales/vi.ts @@ -343,6 +343,8 @@ queue_title: "Hàng đợi", "viewer_field_languages": "Ngôn ngữ", "viewer_field_size": "Kích thước", "viewer_field_files": "Tệp", +"viewer_field_entries": "Mục", +"viewer_field_parental": "Mức kiểm soát của phụ huynh", "viewer_field_parts": "Phần", "queue_install_from_pc": "Cài đặt truyền trực tiếp từ máy tính này", "queue_install_from_server": "Cài đặt truyền trực tiếp từ máy chủ đã lưu", diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 108a0123..f09b3dac 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -349,6 +349,8 @@ queue_title: "队列", "viewer_field_languages": "语言", "viewer_field_size": "大小", "viewer_field_files": "文件", +"viewer_field_entries": "条目", +"viewer_field_parental": "家长控制等级", "viewer_field_parts": "分卷", "queue_install_from_pc": "从这台电脑流式安装", "queue_install_from_server": "从已保存的服务器流式安装", diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts index 552e0a52..983b1a7f 100644 --- a/client/src/i18n/locales/zh-TW.ts +++ b/client/src/i18n/locales/zh-TW.ts @@ -343,6 +343,8 @@ queue_title: "佇列", "viewer_field_languages": "語言", "viewer_field_size": "大小", "viewer_field_files": "檔案", +"viewer_field_entries": "項目", +"viewer_field_parental": "家長監護等級", "viewer_field_parts": "分卷", "queue_install_from_pc": "從這台電腦串流安裝", "queue_install_from_server": "從已儲存的伺服器串流安裝", diff --git a/engine/crates/ps5upload-engine/src/inspect.rs b/engine/crates/ps5upload-engine/src/inspect.rs index 616f8aa3..c4f0665a 100644 --- a/engine/crates/ps5upload-engine/src/inspect.rs +++ b/engine/crates/ps5upload-engine/src/inspect.rs @@ -180,7 +180,11 @@ fn apply_sfo(g: &mut GameInspection, params: &[(String, SfoValue)]) { for (key, value) in params { let shown = match value { SfoValue::Text(t) => t.clone(), - SfoValue::Int(i) => format!("0x{i:08X}"), + // Flags and BCD versions read best in hex; counts and levels don't. + SfoValue::Int(i) if key.starts_with("ATTRIBUTE") || key.ends_with("_VER") => { + format!("0x{i:08X}") + } + SfoValue::Int(i) => i.to_string(), }; g.params.push(ParamField { key: key.clone(), @@ -287,8 +291,11 @@ fn apply_param_json(g: &mut GameInspection, v: &serde_json::Value) { } fn finish(g: &mut GameInspection) { - if g.identity.title_id.is_empty() && g.identity.content_id.len() >= 16 { - g.identity.title_id = g.identity.content_id[7..16].to_string(); + if g.identity.title_id.is_empty() { + // `get`, not slicing: a malformed id may split a multi-byte character. + if let Some(t) = g.identity.content_id.get(7..16) { + g.identity.title_id = t.to_string(); + } } if g.identity.platform.is_empty() { if let Some(p) = platform_from_title_id(&g.identity.title_id) { @@ -422,6 +429,17 @@ fn inspect_tree(path: &Path, format: &str) -> Result { let mut tree = ps5upload_fpkg::source::open(path)?; let files = tree.files().to_vec(); let has = |p: &str| files.iter().any(|f| f.path == p); + let mut too_large = Vec::new(); + // A text file this viewer reads whole, or None when it is absent, too + // large (a damaged image can claim any size) or unreadable. + let mut read_text = |tree: &mut Box, p: &str| { + let f = files.iter().find(|f| f.path == p)?; + if f.size > u64::from(MAX_TEXT_BYTES) { + too_large.push(p.to_string()); + return None; + } + tree.read(p).ok() + }; let mut g = GameInspection { authenticity: "none".to_string(), ..Default::default() @@ -439,7 +457,7 @@ fn inspect_tree(path: &Path, format: &str) -> Result { }; let mut have_param = false; if has("sce_sys/param.json") { - if let Ok(bytes) = tree.read("sce_sys/param.json") { + if let Some(bytes) = read_text(&mut tree, "sce_sys/param.json") { let end = bytes.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1); if let Ok(v) = serde_json::from_slice::(&bytes[..end]) { apply_param_json(&mut g, &v); @@ -448,7 +466,7 @@ fn inspect_tree(path: &Path, format: &str) -> Result { } } if !have_param && has("sce_sys/param.sfo") { - if let Ok(bytes) = tree.read("sce_sys/param.sfo") { + if let Some(bytes) = read_text(&mut tree, "sce_sys/param.sfo") { if let Ok(params) = sfo_params(&bytes) { apply_sfo(&mut g, ¶ms); have_param = true; @@ -470,10 +488,11 @@ fn inspect_tree(path: &Path, format: &str) -> Result { } } let change = format!("sce_sys/{CHANGEINFO}"); - if has(&change) { - if let Ok(bytes) = tree.read(&change) { - g.change_notes = Some(String::from_utf8_lossy(&bytes).to_string()); - } + if let Some(bytes) = read_text(&mut tree, &change) { + g.change_notes = Some(String::from_utf8_lossy(&bytes).to_string()); + } + for p in too_large { + g.warnings.push(format!("{p} is too large to show")); } g.specs.file_count = Some(files.len() as u64); finish(&mut g); @@ -493,18 +512,24 @@ pub fn read_image(path: &Path, format: &str, name: &str) -> Result> { return Ok(read_pkg_entry(path, e, MAX_IMAGE_BYTES)?); } let mut tree = ps5upload_fpkg::source::open(path)?; - Ok(tree.read(&format!("sce_sys/{name}"))?) + let p = format!("sce_sys/{name}"); + match tree.files().iter().find(|f| f.path == p) { + None => bail!("no {name} in this game"), + Some(f) if f.size > u64::from(MAX_IMAGE_BYTES) => bail!("{name} is too large to show"), + Some(_) => Ok(tree.read(&p)?), + } } /// Inspections by token, so the image requests that follow reuse one parse. -/// Keyed by path + size + mtime: a changed file is parsed again. +/// Keyed by path plus the size and mtime of every file the inspection depends +/// on: a changed file, split part or folder PARAM is parsed again. pub struct InspectCache { cap: usize, inner: std::sync::Mutex>, } struct CacheEntry { - key: (PathBuf, u64, std::time::SystemTime), + key: (PathBuf, Vec<(u64, std::time::SystemTime)>), token: String, at: std::time::Instant, inspection: GameInspection, @@ -523,21 +548,18 @@ impl InspectCache { /// The cached token and inspection for this exact file, or a fresh one. pub fn inspect(&self, path: &Path) -> Result<(String, GameInspection)> { - let meta = std::fs::metadata(path)?; - let key = ( - path.to_path_buf(), - meta.len(), - meta.modified().unwrap_or(std::time::UNIX_EPOCH), - ); + let key = (path.to_path_buf(), fingerprint(path)?); { let mut entries = self.inner.lock().unwrap_or_else(|e| e.into_inner()); entries.retain(|e| e.at.elapsed() < CACHE_TTL); - if let Some(e) = entries.iter().find(|e| e.key == key) { + if let Some(e) = entries.iter_mut().find(|e| e.key == key) { + // In use: keep its token alive for the image requests to come. + e.at = std::time::Instant::now(); return Ok((e.token.clone(), e.inspection.clone())); } } let inspection = inspect_local(path)?; - let token = new_token(); + let token = new_token()?; let mut entries = self.inner.lock().unwrap_or_else(|e| e.into_inner()); entries.push(CacheEntry { key, @@ -560,11 +582,43 @@ impl InspectCache { } } -fn new_token() -> String { +/// Size and mtime of `path` and what else its inspection reads: a folder's +/// PARAM files (editing them leaves the folder's own mtime alone) and a +/// package's split parts. +fn fingerprint(path: &Path) -> Result> { + let stamp = |p: &Path| { + std::fs::metadata(p) + .ok() + .map(|m| (m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH))) + }; + let Some(own) = stamp(path) else { + bail!("cannot read {}", path.display()); + }; + let mut out = vec![own]; + if path.is_dir() { + for f in ["sce_sys/param.json", "sce_sys/param.sfo"] { + out.extend(stamp(&path.join(f))); + } + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let dir = path.parent().unwrap_or(Path::new(".")); + // Same walk as the split-set parser: .0, .1, ... + for i in 0..=1024u32 { + match stamp(&dir.join(format!("{name}.{i}"))) { + Some(s) => out.push(s), + None => break, + } + } + } + Ok(out) +} + +fn new_token() -> Result { let mut b = [0u8; 16]; // The OS generator; never a Unix-only path (the engine also runs on Windows). - let _ = getrandom::fill(&mut b); - b.iter().map(|x| format!("{x:02x}")).collect() + // A token is what lets a request read files, so never fall back to a + // guessable one. + getrandom::fill(&mut b).map_err(|e| anyhow::anyhow!("no secure random source: {e}"))?; + Ok(b.iter().map(|x| format!("{x:02x}")).collect()) } static CACHE: std::sync::LazyLock = @@ -894,4 +948,70 @@ mod tests { assert_eq!(g.identity.title, "Fixture"); assert_eq!(g.identity.content_type, "game"); } + + #[test] + fn the_cache_sees_a_folder_param_edit_and_a_split_part_change() { + let dir = test_fixtures::scratch(); + let sys = dir.join("g/sce_sys"); + std::fs::create_dir_all(&sys).unwrap(); + std::fs::write(sys.join("param.json"), br#"{"titleId":"PPSA00001"}"#).unwrap(); + let cache = InspectCache::new(32); + let (t1, g1) = cache.inspect(&dir.join("g")).unwrap(); + assert_eq!(g1.identity.title_id, "PPSA00001"); + std::thread::sleep(std::time::Duration::from_millis(1100)); + // Editing a file inside sce_sys leaves the folder's own mtime alone. + std::fs::write(sys.join("param.json"), br#"{"titleId":"PPSA00002"}"#).unwrap(); + let (t2, g2) = cache.inspect(&dir.join("g")).unwrap(); + assert_ne!(t1, t2); + assert_eq!(g2.identity.title_id, "PPSA00002"); + + let lead = test_fixtures::write_cnt_pkg(dir.join("s.pkg"), "gd"); + std::fs::write(dir.join("s.pkg.0"), vec![0u8; 10]).unwrap(); + let (t3, g3) = cache.inspect(&lead).unwrap(); + std::fs::write(dir.join("s.pkg.0"), vec![0u8; 20]).unwrap(); + let (t4, g4) = cache.inspect(&lead).unwrap(); + assert_ne!(t3, t4); + assert_eq!(g4.source.size, g3.source.size + 10); + } + + #[test] + fn a_non_ascii_content_id_does_not_panic() { + let mut g = GameInspection::default(); + g.identity.content_id = "EP9000-PPSA0123é_00-X".to_string(); + finish(&mut g); + assert!(g.identity.title_id.is_empty() || g.identity.title_id.is_char_boundary(0)); + } + + #[test] + fn oversized_text_in_a_folder_is_not_read_whole() { + let dir = test_fixtures::scratch(); + let sys = dir.join("sce_sys"); + std::fs::create_dir_all(sys.join("changeinfo")).unwrap(); + std::fs::write(sys.join("param.json"), br#"{"titleId":"PPSA00001"}"#).unwrap(); + std::fs::write( + sys.join("changeinfo/changeinfo.xml"), + vec![b'a'; MAX_TEXT_BYTES as usize + 1], + ) + .unwrap(); + let g = inspect_local(&dir).unwrap(); + assert!(g.change_notes.is_none()); + assert!(g.warnings.iter().any(|w| w.contains("changeinfo"))); + } + + #[test] + fn sfo_numbers_read_as_numbers_and_flags_as_hex() { + let mut g = GameInspection::default(); + apply_sfo( + &mut g, + &[ + ("PARENTAL_LEVEL".to_string(), SfoValue::Int(5)), + ("ATTRIBUTE".to_string(), SfoValue::Int(0x10)), + ("SYSTEM_VER".to_string(), SfoValue::Int(0x0505_0000)), + ], + ); + let v = |k: &str| g.params.iter().find(|p| p.key == k).unwrap().value.clone(); + assert_eq!(v("PARENTAL_LEVEL"), "5"); + assert_eq!(v("ATTRIBUTE"), "0x00000010"); + assert_eq!(v("SYSTEM_VER"), "0x05050000"); + } }