Payload Manager 0.6 support: request marker, POST actions, slots (#458)

Payload Manager 0.6.0 refuses a mutation without a same-origin Origin/Referer
unless it carries X-PLDMGR-Request: 1 (403 "Mutation origin or request marker
rejected"), and answers 405 to GET on /loadpayload and /manage:delete. Every
request now carries the marker, and launch/delete are POSTs; 0.5.2 ignores both.

0.6 also moved storage to slots (repo/<id>@<source>, local/<stem>) and lists
them in "items"; its autoload list names slots. The list parser reads items
(with name/version/source) and the Payloads tab keys "Load at boot" by slot,
falling back to the file name on 0.5.

Verified on a FW 13.60 Phat with 0.6.1: overview, store, launch, autoload
add/remove and delete through the engine, and the live installer launch test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-14 19:36:37 -07:00
1 parent f37508feb5
commit b7dd979198
3 files changed
+167 -27

No files matched your search

+9 -1
View File
@@ -7,6 +7,8 @@ import { hostOf } from "../lib/addr";
export interface PmStoredPayload {
path: string;
/** Payload Manager 0.6's slot (`local/<stem>`, `repo/<id>@<source>`); "" on 0.5. */
slot: string;
filename: string;
folder: string;
name: string;
@@ -18,7 +20,8 @@ export interface PmStoredPayload {
export interface PmAutoload {
enabled: boolean;
delay_s: number;
/** File names Payload Manager resolves through its folders, or `!<ms>` pauses, in order. */
/** What Payload Manager loads at boot, in order: file names (0.5) or slots (0.6), with
* `!<ms>` pauses. `autoloadKey` gives a payload's entry. */
list: string[];
}
@@ -120,3 +123,8 @@ export async function pmStore(
}
throw new Error(w.error || "store failed");
}
/** The autoload-list entry that names `p`: its slot on Payload Manager 0.6, else its file name. */
export function autoloadKey(p: Pick<PmStoredPayload, "slot" | "filename">): string {
return p.slot || p.filename;
}
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, Play, RefreshCw, Trash2 } from "lucide-react";
import { Badge, Button, Callout, ErrorCard, Spinner, Toggle } from "../../components";
import { useConfirm } from "../../components/ConfirmDialog";
import {
autoloadKey,
pmDelete,
pmLaunch,
pmOverview,
@@ -149,14 +150,15 @@ export default function PayloadManagerPanel() {
}
const autoload = view.autoload;
const inList = (p: PmStoredPayload) => !!autoload?.list.includes(p.filename);
const inList = (p: PmStoredPayload) => !!autoload?.list.includes(autoloadKey(p));
const stored = view.payloads.filter((p) => p.path.startsWith("/data/pldmgr/"));
const onUsb = view.payloads.filter((p) => !p.path.startsWith("/data/pldmgr/"));
const row = (p: PmStoredPayload) => {
const listed = inList(p);
const key = autoloadKey(p);
const order = autoload ? autoload.list.filter((e) => !e.startsWith("!")) : [];
const pos = order.indexOf(p.filename);
const pos = order.indexOf(key);
return (
<li
key={p.path}
@@ -195,7 +197,7 @@ export default function PayloadManagerPanel() {
onClick={() =>
void setAutoload(
autoload.enabled,
listed ? autoload.list.filter((e) => e !== p.filename) : [...autoload.list, p.filename],
listed ? autoload.list.filter((e) => e !== key) : [...autoload.list, key],
)
}
title={tr(
@@ -214,7 +216,7 @@ export default function PayloadManagerPanel() {
variant="ghost"
size="sm"
disabled={busy !== null || pos <= 0}
onClick={() => void setAutoload(autoload.enabled, moveEntry(autoload.list, p.filename, -1))}
onClick={() => void setAutoload(autoload.enabled, moveEntry(autoload.list, key, -1))}
title={tr("pm_autoload_earlier", undefined, "Load earlier")}
>
<ArrowUp size={12} />
@@ -223,7 +225,7 @@ export default function PayloadManagerPanel() {
variant="ghost"
size="sm"
disabled={busy !== null || pos >= order.length - 1}
onClick={() => void setAutoload(autoload.enabled, moveEntry(autoload.list, p.filename, 1))}
onClick={() => void setAutoload(autoload.enabled, moveEntry(autoload.list, key, 1))}
title={tr("pm_autoload_later", undefined, "Load later")}
>
<ArrowDown size={12} />
@@ -7,12 +7,20 @@
//! sends nothing, and some setups never leave it listening (issues #344,
//! #345). Payload Manager itself stays up and can launch a stored ELF.
//!
//! Its API, read from its own web UI (v0.5.2) and measured on a FW 13.60 Pro:
//! Its API, read from its own web UI (v0.5.2) and measured on a FW 13.60 Pro,
//! then again against 0.6.1 on a FW 13.60 Phat (#458):
//! POST /manage:upload?filename=<name> raw bytes → stored under its folder
//! GET /list_payloads {"payloads": ["/data/pldmgr/…", …]}
//! GET /loadpayload:<path> launch; only its own folders, any
//! other path is "Invalid payload name"
//! GET /manage:delete?filename=<name> remove the stored copy
//! GET /list_payloads {"payloads": ["/data/pldmgr/…", …]} and,
//! from 0.6, "items": [{slot, path, kind,
//! filename, name, version, …}]
//! POST /loadpayload:<path> launch; only its own folders, any
//! other path is "Invalid payload name".
//! 0.6 refuses GET (405) and also takes
//! the slot ("local/<stem>", "repo/<id>@…")
//! POST /manage:delete?filename=<name> remove the stored copy (0.6: 405 on GET)
//! 0.5.2 does not look at the method of these two, so POST works on both. 0.6
//! stores an upload at /data/pldmgr/payloads/local/<stem>/<name> (slot
//! "local/<stem>") and its boot autoload list names slots, not file names.
//! The installer answered on :9115 1.1 s after `loadpayload`, beside a running
//! ps5upload helper, and kept running after its stored copy was deleted.
//!
@@ -76,7 +84,7 @@ pub fn launch_elf(ip: &str, filename: &str, bytes: &[u8]) -> Result<(), String>
.ok_or_else(|| format!("Payload Manager accepted {filename} but does not list it"))?;
let (status, body) = request(
ip,
"GET",
"POST",
&format!("/loadpayload:{}", encode_path(&path)),
None,
)?;
@@ -94,7 +102,7 @@ pub fn launch_elf(ip: &str, filename: &str, bytes: &[u8]) -> Result<(), String>
pub fn forget(ip: &str, filename: &str) {
let _ = request(
ip,
"GET",
"POST",
&format!("/manage:delete?filename={}", encode_query(filename)),
None,
);
@@ -106,6 +114,9 @@ pub fn forget(ip: &str, filename: &str) {
pub struct StoredPayload {
/// The absolute path on the console; what `/loadpayload:` takes.
pub path: String,
/// Payload Manager 0.6's slot (`local/<stem>`, `repo/<id>@<source>`): what its
/// autoload list names. Empty on 0.5, whose list names files.
pub slot: String,
pub filename: String,
/// The folder Payload Manager keeps it under (`/data/pldmgr/payloads/<folder>/`),
/// or the mount root for a payload found on a USB stick.
@@ -149,6 +160,50 @@ fn parse_list(list_json: &[u8]) -> Option<Vec<StoredPayload>> {
.unwrap_or("")
.to_string()
};
// 0.6 describes each payload in "items" (and its USB finds in "usb"); 0.5 only
// lists paths in "payloads" with a "meta" map keyed by file name.
if let Some(items) = v.get("items").and_then(|i| i.as_array()) {
let usb = v.get("usb").and_then(|u| u.as_array());
let mut out: Vec<StoredPayload> = items
.iter()
.chain(usb.into_iter().flatten())
.filter_map(|m| {
let path = m.get("path")?.as_str()?.to_string();
let filename = text(m, &["filename"]);
let filename = if filename.is_empty() {
path.rsplit('/').next().unwrap_or(&path).to_string()
} else {
filename
};
// "local/<stem>" or "repo/<id>@<source>" under its storage; the
// mount root for a USB find.
let folder = path
.strip_prefix("/data/pldmgr/payloads/")
.and_then(|rest| rest.rsplit_once('/').map(|(d, _)| d.to_string()))
.or_else(|| path.rsplit_once('/').map(|(d, _)| d.to_string()))
.unwrap_or_default();
Some(StoredPayload {
slot: text(m, &["slot"]),
name: text(m, &["display_name", "name"]),
version: text(m, &["version"]),
source: text(m, &["source_name", "source"]),
install_source: {
let s = text(m, &["install_source", "origin"]);
if s.is_empty() {
text(m, &["kind"])
} else {
s
}
},
path,
filename,
folder,
})
})
.collect();
out.sort_by_key(|p| !p.path.starts_with("/data/pldmgr/"));
return Some(out);
}
let mut out: Vec<StoredPayload> = v
.get("payloads")?
.as_array()?
@@ -172,6 +227,7 @@ fn parse_list(list_json: &[u8]) -> Option<Vec<StoredPayload>> {
.unwrap_or(serde_json::Value::Null);
StoredPayload {
path: path.to_string(),
slot: String::new(),
name: text(&m, &["display_name", "name"]),
version: text(&m, &["version"]),
source: text(&m, &["source_name", "source"]),
@@ -186,11 +242,12 @@ fn parse_list(list_json: &[u8]) -> Option<Vec<StoredPayload>> {
Some(out)
}
/// Launch a payload Payload Manager already lists (`GET /loadpayload:<path>`).
/// Launch a payload Payload Manager already lists (`POST /loadpayload:<path>`; 0.6 also
/// takes its slot).
pub fn launch(ip: &str, path: &str) -> Result<(), String> {
let (status, body) = request(
ip,
"GET",
"POST",
&format!("/loadpayload:{}", encode_path(path)),
None,
)?;
@@ -203,12 +260,12 @@ pub fn launch(ip: &str, path: &str) -> Result<(), String> {
Ok(())
}
/// Delete a stored payload by file name (`GET /manage:delete`). Payload Manager
/// removes the file and its sidecar.
/// Delete a stored payload by file name (`POST /manage:delete`). Payload Manager
/// removes the file and its sidecar (0.6: the slot folder).
pub fn delete(ip: &str, filename: &str) -> Result<(), String> {
let (status, body) = request(
ip,
"GET",
"POST",
&format!("/manage:delete?filename={}", encode_query(filename)),
None,
)?;
@@ -330,7 +387,14 @@ fn parse_autoload(json: &[u8]) -> Option<Autoload> {
/// keeps its other settings when they are not sent.
pub fn autoload_set(ip: &str, enabled: bool, list: &[String]) -> Result<(), String> {
for entry in list {
if entry.contains(',') || entry.contains('"') || entry.contains('/') {
// A file name (0.5), a slot like "local/x" or "repo/x@y" (0.6), or a "!<ms>"
// pause; never a path or anything that breaks the comma list.
if entry.contains(',')
|| entry.contains('"')
|| entry.starts_with('/')
|| entry.contains("..")
|| entry.chars().any(char::is_whitespace)
{
return Err(format!("{entry:?} cannot be an autoload entry"));
}
}
@@ -403,6 +467,24 @@ fn encode_path(s: &str) -> String {
}
/// One HTTP/1.1 request with `Connection: close`; returns status and body.
/// The request line and headers. Every request carries Payload Manager's request
/// marker: since 0.6.0 a mutation (`POST`, `manage:delete`, `loadpayload`) without a
/// same-origin `Origin`/`Referer` is refused with 403 "Mutation origin or request
/// marker rejected" unless `X-PLDMGR-Request: 1` is present (its `mutation_authorized`
/// in http_server.c). Older versions ignore the header (#458).
fn request_head(method: &str, path: &str, addr: &str, body_len: usize) -> String {
let mut head = format!(
"{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nX-PLDMGR-Request: 1\r\n"
);
if method == "POST" {
head.push_str(&format!(
"Content-Type: application/octet-stream\r\nContent-Length: {body_len}\r\n"
));
}
head.push_str("\r\n");
head
}
fn request(
ip: &str,
method: &str,
@@ -419,14 +501,7 @@ fn request(
stream.set_read_timeout(Some(IO_TIMEOUT)).ok();
stream.set_write_timeout(Some(IO_TIMEOUT)).ok();
let body = body.unwrap_or(&[]);
let mut head = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n");
if method == "POST" {
head.push_str(&format!(
"Content-Type: application/octet-stream\r\nContent-Length: {}\r\n",
body.len()
));
}
head.push_str("\r\n");
let head = request_head(method, path, &addr, body.len());
stream
.write_all(head.as_bytes())
.and_then(|()| stream.write_all(body))
@@ -477,6 +552,18 @@ fn dechunk(mut raw: &[u8]) -> Option<Vec<u8>> {
mod tests {
use super::*;
#[test]
fn every_request_carries_the_request_marker_payload_manager_0_6_demands() {
let post = request_head("POST", "/manage:upload?filename=a.elf", "10.0.0.2:8084", 3);
assert!(post.starts_with("POST /manage:upload?filename=a.elf HTTP/1.1\r\n"));
assert!(post.contains("\r\nX-PLDMGR-Request: 1\r\n"));
assert!(post.contains("\r\nContent-Length: 3\r\n"));
assert!(post.ends_with("\r\n\r\n"));
let get = request_head("GET", "/manage:delete?filename=a.elf", "10.0.0.2:8084", 0);
assert!(get.contains("\r\nX-PLDMGR-Request: 1\r\n"));
assert!(!get.contains("Content-Length"));
}
#[test]
fn finds_the_stored_copy_in_its_own_folder() {
// The real /list_payloads body from a FW 13.60 Pro, plus the upload.
@@ -519,6 +606,49 @@ mod tests {
assert_eq!(list[1].name, "");
}
#[test]
fn lists_payload_manager_0_6_items_with_their_slots() {
// Payload Manager 0.6.1 on a FW 13.60 Phat: a repository payload and a
// web upload. "payloads" is still there but carries no names.
let body = br#"{"self":{"id":"pldmgr","running_version":"0.6.1","pending_version":""},"items":[{"slot":"repo/elfldr@e8cce5d1","path":"/data/pldmgr/payloads/repo/elfldr@e8cce5d1/elfldr_v0.26.elf","kind":"repo","filename":"elfldr_v0.26.elf","id":"elfldr","source_url":"https://itsplk.github.io/ps5-payloads-mirror/payloads_v2.json","source_name":"Official Repository","name":"elfldr","version":"v0.26","checksum":"ed6d","last_update":"2026-08-29","author":"","details_url":"","source":"https://github.com/ps5-payload-dev/elfldr/releases","description":"An ELF loader"},{"slot":"local/ps5upload-probe","path":"/data/pldmgr/payloads/local/ps5upload-probe/ps5upload-probe.elf","kind":"local","filename":"ps5upload-probe.elf","origin":"web_upload","origin_detail":""}],"usb":[{"slot":"usb/x","path":"/mnt/usb0/x.elf","kind":"usb","filename":"x.elf"}],"payloads":["/data/pldmgr/payloads/repo/elfldr@e8cce5d1/elfldr_v0.26.elf","/data/pldmgr/payloads/local/ps5upload-probe/ps5upload-probe.elf"]}"#;
let list = parse_list(body).unwrap();
assert_eq!(list.len(), 3);
assert_eq!(list[0].slot, "repo/elfldr@e8cce5d1");
assert_eq!(list[0].folder, "repo/elfldr@e8cce5d1");
assert_eq!(list[0].name, "elfldr");
assert_eq!(list[0].version, "v0.26");
assert_eq!(list[0].source, "Official Repository");
assert_eq!(list[0].install_source, "repo");
assert_eq!(list[1].slot, "local/ps5upload-probe");
assert_eq!(list[1].filename, "ps5upload-probe.elf");
assert_eq!(list[1].install_source, "web_upload");
assert_eq!(list[2].folder, "/mnt/usb0");
assert_eq!(
stored_path(body, "ps5upload-probe.elf").as_deref(),
Some("/data/pldmgr/payloads/local/ps5upload-probe/ps5upload-probe.elf")
);
}
#[test]
fn autoload_entries_may_be_0_6_slots_but_never_paths() {
for bad in ["a,b", "/data/x.elf", "a/../b", "x y"] {
assert!(
autoload_set("127.0.0.9", true, &[bad.into()]).is_err(),
"{bad}"
);
}
// Slots and pauses pass the shape check and reach the network (which fails here).
for ok in [
"repo/elfldr@e8cce5d1",
"local/ps5upload-probe",
"!3000",
"kstuff.elf",
] {
let e = autoload_set("127.0.0.9", true, &[ok.into()]).unwrap_err();
assert!(!e.contains("cannot be an autoload entry"), "{ok}: {e}");
}
}
#[test]
fn reads_the_autoload_list_in_order() {
let body = br#"{"AUTOLOAD_ENABLED":true,"AUTOLOAD_LIST":"kstuff.elf,!3000,smp.elf","LAST_REPOSITORY_UPDATE":0,"AUTO_BROWSER_OPEN":false,"AUTOLOAD_DELAY":5,"KILL_DISC_PLAYER_ON_STARTUP":false,"SCAN_USB_PAYLOADS":false,"AUTO_INSTALL_APP":false,"MULTI_SOURCES_ENABLED":false}"#;