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:
phantomptrandClaude Opus 5.5 committed 2026-09-28 00:29:19 -07:00
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 -11
View File
@@ -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]);
+2
View File
@@ -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": "تثبيت بالبث من خادم محفوظ",
+2
View File
@@ -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": "সংরক্ষিত সার্ভার থেকে স্ট্রিম ইনস্টল",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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é",
+2
View File
@@ -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": "सहेजे गए सर्वर से स्ट्रीम इंस्टॉल",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "保存済みサーバーからストリームインストール",
+2
View File
@@ -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": "저장된 서버에서 스트림 설치",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "Потоковая установка с сохранённого сервера",
+2
View File
@@ -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": "ติดตั้งแบบสตรีมจากเซิร์ฟเวอร์ที่บันทึกไว้",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "从已保存的服务器流式安装",
+2
View File
@@ -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": "從已儲存的伺服器串流安裝",
+143 -23
View File
@@ -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, &params);
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");
}
}