25 KiB
Development
This document describes how to build and deploy the Payload Manager.
How to Build
1. Build the Frontend
You must build the React UI first. This converts the JSX into the dist/index.html file that gets embedded.
make frontend-build
2. Build the SDK Docker Image
If you haven't already, build the SDK environment:
docker build -t ps5-payload-sdk-pldmgr -f Dockerfile.sdk .
3. Build the ELF
Use the SDK container after building the frontend. Preserve your working ELF and build a candidate at a new path; this regenerates changed embedded assets without deleting previous candidates.
mkdir -p scratch
docker run --rm --network=none -v "$(pwd):/src" -w /src \
ps5-payload-sdk-pldmgr make ELF=scratch/pldmgr-candidate.elf
The resulting candidate is scratch/pldmgr-candidate.elf. The SDK image and
embedded CA header must already exist for this offline compilation. The ELF
target does not track ordinary header dependencies; use a fresh candidate name
when changing headers alone. A normal release build with make all produces
pldmgr.elf at the repository root.
Automated Deploy
For a build-and-send cycle, deploy.sh builds the frontend, runs
make clean all in the existing ps5-payload-sdk-pldmgr image, and sends the
root pldmgr.elf to port 9021 using socat. It does not call /shutdown,
build the SDK image, or verify successful startup on the console. If replacing a
running instance, stop it explicitly with an authorized POST first; keep a
working ELF outside the root output path because the script cleans that output.
That clean also removes the embedded CA header, whose regeneration downloads the
CA bundle; use the candidate build above with an existing header for offline
compilation.
./deploy.sh [PS5_IP]
(Requires PS5 IP as the first argument).
Manual Deploy
- Load the built ELF using your autoloader, or send it to a separate running ELF loader on port 9021. Payload Manager does not provide that loader itself.
- The menu will be available at
http://[PS5_IP]:8084.
Automated Verification
The HTTP response suite compiles the actual src/http_server.c with stubbed
PS5 services and real libmicrohttpd response objects. It injects response-buffer,
response-creation, CORS-header, queue, reallocation, and file I/O failures, then
sends 10,000 requests through a real loopback HTTP server. It also checks
disconnected/chunked requests, concurrent uploads, and server-stop cleanup in
thread-per-connection mode. ASan (including leak detection) and UBSan
are enabled by default. Test files, storage paths, and binaries are temporary;
the suite does not connect to a console or launch payloads.
On a host with Clang, its sanitizer runtime, Python 3, pkg-config, and libmicrohttpd/libcurl development files installed:
make test-host
make test-frontend
The container option builds libmicrohttpd 1.0.1, matching build_deps.sh:
docker build -f tests/Dockerfile -t ps5-payload-manager-tests .
docker run --rm --network=none -v "$(pwd):/src:ro" ps5-payload-manager-tests make test-host
The build downloads test dependencies; the test container itself has external
networking disabled. Linux sanitizer support is included in the image. The
optional --no-sanitize argument is for hosts without sanitizer support; use
the default sanitizer run for verification.
The JSON encoder suite compiles actual src/utils.c and checks its output with
Python's independent JSON and UTF-8 decoders. It covers Unicode, all non-NUL
control bytes, invalid UTF-8 replacement, every small escape/scalar boundary,
truncation status, and guarded destination bounds, with ASan/UBSan enabled.
C strings cannot represent embedded NUL. The decoder rejects decoded NUL in the
requested value, while accepting valid escaped NUL in unrelated fields.
The decoder suite passes exact, non-NUL-terminated buffers to actual
src/json_helpers.c; it checks bounds, direct-member selection, all escapes,
UTF-8, surrogate pairs, malformed input, and strict/prefix overflow contracts.
Objects are validated completely with a nesting limit of 64. Strict extraction
clears the result on overflow; descriptions/release notes explicitly opt into
UTF-8-safe prefixes. The backend suite uses real repository, source save/load,
and slot metadata read/write code inside temporary storage. It parses their
results independently with Python. Unused network/PS5 code is discarded at
link time, and no download or payload launch occurs. make test-host runs all
host helper suites and actual-HTTP scenarios listed in the Makefile, with sanitizers.
The serializer suite exercises actual history/log/payload/process/repository output at small and large capacities, sixfold escape expansion, failed format calls, and metadata/source preservation on serialization failure. Repository fixtures use fresh temporary caches and a recording download function that aborts if called. Process records use a host fixture; this checks serialization, not the PS5 kernel ABI. Current native builds pass without warnings.
The allocation suite executes actual repository parsing, managed-slot discovery,
cached-source serialization, and all USB discovery branches. It faults every
observed growth/read/path-copy allocation in its current fixtures and verifies
that failure clears results and releases partial collections. USB paths are
redirected to temporary fixtures using PLDMGR_USB_ROOT_FORMAT; production
defaults remain /mnt/usb%d. No fixture payload is launched.
The storage suite executes real uploads, USB imports, and repository installations with local recorded download bytes. It checks conflicts, concurrent keys, complete payload/metadata publication, I/O faults, media changes, and process-interruption recovery. Storage uses unique staging directories and a journal/backup; startup recovers interrupted commits before migration or serving requests. Public slot layout is preserved. File syncing is checked; unsupported directory syncing limits power-loss durability. Configuration remaps and migration are tested separately.
The integrity suite uses local HTTP/HTTPS servers and generated trusted, untrusted, expired, and wrong-host certificates. It tests protocol restrictions, body/size/I/O failures, strict optional checksum policy, refreshed manifest entry validation, and preservation of installed data on failed retries. The actual launcher runs against a recorded socket, covering canonical roots, regular files, self guards, and symlink substitutions during descriptor-based opening. No real loader is contacted.
API actions now require POST and a matching browser Origin/Referer, or an explicit
X-PLDMGR-Request: 1 header when both are absent. Foreign origins cannot use that
marker to bypass checks. GET remains available for reads; anonymous/foreign reads
do not activate browser-mode autoload. API CORS/preflights are restricted to the
matching Host. This is browser CSRF protection, not LAN authentication or DNS
rebinding protection.
The frontend uses shared marked request helpers and same-origin unload beacons
with keepalive fallback. The Vite proxy preserves the frontend Host, and the mock
server mirrors action policy. Public mock README/JSON fixtures retain ordinary
cross-origin GET compatibility. The verification workflow also runs actual Chrome
against a native stub server and Vite, checking hostile requests, real pagehide
beacons, and zero rejected-request effects. Run node tests/run_browser_mutation_tests.mjs for these checks.
Settings now accept validated partial updates. Saves atomically replace individual
files, preserve unspecified fields/timestamps, and return errors on failed writes.
The UI sends only the fields it edits. Settings/favorites/autoload remaps and
source edits serialize read-modify-write; history reads serialize a snapshot of
the 20 most recent entries. The new run_config_tests.py covers actual persistence,
injected I/O failures and concurrent history/settings/source operations.
Run python3 tests/run_config_tests.py --thread-sanitize on a host supporting
Clang ThreadSanitizer; local macOS verification passed and CI has a macOS job.
A multi-file settings request validates fully first, publishes lists before scalar
settings, and reports 500 if any publication fails. Earlier completed files stay
published; per-file atomicity does not provide an all-or-nothing transaction.
Autoload now owns one joinable worker and returns a coherent locked snapshot. Active reset preserves waiting/countdown/execution; terminal reset cannot rerun it. Abort/stop interrupt delays and loader waits. Sequences are parsed once from one regular file (4095 bytes, entries up to 511 bytes, delays up to one hour); status retains that running sequence if the file is edited. Processed counts and per-entry outcomes distinguish success/failure/skip. Anonymous/foreign status reads remain passive; an authorized read explicitly signals frontend readiness.
Loader connects time out after five seconds, and full transfers after thirty;
nonblocking/poll loops handle partial sends and cooperative cancellation. A
cancel cannot undo a payload already executed or bytes already sent. The actual
worker, socketpair loader recorder and real HTTP worker fixtures are part of
make test-host; run_autoload_tests.py --thread-sanitize covers concurrent
status/reset/start paths. The PS-button detector remains the existing headless
per-read implementation.
Startup lookup and process listing now share a bounded, validated sysctl snapshot.
Record sizes must advance within the returned bytes, all consumed fields must
fit, and names must terminate inside their SDK field. A malformed snapshot is
rejected in full before exposing a PID/list. Queries retry ENOMEM up to three
attempts and allocate at most 16 MiB. Failed listings become HTTP 500 through
the existing response-error path. SDK assertions preserve the current PID/RSS/
process-name offsets; offset 447 is ki_comm, rather than the startup code's
previously misnamed ki_tdname. python3 tests/run_process_tests.py runs the
actual listing/lookup with synthetic kernel bytes and sanitizers; --baseline
reproduces the previous bounds reads and startup loop from 9ffd392.
SSE log clients now retain independent sequence cursors and partially delivered
frames. Catch-up and live delivery preserve retained ring order even with a
one-byte callback buffer. Multiline text stays one EventSource message; a named
gap event reports overwritten entries and is shown in the log UI. Valid
Last-Event-ID resumes after a delivered entry within this daemon's log sequence;
invalid/future IDs replay retained history. The ring holds 100 entries in memory,
and cannot recover overwritten logs or logs from a previous process lifetime.
HTTP restarts retain the ring, but old response contexts terminate on generation
change. Every production MHD stop first wakes blocked SSE callbacks, including
standby/network restart and shutdown. Run python3 tests/run_log_stream_tests.py
for decoded/bounded callback tests, --baseline to reproduce the previous bugs,
or --thread-sanitize for shared-state checks. The actual HTTP/SSE fixture is
python3 tests/run_http_tests.py --logs-only. Both are part of CI.
The autoload editor waits for both persisted autoload fields before showing
controls or saving. The initial empty config, failed fetches, and incomplete
responses cannot enable autoload or flush an empty list. Run
node tests/run_autoload_editor_browser_tests.mjs with Chrome installed
(CHROME_BIN overrides the default path). It tests the real React editor with
controlled delayed responses, reloads, navigation, exit saves and list retention;
CI runs this regression too. --baseline reproduces the destructive behavior
using the editor from bfbe474 without changing workspace files.
The editor's external session now owns its draft and serial save queue; rapid handlers read the latest draft without writing refs during render. Failed or thrown saves expose Retry, and a failed multi-field request invalidates the previous acknowledgment because earlier fields may already have been published. Exit saves use the latest draft without pretending that a queued beacon was acknowledged. Favorites keep their 250 ms debounce and serialize in-flight writes; stale config responses cannot overwrite newer local edits. State updater functions contain no favorite network writes. Countdown/progress values are finite and clamped, including zero delay. Process/status polling rejects stale results and cleans up timers, with transport cancellation when AbortController is available and callback guards on Safari 11 without it. Log scrolling distinguishes reading history from newly arriving messages. Payload-card action and reorder buttons are siblings, avoiding nested interactive buttons.
npm run build now requires zero-warning lint and all Node tests through
prebuild; CI uses that gate and the actual-component Chrome checks.
cd frontend && npm run test:browser runs reload retention, the broader
StrictMode/lifecycle regression, and real-DOM markdown/offline/locale checks.
The runner's --lifecycle-baseline reproduces
the previous editor's unhandled rejected save using the committed source.
Collected arrays are limited to 4,096 items and 64 MiB of allocated array capacity, whichever is smaller for the item type. Repository parsing accepts up to 16 MiB of input text. These bounds apply to the parser/scanner collections; they do not impose download-stream limits or an aggregate daemon memory budget. Collection failures become explicit errors rather than successful partial lists.
.github/workflows/verify.yml runs all host suites, existing frontend tests,
and gated frontend build without publishing or deploying. Frontend lint has
zero errors/warnings and is required before the build. The host suite tests response
ownership/status handling, request cleanup, and autoload-list bounds. Storage
transactions, autoload worker concurrency, and PS5 firmware behavior remain
separate validation scopes. The main HTTP fixture stubs backend storage/services; the separate
--config-only HTTP fixture uses actual config persistence. Successful mock imports move actual files inside temporary fixtures.
The HTTP server accepts config/source bodies up to 1 MiB and streamed uploads
up to 512 MiB. Larger declared or accumulated bodies return HTTP 413; empty
request bodies return HTTP 400. Upload staging files use private, unique
.upload-* names and are removed by request teardown if uncommitted. This does
not purge older orphan files or guarantee cleanup after power loss/SIGKILL.
Autoload and favorites CSV responses allow 4,095 bytes; larger, unreadable, or
NUL-containing lists return explicit HTTP 500 JSON. Favorites preserve long
lines rather than splitting them into extra CSV entries. JSON builders discard
the complete output after any formatting/escape/capacity error; HTTP converts
serialization failures into complete HTTP 500 JSON.
Response buffer budgets include the terminator: payload/repository lists 4 MiB,
source lists 512 KiB, process/history/config 64 KiB, and logs 154,032
bytes (enough for the full ring at maximum escape expansion). Metadata uses
64 KiB; source saves allocate a bound derived from MAX_SOURCES and field sizes.
These are per-response/serialization budgets; scanner/parser arrays and total
connection concurrency are separate scopes. No unbounded response growth or
automatic repeated scan is introduced.
Native compilation remains a separate gate. To preserve the previous ELF,
build a candidate under scratch/ rather than run make clean:
mkdir -p scratch
docker run --rm --network=none -v "$(pwd):/src" -w /src \
ps5-payload-sdk-pldmgr make ELF=scratch/pldmgr-candidate.elf
This command assumes the SDK image, frontend distribution, and embedded CA header already exist. For a new checkout, follow the normal build setup first. The ELF target currently does not track ordinary header dependencies; use a new candidate filename or force the ELF target to rebuild after header-only edits.
Markdown emoji use bundled Twemoji PNG sprites. The 3,838 assets share one
20px-tile image embedded in the single HTML file; no CDN or external emoji
requests occur. A postbuild check verifies the PNG is embedded exactly once,
attribution remains and the old Twemoji CDN URL is absent. Source/license/attribution and optional regeneration instructions
are in frontend/src/assets/emoji/README.md; builds need no asset download.
Native Unicode emoji can render as missing glyph boxes on PS5, so Unicode is
kept as the safe text fallback when DOM sanitization is unavailable.
DOMPurify first removes untrusted media, classes/styles, forms and scripts while retaining links/code/tables/headings. Sanitized text nodes produce sprite spans; a second pass sanitizes the final HTML, including those trusted classes and accessibility attributes. Loaded notes and browser-cached README responses remain readable without WAN; this does not add persistent caching or offline uncached downloads.
All shipped locales are registered, including Dutch. Active-key coverage, interpolation/React markup, and locale cardinal forms are mandatory Node checks. Native language names work without Intl.DisplayNames; a scoped i18next cardinal fallback supports older WebKit without Intl.PluralRules. Locale completeness is not measured by requiring every value to differ from English.
The actual markdown/locale browser check can also be run separately:
node tests/run_markdown_locale_browser_tests.mjs
node tests/run_markdown_locale_browser_tests.mjs --baseline
The baseline uses the previous committed renderer without modifying source files, reproducing the CDN dependency and unavailable-DOM raw HTML fallback. The test blocks external requests; it runs against a local Vite fixture and Chrome.
HTTP Access Contract
The native daemon serves the UI/API on HTTP port 8084. It sends approved payloads to a separate ELF loader on port 9021; it does not provide that loader or a login/pairing service. The mock API uses loopback 8081, and Vite normally uses 5173 with its same-origin proxy.
| Request class | Method and behavior |
|---|---|
| Read APIs | GET: /list_payloads, /get_config, /processes_list, /history_list, /repository_payloads, /repository_icons?source=<source-id>, /sources_list, /autoload_status, /log, /events, /version, /getip, /manage:check, /usb_move_check. Ordinary repository reads may refresh their cache. Anonymous/foreign autoload status reads do not signal frontend readiness. |
| Explicit mutations | POST: /manage:upload, /manage:delete, /loadpayload:<slot-or-path>, /set_config, /abort, /autoload_clear, /repository_refresh, /repository_cache_clear, /repository_install, /sources_set, /sources_add, /sources_remove, /usb_move_perform, /process_kill, /shutdown. GET does not perform these actions. |
| Preflight | OPTIONS is validated against the requesting origin, route/method and supported headers. Foreign origins do not gain mutation access. |
| Removed updater compatibility | GET /update_check returns {"status":"none"}. It does not check, download or apply updates. |
A mutation requires an Origin matching the HTTP Host, including port; when
Origin is absent, a matching Referer suffices. Only when both are absent can
X-PLDMGR-Request: 1 authorize it. An explicit foreign Origin or Referer is not
overridden by that marker, and Sec-Fetch-Site: cross-site is rejected. Thus an
older same-origin browser window can still make a valid POST without the marker.
The frontend helper supplies the marker for normal actions; same-origin exit
beacons can use the Origin/Referer path.
These are browser CSRF checks, not a secret credential, LAN authentication or DNS-rebinding protection. A terminal script/direct LAN application can provide its own headers and perform actions. Read responses are not access-controlled by a login either. Use a trusted local network; pairing/authentication remains separate feature work. Do not bypass rejected HTTPS certificate checks or add wildcard CORS to native mutations as a workaround.
Configuration and lists live in /data/pldmgr/: pldmgr_config.txt,
autoload.txt, favorites.txt, sources.json and repository caches. Managed
payloads use /data/pldmgr/payloads/repo/ and /data/pldmgr/payloads/local/ slots.
USB libraries include /mnt/usb0/pldmgr through /mnt/usb7/pldmgr; optional root
USB scanning adds .elf/.bin files on those drives. Launches require supported
regular files within canonical approved roots, and block the manager itself.
Imported/repository slot data is published with staging/recovery; configuration
remaps and multi-file settings updates have the limits described above.
Hardware Verification
Automated host/browser checks use stubs and local transports. They do not prove PS5 kernel ABI, native TLS behavior, fonts/layout, rest/resume or the external loader. Verify these on the target console after runtime changes.
Repository icon caching
Catalogs use icons_url and item-level icon_key; see
CUSTOM_REPOSITORIES.md for the format and refresh policy.
The daemon stores repo_icons_<source-url-sha256>.json and .meta alongside
repo_cache_*.json. Catalog/icon refresh, icon snapshots and cache clearing share
a mutex. Icon downloads are bounded and validated before atomic publication;
failed updates preserve good data and use a one-hour retry delay.
GET /repository_icons?source=<configured-id> serves the local icon catalog.
It cannot fetch an arbitrary requested URL. POST /repository_cache_clear uses
the existing mutation-origin policy and deletes only recognized cache filenames.
python3 tests/run_repository_icons_tests.py checks the production caching code
with recorded downloads and sanitizers, without contacting a console or WAN.
node tests/run_markdown_locale_browser_tests.mjs --repository-icons checks the
actual repository provider, rendered images and Settings action in Chrome, with
all external requests blocked. Mock repository fixtures use test_repo.json and
test_icons.json.
Payload Manager self-updates
The official repository alone supplies pldmgr updates. Downloads require a
SHA-256 checksum and an embedded PLDMGR_VER:X.Y.Z signature matching the
manifest version. The manifest may have a leading v; embedded versions must
be numeric stable releases because legacy launchers cannot parse that prefix
or reliably compare prereleases. A newer update is published by atomic rename
to /data/pldmgr/payloads/pldmgr/pldmgr_vX.Y.Z.elf, outside managed slots.
Migration preserves both pldmgr and payload-manager directories, ignoring
case. Startup scans these directories for the highest version strictly above
MENU_VERSION and uses a dedicated, symlink-rejecting ELF handoff. Failed
handoffs continue normal startup. Equal-version files remain available for
older initial launchers; startup must not delete them.
/list_payloads exposes self: {id, running_version, pending_version} separately
from payload slots. A pending version is present only when newer than the
running version. The official catalog uses the higher version for Update
eligibility. Self-updates never use conflict/reinstall/keep-both flows and
remain blocked by regular launch, autoload, USB and upload paths. /update_check
remains disabled; there is no restored notification banner or settings toggle.
tests/run_self_update_tests.py covers selection, malformed/non-ELF files,
legacy directory preservation, official-only installation, checksum/version
validation, failed commit preservation, pending status, upload/USB guards, and
recorded loader transfers.
It runs under make test-host without connecting to a console.
Changes from the self-update handoff plan
- Keep
repository_entry_validstrict for ordinary payloads and add a separate source-aware validator for self-updates, including refreshed download attempts. - Require a checksum, a matching embedded version, and a version newer than both the running and pending versions; reject explicit storage modes.
- Generate a canonical legacy filename from the embedded version instead of trusting the repository filename for the final destination.
- Retain equal/older files at startup. Deleting the currently running update would prevent old initial launchers from finding it on their next boot. Superseded files in the canonical directory are removed only after publication and directory sync; failures retain the previous files and are logged.
- Expose separate self-update status instead of synthesizing a payload slot.
- Translate the download notice in all bundled locales because repository tests require complete active-key coverage; English-only fallback would fail them.