mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-21 19:33:08 -05:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ead8079a6e
commit
808780f2e3
23 files changed
+225
-34
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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={<Copy size={12} />}
|
||||
onClick={() => void navigator.clipboard?.writeText(copyText).catch(() => {})}
|
||||
onClick={() => void writeClipboard(copyText)}
|
||||
>
|
||||
{tr("viewer_copy", undefined, "Copy details")}
|
||||
</Button>
|
||||
@@ -356,13 +369,15 @@ export function PackagePanel({
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [installed, setInstalled] = useState<PanelChecks["installed"]>(null);
|
||||
const [free, setFree] = useState<number | null>(null);
|
||||
const latest = useRef<string | null>(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]);
|
||||
|
||||
|
||||
@@ -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": "تثبيت بالبث من خادم محفوظ",
|
||||
|
||||
@@ -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": "সংরক্ষিত সার্ভার থেকে স্ট্রিম ইনস্টল",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "सहेजे गए सर्वर से स्ट्रीम इंस्टॉल",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "保存済みサーバーからストリームインストール",
|
||||
|
||||
@@ -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": "저장된 서버에서 스트림 설치",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Потоковая установка с сохранённого сервера",
|
||||
|
||||
@@ -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": "ติดตั้งแบบสตรีมจากเซิร์ฟเวอร์ที่บันทึกไว้",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "从已保存的服务器流式安装",
|
||||
|
||||
@@ -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": "從已儲存的伺服器串流安裝",
|
||||
|
||||
@@ -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<GameInspection> {
|
||||
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<dyn ps5upload_fpkg::source::SourceTree>, 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<GameInspection> {
|
||||
};
|
||||
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::<serde_json::Value>(&bytes[..end]) {
|
||||
apply_param_json(&mut g, &v);
|
||||
@@ -448,7 +466,7 @@ fn inspect_tree(path: &Path, format: &str) -> Result<GameInspection> {
|
||||
}
|
||||
}
|
||||
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<GameInspection> {
|
||||
}
|
||||
}
|
||||
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<Vec<u8>> {
|
||||
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<Vec<CacheEntry>>,
|
||||
}
|
||||
|
||||
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<Vec<(u64, std::time::SystemTime)>> {
|
||||
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: <name>.0, <name>.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<String> {
|
||||
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<InspectCache> =
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user