98 Commits

Author SHA1 Message Date
97934d641a Release 0.5.5: two feed bug fixes
Add a feed loads its Popular list again over Directory/Popular (#1); a site that
sends a message instead of a feed now says what it sent and flags the publisher (#2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZbKERNSt4vQfyGV8rvkqp
2026-09-14 21:38:56 +00:00
afbe6367cc Say what a site sent when it is not XML at all
doghouse answers 200 with "Unable to establish a DB connection", and parse()
reported two parser errors about end of input that buried it. A body that does
not start with < now reports its first line, and explain_failure flags it as
the publisher's problem. Malformed XML keeps the parsers' errors.

Fixes #2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZbKERNSt4vQfyGV8rvkqp
2026-09-14 21:33:13 +00:00
af38583b53 Give listFeeds its container: Add a feed loads its Popular list again
The dialog and the Directory/Popular pane both rendered into id="popular", and
listFeeds looked it up by id, so the dialog's list landed in the pane behind it.

Fixes #1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZbKERNSt4vQfyGV8rvkqp
2026-09-14 21:28:49 +00:00
eeb72fd677 Add Cloudflare's security-audit skill
Vendored from cloudflare/security-audit-skill under .agents/skills,
pinned in skills-lock.json and linked into .claude/skills. Also adds
the project's shared permission allow-rules in .claude/settings.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
2026-09-14 21:08:10 +00:00
320cb48b5d Move the to-do list to Gitea issues
TODO.md's open items are now issues #1-#7 on git.sdf1.net, with
blocked-by links between the Directory ones. CLAUDE.md says where
to find them so a later session doesn't recreate the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
2026-09-14 21:08:10 +00:00
f2a61ad88c Halve child-feed indent again (22px -> 11px)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
2026-09-14 20:47:39 +00:00
8d84f9fe8c Halve child-feed indent; update TODO with error-log findings
The Patreon/OPML group indent (44px) read as too deep; 22px still
reads as nested without eating that much row width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
2026-09-14 20:45:55 +00:00
e38e3c563c Remove unused minus icon; update TODO
Audited the ICON set for consistency: minus was defined but never
referenced anywhere (circleMinus already covers Unsubscribe).
Everything else checked out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
2026-09-14 20:19:03 +00:00
a30edff248 Release 0.5.4: remembered view, Auto theme, Currently Listening
- Remember the feed/place and tab across a reload or new visit; an
  unknown or unsubscribed one lands on All Subscriptions instead of the
  first feed alphabetically.
- Add an Auto theme that follows the system's light/dark setting, and
  move Dark/Light/Classic/Auto into Settings as a dropdown alongside the
  header button's toggle.
- Add Currently Listening below Popular: episodes started and not
  finished, across every subscribed feed, one tap to resume. Reuses the
  existing entries/filter machinery (Filter::InProgress) rather than a
  new endpoint.
- Likely fix for the iOS bug where the topbar stopped responding to taps
  until a hard refresh (100vh -> 100dvh); unverified on a real device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
2026-09-14 15:38:03 +00:00
be3820bbbd Release 0.5.3: OPML orphan scan, feed error UI, small UI fixes
- Stop scanning an OPML/Patreon feed's derived rows once nobody subscribes
  to it; retire them (drop or orphan) the way sync_group already does when
  the list itself drops one. This is what let 922 defunct davewiner feeds
  keep scanning hourly after the OPML left config.
- Repair feed XML with a bare `&`, and give a plain reason (moved web page
  with its new address when linked, or nothing yet for an empty body)
  instead of a raw parser error.
- Show a failing feed's plain-English reason and next step (Unsubscribe /
  Use the new address) in the sidebar and on its own page, once it has
  been down a day.
- Fix four small UI bugs: show-note links open in a new tab, video files
  play as video, an opened item no longer disappears from the Unread tab,
  and Subscribe/Unsubscribe get their own icons.
- Fix Settings disappearing for non-admin accounts: it was hiding the
  whole modal instead of just the admin-only parts (Users, the editable
  schedule/quota, Save), which are the only parts the server actually
  refuses them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
2026-09-14 14:53:45 +00:00
51ce0bf9eb TODO.md: show a publisher's error in the UI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Dcx59boh4pasuNwAVU7un
2026-09-13 13:13:15 +00:00
6ec900e456 TODO.md: the errors in the production log
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Dcx59boh4pasuNwAVU7un
2026-09-13 13:06:16 +00:00
d4304869b6 TODO.md: cleared, the database trim and Popular are done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:30:10 +00:00
9aae3097e7 Release 0.5.2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:27:42 +00:00
2ff2074755 Answer status to the client that asked, not everyone
status is a terminal event. Broadcast, the healthcheck's answer ended any
ipx fetch that was watching a scan, which stopped reading at the next probe
while the scan carried on. It could not happen while status waited behind
the scan; answering it at once made it happen every 30 seconds. Each
connection's writer now takes private replies beside the broadcast, and
the test checks another client hears nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:27:42 +00:00
1698cf8d1e Answer status on the socket instead of queuing it behind the worker
The worker runs one job at a time, and status was one of its jobs, so the
Docker healthcheck waited behind the startup scan (54 seconds of it after
the last deploy) and timed out at 5. Any scan or download longer than
three probes would have had a working daemon marked unhealthy. The socket
now answers status straight away; everything else still queues. A test
fills the queue and checks status comes back anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:19:00 +00:00
b94a74ef15 Sign out through the proxy when the proxy signed you in
Sign out cleared ipx's cookies and showed its password page, while
Cloudflare Access still vouched for the person: nothing was signed out,
and the page looked like the wrong login. /api/me now says, for someone
the proxy signed in, where to go instead ([web] sign_out_url, which is
/cdn-cgi/access/logout behind Access), and /login sends anyone the proxy
vouches for on to their feeds. The header check both use is one function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:10:30 +00:00
9a8a3c696f docs: the ipodderx tile in Authentik's library
A bookmark application with no provider, so ipodderx shows in the library
beside Outline. Recorded with its id and how to delete it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:03:06 +00:00
bedf64e645 docs/sso.md: the sign-in setup ipodderx.sdf1.net really runs
Authentik is Cloudflare Access's OpenID Connect identity provider, not
something in the request path, and the tunnel's requests reach ipx from
the content_default gateway, 192.168.16.1, not 127.0.0.1. The page is
rewritten from what was measured, with checks for both the trusted and
the refused path, and docs/history.md records every change made to get
there with how to undo it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:58:18 +00:00
586d2c07a1 ipx user rename: give an account the name the proxy signs it in as
An account made by hand before the proxy was set up is called what it was
given ('rays'), while Cloudflare Access vouches for an email address. With
auto_create_users on, the first visit through the tunnel would make a
second, empty account. Renaming keeps the id, so feeds, read state and
admin rights go with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:54:29 +00:00
2ba83c3aed Keep when each account was added and when it last signed in
users.created comes back, beside a new last_login, for whoever maintains
the server. A password sign-in, the token link and a request through the
proxy all count, recorded to the hour so the proxy's per-request vouching
is not a write each time. Settings -> Users and ipx user list show both.
The three user queries now share one row mapping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:37:09 +00:00
1352f0d54d Show notes cut off mid-tag give way to the item's description
libsyn served Daily Meditation Podcast's content:encoded cut at the '>'
inside a Tailwind class pasted from a web app, so 57 items began halfway
through a tag and the page showed the rest of it as text. Their
description was whole. A body that closes an attribute list before any
tag opens now falls back to the description, for RSS and Atom alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:29:00 +00:00
a958f7cb37 Trim the state database; Popular lists feeds the way Directory does
Drops the created columns on users, subscriptions and sessions, which were
written by every insert and read by nothing, and migrate()'s add list, whose
columns all predate 0.3.0. Removes Db::subscribed_feed_ids (no callers),
Db::subscriber_count (one caller wanting > 0) and Managed.orphaned (never
read). The old-database test now builds the tables with foreign keys on.

Popular now lists the feeds inside an OPML or a Patreon creator, never the
collection, as Directory does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:15:55 +00:00
457a58dcc5 Directory lists the feeds inside an OPML, not the OPML
Popular still counts an OPML as one feed, since everyone subscribed to it
counts for every feed inside and they would bury the rest. The directory is
for finding a show, so it lists them one by one and never the OPML. A feed
inside an OPML that looks private is hidden with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 02:26:52 +00:00
2af57065c6 Release 0.5.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 02:15:53 +00:00
564b011c7a Give the folder triangle a wider gutter
The feed list's left padding grows from 8 to 16 px and the triangle's
button from 16 to 24 px wide, so it is no longer cramped against the
folder's art. Everything in the list shifts together, so feeds still line
up with the places above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 02:13:32 +00:00
9eb7aadced Release 0.5.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 02:06:23 +00:00
c6bceaef37 Docs: a slow migration and a CLI run at the same time
Every ipx command migrates when it opens the database, so the healthcheck
collided with the daemon while it dropped the old entries columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 01:59:05 +00:00
dc63d6acaf Cut what the audit found: dead columns, one-time upgrades, three deps
Works through TODO.md from the 2026-09-12 over-engineering audit. Drops the
entries.read/flagged/position columns (migrate() removes them from older
databases), migrate_opml_children, the legacy interval_mins key, the
contrib/ systemd units, test-only Db wrappers, a duplicate token generator,
redundant logbuf visitors, unused page state and CSS, and the infer, dirs
and tokio-stream dependencies. The icon is served once as /icon.png instead
of inlined four times, taking about 94 KB off the two pages.

The adoption's subscription half was not dead: it gives a fresh install's
first admin the config's feeds. It stays as adopt_catalogue, now tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 01:55:44 +00:00
8937f35f00 TODO.md: cleared, the design pass is done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 01:31:08 +00:00
0990f2a90d Design pass on the web UI: amber for new, EQ bars, keyboard feed list
Works through TODO.md from the 2026-09-11 review. Unread badges, dots and
download bars take the icon's amber; the playing item is marked by EQ bars
that move only while it plays. The feed list is usable from the keyboard,
focus rings show everywhere, and folders get a mosaic of their shows' art
with the triangle hung in the margin. Sentence-case labels, fewer bold
weights, tinted initials tiles, shorter header lines, "Kept" everywhere,
and the list gets the room the empty panes had. Reduced motion is honoured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-11 22:24:41 +00:00
0cc002cdfa todo.md: folders in the sidebar; drop the system-theme item
The disclosure triangle, looked at closely: the feed list cannot be
reached from the keyboard (span and div, no tabindex), every feed is
pushed 28 px right for a slot only folders use, the target is 18 px,
shows barely nest under their folder, a folder looks like a feed, and
a selected feed's placeholder tile vanishes in Dark and Light.

Following the system light or dark setting is off the list, by
choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wi22VSVrkAvqNj61eqHsm9
2026-09-11 19:39:49 +00:00
2c9e899762 todo.md: the design review's list, to do later
A review of the web UI against screenshots of every view in all three
themes. The palette and Classic carry the iPodderX identity; Dark and
Light do not. First three: amber for new (unread badges, dots,
download progress), EQ bars as the playing marker, and toolbar focus
rings that overflow:hidden currently clips. The rest is weights,
sentence-case labels, pane sizes, header lines, wording, system theme,
reduced motion, art, sign-in and the Settings export/import icons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wi22VSVrkAvqNj61eqHsm9
2026-09-11 18:53:42 +00:00
2e416f96cf Patreon creators split into their shows; filters follow settings
A Patreon token pasted into Add feed, or a creator link without
&show=, becomes a folder of that creator's shows, found through
Patreon's web API and kept in step like a subscribed OPML (sync_group,
split out of sync_opml). A creator already read as one feed is split
too: each show takes over the files and read state it held
(Db::adopt). A creator with one show stays a plain feed.

Filter verdicts are judged again every scan, so turning on Allow
explicit brings skipped items back. Add feed has an explicit box.
Feeds in a group follow your settings on the group, as its dialog
said. A new feed no longer takes the id of a removed one at a
different URL and shows its old items. See CHANGELOG.md [Unreleased]
and docs/history.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wi22VSVrkAvqNj61eqHsm9
2026-09-11 18:24:47 +00:00
9269aa99f7 README: a short overview that points into docs/
It described the layout from before 0.4.0 (items across the top, a
player below), and carried long sections on OPML, the log view and
this server's own deploy steps, all of which docs/ and CLAUDE.md cover.
It now says what ipx does, how to run it with Docker or from source,
the first sign-in, the TLS caveat, where the docs are, and the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 17:36:34 +00:00
ae8123250b Release 0.4.0
The original iPodderX layout (toolbar, places, item table, Files pane),
the Classic theme, Directory and Popular, All Subscriptions with mark
everything read, sortable columns and a Size column, one meaning per
icon across the UI, and fixes for the double play, Escape in dialogs,
the dark-theme password box and paid feeds listed in Popular. See
CHANGELOG.md [0.4.0] and docs/history.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 17:35:08 +00:00
2c34a144ba History: the icon pass, sorting and the double play; fix a stale changelog line
docs/history.md gets the long-form entry CLAUDE.md asks for: what the
UI pass found, the three bugs it turned up (Escape inside a dialog's
text box, the white password box, the stray dot), why sorting is done
on the server, and how the Files pane came to play a file twice.
CHANGELOG's Added line for the item table named the old columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 17:32:41 +00:00
669e8b5124 Files pane plays through the player bar, once
The pane drew its own <audio controls> for a downloaded file, and its
onplay also started the player bar, so one click played the same file
twice at once. The pane now has a play button (with the file's type
icon, like the other rows) that hands that exact file to the player
bar, the only player. play() takes the file, so another of an item's
files starts from its top instead of resuming the first. Dead CSS for
the pane's <audio> removed.

Test: play in the Files pane leaves one <audio> on the page, the bar's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 17:30:03 +00:00
5362436766 Sortable item table, Size in its own column, Subscribed as an icon
- Every column heading sorts (kept, title, feed, file type, size,
  published); a second click reverses it. The server sorts through a
  fixed whitelist (order_sql), so it covers the whole list, not the
  fifty loaded; the choice is remembered in the browser.
- Size is its own column and shows KB for small files instead of
  "0 MB". The Item heading is Title.
- Popular/Directory/Add feed: Subscribed is a green circle-check.
- Tests: every sort column runs and orders both ways (db); the table
  sorts by title both ways and remembers across a reload (browser).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 17:17:16 +00:00
0efc49519c One icon per meaning across the UI; mark everything read in All Subscriptions
- All Subscriptions' header checks every feed and marks everything read
  (POST /api/read-all, the same feeds the view lists); it asks first.
- Minus unsubscribes everywhere (the feed header's x read as "close"),
  x only closes or cancels, plus adds/subscribes/imports, and a dialog's
  confirm carries its action's icon. Remaining word buttons, the player
  and the folder arrow are Font Awesome 7.3.1 icons.
- Toolbar grouped by what it acts on (add, unsubscribe, scan | play,
  read, keep); read and keep show the selected item's state.
- The OPML subscription page uses the same header as a feed.
- Fixed: Escape ignored inside a dialog's text box (Add feed could not
  be closed with it), white password box in the dark theme, stray dot
  in an undated item's details.
- Tests: one action one icon across toolbar, page and all 8 dialogs;
  All Subscriptions mark everything read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 17:05:47 +00:00
57dcba2d1a One file icon, green when downloaded; mark unread is an envelope
- The separate check (and warning) beside a file's type icon is gone:
  the type icon itself is green once the file is downloaded and red when
  the download failed, with the details in its tooltip. One icon per row
  keeps the column lined up. On Classic's blue selection they are a
  lighter green and red rather than white.
- Mark unread under an item's title was a solid circle, which read as a
  record button. It is Font Awesome's closed envelope now.
- Drops the unused circle-check, circle-exclamation and circle icons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 15:20:32 +00:00
e95cccc66f Icons are Font Awesome Free, embedded as SVG
The ICON set is now Font Awesome Free 7.3.1: the 29 icons the page uses,
taken from svgs/solid and svgs/regular at that tag and embedded as SVG
paths, 12.8 KB in all. No webfont to download and nothing fetched from a
CDN. The CC BY 4.0 attribution is above the set, and in the README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 15:14:51 +00:00
7295be8b25 Drawn icons throughout; file state and type as icons, no PENDING
- Every button's icon is drawn from one small SVG set (ICON), in bold
  strokes of the button's own colour, instead of font characters: ⟳ ⤓ ↗
  and the like came out thin and tiny and differed from font to font.
  Static buttons name theirs with data-icon. Keep is a flag everywhere.
- A file's state is an icon: a check when downloaded, a warning with the
  error in its tooltip when it failed, nothing while it waits. Its type
  (audio, video, image, pdf, torrent, other) is an icon with the word in
  its tooltip. The DOWNLOADED and PENDING chips are gone, which also
  fixes them being hard to read on Classic's blue selection.
- Tests find state and type by their tooltips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 15:11:15 +00:00
f1b0d97b81 Classic: white text across a selected row, white lists
A selected row's file size stayed grey on the Aqua blue; the whole row
is white now. The directory's rows took the source list's pale
blue-grey; lists were white in the original.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 15:03:04 +00:00
ae47e31a97 Classic theme after the 2004 Mac app; header and dialog buttons as icons
- A Classic theme beside Dark and Light: brushed-metal toolbar and
  status bar, a pale blue-grey source list, Aqua blue for whatever is
  selected, red unread badges, a striped table with blue titles, a blue
  bar behind the item's title, and Lucida Grande. The theme button steps
  through all three and remembers the choice.
- The feed and OPML headers' buttons (scan, download latest, mark all
  read, settings, unsubscribe) and the Settings, feed settings and
  Download latest dialogs' buttons (save, download, cancel) are icons,
  with the words in title and aria-label.
- Tests: the theme button reaches Classic and it survives a reload; the
  header buttons are found by action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 15:00:30 +00:00
8a309eb652 Files pane and item buttons are icons, with the words in tooltips
Save, delete, view and download in the Files pane, and mark read, keep
and open the original under an item's title, are icons now. The word is
in each one's title and aria-label, so it is still there on hover and
for screen readers. Tests find them by title or action, not text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 14:57:03 +00:00
1a2b0d87c6 CLAUDE.md: what to do when Docker Hub rate-limits the image build
The build asks Docker Hub about its two base images every time unless
they are stored locally, and this host has no Docker Hub login, so a busy
day ends in 429 Too Many Requests. Pulling them from mirror.gcr.io and
tagging them locally lets the build go ahead without asking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 14:49:58 +00:00
d7fac2d0e7 Never list paid-feed services; scan on add; no "reaped"; slimmer header
- Security: feeds from Patreon, Supercast, Supporting Cast, Glow and
  Memberful are never listed in Popular or the Directory. A Supercast
  feed keeps its key in the URL's path, which the query check missed, so
  it was being listed.
- Adding a feed queues a scan of it, and an OPML import that added feeds
  scans what is due, so items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never
  downloaded: no "reaped" chip, just the Download button. The retention
  summary says "deleted".
- The feed header keeps its title and stats to one line each and wraps
  its buttons; a single feed's table drops the Feed column.
- Tests: adding a feed shows its item without Scan; a deleted file shows
  no "reaped"; paid-feed hosts and acast public ids in the unit test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 14:47:03 +00:00
df9b7645d6 The original iPodderX layout: toolbar, places, item table, Files pane
- A toolbar across the window with the original's groups: add and
  unsubscribe, play, mark read and keep for the selected item, scan, a
  search box for what is showing, and Settings and Log (admins only).
- Directory, Popular and All Subscriptions sit at the top of the feed
  list and open in the main pane; the Popular and Directory buttons and
  their dialogs are gone.
- All Subscriptions lists every item from every feed you subscribe to:
  GET /api/entries, the per-feed query with its scope widened. The
  enclosure lookup after it matches files to rows by feed and guid, since
  a page can now span feeds.
- Items are a table (unread, kept, item, feed, file, published) with a
  Files pane beside it, the text below, and a status bar with totals. On
  a phone the files follow the text and the table is title and date.
- Tests: enclosures are checked in #files; the toolbar's read, keep and
  play act on the selected item; All Subscriptions holds only your feeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 14:39:05 +00:00
f3825cfc57 Popular is a top 10; a Directory lists every listed feed A to Z
- GET /api/popular returns the ten most subscribed feeds. The new GET
  /api/directory returns every feed that may be listed, sorted by name,
  from the same list: everyone counted, you included, never a URL, never
  a private feed or a feed inside an OPML. POST /api/popular/{id} still
  subscribes to anything on it.
- A Directory button sits beside Popular; both open the same list
  screen. The sidebar toolbar wraps rather than squeezing four buttons.
- Tests: the directory is A to Z, Popular is its top ten, Paid Show is
  in neither, and subscribing works from the directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 14:13:16 +00:00
c0f4b0bcb2 Popular button in the sidebar; the popular list counts everyone
- A Popular button beside + Feed opens the popular list directly; the
  Add feed dialog keeps it too.
- The list counts every subscriber, you included. Your own feeds stay on
  it, marked Subscribed, and clicking one opens it. Private feeds and
  feeds inside an OPML are still never listed, for anyone.
- GET /api/popular rows carry `subscribed`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 14:05:24 +00:00
8b8b48302b Popular on this server; changelog follows Keep a Changelog; 0.3.0
- Add feed lists what other accounts subscribe to, most subscribers
  first, and subscribes you by id (GET /api/popular, POST
  /api/popular/{id}). Rows never carry a URL. Feeds from an OPML and
  anything that looks private (a login, credentials in the URL, a key
  such as auth= or token=) are never listed, and the subscribe route
  checks the id against the same list.
- CHANGELOG.md follows Keep a Changelog 1.1.0: 0.1.0 (2026-09-09, the
  CLI), 0.2.0 (2026-09-10, the web UI), 0.3.0 (2026-09-11, accounts and
  sharing). The long-form entries moved unchanged to docs/history.md.
- Cargo.toml is 0.3.0. CLAUDE.md says how to add an entry and cut a
  release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 13:56:57 +00:00
5d3fdde4da Upload an OPML file to import; tests for every way in and out
- The import screen has a file picker beside the paste box. The page
  reads the file, checks it looks like OPML before sending, and clears
  the picker when it is refused and after it is imported. The file is
  sent as text and never written to disk on the server.
- The server parses the OPML before touching anything and answers 400
  "that is not an OPML file" (was a 500). subscribe_opml takes a parsed
  document, so ipx import also refuses a non-OPML file by name.
- Tests: Settings' Export OPML download and paste import; uploading an
  RSS file (refused) and a real OPML; the server's 400; the admin's
  export round-tripped into a second account; ipx import/export in a
  scratch config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 13:42:38 +00:00
8784d0a3fd OPML import subscribes you; export lists only your feeds
Import predated accounts: it only added URLs missing from config.toml
and subscribed nobody. Importing another account's export did nothing
("Imported 0 feed(s)"), and a genuinely new feed had no subscriber, so
it was never scanned. Web and CLI import now share subscribe_opml,
which subscribes the caller (the CLI: the first admin) to every feed in
the file and reports new vs already-subscribed.

Export wrote the whole catalogue to anyone signed in, including other
people's private feed URLs. It now lists only your own subscriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 12:53:29 +00:00
5e95557cbb User admin in the web UI, admin-only log, unread-first OPML feeds
- Settings > Manage users: add an account (password, or none for proxy
  sign-in), toggle admin, remove. Backed by GET/POST /api/users and
  PATCH/DELETE /api/users/{id}, 403 for non-admins. The only admin
  cannot be demoted or removed.
- GET /api/logs is admin-only and the Log button is hidden for others;
  the log names every account, feed and failed sign-in.
- Feeds inside an OPML list those with unread items first, in the
  sidebar folder and on the subscription's page.
- Deploying is now buildx --push to 192.168.1.130:5000 and recreating
  the ipodderx service of the Arcane project content; CLAUDE.md and the
  README's Docker section say so.
- Tests: Playwright for user admin, the last-admin guard, 403s for a
  non-admin and the unread ordering (new Aardvark Radio fixture); a unit
  test for last_admin; the smoke test drives usersModal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
2026-09-11 12:43:28 +00:00
6114add4a6 Documentation: docs/, a changelog, and CLAUDE.md
PROGRESS.md becomes CHANGELOG.md with the finished step lists moved to an
appendix. The README is an overview pointing at docs/: configuration,
cli, users, sso (refreshed for accounts and admin-only settings), and
architecture. CLAUDE.md collects what working on this code actually
requires -- pkill -x not -f, the page being compiled in, the dead columns
on entries, the Playwright worker that deleted its own database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-11 03:00:25 +00:00
c47c224372 Pruning respects a star from anyone
prune_entries still guarded on entries.flagged, which nothing writes
since read state moved to entry_state -- so starring a text item with no
file would not have saved it from the age sweep. It follows the reaper's
rule now, and takes orphaned read state with whatever it deletes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-11 02:41:12 +00:00
686851b448 Warn before deleting a file other people share
A feed with other subscribers labels the button Delete for everyone and
names them in the confirmation. The server decides: if anyone else has
starred the item or not played it, DELETE returns 409 with the reason and
only ?force=true proceeds. A feed's header says when it is shared, which
answers why a file nobody here asked for exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-11 02:37:07 +00:00
f0d03c79c8 first commit 2026-09-11 02:31:09 +00:00
7df4ee7dde Retention follows per-user read and starred
reap_candidates still read entries.read/flagged, which nothing writes
since read state moved to entry_state -- so starring no longer protected
a file and the read-first ordering was dead. One file serves every
subscriber, so anyone starring it keeps it, and it counts as read only
once everyone subscribed has read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-11 02:25:24 +00:00
d46ec73261 Per-user read state and subscriptions
Read, starred and position move to entry_state; subscriptions carry each
person's keywords, auto-download, explicit and per-scan limit. The feed
list and unread counts are per person, and the existing library is
adopted by the admin on first start.

The feed URL, folder and schedule stay shared and admin-only: one file
serves everyone, so they describe the file rather than a preference.
Scanning merges subscribers' wants -- anyone wanting an item is enough --
via merge_policy, which is pure and tested.

Also: the test fixture wiped its data directory from every Playwright
worker, deleting the database out from under the running daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-11 02:17:16 +00:00
4810bb5cfb Make scanning an admin setting, and document SSO
The per-feed schedule picker is gone and global Settings is admin-only,
enforced in the handlers with 403s rather than just hidden: polling costs
bandwidth and affects everyone reading the feed, so it belongs to the
operator. Folders, keywords and per-feed limits stay open to anyone.

docs/sso.md covers Cloudflare Zero Trust and Authentik end to end,
including why trusted_proxies names the proxy and not a subnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 23:16:33 +00:00
06f182b555 Accounts and sign-in, and fix the read toggle
epAction's redraw closure called itself when handed a row, so Mark read
recursed until the stack blew; it now swaps that row in place. Opening an
item also marks it read, redrawn where it stands so nothing vanishes from
under the pointer on the Unread tab.

Step A of multi-user: users and sessions tables, Argon2id, a session
cookie, ipx user subcommands, and a trusted proxy header for Cloudflare
Zero Trust -- honoured only from a trusted_proxies address. The shared
token still works and is the admin. A new database starts with
admin/ipodderx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 23:11:20 +00:00
ed26fa2061 Write down the multi-user plan
SQLite stays; sign-in is local user/pass or the Authentik already
fronting ipodderx.sdf1.net. Feeds, items and files shared; read state
and subscriptions per user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 20:04:37 +00:00
772dda5154 Call them items, not episodes
Half the library is text feeds, so the UI no longer assumes a podcast:
counts, search, the empty detail pane, the phone back button, retention
and per-feed settings, and the download dialog all say item. S1E1 badges
and the episode column stay -- those are the itunes:episode field, which
only appears when a feed publishes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 19:12:58 +00:00
3817dbebdd Stop the UI strobing during a scan
85 feeds meant 85 feed_done events, each rebuilding the sidebar and
reloading the episode list. Bursts collapse into one refresh, per-feed
"N new" toasts add up into one summary, and both lists keep their scroll
position across a rebuild. A full scan now costs 11 feed refreshes
instead of 85.

Settings and Log move to a footer under the feed list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 19:09:10 +00:00
3ee65f3155 Default to All, move OPML into settings, dress the feed actions
Opening a feed whose episodes are all read showed an empty list, so All
leads the tabs and is the default. OPML import/export moves under
Settings -- an occasional job, not a daily control -- freeing the
sidebar. Feed actions become icon pills, with Unsubscribe pushed to the
far end and quietened; it sat beside Settings looking identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 19:02:04 +00:00
d157c88ba9 Make the UI work on a phone
The ☰ button lived in the player bar, which is hidden until something
plays, so the feed list was unreachable on a phone. It moves to a bar
that is always present, and the sidebar gets a scrim.

The reading pane takes the whole screen over the list with a back
button, the player stacks into two rows above it, and the page no longer
scrolls sideways -- a grid column is min-content wide by default, so one
long headline dragged everything off the right edge.

Covered by a Playwright case at 390x844.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:58:10 +00:00
7473d5b7fb Mark all read on an OPML subscription
Marking the subscription read did nothing: its own row holds no entries.
read-all now resolves the feeds grouped under the id -- via
subscriptions(), so a child promoted to config is included -- and marks
those. Button sits before Unsubscribe, where every other feed keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:45:46 +00:00
8f5e2749ff Sum a subscription's counts from the feeds it holds
An OPML folder has no entries of its own, so its row always showed zero
unread however much was waiting inside. Summed from every feed it holds
-- not just the ones a filter left showing, so the count doesn't move as
you type -- with the badge capped at 999+ so a four-digit number doesn't
eat the title beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:42:42 +00:00
dfcdf47143 Line up the feed sidebar
Every row reserves the chevron slot, so artwork and titles share one
column instead of stepping left when a feed has no children. Children
keep a single icon size and read as nested from the indent alone. Labels
stack on one line-height, and the unread count has a min-width so a
three-digit feed doesn't shove its own title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:39:25 +00:00
470f3e1ff1 Use an item's thumbnail as its picture
Resolves in order of deliberateness: itunes:image, media:thumbnail, a
media:content that says it is an image, then an image enclosure -- which
is where a blog's article picture actually lives, so those entries had
artwork available all along and showed none. Audio enclosures are never
taken for pictures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:24:27 +00:00
c77d152015 Stop showing the skip reason next to a skipped enclosure
The chip already names the kind ("image"), so the sentence beside it added
nothing. A reason is now shown only when the state is an error. It is
still recorded and still reaches the API and the log, where it is useful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:16:41 +00:00
5190a29cdb Refetch when a 304 arrives with nothing stored, and soften a skip reason
Deleting entries during a cleanup left each feed's ETag in place, so the
rescan got 304s, skipped parsing, and 57 feeds stayed empty until a
publisher happened to change something. A 304 while the feed holds zero
entries means the validator has outlived the data, so the daemon drops it
and asks again.

"not a wanted media type" was jargon, and storing it in last_error painted
an ordinary filter decision red. It reads "not audio or video" now, and a
reason is only shown as an error when the state actually is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:14:57 +00:00
dd9c0881ee Keep every enclosure on an item, and view without downloading
The rss crate keeps at most one enclosure per item and, when a feed ships
several, silently keeps the last -- so a two-file item lost its first
file. enclosures_by_item reads them from the XML in document order,
unescaping attributes so a URL's &amp; survives. The row summarises the
one you would act on and counts the rest; the pane below lists them all.

Non-media enclosures gain a View link opening in a new tab: the
publisher's URL, or the local copy once downloaded. A direct link, not a
proxy, so the daemon does not become a fetch-anything relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:01:25 +00:00
6d6b4dd1e4 Do not offer a player for a file that is not audio or video
The media-type filter stopped new image enclosures being fetched, but ones
already on disk still got a play button and an <audio> element, because
the UI tested for a path rather than for a playable type. Four places did
this, including play() itself, which picked the first downloaded enclosure
whatever it was.

isPlayable() checks audio/* or video/*, falling back to the extension when
a feed declares no type. A downloaded non-media file now shows as its kind
with Save and Delete, so it stays available without posing as an episode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:50:39 +00:00
ddb9dbb7e1 Three-pane layout: feeds, items, item text
Feeds beside, the feed's items above, and the selected item's text with
its enclosures below -- the shape iPodderX used. Selecting a row fills the
pane below instead of expanding inline; enclosures render there as a
player when the file is present and a labelled download when it is not.
The divider drags and its position is remembered.

The archived site kept no usable screenshot of the original window, only
marketing panels, so this follows the description rather than reference
art.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:45:18 +00:00
a83f62ccd3 Do not auto-download enclosures that are not audio or video
Blog feeds put each article's header image in an <enclosure>, so a text
feed read as a podcast full of episodes: 149 images, 74 MB across 11
feeds. media_types defaults to audio and video, with a per-feed override.

Such enclosures stay listed and stay downloadable by hand; the row names
what it is rather than saying "skipped". An unknown type is allowed, since
the real type is only known after downloading, and a torrent is allowed as
a container judged once unpacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:13:59 +00:00
8b21d19365 Log tabs with a daemon I/O view, and Playwright UI tests
The log view splits into All / Daemon I/O / Scans / HTTP. Daemon I/O is
the control protocol itself, logged where every command funnels through
so it covers socket clients, the CLI and the web UI alike. stderr and the
in-app buffer now have separate filters, so the UI can keep debug detail
the terminal should not carry.

Playwright drives a real browser against a daemon on fixture feeds. Eight
tests, each mapping to a bug that reached a user -- the Rust tests and the
stub-DOM smoke test cannot see a wrong selector or a dead handler.

It immediately found one: OPML folders rendered expanded by default,
because the code stored closed groups, so any folder never toggled counted
as open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:07:19 +00:00
4429f1119c Look up derived feeds everywhere, not just in config
Moving OPML feeds into the database left several call sites still
searching config.toml only, so anything inside a subscription looked
unsubscribed: Download failed outright, status and the startup line
counted 3 feeds instead of 85, add could duplicate or collide with a
derived feed, and rm could not remove one.

The first grep for this missed the failing call because the method chain
spans lines; searching with newlines collapsed found all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 16:55:54 +00:00
665d5b8ecb Keep OPML feeds out of config, cap downloads, log daemon work
Writing 82 derived feeds into a hand-edited config.toml made it
unreadable. The OPML is the source of truth, so its feeds are re-derived
each scan and held in the database, inheriting the subscription's
settings; editing one promotes it to a real entry. A migration moves
existing children out -- 611 lines to 38 -- keeping all entries and files.

max_new_per_check defaulted to unlimited, so subscribing to an OPML of 82
feeds pulled whole back catalogues. It now defaults to 3 via [general],
capping every feed that does not set its own, and the pending queue orders
by publish date so a cap of 3 means the three newest.

Scans and downloads travelled as socket events only, so the log view
showed no daemon activity. They are mirrored into tracing, with routine
skips at debug -- at 82 feeds those alone would flush the buffer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 15:15:54 +00:00
c86d698363 Subscribe to an OPML, not just import one
A feed whose body sniffs as OPML is treated as a subscription list and
re-read on every scan, as iPodderX did. Listed feeds become real config
entries grouped under it, inherit its settings, land in one nested folder,
and are scanned in the same run.

When a feed leaves the OPML: removed if nothing was downloaded, kept and
flagged otherwise, so a downloaded file is never orphaned.

folder_for sanitized the whole folder string and would have flattened the
nesting; each segment is sanitized separately now, and a traversal still
cannot escape the download directory. Db::memory() also runs migrate(),
which it did not, so a migration-only column passed tests while missing in
production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 15:01:03 +00:00
5f6e2a8dc1 Add a log view, and Docker packaging
The Log button shows the running daemon live: feed scans, downloads,
torrents and every HTTP request. It reads a ring buffer filled by a
tracing layer rather than tailing a file, so it works under Docker where
logs go to stdout. The access-log middleware skips /api/logs, or the
panel's poll would log itself forever.

Detached torrents could leave a row stuck in 'downloading' across a
restart, where nothing would ever revisit it; those are requeued at
startup.

Dockerfile, entrypoint and compose: 114 MB runtime, config bound to
0.0.0.0 on first run since container loopback is unreachable, drops to
PUID:PGID for Unraid, and a healthcheck that goes through the control
socket so a wedged worker reads as unhealthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 12:06:54 +00:00
19309d609f Run torrents off the command worker
A torrent ran inline on the single sequential worker, so it blocked every
feed scan, HTTP download and status command behind it -- for up to
stall_mins waiting on metadata, and for up to seed_time_mins seeding after
finishing. A live daemon was wedged with 47 pending torrents and would not
answer a status command for 15s.

spawn_torrent detaches the job behind a 2-permit semaphore and marks the
row 'downloading' so a rescan cannot queue it twice. One-shot CLI runs
stay inline, or the process would exit mid-download.

Torrents themselves verified working: a 755 MB Debian netinst downloaded
to completion against a real swarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 02:51:21 +00:00
e97f2b9c2f Schedule pickers, and target progress at one row
"Check feeds every" becomes a number plus a unit dropdown in both global
and per-feed settings; parse_interval gained weeks to back it. The
per-feed dropdown can select the global default, clearing the override.

Fixes progress painting every pending row: the event carried no enclosure
id, so the handler had nothing to target and set the width on all of them.
Adding a feed looked like it was downloading everything. Progress,
DownloadDone and DownloadError now carry the enclosure id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 02:38:14 +00:00
9960befed5 Fix the UI dying at load, and add a page smoke test
$('#prefs').onclick referenced a prefsModal that was never defined, and an
uncaught ReferenceError stops the whole script -- taking the theme toggle,
the feed filter, the event stream and loadFeeds() down with it, so the app
rendered an empty shell.

The scheduling patch had anchored on a function the rewrite already
deleted; str.replace matched nothing and said nothing.

tests/page-smoke.js executes the page against a stub DOM so this class of
failure is visible, since every server-side check passed while the UI was
completely dead. Handler wiring now skips a bad reference instead of
throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 02:31:18 +00:00
9dc4c1ddfa Add feed check scheduling, global with per-feed override
general.schedule and feeds.<id>.schedule take "every 30m", "4h", "1d" or
bare minutes. The legacy interval_mins is still read. An explicit per-feed
schedule wins over the publisher's ttl; without one, ttl still raises the
interval when they ask to be polled less often.

Fixes two bugs found while testing it:

null never cleared a field. serde maps JSON null onto the outer None of an
Option<Option<T>>, so "clear this" was indistinguishable from "not
supplied" and every clear silently no-opped with a 204.

The daemon ignored SIGTERM while working. select! races branches only at
selection time, so a signal queued behind an in-flight download and the
process had to be SIGKILLed. The stop signal now cancels work in progress:
SIGTERM mid-download exits in 1s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:25:38 +00:00
9c5e7716c5 Name the app iPodderX and cut the icon's white background
A blanket white-to-transparent would have holed the device, whose body is
also white, so the background is flood-filled from the corners inward and
stops at the outline. Verified composited on the dark theme background
rather than by trusting the alpha channel.

The uppercase transform on the heading had to go too, or the name renders
as IPODDERX.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:13:12 +00:00
cbd5e68d13 Derive the colour scheme from the iPodderX icon
Palette sampled from the icon rather than chosen to resemble it: the
silver device ramp, the screen blues, and the amber EQ bars. Dark theme
builds down from the screen navy; light theme uses the device body with
the deeper blue as accent.

Contrast was measured, not assumed: --faint, which carries dates and
sizes, failed AA in both themes and was moved along the icon's own grey
ramp until it passed. Lowest pair anywhere is now 4.54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:08:59 +00:00
e05ea62051 Use the original iPodderX icon instead of a placeholder
Recovered from the Internet Archive's capture of ipodderx.com: the icon
was never in either repo, since only the Python engine was open-sourced
and the .icns lived in the Cocoa bundle. Kept as a file in web/ and
embedded as a data URI for the sidebar mark and the favicon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:03:48 +00:00
aecad18d22 Make the feed URL editable, with a copy button
Entries and history are keyed by feed id, so changing a URL keeps them --
the point being that a feed URL can carry an auth token that gets rotated.
Changing it clears the stored ETag/Last-Modified, which belong to the old
URL and could otherwise produce a bogus 304.

The copy button cannot use navigator.clipboard: that needs a secure
context and this is served over plain HTTP on a LAN address. Falls back to
execCommand.

Invalid input now returns 400 rather than 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:00:20 +00:00
44623098ae Mark episodes read when finished, not when started
play() set read=1 the instant playback began. The default view is the
Unread tab, so pressing play removed the episode from the list being
looked at, which reads as the episode going missing. All four affected
rows had position=0: started, never listened to.

Read is now set on 'ended' or past 90% of the duration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 01:55:46 +00:00
72280b9ccd Pin RSS <title> as the only source of an episode title
Confirmed byte-for-byte against a live feed, and tested against a fixture
whose itunes:title differs: the RSS title wins, and season/episode stay
metadata rather than being folded into the displayed name. An item with a
season but no episode number keeps a null episode instead of inventing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 01:48:56 +00:00
5666166769 Full-featured web UI
Rewrites the page around a persistent player (speed, seek, resume,
MediaSession, keyboard shortcuts), artwork, filter tabs, episode search,
pagination and live progress, with modals and toasts replacing prompt()
and a status line.

Backend gains the metadata that makes that possible: feed and episode
artwork, durations, season/episode numbers and playback position, plus
filters, search, totals, mark-all-read, download-latest and OPML over
HTTP. Schema changes arrive through a real migration, since CREATE TABLE
IF NOT EXISTS does nothing to an installed database.

Fixes filtering, which returned 500 whenever no search term was given:
the search clause was dropped while its parameter was still bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 01:42:55 +00:00
93b4815d84 Make the UI's Download button download the episode you clicked
POST /api/enclosures/{id}/download requeued the row and asked for a
normal scan, but a scan takes the lowest-id pending rows up to
max_new_per_check. With a large backlog and a small cap the requested
row was never a candidate, so other episodes downloaded while it stayed
pending.

A queue expresses what is outstanding, not what was asked for. Download
is now its own command that fetches one specific enclosure immediately,
ignoring queue order and the per-scan cap, still via the single worker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 01:11:12 +00:00
74ec6e9281 Phase 2: web front end
axum served from inside the daemon so it reads SQLite and the event bus
directly: browse feeds, read show notes, play with seeking, download and
delete files, mark read/flag, and edit feed settings.

Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI
edits apply without a daemon restart. Access is a shared token minted from
/dev/urandom, carried in a cookie because an <audio> element cannot send
headers. Show notes are untrusted feed HTML and are sanitized with ammonia
server-side.

read/flagged finally have a writer, which retention has needed since it
started ordering by them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 00:55:16 +00:00
ed47e456d4 Turn filename separators into dashes rather than dropping them
Splits the forbidden set: / \ | : were separating words, so they become
"-"; ? * < > " ' just go. Runs of dashes and spaces collapse to " - "
when the run held whitespace and to a bare "-" when it did not, so
"Show | Series" reads "Show - Series" while "AC/DC" stays "AC-DC".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 00:40:17 +00:00
c12e8ca19c Collapse whitespace left by stripped filename separators
A real feed titled with pipe separators produced a folder named
"Get in the Trunk  Anthology Series  Delta Green": removing a forbidden
character left the gap around it. Runs of whitespace now collapse, and
control characters map to a space rather than vanishing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 00:37:30 +00:00
79 changed files with 17793 additions and 681 deletions

View File

@@ -0,0 +1,83 @@
# AI, LLM, and Agent Hunting
#### When to use this file
Reach for this file when a language model participates in a trust-sensitive decision: chatbots and assistants, RAG pipelines, persistent agent memory, agent/tool-calling loops, MCP servers and clients, code that builds prompts from untrusted input, or code that consumes model output and acts on it. The important data flow is *untrusted content → model or memory → capability, authority, or sink*.
Use this alongside `ATTACK-CLASSES.md`, not instead of it. Transport, access control, query construction, filesystem use, and output rendering remain ordinary trust boundaries. This file covers the model-specific delegation layer. Split large targets by retrieval, memory, tool dispatch, MCP, and output handling.
## Core discipline (include in every agent prompt for this domain)
```
- Prompt injection alone is not a finding. Require a code-level boundary failure: content reaches another principal's context, invokes authority the requester lacks, discloses data they cannot read, or drives a sink they cannot reach directly.
- Model output, memory, tool descriptions, and MCP responses are untrusted inputs. Point to the code that grants authority, trusts output, writes durable state, or feeds a sink.
- A guardrail prompt is not a security boundary. Count only deterministic checks, resource-scoped authorization, isolation, binding, and constrained credentials.
- State the attacker, affected principal, effective execution identity, resource, exact action, authority used, and observable impact. An intentional direct request to use the requester's existing authority is not a delegation defect merely because a model executes it.
- Authorization and action binding are separate controls. Attacker-controlled content that causes an action under an affected principal's valid authority is an action-binding failure when that principal did not intentionally request or approve the exact action.
- Classify every candidate as `confirmed` only after source evidence and bounded local validation establish the boundary and result. Use `needs_validation` when a required provider, deployment, model, renderer, or identity behavior is not observable locally.
```
## Context, retrieval, and memory attack classes (subagent_type: `general`)
**Indirect injection through retrieved or ingested content**
An attacker can write a RAG document, indexed page, file, email, issue body, tool response, or metadata that enters a different principal's model context. Trace who can write each source, how retrieval scopes it, whose session consumes it, and what capability is enabled there. Check isolation, resource authorization, and binding to the consuming principal's intent separately. The defect is a missing deterministic control, not persuasive text by itself.
**Cross-session or cross-tenant context bleed**
Conversation history, embeddings, retrieval results, or prompt caches are keyed too broadly. Verify tenant and ACL filters in the query itself and every cache key. A tenant field stored on an object is not enforcement if an alternate query, shared cache, or batch path omits it.
**Persistent memory poisoning**
Attacker-controlled content or model summaries are written into memory that later shapes another task, user, or privileged session. Review who may create, update, merge, and delete memory; its provenance and tenant scope; whether low-trust observations become durable instructions or facts; and whether retrieval distinguishes user preferences from tool policy. Memory intentionally saved by a user and used only for that user's intentional, allowed requests is not a cross-boundary finding.
**Prompt role and provenance confusion**
Prompt assembly lets untrusted text impersonate a system message, prior turn, tool result, policy, or memory record. Look for string concatenation, untyped history, caller-controlled role fields, and serialization round trips that lose source labels. Confirm that the forged provenance changes a deterministic trust decision or reaches a meaningful capability.
## Tool and action attack classes (subagent_type: `general`)
**Tool-argument injection into a downstream sink**
Model-produced arguments reach SQL, shell, file, URL-fetch, or privileged APIs without handler-side validation. Treat the tool schema as input parsing, then follow each field from decoded call to sink. Structured output narrows shape; it does not establish authorization, safe paths, safe URLs, or query semantics.
**Excessive agency and confused-deputy authority**
The agent uses a service identity or broad credential, while the tool handler does not re-check the requesting principal's permission on the named resource. Verify both the effective identity and whether the caller could perform that exact operation through the normal product interface. A shared credential with enforced per-user query scope is not a defect.
**Action-confirmation and approval binding**
A user approves one described action but execution can use changed arguments, a different resource, a different principal, or a later model turn. An action-binding defect also exists when attacker-controlled content causes a side effect under a victim's valid authority without the victim's intentional request or approval, even if generic authorization permits the victim to perform it. Review whether intent or confirmation binds the normalized tool name, complete argument object, requester, target, amount, expiry, and batch membership. Check retries and resumed sessions: an approval must not authorize a mutated or duplicate side effect.
**Tool-schema and dispatcher disagreement**
The schema accepts aliases, extra fields, duplicate keys, coercions, nested free-form objects, or out-of-range values that the dispatcher or handler interprets differently. Compare schema validation, canonicalization, generated bindings, and handler defaults. Validate again where values become resource selectors or security-relevant options.
**Unbounded delegated action loops**
A bounded request can enqueue repeated spend, send, mutation, or external API work without a per-request budget, per-action authorization, cancellation, or idempotency control. Confirm impact on shared cost, quotas, other users, or durable state. Do not test by exhausting a service; use code-level accounting and a locally bounded loop.
## MCP and sub-agent trust classes (subagent_type: `general`)
**Sub-agent and MCP trust inheritance**
A delegated task receives the full session, credentials, memory, or capabilities rather than the least authority required. Check the principal and tenant carried into each call, capability narrowing, credential audience, and whether delegated results are treated as untrusted on return.
**MCP server and tool identity confusion**
Calls or results are routed by attacker-influenceable server names, tool names, request IDs, resource URIs, or model-selected aliases rather than the authenticated connection and outstanding request. Check whether two servers can claim the same tool or resource identity, whether reconnect changes the binding, and whether a response from one server can satisfy another server's pending call.
**MCP metadata and schema as policy**
Tool descriptions, resource metadata, prompts, completion hints, or schemas supplied by an MCP peer are trusted as policy or authorization. These fields can guide the model but cannot grant capability. Find the deterministic allowlist, server identity check, and handler authorization that remain authoritative when metadata conflicts.
## Output and disclosure attack classes (subagent_type: `general`)
**Insecure output rendering**
Model output reaches an executing HTML, Markdown, template, URL, or command sink without the sink's required encoding and policy. For browser rendering, verify auto-loaded resources and CSP or sanitization in `CLIENT-SIDE.md`; renderer behavior outside the repository makes the candidate `needs_validation`.
**Sensitive context extraction**
The assembled context contains credentials, another user's data, private source, or policy values that themselves grant access, and user-influenced output exposes them. Read prompt assembly and data-fetch code. Disclosure of generic instructions or behavior that does not cross a data boundary is not a finding.
## Universal moves (apply across the above)
- Draw four maps first: each execution identity, each capability, every writable context or memory source, and each output destination. Then connect the principal at the start to the authority at the end.
- Start at side-effecting tools and work backward through dispatcher, schema, confirmation, model context, retrieval, and ingestion. Start at durable memory reads and trace every writer.
- Compare direct, queued, retry, resume, batch, and delegated paths for the same action. The strongest gate must apply after arguments are final and before every side effect.
## Validation rules (apply before reporting ANY finding here)
1. Name the crossed boundary and observable result: attacker, affected principal or shared resource, execution identity, target, and unauthorized or unrequested action or disclosure.
2. For confused-deputy authority claims, prove the tool lacks requester-and-resource authorization and that the attacker cannot perform the same action normally. For action-binding claims, instead prove attacker-controlled content caused an action under the affected principal's authority that the principal did not intentionally request or approve. Valid generic authorization does not establish that intent.
3. For memory or retrieval claims, cite both the attacker-controlled write and the later cross-principal read or privileged decision. A shared record without a reachable consumer is not enough.
4. For action binding, establish the intentional request or normalized approved object, if any, and compare it with the object the handler uses. Confirm a locally observable unrequested action, mutation, duplicate, or authority change without extending the test into harmful execution. For schema disagreement, compare the normalized validated object with the handler's object.
5. For MCP identity claims, verify the authenticated connection, request correlation, tool namespace, and effective credential. Mark `needs_validation` if external server identity or deployment routing is required.
6. Return `confirmed` findings only with a complete source trace and meaningful result. Return `needs_validation` for a specific unresolved boundary fact and state the bounded local or owner-observed check needed to resolve it.

View File

@@ -0,0 +1,130 @@
# Attack Classes
#### Attack classes — choose and split based on Phase 1
Select attack classes relevant to the application type. Not every class applies to every codebase. The list below is a starting point; add application-specific classes from Phase 1 and split large codebases per subsystem. Frame work as finding, validating, fixing, and prioritizing vulnerabilities. Keep validation to source review and bounded local fixtures; do not develop payload chains, test availability on live services, or take action in shared environments.
Use `confirmed` only when source evidence and bounded validation establish the full boundary and meaningful result. Use `needs_validation` when a specific deployment, provider, platform, identity, or runtime fact is unavailable; state the missing fact and the safe owner-observed or local check that resolves it.
> **Native / binary / kernel targets** (C/C++/Rust-unsafe, kernel modules, parsers and decoders, FFI, concurrent runtimes, binary loaders, JITs, firmware): use the memory-safety, integer/ABI, concurrency, binary-loader, and privileged-interface classes in [MEMORY-SAFETY-AND-BINARY.md](MEMORY-SAFETY-AND-BINARY.md).
>
> **AI / LLM / agent targets** (chatbots, RAG, persistent memory, tool-calling agents, MCP servers/clients, prompt assembly, or model-controlled actions): use the context, memory-poisoning, action-binding, tool-schema, MCP-identity, and output classes in [AI-AND-LLM.md](AI-AND-LLM.md).
>
> **HTTP, web, and identity targets** (ordinary web apps, APIs, reverse proxies, CDNs, gateways, custom HTTP parsers, sessions, CSRF, JWT, OAuth/OIDC, SAML, MFA, passkeys, account recovery/linking, API keys, or mTLS): use [WEB-PROTOCOL-AND-AUTH.md](WEB-PROTOCOL-AND-AUTH.md).
>
> **Client-side and browser targets** (SPAs, browser extensions, embedded webviews, service workers, browser storage, cross-window messaging, CORS, WebSockets, or DOM rendering): use [CLIENT-SIDE.md](CLIENT-SIDE.md).
>
> **Supply-chain and release targets** (dependency resolution, generated inputs, CI, release/signing/promotion, updates, plugins, or extensions): use [SUPPLY-CHAIN-AND-RELEASE.md](SUPPLY-CHAIN-AND-RELEASE.md).
>
> **Cloud and deployment targets** (IAM, infrastructure as code, containers/Kubernetes, service mesh, serverless/edge, ingress, provider events, or runtime configuration): use [CLOUD-AND-DEPLOYMENT.md](CLOUD-AND-DEPLOYMENT.md).
>
> **Protocol, RPC, and messaging targets** (gRPC, GraphQL transports, Protobuf/Cap'n Proto/Thrift, custom protocols, queues, brokers, pub/sub, webhooks, or streaming RPC): use [PROTOCOLS-RPC-AND-MESSAGING.md](PROTOCOLS-RPC-AND-MESSAGING.md).
>
> **Resource-exhaustion and availability targets** (untrusted work can consume shared CPU, memory, disk, connections, workers, queues, quotas, or operator-owned spend): use [RESOURCE-EXHAUSTION-AND-AVAILABILITY.md](RESOURCE-EXHAUSTION-AND-AVAILABILITY.md).
>
> **Data-isolation and lifecycle targets** (multi-tenant stores, caches/search, object links, analytics, export/backup, migration, deletion, retention, or restore): use [DATA-ISOLATION-AND-LIFECYCLE.md](DATA-ISOLATION-AND-LIFECYCLE.md).
>
> **Desktop, mobile, and local-IPC targets** (native apps, deep links, webview bridges, exported components, privileged helpers, local daemons, Unix sockets/XPC/Binder/D-Bus): use [DESKTOP-MOBILE-AND-LOCAL-IPC.md](DESKTOP-MOBILE-AND-LOCAL-IPC.md).
**Injection** (subagent_type: `general`)
Trace untrusted input from entry point to dangerous sink. What counts as a "dangerous sink" depends on the application:
- Web apps: SQL queries, HTML output, shell commands, template engines, file paths, HTTP redirects, deserialization
- Libraries: any function that processes caller-supplied data without validation — buffer operations, parsers, format strings
- CLI tools: shell command construction, file path handling, environment variable interpolation
- Services: query construction, message serialization, log injection, LDAP/XPATH queries
- Client-side (browser/JS): DOM XSS, prototype pollution, `postMessage`/origin trust, and other browser-side classes — covered by the [CLIENT-SIDE.md](CLIENT-SIDE.md) companion blocks when selected
Do not stop at the obvious direct paths. Look for indirect injection: data stored safely, then retrieved and used in a dangerous context by different code. Look for injection through field names, keys, headers, and metadata — not just values. Look for injection into secondary systems (logs, caches, search indexes, analytics).
**Access control** (subagent_type: `general`)
Verify that a caller cannot do something outside its authority. Go beyond checking whether permission checks exist — verify they check the *right* permission for the *right* resource via the *right* mechanism:
- Is there a path to the same state change that checks a different (weaker) permission?
- Can a field in the request body override what the permission system intended to restrict?
- Are there endpoints that gate on authentication but forget authorization?
- Does the same resource have multiple access paths with inconsistent checks?
- What about bulk/batch/export/import operations — do they enforce per-item permissions?
For complex access models, split into separate agents for auth bypass vs authorization logic.
**Resource and file handling** (subagent_type: `general`)
- Path traversal (reading/writing outside intended directories) — including through symlinks, encoded sequences, and null bytes
- SSRF (making the application fetch attacker-controlled URLs) — including through redirects, DNS rebinding, and URL parser differentials
- Unsafe deserialization, archive extraction (zip slip), temp file handling
- Memory safety (if applicable): buffer overflows, use-after-free, integer overflow
- Race conditions on file operations (TOCTOU between check and use)
**Cryptography and secrets** (subagent_type: `general`)
- Weak randomness for security-critical values (tokens, keys, nonces)
- Hardcoded secrets, secrets in logs, error messages, URLs, or client-visible responses
- Broken key derivation, missing HMAC verification, nonce reuse
- Timing side-channels on secret comparison
- Misuse of crypto primitives (ECB mode, unauthenticated encryption, static IVs, etc.)
- What happens when crypto operations fail? Does the error path fall back to no-crypto?
**Business logic** (subagent_type: `general`)
Hunt logic errors by hand: standard scanners cannot find them, and they yield high-impact findings. For each major workflow:
- **State machine violations**: Can you skip steps? Go backwards? Reach an invalid state? What happens if you replay a completed flow? What about partial failure — if step 2 of 3 fails, is step 1 rolled back?
- **Race conditions with business impact**: Concurrent operations that produce invalid states (double-spend, double-approve, lost updates). Focus on operations that check-then-act non-atomically.
- **Numeric/quantity manipulation**: Negative values, zero values, overflow, precision loss, type coercion between string and number.
- **Access boundary violations**: Not "does the permission check exist" but "is it the right check for the business rule?" Can input to one operation bypass a restriction enforced on a different operation for the same effect?
- **Implicit trust assumptions**: Data from storage, config, other components, or plugins assumed safe because "we validated it on the way in." What if a different code path wrote it?
- **Time-based logic**: Expiry checks, scheduling, rate windows, clock skew. What happens at exact boundary moments? What about timezone differences between components?
- **Default and fallback behavior**: What is the security posture when config is missing? When a feature flag is off? When a dependency is unavailable? When the system is mid-migration?
**Feature abuse and data leakage** (subagent_type: `general`)
Legitimate features used for unintended purposes. Look for bugs in the design, not only in the code:
- **Export/backup as exfiltration**: Can a low-privilege user trigger an export, snapshot, or backup that includes data above their access level? Can they export other users' data? Does the export include deleted/draft/private content? Revision history that was supposed to be pruned?
- **Import/restore as injection**: Can import overwrite existing data? Can it create records that bypass normal validation? Can it inject content into collections the user has no write access to? Does it respect the same permission model as the UI?
- **Search/filter/sort as oracle**: Can search queries reveal whether content exists that the user cannot directly access? Do filter parameters let users probe statuses, roles, or fields they should not know about? Does sorting by a hidden field reveal its values through result ordering?
- **Enumeration through side effects**: Do error messages differ between "does not exist" and "no access"? Do response times differ? Response sizes? HTTP status codes? Can you enumerate users through password reset, invite, or registration flows?
- **Preview/draft/staging leakage**: Are preview tokens scoped to one item or do they unlock broader access? Can draft content be discovered through search, RSS feeds, sitemaps, or API listing endpoints? Can cache headers cause a CDN to serve private content publicly?
- **Notification/webhook as SSRF**: Can a user set a notification URL, webhook URL, or callback URL that the server fetches? Is it validated against internal networks? What about after a redirect?
**Chained vulnerabilities and trust boundaries** (subagent_type: `general`)
Individually allowed or contained behavior can become a vulnerability when another component or lifecycle step relies on a stronger guarantee:
- **Multi-step boundary failures**: Map what a low-privilege principal may read, write, invoke, and retain, then connect only concrete outputs to later trust decisions. Confirm each prerequisite and do not assume a downstream effect.
- **Cross-component trust gaps**: Component A validates input and passes it to component B. Compare the exact guarantee A produces with what B assumes, including truncation, type coercion, normalization, tenant scope, and plugin/extension access.
- **Second-order use**: Data safe when stored may become dangerous in a later context. A field name becomes a JSON path, a slug becomes a file path, escaped text enters raw rendering, or a stored string becomes a URL, regex, template, or policy expression.
- **Scope and capability growth**: Token, API-key, plugin, OAuth, MCP, or AI capabilities become broader after delegation, refresh, caching, role change, or composition. Name the concrete operation the resulting principal should not have.
- **Timing and ordering**: Review setup, migration, soft-delete, revoke/cache expiry, check/use, and validate/consume windows. Confirm stale state is accepted before reporting.
- **Rollback and recovery**: Undelete, restore, revision rollback, and cancellation must apply current ownership, validation, and authorization. Confirm which invalid state is restored.
**Wildcard** (subagent_type: `general`)
You are not given a category. Find vulnerabilities outside the standard classes already assigned.
Read code that looks boring or disconnected from security. Follow incomplete, experimental, compatibility, and fallback features, but retain the same concrete boundary and validation requirements as every other class.
Use these starting points, but do not limit yourself to them:
- What is the strangest code in the codebase? Why does it exist? What happens if it is abused?
- Are there any features that feel half-finished, experimental, or bolted on? Those have the weakest security because they got the least review.
- What happens if you use the API in a way the frontend never would? The UI constrains users, but the API does not. What API calls are possible but never made by the client?
- Are there any hidden or undocumented endpoints, parameters, headers, or features? Look at route registrations, middleware, and config for things that are not in the docs.
- What happens when you mix features that were not designed to work together? Localization + preview + caching. Import + plugins + webhooks. OAuth + impersonation + API keys.
- Is there anything interesting in the git history? Reverted security fixes, commented-out auth checks, secrets that were committed then removed (still in history).
- Which valid-account actions affect other users, shared integrity, availability, or operator-owned cost? Verify containment, quotas, authorization, and recovery around those actions.
- Which operations are irreversible or require elevated confirmation? Bind authorization and approval to the final principal, action, and resource.
- What assumptions does the code make about the environment? That the database is local, that the clock is accurate, that DNS is trustworthy, that the filesystem is case-sensitive?
- Look at the test files — what are they **not** testing? Compare the edge cases the developer thought about (tests exist) with the ones they did not (no tests).
Pursue anomalies inside your assigned scope until the invariant is settled. If something looks strange, read it until you can state whether it is safe. If a function has a comment explaining why it is safe, verify the explanation. If a variable is named `temp` or `hack` or `legacy`, read it closely.
**Obvious things** (subagent_type: `general`)
Other agents hunt subtle bugs. This agent checks the basic exposures that are easy to overlook because everyone assumes someone else already checked them:
- Are there any hardcoded passwords, API keys, tokens, or secrets in the source? (grep for `password`, `secret`, `apikey`, `token`, `Bearer`, `-----BEGIN`, common default passwords)
- Are there any TODO/FIXME/HACK/XXX comments that reference security? (`TODO: add auth`, `FIXME: validate input`, `HACK: skip permission check`)
- Is debug mode / dev mode properly gated? Can it be enabled in production via environment variable, query parameter, or header?
- Are there test/example/seed credentials that work in production?
- Is there a `/debug`, `/admin`, `/test`, `/status`, `/health`, `/metrics`, `/env`, `/.env`, `/config` endpoint that is unprotected?
- Are there any `.env`, `.env.local`, `credentials.json`, `*.pem`, `*.key` files checked into the repo?
- Does the `.gitignore` actually cover secrets, uploads, and local config?
- Are dependencies pinned? Are there known CVEs in the dependency tree? (check lockfiles)
- Are there any `eval()`, `exec()`, `child_process`, `Function()`, `vm.runInContext`, `import()` with dynamic input?
- Are CORS headers set to `*` or overly permissive? Is `Access-Control-Allow-Credentials` combined with a wildcard origin?
- Are cookies missing `HttpOnly`, `Secure`, or `SameSite` attributes?
- Are there any open redirects? (parameters named `redirect`, `return`, `next`, `url`, `goto`, `continue` that feed into redirects without validation)
- Is TLS enforced? Are there any HTTP-only endpoints?
- Are error responses in production returning stack traces, internal paths, or SQL errors?
This agent does not need to be creative. It needs to be thorough and literal. Check every item. Report each result.
**Important**: For any finding this agent reports, it must verify the full code path, not just surface appearance. If a cookie is missing `HttpOnly`, check whether the cookie contains security-sensitive data and whether JS needs to read it by design. If an error message contains a field name, check whether the field is ever actually populated with sensitive data. A flag is not a finding — trace the impact before reporting.

View File

@@ -0,0 +1,83 @@
# Client-Side and Browser Hunting
#### When to use this file
Reach for this file when meaningful trust decisions or untrusted rendering happen in a browser: single-page apps, browser extensions, embedded webviews, service workers, offline applications, and code that renders attacker-influenceable content into the DOM, receives cross-window messages, or uses browser storage. These paths include sources the server never sees, such as URL fragments, `window.name`, `postMessage`, and previously cached content.
Use alongside `ATTACK-CLASSES.md`. This file covers browser sources and sinks, origin boundaries, browser persistence, and cross-site state oracles. Use `DESKTOP-MOBILE-AND-LOCAL-IPC.md` for the native side of a webview bridge, and `WEB-PROTOCOL-AND-AUTH.md` for server-side CSRF, sessions, and auth callbacks.
## Core discipline (include in every agent prompt for this domain)
```
- A client-side candidate needs a controllable source and an executing or disclosing sink. Name both and show attacker-influenced data reaching the sink.
- The impact must reach a victim's session, another origin, or shared persistence. Self-injection and disclosure of the attacker's own data are not findings.
- Framework escaping, browser same-origin policy, CSP, COOP/CORP, service-worker scope, and modern noopener defaults are real controls. Verify them before assigning impact.
- Browser storage and caches are shared by origin and may outlive login state. Identify who writes, who reads, and which account, tenant, or worker lifecycle clears each record.
- Use `confirmed` only for complete source evidence plus bounded local browser tests. Use `needs_validation` when renderer, extension permission, deployed header, or browser-policy behavior is required but unavailable.
```
## DOM and object-state attack classes (subagent_type: `general`)
**DOM-based XSS**
Trace `location` fields, `document.referrer`, `window.name`, message data, storage, and browser-controlled document state into `innerHTML`, `outerHTML`, `document.write`, string-evaluating APIs, executable URLs, jQuery HTML APIs, or framework escape hatches. Interpolation escaped by the framework is not a finding.
**DOM clobbering**
Attacker-injected `id` or `name` attributes shadow a global, form property, configuration object, or initialization flag later trusted by code. Require both a markup path that preserves the attribute and a security-relevant use of the clobbered value.
**Prototype pollution and gadget chain**
An attacker-controlled key reaches a recursive write such as deep merge or path assignment and modifies prototype state. Then a reachable gadget consumes the polluted property to change authorization, execution, navigation, or rendering. `JSON.parse`, a shallow copy, or pollution without a gadget is not enough.
## Cross-origin messaging and network attack classes (subagent_type: `general`)
**`postMessage` origin and source trust**
A handler performs a sensitive action with `event.data` without an exact origin allowlist and, where multiple frames share an origin, the expected `event.source`. On the send side, sensitive data sent to `*` reaches an unintended embedder. Weak substring, prefix, suffix, or unanchored-regex origin matching is not an origin check.
**Cross-site WebSocket request use**
A WebSocket upgrade accepts ambient cookies from an untrusted origin without an `Origin` check or channel-specific token, allowing the victim's session to read or mutate data. Confirm both the upgrade behavior and a security-relevant message handler.
**Credentialed CORS trust**
The server reflects or weakly matches `Origin` while allowing credentials and returns sensitive responses. A bare wildcard with credentials is rejected by browsers; report only the actual reflected/allowed origin path and cross-origin data or mutation.
## Service-worker and browser-storage attack classes (subagent_type: `general`)
**Service-worker registration and scope takeover**
Attacker-influenceable content can become the registered worker script, control a path that receives an over-broad `Service-Worker-Allowed` scope, or alter update imports without integrity control. Verify the final script URL, response MIME type, origin, scope, and who controls every imported script. A normal same-origin worker with intended scope is not a defect.
**Service-worker cache and identity confusion**
The worker caches personalized responses without including account, tenant, authorization state, or request mode in its policy, then serves them after account switch or logout. Review fetch-event routing, cache names and keys, navigation fallbacks, cache cleanup, and whether error/offline paths return another user's prior response.
**Browser-storage disclosure and stale authorization**
Tokens, private responses, draft data, or authorization decisions remain in `localStorage`, `sessionStorage`, IndexedDB, Cache Storage, extension storage, or client state and become readable by another account or less-trusted same-origin component. Storage of a token alone is not a finding; require a realistic reader with less authority, or continued use after revocation/logout.
**Cross-context storage and broadcast confusion**
`storage` events, `BroadcastChannel`, shared workers, or origin-wide caches carry identity or commands between tabs without binding them to the current session. Check account switching, private/public windows, tenant changes, and stale tabs that can overwrite newer auth state.
## Cross-site information leak classes (subagent_type: `general`)
**XS-Leaks and cross-origin state oracles**
An attacker page can distinguish protected cross-origin state through resource load/error events, frame or window state, redirect behavior, timing, cache state, or response size while the browser attaches victim credentials. Require one concrete secret-bearing predicate such as whether a private object, role, or account exists. Generic timing variance or public-resource availability is not a finding.
**Window and opener state disclosure**
A cross-origin window's permitted metadata or navigation result reveals protected state, or a retained opener/named-window relationship lets an attacker-controlled page influence a privileged navigation. Check COOP, frame protections, `noopener`, exact origin, and whether the observable state is confidential.
## UI-redress and navigation attack classes (subagent_type: `general`)
**Clickjacking**
A framed, state-changing action lacks effective `frame-ancestors`, `X-Frame-Options`, or equivalent UI isolation. Require the sensitive action and confirm it can complete in the framed state; missing headers on read-only content are hardening notes.
**Client-side navigation confusion**
A client source controls redirect or navigation without scheme and destination policy, including executable `javascript:` or `data:` destinations. Reverse tabnabbing applies only where code explicitly keeps `window.opener`, uses `window.open` without isolation, or supports a browser without implicit `noopener`.
## Universal moves (apply across the above)
- Start from DOM, navigation, worker, message, and storage sinks, then trace backward to browser-only and server-controlled sources. Record the browser policy that should stop the path.
- Test account switch, logout, worker update, offline fallback, and stale-tab state with a local test origin and dummy accounts. Do not use production users, origins, or shared services.
- For XS-Leaks, list only predicates proved by source and local browser behavior. Then identify the response headers or rendering choice that would remove the oracle.
## Validation rules (apply before reporting ANY finding here)
1. Cite the source, sink, browser policy, affected origin/session, and observable mutation or disclosure.
2. For prototype pollution, prove the recursive write and a security-relevant gadget. For DOM clobbering, prove the markup survives and the shadowed value is used.
3. For service workers and storage, prove lifecycle reachability: an attacker-controlled write or cache entry must reach a different account, tenant, or later authorization state.
4. For messaging, CORS, WebSocket, and XS-Leaks, show exact origin/source validation and the protected state or action exposed. Confirm that CSP, COOP/CORP, cookies, and SameSite policy do not already block it.
5. Return `confirmed` findings only with a complete client path and bounded local evidence. Return `needs_validation` with the precise deployed header, extension permission, browser version, or renderer behavior an owner must verify.

View File

@@ -0,0 +1,86 @@
# Cloud and Deployment Hunting
#### When to use this file
Reach for this file when the repository defines cloud identity, infrastructure, containers, Kubernetes, service mesh, serverless functions, edge workers, ingress, object storage, managed services, or environment-specific configuration. This domain asks whether deployed components receive the intended identity, isolation, network reachability, secrets, and policy. Source often expresses intent rather than live fact, so separate source-confirmed defects from deployment validation needs.
Use `SUPPLY-CHAIN-AND-RELEASE.md` for build and promotion trust, `WEB-PROTOCOL-AND-AUTH.md` for HTTP proxy semantics, and `DATA-ISOLATION-AND-LIFECYCLE.md` for data-store tenant scope.
## Core discipline (include in every agent prompt for this domain)
```
- Do not infer a live exposure from a manifest alone. Establish which environment consumes it, what defaults or overlays modify it, and whether the source path is active.
- Map each workload's identity to specific operations and resources. Broad policy is a finding only when lower-trust input can reach an unauthorized action.
- Ingress, proxies, service mesh, metadata services, and admission policy are real boundaries, but only count a control when its configuration and attachment are visible.
- Secret references are not secret disclosure. Require a lower-trust reader, output, artifact, log path, or unsafe fallback.
- Use `confirmed` for active in-repo configurations and local rendering/policy validation. Use `needs_validation` for account policy, network attachment, runtime admission, hosted metadata, or drift that needs owner observation.
```
## Workload identity and IAM attack classes (subagent_type: `general`)
**Workload identity overreach**
A workload, pod, function, edge worker, or node identity can act on tenants, accounts, resources, or APIs beyond its role, and untrusted request or job input selects that target. Review cloud policy conditions, resource patterns, service-account attachment, namespace mapping, and fallback credentials.
**Cross-account or cross-tenant role confusion**
Role assumption, external IDs, token exchange, workload federation, or resource policies accept identity claims not bound to the intended source account, audience, repository, namespace, or workload. Establish both trust policy and caller-controlled claim.
**Application authorization delegated to cloud metadata**
An app trusts caller-supplied identity headers, tags, labels, account IDs, or resource metadata without verifying they came from the cloud control plane or a trusted proxy. Cloud IAM and application authorization are separate checks.
## Ingress, network, and control-plane attack classes (subagent_type: `general`)
**Unexpected service or management-plane reachability**
An ingress, service, listener, security group, load-balancer annotation, port mapping, or server bind exposes an admin, debug, metrics, node, control-plane, or internal API to a lower-trust network. Missing network controls alone are `needs_validation`; a repository-controlled public route to a sensitive handler can be `confirmed`.
**Trusted-proxy and mesh identity bypass**
A backend accepts forwarded identity, mTLS subject, or authorization metadata from peers outside the intended ingress/sidecar, or an alternate port and health/legacy path bypasses the mesh. Verify header stripping, peer reachability, and fail-open behavior when the proxy is absent.
**Metadata and internal-service reachability**
An untrusted URL, destination, or protocol selection reaches instance/container metadata, control-plane sockets, or internal APIs with workload credentials. Trace URL parsing and redirect handling under `ATTACK-CLASSES.md`; here establish deployed network, metadata-version, and identity boundaries.
## Container and orchestration attack classes (subagent_type: `general`)
**Host or control-plane capability exposure**
A lower-trust workload can select privileged mode, capabilities, host namespaces, host paths, device mounts, container runtime sockets, or service-account tokens that cross into node/control-plane authority. Bare absence of seccomp or read-only filesystem is hardening unless a reachable operation crosses that boundary.
**Admission and policy path inconsistency**
One deployment route enforces image identity, namespace, resource, secret, or privilege policy while another controller, job, upgrade, restore, or compatibility path does not. Confirm the alternate route and resulting deployed object.
**Namespace and label trust confusion**
Network, admission, secret, or workload-identity policy relies on labels, annotations, names, or namespaces that a less-trusted principal can set. Compare who controls selectors with what authority matching grants.
## Configuration and secret lifecycle attack classes (subagent_type: `general`)
**Security-control precedence drift**
Development values, chart defaults, environment variables, command-line flags, feature gates, sidecar injection, or per-region overlays disable authentication, transport security, tenant scoping, or audit policy in a deployed environment. Render the final configuration for each maintained deployment, not just the base file.
**Secret exposure across workload boundaries**
Secrets enter logs, crash reports, process arguments, shared environment, broad volumes, build outputs, service discovery, or read APIs accessible to another workload or tenant. Check secret type and authority; a public endpoint or key ID is not a credential.
**Credential renewal and outage fallback**
Failure to mount, refresh, rotate, or revoke a workload credential causes stale credentials to remain active or an app to accept a less trusted identity mode. Review startup, readiness, reconnect, and cached-client behavior.
## Managed storage, events, and edge attack classes (subagent_type: `general`)
**Object and signed-URL policy confusion**
Bucket/container policy, object keys, CDN origins, or signed URLs fail to bind principal, operation, object namespace, audience, or expiry. Review list/version operations and write paths as well as reads.
**Event-source identity confusion**
A function or worker trusts event body fields as source identity without validating provider-signed envelope, subscription/topic, account, region, and replay state. Compare push, pull, retry, and dead-letter paths.
**Edge/runtime boundary mismatch**
An edge or serverless runtime assumes a secret, API, filesystem, isolation, or tenant policy that differs from the origin runtime, and fallback to origin changes authority or cache behavior. Confirm which configuration selects each path.
## Universal moves (apply across the above)
- Render every maintained environment and make a matrix of external port, workload identity, network peers, mounted secrets, and cloud resources. Differences require an owner or policy explanation.
- Follow a lower-trust request, object, label, or event into cloud policy. Show which workload credential performs the final operation and what condition should scope it.
- Diff normal deploy, migration, restore, node maintenance, failover, and local/emulator paths. Review behavior when mesh, admission, identity, secret, or policy service is unavailable.
## Validation rules (apply before reporting ANY finding here)
1. Establish the active source path and effective deployment object; otherwise use `needs_validation` and state which rendered manifest or owner-observed attachment is missing.
2. Name the lower-trust caller/workload, cloud or application identity, controllable selector, affected resource, and unauthorized operation or disclosure.
3. Verify provider and orchestrator defaults at the pinned version. Do not assume a public IP, reachable metadata service, permissive firewall, or absent admission attachment.
4. Local validation may render templates, evaluate policy, inspect container/user namespaces in an isolated fixture, or run an emulator with dummy identities. Do not probe live endpoints or alter shared cloud resources.
5. Return `confirmed` only with a complete active source trace and concrete boundary result. Return `needs_validation` with the exact deployed policy, identity attachment, overlay, network, or drift observation needed.

View File

@@ -0,0 +1,84 @@
# Data Isolation and Lifecycle Hunting
#### When to use this file
Reach for this file when the target stores multi-tenant or access-controlled data, derives search/index/cache/analytics copies, issues object links, exports or restores records, migrates schemas, or promises deletion, revocation, and retention behavior. This domain follows one data item through every copy and state transition. Use `ATTACK-CLASSES.md` for endpoint-level access control and `CLOUD-AND-DEPLOYMENT.md` for provider-level storage policy.
Split large targets by primary storage, cache/search, object/blob storage, analytics/logging, export/backup, deletion/revocation, and migration.
## Core discipline (include in every agent prompt for this domain)
```
- A tenant or owner field on a record is not isolation. Find the query, key, path, policy, or row-level control that enforces it for each read and write path.
- Trace derived copies. Sanitized primary data can become unsafe in search, cache, analytics, export, previews, logs, replicas, and backups with different ACL and retention rules.
- Deletion and revocation are lifecycle contracts. Check current, historical, cached, indexed, exported, restored, and queued copies within the product's stated boundary.
- Privacy or retention preference is not automatically a security vulnerability. Require an explicit data-access boundary or deletion/revocation guarantee and an unauthorized reader or later operation.
- Use `confirmed` for complete source-visible lineage and bounded dummy-tenant tests. Use `needs_validation` when external storage policy, retention, CDN behavior, replica lag, or backup access is unavailable.
```
## Tenant and object-isolation attack classes (subagent_type: `general`)
**Missing tenant or owner enforcement**
A read, update, delete, list, count, or bulk query identifies an object without binding it to the authenticated tenant/owner, or trusts body fields to supply that identity. Compare direct lookup, nested relationship, background, admin, import, and legacy paths.
**Composite-key and namespace collision**
Cache keys, object paths, database uniqueness, search document IDs, temporary files, or deduplication keys omit tenant or environment. Two principals can overwrite or retrieve the same logical key even though application records carry separate owners.
**Policy and query disagreement**
Row-level policy, ORM default scopes, authorization filters, and raw/bypass clients apply different predicates. Check joins, aggregates, aliases, views, transactions, `unscoped` or service clients, and error paths where context is missing.
**Blob and signed-reference overreach**
Object keys, attachment IDs, version IDs, shared links, or signed URLs permit operations or namespaces beyond the issuing principal's access, or remain valid after the underlying ACL changes. Bind operation, exact object/version, audience, expiry, and tenant.
## Derived-data and disclosure attack classes (subagent_type: `general`)
**Search, cache, and index ACL drift**
A primary record's ACL or lifecycle changes without invalidating a searchable, cached, embedded, thumbnail, RSS, preview, or index copy. Validate filtering at retrieval time as well as document ingestion and invalidation.
**Analytics, logs, traces, and diagnostics as alternate readers**
Private content or credentials are emitted into systems with broader access, longer retention, or tenant mixing. Confirm the data class and realistic reader; field names, public identifiers, and operator-only content under intended policy are not enough.
**Enumeration and aggregate oracles**
Counts, filters, ordering, errors, unique constraints, timings, notification behavior, or existence checks disclose protected object or account state. Require a concrete confidential predicate and observable distinction, not general response variance.
## Export, backup, restore, and migration attack classes (subagent_type: `general`)
**Export and backup scope expansion**
An export, snapshot, portability package, report, or backup includes other tenants, inaccessible object fields, soft-deleted data, secret values, or history above the requester's access. Check per-item authorization after selection and authorization to download the final artifact.
**Import and restore authority expansion**
Restore/import bypasses owner, schema, ACL, uniqueness, or validation rules, overwrites existing resources, or recreates records in a tenant the requester cannot write. Validate archive contents as untrusted and authorize the resulting operation rather than trusting prior provenance.
**Migration default and ownership confusion**
Old records lack tenant/ACL/lifecycle fields, incompatible IDs collide, or partial rollout makes new and old readers apply different defaults. Review backfill, dual-read/write, compatibility, rollback, and resumed-migration paths.
**Backup and replication boundary drift**
Encryption keys, storage accounts, cross-region replicas, restoration environments, or support snapshots have broader identity or tenant scope than primary data. Source can confirm only in-repo policy; hosted access and retention require `needs_validation`.
## Deletion, revocation, and lifecycle attack classes (subagent_type: `general`)
**Soft-delete and tombstone bypass**
Direct lookup, search, relation traversal, object link, background processor, or restore ignores the lifecycle predicate and returns or acts on a deleted/revoked record. Check whether soft-deleted identifiers can be re-registered before all references are gone.
**Stale authorization and derived copy use**
Membership removal, ACL update, consent withdrawal, secret revocation, or role downgrade does not invalidate sessions, caches, subscriptions, jobs, or materialized data that continue to authorize future operations.
**Retention and queued-work overrun**
Deletion completes in primary storage while queued processors, retries, exports, analytics, or generated artifacts recreate or retain the data beyond the promised boundary. Find idempotent deletion and tombstone propagation.
**Restore reintroduces invalid state**
Backup, undo, undelete, or replica recovery restores data, credentials, memberships, or permissions that current policy no longer allows. Re-authorize restored state and reapply lifecycle changes made after the snapshot.
## Universal moves (apply across the above)
- Pick one protected record and draw primary write, query, cache, index, event, export, backup, deletion, and restore paths. Mark principal and tenant at every edge.
- Compare two dummy tenants through the same local service methods, then repeat after ACL change, deletion, account switch, and restore. Do not use real user data.
- Start at bypass clients, background jobs, migrations, global uniqueness, and cache keys. These paths commonly omit request-scoped identity that interactive endpoints carry.
## Validation rules (apply before reporting ANY finding here)
1. Name attacker or lower-trust principal, protected data/state, affected owner/tenant, alternate copy or operation, and unauthorized disclosure or mutation.
2. Cite both intended source-of-truth policy and the path that omits or disagrees with it. Confirm another layer does not enforce the same tenant/lifecycle condition.
3. Use local dummy tenants and non-sensitive fixtures to prove cross-scope access or stale lifecycle behavior. Stop at the minimum observable record or operation.
4. If external cache, object storage, replicas, analytics, backup, or retention policy is required, classify `needs_validation` and state the owner-observed check.
5. Return `confirmed` only with complete lineage and concrete boundary impact. Return `needs_validation` with the exact unresolved storage, ACL, invalidation, retention, or restore fact.

View File

@@ -0,0 +1,89 @@
# Desktop, Mobile, and Local IPC Hunting
#### When to use this file
Reach for this file when the target is a desktop or mobile app, privileged helper, updater, local daemon, webview host, deep-link handler, browser native-messaging host, or local IPC client/server. Relevant untrusted actors may be a downloaded document, remote web content, another local app, another OS user, a sandboxed process, or a lower-privilege account. State that starting capability instead of treating all local users as equivalent.
Use `CLIENT-SIDE.md` for browser-side webview behavior, `MEMORY-SAFETY-AND-BINARY.md` for native memory and loader safety, and `SUPPLY-CHAIN-AND-RELEASE.md` for update authenticity.
## Core discipline (include in every agent prompt for this domain)
```
- Establish the realistic local or remote-content attacker: another app, another OS user, a sandboxed child, an untrusted document, or a remote origin. Self-harm within the same account and authority is not a boundary violation.
- Paths, process names, bundle/package IDs, and claimed sender fields are not peer authentication. Use OS peer credentials, code identity, capability handles, or protected channel state.
- The native bridge or helper must authorize each operation and final resource after parsing. A trusted UI or broker does not make attacker-influenceable arguments trusted.
- OS sandbox, signing, entitlements, permissions, keychain ACLs, exported-component policy, and prompt behavior are real controls when pinned and visible.
- Use `confirmed` for source evidence plus bounded local/emulator tests. Use `needs_validation` when signing, manifest merge, OS version, device policy, installer ACL, or packaging is required but not observable.
```
## Deep-link, callback, and navigation attack classes (subagent_type: `general`)
**Custom-scheme and deep-link ambiguity**
Another app or page can invoke a route that mutates state, imports data, completes authentication, or selects an account without a current-session and one-time callback binding. Review URI normalization, duplicate query fields, scheme/host/path matching, exported activity/handler policy, and stale/replayed links.
**App and account handoff confusion**
OAuth, SSO, magic-link, invite, device pairing, passwordless, or payment callbacks return to the wrong installed app, profile, tenant, or pending transaction. Bind state to the initiating app identity, current session, account, provider, operation, and expiry.
**File-open and intent authority confusion**
An associated file, share intent, drag/drop item, pasteboard/clipboard record, notification action, or open-file event triggers a privileged operation without confirming content type, sender trust where applicable, current user intent, and final target.
## Webview and native-bridge attack classes (subagent_type: `general`)
**Navigation-origin to bridge confusion**
Remote or attacker-controlled frames can reach a JavaScript/native bridge intended only for packaged content. Validate origin at call time and after every navigation, redirect, subframe creation, popup, and error/fallback page. URL-prefix checks and initial-load checks are insufficient.
**Over-broad native bridge capabilities**
Web content can select arbitrary files, commands, IPC methods, credentials, or system actions through a generic bridge. Check method allowlists, normalized arguments, user/tenant authority, gesture/confirmation requirements, and return-value disclosure.
**Webview file and universal access**
Remote content can read app-local files, privileged custom schemes, or internal origins because file access, universal access, mixed content, debug interfaces, or custom protocol handlers join origins unexpectedly. Missing a restrictive setting without reachable protected content is hardening.
## Local IPC and exported-component attack classes (subagent_type: `general`)
**IPC peer-authentication gaps**
Unix sockets, named pipes, XPC, Binder, D-Bus, native messaging, RPC, shared memory, or loopback listeners accept a lower-trust peer without checking OS credentials, code identity, sandbox token, or channel ownership. Require a meaningful method or disclosure behind the channel.
**Claimed principal versus channel identity**
The authenticated process/channel belongs to one app or user, but request fields select another user, tenant, profile, or capability. Bind each method and resource to the peer credential rather than a caller-declared identifier.
**Exported service, activity, receiver, or provider overreach**
A mobile component or local automation endpoint is externally invokable and performs an operation intended for the app itself. Review final merged manifests, intent filters, permission/signature level, path grants, and alternate aliases. Manifest status unknown after packaging requires `needs_validation`.
**IPC lifecycle and correlation confusion**
Predictable request IDs, reused handles, stale channels, inherited descriptors, world-writable socket paths, or restart behavior lets one peer answer, cancel, or reuse another peer's operation. Review creation permissions and cleanup of socket files, locks, ports, and shared mappings.
## Privileged-helper and local-file attack classes (subagent_type: `general`)
**Privileged helper as confused deputy**
A low-privilege caller can select a privileged command, file, service, user, or system setting without per-operation authorization. Review sudo/polkit/UAC/XPC helper rules and ensure the helper independently validates normalized arguments.
**Install, update, and repair path trust**
A privileged installer/helper reads manifests, scripts, packages, symlinks, working directories, or repair state writable by a lower-trust actor after authorization. Bind authorization to immutable content and safe destination paths.
**Local file ownership and TOCTOU**
The app checks a file/path then follows replacement, symlink, mount, or case/normalization changes during a privileged read/write. Use descriptor-relative operations and verify final ownership. Focus `MEMORY-SAFETY-AND-BINARY.md` on parsing after the file is opened.
**Credential-store and local-secret boundary mismatch**
A keychain/keystore item, token file, backup, log, clipboard, notification preview, or local config is readable by another app/profile/user with less authority. Plaintext readable only by the same intended OS account is not automatically a vulnerability; state the lower-trust reader and credential power.
## Application-state and device-lifecycle attack classes (subagent_type: `general`)
**Account switch, logout, and device restore leakage**
Cached data, background tasks, widgets, notifications, local databases, webview storage, or biometric approvals survive logout/account change and appear under a later account. Review backup/restore and multi-profile behavior.
**Pending-action and user-presence confusion**
Notification, widget, shortcut, share sheet, biometric prompt, or deferred operation authorizes a different action than displayed, executes after expiry, or uses another profile's pending state. Bind confirmation to normalized action, resource, account, and current foreground state.
## Universal moves (apply across the above)
- Enumerate every process, app component, local endpoint, URI scheme, file association, webview origin, and helper. Record OS identity, runtime privilege, caller, and callable operation.
- Read final packaging inputs: merged manifest, entitlements, installer rules, native-messaging registration, protocol handlers, and ACL creation. Source declarations can be overwritten downstream.
- Validate with dummy profiles and non-sensitive local fixtures on an isolated machine/emulator. Do not interact with other users' apps, credentials, or production services.
## Validation rules (apply before reporting ANY finding here)
1. Name the attacker starting capability, OS/app principal crossed, entry channel, accepted argument or state, and unauthorized operation or disclosure.
2. Confirm OS sandbox, peer credential, signing, entitlement, permission, user-consent, and installer controls that apply. Unknown packaging/runtime facts require `needs_validation`.
3. For webview bridges, cite both navigation/origin control and privileged native sink. For IPC, cite peer authentication and per-resource authorization. For helpers, verify final normalized destination.
4. Keep local tests bounded and use dummy content/accounts. Stop after proving the boundary result; do not extend proof into persistence or broader system modification.
5. Return `confirmed` only with a complete source and local evidence chain. Return `needs_validation` with the exact OS, manifest, signing, ACL, or device-lifecycle fact required.

View File

@@ -0,0 +1,251 @@
# Vulnerability Hunting
### Phase 2: Run coverage-led hunting waves
The parent assigns `planned` ledger units to `general` agents. Use enough focused hunters to cover the units without combining unrelated boundaries. One hunter may own closely related units in one subsystem; no unit may be silently unassigned because of an agent-count limit — a unit the budget cannot reach is explicitly `deferred` with reason `budget_cannot_reserve_critics_and_validation`.
When a budget or profile caps hunter count, assign units in priority order and record the ordering rationale in the ledger. Rank by: (1) unauthenticated or lowest-trust entry surfaces before authenticated ones; (2) boundaries protecting the most valuable resources (credentials, cross-tenant data, code execution, release authority); (3) prior-run gaps, revalidation targets, and changed source before same-source re-passes; (4) units whose class historically yields confirmed findings for this target type over speculative ones. Ties break lexicographically by `coverage_id` so runs stay deterministic.
Before launch, the parent changes assigned units to `in_progress`, sets a canonical lowercase `agent_id`, and creates that agent's `scratch/` and parent-owned `artifacts/`. Hunters read source and parent-provided context, write only to their unique `scratch/`, and return one structured result through the Task tool. They never write retained artifacts or edit target source, `architecture.md`, `coverage-ledger.json`, `findings.json`, or another agent's files.
## Required hunter prompt
Every hunter prompt contains these parts in this order:
1. A two-sentence role preamble: the hunter's goal is to find source-grounded security invariant failures in its assigned units, and it must return exactly one JSON object matching the structured-result contract at the end of this prompt.
2. `architecture.md` verbatim.
3. Assigned coverage IDs, subsystem, boundary, repository-relative starting paths, and each unit's assignment block map from `coverage-ledger.json`.
4. The exact selected blocks, copied verbatim: each selected ordinary attack-class block from `ATTACK-CLASSES.md`, and from each selected companion its `Core discipline`, each chosen attack-class subsection, `Universal moves`, and `Validation rules`. Ordinary blocks are self-contained and carry no companion-style `Core discipline`, `Universal moves`, or `Validation rules` sections. Do not send block or companion names alone.
5. Explicit excluded ordinary and companion blocks with a reason for each exclusion.
6. The core hunting method below, followed by the promotion procedure block.
7. The core validation rules below.
8. Carried same-source prior confirmed exclusions, each limited to fingerprint, title, and root cause, plus peer-owned current coverage IDs that this hunter must not duplicate.
9. The unique scratch/artifact paths, safe agent ID, predeclared promotion allowlist and byte limits, and the structured-result contract, including the Structured hunter result block below and the `confirmed` and `needs_validation` branches of `report-schema.json` copied verbatim.
A prompt may select several companion blocks when the same path crosses several domains. Keep their constraints together. Scope is the hunter's coverage obligation, not permission to duplicate excluded work. If an unexpected different boundary appears, return it under `uncovered` so the parent creates a stable ledger unit and assigns it in the next wave.
#### Core hunting method — include in every hunter prompt
```text
## Defensive vulnerability-finding method
Your goal is to find source-grounded security invariant failures and the smallest fix,
not to expand harm beyond the boundary result. Stay within source review and bounded local execution.
Do not contact deployed endpoints, provider APIs, registries, identity systems,
message brokers, shared services, or other users. Use local dummy data only.
READ THE CODE AT DEPTH. Follow each assigned input through parsing, identity,
authorization, normalization, state, derived copies, and the final sink. Read sibling,
legacy, batch, retry, cancellation, migration, and error paths that produce the same
effect. Compare sibling controls for equivalence, not only presence, and compare what
one component guarantees with what the next component assumes.
WORK FROM A CONCRETE INVARIANT:
1. Name the lower-trust principal and starting capability.
2. Name the accepted value, action, state transition, or resource selector.
3. Locate the control that should reject, bind, isolate, limit, or revoke it.
4. Trace the exact source path after that decision.
5. Stop at the smallest affected dummy record, wrong return value, process-integrity
effect, or locally observable shared-resource effect.
6. State a source-level change and regression case that enforce the invariant.
DEPTH BOUND: trace only paths that can reach your assigned boundary or whose
guarantees that boundary relies on. Stop a line of investigation as soon as the
invariant is settled either way, and record the result in your structured output —
a covered, candidate, or blocked disposition, or an `uncovered` entry — instead of
continuing to search.
TEST SAD PATHS AND DISAGREEMENTS. Check absent, empty, zero, negative, maximum,
over-limit, duplicate, mixed encoding, stale, revoked, reordered, concurrent,
partially migrated, failed dependency, and rollback state only where the interface
accepts them. Compare canonicalization and units at every parser or policy handoff.
For multi-step issues, treat each output as a prerequisite and do not assume a later
boundary. If any prerequisite is not established, record a blocker.
When a proposed high or critical candidate reveals a reusable root cause, search paths
owned by the assigned coverage IDs for lexical, structural, and logical variants.
Consolidate the same root cause, but establish each variant's conditions and impact
independently. Do not investigate peer-owned units. Return a variant with no current
coverage unit as `uncovered`.
USE THE NARROWEST LOCAL CHECK THAT SETTLES THE CLAIM. Target-controlled builds,
tests, processes, browsers, emulators, fuzzers, and fixture processing may run only
inside the parent-approved OS-enforced sandbox. It must disable external networking,
start from an empty allowlisted environment, expose target and tools read-only, permit
writes only to your scratch directory, and apply low CPU, memory, process, file-size,
disk, and wall-clock limits. Isolated loopback is allowed only for a local fixture.
If any control is unavailable, do not execute: return needs_validation with that exact
blocker. Prefer an existing unit test, minimal function harness, dummy-tenant service
call, small malformed fixture, deterministic race schedule, or locally rendered policy.
Do not install or fetch tools.
Record the exact input, command, limits, and minimum result. For the environment,
record only allowlisted variable names and safe non-secret values needed to reproduce
the check. Never capture the ambient environment, inherited variables, credentials,
authentication state, or unrelated host paths. The target-controlled process writes
only in scratch. After the sandbox and all its processes terminate, only trusted
parent-side code may promote predeclared scratch-relative files, following the
promotion procedure block included verbatim in this prompt. You and target code never
write retained artifacts. If promotion is unavailable or fails for decisive evidence,
return needs_validation with the exact promotion blocker.
Never stress availability, invoke a live target, use a real credential, publish an
artifact, or continue past the minimum observed effect.
A deployment, browser, provider, broker, OS, proxy, package, secret, or identity fact
outside source is not proof either way. If one such fact is decisive, return a
needs_validation record with the exact missing observation and safe owner-observed check.
```
#### Promotion procedure — copy this promotion procedure verbatim into every hunter prompt
```text
Artifact promotion procedure (trusted parent-side code only):
Reference only for you: the parent performs these steps; you never perform them.
Before execution, the parent opens and retains trusted, non-inheritable directory
descriptors for the agent's scratch/ and artifacts/ roots, and records an allowlist
of expected scratch-relative artifact files plus explicit per-file and cumulative
byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox
and all its processes terminate, trusted parent-side code promotes each allowlisted
file separately:
1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or
symlinked components.
2. Walk each parent component from the retained scratch-root descriptor with
no-follow directory-relative operations; never reopen by path.
3. Open the leaf no-follow and nonblocking.
4. Verify with `fstat` that it is a regular file with link count exactly one and
within the recorded per-file and cumulative byte limits.
5. Enforce those limits again while reading from that descriptor.
6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity,
type, link count, or size.
7. For the destination, walk every parent component from the retained
artifacts-root descriptor with no-follow directory-relative operations; require
each existing component to be a real directory, and create any missing directory
exclusively before reopening and verifying it no-follow.
8. Create the leaf exclusively without following links, verify that the opened
destination is a regular file with link count exactly one, and copy from the
verified source descriptor without reopening either path.
9. Use equivalent race-safe APIs on non-POSIX systems.
10. Never recursively copy or glob scratch, extract an archive into artifacts, or
open or promote a symlink, FIFO, socket, device, directory, hard-linked file,
changing file, or file that exceeds its bound.
11. If any check is unavailable, cannot be enforced, or fails, discard the scratch
entry; if it is decisive evidence, retain `needs_validation` with the exact
promotion blocker.
```
#### Core validation rules — include in every hunter prompt
```text
## Candidate gate
1. A candidate needs a complete repository-relative source trace and evidence for the
claimed root cause, including the strongest source-visible control.
2. A proposed confirmed record needs a bounded local observed result, meaningful impact
across a stated boundary, complete conditions, and no visible preventing layer.
3. Do not strengthen a crash into code execution, ordinary work into shared availability,
or a same-principal action into privilege gain.
4. If a required fact is not source-visible or locally observable, use
needs_validation. Name exact blockers; do not give it severity or speculative completion.
5. A missing best practice with no affected principal/resource is excluded or hardening,
not a finding. A candidate disproved by source is not needs_validation.
6. Use the same source-derived fingerprint for the same root cause in every state.
It must match `^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$` and must not include a line,
wave, agent, severity, or verdict.
7. Return an empty candidate array when nothing survives these gates.
```
## Local validation boundaries
Local execution is for confirmation, not impact expansion:
- **Allowed only in the required OS sandbox:** offline builds with present dependencies; isolated-loopback processes using dummy state; unit and integration tests; small fixture processing; sanitizers; bounded fuzz/regression tests; deterministic concurrency checks; local browser/emulator tests with dummy accounts; rendered manifests and policy evaluation with dummy identities; mocked external or paid calls.
- **Disallowed:** live or deployed traffic; requests to services not started for this isolated check; network dependency installation; real accounts or credentials; production data; shared queues, cloud resources, runners, registries, signing or release services; publishing; stress, saturation, or cost generation; any work after the minimum dummy-data boundary result.
The sandbox starts with an empty environment, gives target code no external network or host writable path, and enforces explicit low resource and time limits for every check, not only checks expected to be expensive. Scratch output remains target-controlled after exit. Promote it only with the no-follow, path-confined, regular-file, bounded-size host procedure in `SKILL.md`. Missing any sandbox or promotion capability does not erase a source-grounded candidate; represent the exact blocker in `needs_validation`.
## Structured hunter result
Return exactly one JSON object, with no surrounding prose:
```json
{
"units": [
{
"coverage_id": "one assigned ID",
"disposition": "covered|candidate|blocked",
"reviewed_paths": ["repo/relative/path"],
"checks": [
{
"agent_id": "canonical owner of this check",
"reviewed_paths": ["repo/relative/path owned by this check"],
"invariant": "specific control checked for this unit",
"method": "source|local",
"result": "what source or the bounded check established",
"artifact": "agents/<agent-id>/artifacts/file for local, null for source"
}
],
"candidate_fingerprints": [],
"unresolved": []
}
],
"candidates": [],
"hardening": ["concrete non-finding note"],
"uncovered": [
{
"surface": "...",
"boundary": "...",
"subsystem": "...",
"attack_class": "...",
"starting_paths": ["repo/relative/path"],
"reason": "why this needs its own deterministic coverage unit"
}
]
}
```
Each `candidates` entry is schema-shaped except that it uses `proposed_verdict` in place of `verdict`:
- `proposed_verdict: "confirmed"`: include every field required by the `confirmed` branch of `report-schema.json` other than `verdict`: `fingerprint`, title, description, `root_cause`, `intended_behavior`, ordered `trace`, `evidence`, `conditions`, target-neutral `execution`, `remediation`, `severity`, and `confidence`. The execution instructions describe only the bounded local check already performed. `payloads` holds the minimum test input, fixture, or native invocation. `observed_result` records actual local output. Overall severity must not exceed observed impact.
- `proposed_verdict: "needs_validation"`: include every field required by that schema branch other than `verdict`: `fingerprint`, title, description, `claimed_root_cause`, ordered `trace`, `evidence`, nonempty `blockers`, and `validation_plan` with at least one applicable `local` or `deployment` step. Do not invent an inapplicable context. Do not include severity, execution, remediation, reason, or a confirmed `root_cause`. `deployment` is an owner-observed check, not a request to probe a live target.
Every assigned coverage ID appears exactly once in `units`. A `covered` unit needs an owner, nonempty `reviewed_paths` and `checks`, no unresolved fact, and no candidate. A `candidate` unit has the same owned evidence and is the only state that carries linked fingerprints. A `blocked` unit is an owned partial review with nonempty paths, checks, and unresolved facts but no fingerprint. All source paths are repository-relative, never absolute or traversal paths. A trace with several entries begins at `entrypoint`, ends at `sink`, and labels intermediate steps `propagation`. Every check has its own canonical lowercase `agent_id` and nonempty `reviewed_paths`; the unit-level list is exactly the union of those owned paths. A `source` check uses `artifact: null`. A `local` check uses one successfully parent-promoted regular file beneath `agents/<check.agent_id>/artifacts/`; this permits a verifier to add independently owned evidence without taking ownership from the hunter. Never link scratch, an output-root file, or another check owner's artifact.
## Parent consolidation and ledger update
The parent validates each unit result, maps it to exactly one assigned `coverage_id`, and updates only that ledger unit. Reject duplicate or absent IDs, unsafe unit or check agent IDs, source checks with artifacts, and local artifacts that trusted parent-side code did not promote into the check owner's artifacts subtree. Copy the unit's `reviewed_paths`, its `checks` into the unit's `local_checks`, linked artifact paths, candidate fingerprints, and unresolved facts into the ledger. Retain each hunter's `hardening` list in a parent bookkeeping field on the relevant units (outside the semantic fields) so Phase 6 can report it. A failed, malformed, or unsupported conclusion leaves that unit `planned` for reassignment. Untouched budget/profile units become unassigned `deferred` units with empty evidence and a reason; do not hide partial evidence in `deferred`. Run `validate-coverage-ledger.cjs` after the update; an invalid ledger cannot drive another assignment. This per-unit contract allows one hunter to close one unit while returning a candidate or blocker for another.
Consolidate candidate entries by fingerprint and then by root cause. One root cause that exposes several entry paths or effects is one candidate with the strongest complete trace. Related but independent missing controls use separate fingerprints. Record duplicate fingerprints in the relevant ledger unit and do not send duplicate candidates to validation.
## Coverage-critic waves
Immediately after each hunter wave, spend the reserved invocation on one fresh `research` post-wave coverage critic. It receives `architecture.md`, the full coverage ledger including each assignment block map, current candidate fingerprints and states, and the prior-ledger gap summary. It reads source but does not write or run targets. Require exactly this JSON:
```json
{
"missing_units": [
{
"surface": "...",
"boundary": "...",
"subsystem": "...",
"attack_class": "...",
"starting_paths": ["repo/relative/path"],
"selected_companion_blocks": ["FILE.md#section"],
"excluded_blocks": [{"block": "FILE.md#section", "reason": "..."}],
"reason": "source-backed coverage gap"
}
],
"reassign_ids": ["existing-id-that-did-not-close"],
"resolved_prior_leads": ["fingerprint"],
"stop": false
}
```
The critic checks for unmapped entry points, unchecked parallel paths, missing lifecycle modes, selected companion classes without a unit, unjustified exclusions, units closed without paths/checks, and prior `needs_validation` or changed-source gaps that no unit addresses. It proposes coverage, not findings. `stop` is the critic's own assessment: `true` only when it accepts no `missing_units` and no `reassign_ids`; the parent's loop condition below, not `stop` alone, decides whether another wave runs. For each fingerprint in `resolved_prior_leads`, the parent marks the linked unit or prior-lead entry resolved and records the critic's source-backed reason.
The parent rejects proposed units outside the review scope or source/local boundary, derives canonical IDs for accepted units, and deduplicates them against current units. A prior same-source completed unit may supply evidence; prior `deferred`, `blocked`, `out_of_scope`, or changed-source units become current work and never suppress an accepted unit. Fail rather than merge a canonical ID collision. For each legitimate `reassign_id` with live `blocked`, `covered`, or `candidate` evidence, append that exact terminal record to the unit's `attempts` with the critic's source-backed `reassignment_reason`. Preserve its owner, checks, artifacts, fingerprints, and unresolved facts in that archive. Increment the live `wave`; the next hunter must be a fresh owner and receives an `in_progress` unit with empty live evidence. The hunter's terminal result writes only its new evidence into the live fields. Never copy an archived owner's checks or artifacts into the new live attempt. Sort IDs and validate the ledger before another assignment. In `standard` and `deep`, when the post-wave critic reports no accepted `missing_units` or legitimate `reassign_ids` and no `planned` units remain, spend the separately reserved invocation on a distinct final-clean critic. Complete coverage only when that critic also returns no accepted work. If it finds work, queue it and repeat the wave, post-wave critic, and final-clean process. If time or resources force an early stop, mark every untouched unit `deferred`, preserve the critic's reason, and disclose the gap in the report. Never use a silent wave or agent cap as evidence of complete coverage.
The run profile bounds this loop. A `quick` run has exactly one hunter wave followed by exactly one final critic pass. Add each accepted `missing_unit` to the current ledger and mark it `deferred` with reason `quick_profile_final_critic`. For each legitimate evidence-bearing `reassign_id`, archive the live terminal state in `attempts`, increment `wave`, and set the live state to unassigned `deferred` with empty evidence and reason `quick_profile_final_critic`. Do not launch a second hunter wave or another critic. In a scoped run, the critic still reports out-of-scope gaps it notices, but the parent records them as `out_of_scope` with the critic's reason instead of assigning them. The early-stop rule above is the same mechanism: `quick` is a pre-declared early stop, not evidence of complete coverage.
A budget bounds it the same way. Before assigning each wave, compare remaining budget against its hunter count, the validation reserve, the immediate post-wave critic, and the retained final-clean critic (`quick` reserves only its single final post-wave critic). Shrink the hunter wave to fit, taking units in priority order. If those mandatory reserves do not fit, launch no hunter from that wave and mark its planned units `deferred` with reason `budget_cannot_reserve_critics_and_validation`. Critic-proposed units enter the same ranked queue rather than extending the budget. If surviving candidates exceed the validation reserve, follow the incomplete-run rule in `SKILL.md`: stop hunting, validate in fingerprint order, retain unvalidated units as unresolved candidates, and never present them as findings.

View File

@@ -0,0 +1,101 @@
# Memory Safety, Binary, and Kernel Hunting
#### When to use this file
Reach for this file when the target processes untrusted bytes in a memory-unsafe or privileged context: C/C++/Objective-C, Rust `unsafe`, FFI, kernel modules and drivers, parsers and decoders, network daemons, firmware, binary loaders, language runtimes, and JITs. Use `PROTOCOLS-RPC-AND-MESSAGING.md` for protocol authorization and state-machine logic, and this file for process integrity, memory safety, ABI boundaries, and loader behavior.
Pick relevant classes from Phase 1 and split large targets by parser, allocator/lifetime, FFI, concurrency, loader, runtime, or privileged interface.
## Core discipline (include in every agent prompt for this domain)
```
- Re-derive every bound and lifetime from attacker-controlled inputs and all callers. Validate against the worst accepted case, not a typical test vector.
- A panic, sanitizer finding, or crash proves a defect only when a realistic untrusted input reaches it. Do not infer memory corruption, code execution, or shared availability impact from a label alone.
- Validate in a local harness with sanitizers, deterministic concurrency tests, existing fuzz targets, and debugger-assisted fault classification. Stop after proving the violated invariant and observable impact; do not develop post-corruption techniques.
- Assembly, JIT code, custom allocators, intra-object accesses, and foreign libraries can escape sanitizer coverage. Identify which relevant instructions are instrumented.
- Classify as `confirmed` only after source evidence and bounded local validation establish the defect and effect. Use `needs_validation` when ABI, allocator, architecture, feature, deployment, or reachability facts remain unknown.
```
## Bounds, integer, and representation attack classes (subagent_type: `general`)
**Out-of-bounds read or write**
A length, offset, index, or terminator reaches a fixed or allocated buffer without a correct bound. Recalculate available headroom after prefixes, alignment, padding, and terminators. Check both source and destination capacity, and whether a short input is read before its declared length is trusted.
**Integer overflow, underflow, truncation, and signedness**
Review attacker-controlled arithmetic before allocation, copy, loop, indexing, and pointer operations. High-hit patterns include `a - b` with `b > a`, `count * element_size`, additions near the type maximum, negative values converted to unsigned, 64-bit lengths narrowed to 32-bit fields, and sentinel values such as `-1` becoming a large size. Confirm which checked representation is later used.
**Unit and pointer-depth confusion**
Code mixes bytes, elements, code units, pages, words, wire units, or nested pointer element sizes. Compare the unit at parse, validation, allocation, API boundary, and copy. A bounds check using the same wrong unit as the allocation is still wrong.
**Uninitialized or partially initialized data**
A buffer, padding, struct field, or vector capacity is returned, compared, hashed, serialized, or passed across a trust boundary before initialization. Require an observable consumer and realistic output length; stack allocation by itself is not disclosure.
## Lifetime, type, and concurrency attack classes (subagent_type: `general`)
**Use-after-free, stale view, and double free**
Owners are released while callbacks, wait queues, timers, iterators, borrowed slices, or cached raw pointers can still use them. Review every error, cancellation, close, and realloc path. For embedded notification anchors, each free path must drain or detach all observers.
**Type confusion and invalid downcast**
A tag, vtable, union discriminator, object kind, or foreign handle is checked differently from the representation later read. Look for unchecked dynamic casts, stale tags after reuse, and serialized types whose validated element differs from the element consumed. Confirm a wrong-type read or write locally without extending the test beyond the violated invariant.
**Reference-count and ownership races**
Non-atomic retain/release, a check followed by an unlocked use, or inconsistent ownership across threads can free or mutate an object during access. Compare fast, error, shutdown, and compatibility paths for the same lock and ownership rules.
**Shared-state races and TOCTOU**
Concurrent parser streams, global caches, lazy initialization, signal handlers, and resource teardown can invalidate bounds, policy, or pointers established earlier. Verify the race with a repeatable local schedule, barrier, or thread sanitizer; a hypothetical interleaving without a security-relevant state transition remains `needs_validation`.
**Lock-order, deadlock, and starvation**
Externally reachable operations acquire locks in inconsistent order or hold them across callbacks and blocking I/O. Report under availability only when bounded input can stop shared progress; otherwise record it for fixing as a concurrency defect.
## FFI and ABI attack classes (subagent_type: `general`)
**Pointer-length and ownership contract mismatch**
Caller and callee disagree on who allocates, frees, pins, or mutates a buffer, how long a pointer remains valid, or whether a length is bytes or elements. Trace both sides of every `extern`, CGo/JNI/Python/native binding, and generated wrapper. Check null, zero length, aliasing, and callback retention.
**Layout, alignment, and enum disagreement**
Foreign code receives a struct, bitfield, packed record, callback signature, integer width, enum, or calling convention that differs by architecture or build flag. Verify `repr`, packing, alignment, endianness, and ABI-specific types. An in-repo declaration mismatch can be confirmed locally; an opaque foreign implementation requires `needs_validation`.
**Unwind, exception, and thread-affinity violations**
Exceptions or panics cross an ABI that forbids unwinding, callbacks run after teardown, or APIs requiring one runtime thread are invoked elsewhere. Review error conversion and cancellation. Confirm whether the process aborts or state is corrupted before assigning impact.
## Binary loading and runtime attack classes (subagent_type: `general`)
**Library, plugin, and executable search-order trust**
A privileged process loads a library, plugin, runtime image, or helper from a path writable by a less-trusted principal, or resolves a bare name through an attacker-influenceable working directory or environment. Compare intended installation ownership with each fallback and compatibility search path. A user loading their own plugin into their own process is not a boundary violation.
**Missing artifact identity or signature binding**
A loader verifies one file or metadata record but maps a different image because path resolution, file replacement, architecture slices, or embedded resources are not bound to the check. Supply-channel authenticity belongs in `SUPPLY-CHAIN-AND-RELEASE.md`; this class covers the local verification-to-map gap.
**Malformed binary metadata and relocation handling**
Offsets, counts, sections, relocations, symbols, bytecode, or debug metadata are trusted before range, overlap, and representation checks. Test parsers with bounded local fixtures and sanitizers. Separate memory corruption from a safely rejected malformed file.
**JIT and generated-code consistency**
Validator, interpreter, optimizer, and generated code disagree about types, bounds, side effects, or lifetime. Diff optimized and unoptimized paths using the same local input. Confirm a process-integrity effect; output variance that stays within language semantics is not a finding.
**Unload, reload, and teardown safety**
Live function pointers, callbacks, worker threads, or data views survive module unload or runtime reset. Review shutdown and failed-load cleanup as closely as startup.
## Kernel and privileged-interface attack classes (subagent_type: `general`)
**User-copy bounds and repeated reads**
A syscall, ioctl, driver, or kernel parser derives a trusted fact from user memory then reads the same mutable address again. Copy the full request once or revalidate the later copy. Also audit size, direction, and access checks at each user-copy primitive.
**Privileged object lifecycle and dispatch consistency**
Externally reachable objects have unbalanced retain/release, teardown without observer drain, unchecked selector/table indices, or duplicated compatibility paths that omit a guard. Diff each dispatch and free path side by side.
**Under-authorized powerful interfaces**
A device node, admin socket, helper, or management API validates shape but not the caller's authority over the resource. Establish actual interface ownership and reachability; permissions or sandbox policy outside the repository make this `needs_validation`.
## Universal moves (apply across the above)
- Audit fixes and duplicated paths for the same source-to-sink shape. A check in one caller, architecture, protocol role, feature flag, or compatibility path does not protect its siblings.
- Build a table for every parser or FFI boundary: accepted length/type, checked representation, allocation owner, consumer, thread, and teardown. Most native findings are one disagreement in that table.
- Use existing corpora and small locally generated boundary fixtures. Save exact sanitizer/runtime output and the input property that triggers it; avoid large resource consumption and any live target.
## Validation rules (apply before reporting ANY finding here)
1. Establish a realistic untrusted entry and exact operation that violates a bounds, type, lifetime, ABI, concurrency, loader, or authority invariant.
2. Classify the observable effect: invalid read, invalid write, stale alias, wrong object, uninitialized output, unauthorized image load, deadlock, or safe process termination. Do not claim a stronger effect than observed.
3. Run the narrowest local harness, existing test, sanitizer, or fuzzer needed to reproduce the effect. Verify sanitizer coverage of the faulting operation and record architecture/build conditions.
4. For concurrency, use a deterministic schedule or sanitizer trace. For binary loading, prove the checked identity differs from the mapped identity and name the lower-trust writer.
5. Return `confirmed` findings only with exact input, source trace, and observed result. Return `needs_validation` for a specific unresolved reachability, ABI, build, deployment, or runtime fact and state the bounded check needed.

View File

@@ -0,0 +1,81 @@
# Protocols, RPC, and Messaging Hunting
#### When to use this file
Reach for this file when the target uses gRPC, GraphQL transports, Cap'n Proto, Thrift, Protobuf, custom binary protocols, streaming RPC, webhooks, brokers, queues, pub/sub, or event buses. It covers peer identity, logical message interpretation, routing, replay, ordering, and delivery semantics. Use `MEMORY-SAFETY-AND-BINARY.md` for parser memory safety, `WEB-PROTOCOL-AND-AUTH.md` for HTTP framing, and `RESOURCE-EXHAUSTION-AND-AVAILABILITY.md` for availability impact.
Split large systems by producer/consumer pair, external/internal peer role, synchronous RPC, streaming, and asynchronous message path.
## Core discipline (include in every agent prompt for this domain)
```
- "Internal" is not authentication. Name the peer identity at every hop and show how it becomes the application principal used for authorization.
- Schema validation proves message shape, not provenance, resource authority, ordering, or safe values. Follow decoded fields to policy and side effects.
- Broker guarantees and application guarantees differ. Write down retry, ordering, acknowledgement, deduplication, and transaction behavior before evaluating state changes.
- Parser disagreement requires two concrete consumers, schema versions, or wire representations and one security-relevant divergent value.
- Use `confirmed` for source-complete paths plus bounded local producer/consumer tests. Use `needs_validation` for broker ACL, service-mesh identity, topic attachment, or compatibility behavior outside the repository.
```
## Framing, schema, and interpretation attack classes (subagent_type: `general`)
**Message boundary and canonicalization disagreement**
Components disagree on length, compression, duplicate fields, unknown fields, encoding, numeric width, normalization, or envelope/body precedence. Compare generated and custom parsers, gateways, language bindings, and version converters. Confirm which principal, resource, or operation differs after decoding.
**Union, enum, and default confusion**
Unknown variants, missing discriminators, zero values, default privileges, or compatibility mappings reach code that assumes a validated case. Review exhaustive dispatch, default branches, and how old consumers interpret newly added fields.
**Envelope and payload identity mismatch**
Authorization uses trusted-looking routing or envelope metadata while the handler acts on a conflicting tenant, account, subject, object, or sender in the body. Identify which source is authoritative and ensure clients cannot override it.
## RPC identity and authorization attack classes (subagent_type: `general`)
**Interceptor and method-path inconsistency**
An authn/authz interceptor applies to unary methods but not streams, reflection, health, gateway-transcoded paths, compatibility services, or individual stream messages. Compare every registration and route to the same operation.
**Peer identity to application-principal confusion**
mTLS, workload identity, bearer metadata, forwarded identity, or broker credentials authenticate a channel, but a caller-controlled field selects the user or tenant. The channel identity and claimed principal must be bound by deterministic policy.
**Per-item and streaming authorization gaps**
A stream, subscription, batch, or bulk message is authorized once, then later items name different resources or continue after role, membership, or token revocation. Re-check where scope can change and bind subscriptions to their original principal.
**Callback and reply-correlation confusion**
Predictable, reused, or cross-tenant correlation IDs let a response, webhook, cancellation, or acknowledgment satisfy another caller's pending operation. Bind each outstanding request to authenticated peer, tenant, operation, and lifecycle.
## Broker and queue isolation attack classes (subagent_type: `general`)
**Topic, routing-key, and subscription scope gaps**
A publisher or subscriber can select another tenant's topic, wildcard, consumer group, partition, reply queue, or dead-letter route. Check broker-enforced ACLs where visible and application-side namespace construction. Tenant text inside a payload is not isolation.
**Dead-letter, retry, and diagnostic disclosure**
Messages routed to dead-letter queues, error topics, tracing, or operator views contain secrets or cross-tenant payloads accessible to a lower-trust consumer. Review policy and redaction at the failure path, not just normal delivery.
**Untrusted producer treated as control plane**
A message body can declare itself an admin event, provider callback, replication record, or migration instruction without an independently authenticated producer and event type. Verify signatures and source/account/audience binding before privileged handling.
## Replay, ordering, and transaction attack classes (subagent_type: `general`)
**Duplicate delivery and idempotency gaps**
Retries or redelivery repeat a side effect because deduplication is absent, occurs after mutation, or uses a key that collides across tenants or operations. Confirm the broker's delivery model and the side effect that is not naturally idempotent.
**Out-of-order and stale message acceptance**
Older state, revoked membership, canceled work, or pre-step-up authorization arrives after newer state and overwrites it. Review sequence/version checks, tombstones, partition changes, and restore/replay workflows.
**Acknowledgment/commit ordering defects**
Acknowledgment occurs before durable commit and loses security-relevant work, or commit happens before an unreliable acknowledgment and duplicates a mutation. Evaluate transactional outbox/inbox behavior and failure recovery.
**Partial multi-consumer transitions**
Several consumers jointly implement one authorization or business transition, but retries and partial failure leave only a subset committed. Identify invariants that must become durable atomically or compensate with current authorization.
## Universal moves (apply across the above)
- Draw producer → broker/transport → gateway → consumer → storage for each message family. At each hop record authenticated peer, authoritative tenant/resource fields, validation, and side effect.
- Feed the same small fixture to every in-repo schema version or language binding. Test duplicate, missing, unknown, boundary, replayed, and reordered messages without producing load.
- Compare normal, retry, dead-letter, replay, migration, reflection, stream, and gateway-transcoded routes. Security policy must survive transport changes.
## Validation rules (apply before reporting ANY finding here)
1. Name the realistic producer or peer, accepted message, authenticated channel identity, affected principal/resource, and unauthorized mutation or disclosure.
2. For disagreement claims, cite both parsers/consumers and the divergent decoded value. Safe rejection by either side prevents confirmation.
3. For replay/order claims, establish actual delivery guarantees and reproduce the invariant failure with a bounded local/in-memory transport.
4. For authorization and isolation, verify all interceptor, broker ACL, gateway, and consumer layers visible in source. External attachments make the candidate `needs_validation`.
5. Return `confirmed` only with the complete message lifecycle and observed meaningful result. Return `needs_validation` with the exact broker, service identity, route, or delivery fact required.

View File

@@ -0,0 +1,156 @@
# Reconnaissance
### Phase 1: Map the source and plan coverage
The parent initializes `run-metadata.json`, applies the strict pre-reconnaissance budget gate in `SKILL.md`, then creates agent scratch roots and the shared ledger before hunting. If the gate fails, record the incomplete status in metadata and launch no reconnaissance agent. Reconnaissance reads the target and locally available build/configuration state only. It does not contact deployed endpoints, external identity providers, registries, brokers, cloud APIs, or other shared services.
Launch several `research` agents in parallel. They return structured facts to the parent and do not write files.
**Agent 1a: Product, stack, and local operation**
```text
Read the target at <target>. Do not use network access. Return:
1. Product type, users, operators, and ordinary trust-sensitive actions.
2. Languages, frameworks, build system, runtimes, and locally visible deployment models.
3. Repository-relative entry points and subsystem boundaries.
4. Exact build and test commands that could run offline with local dependencies, their expected write locations, and the target-controlled inputs they process. Do not run them during reconnaissance.
5. Comparable software or protocol visible from local documentation and dependencies. If no useful comparison is source-grounded, say so.
6. Missing local toolchains or runtime facts that limit bounded execution.
Return only source facts with repository-relative file:line references.
```
**Agent 1b: Principals, authority, and controls**
```text
Read all source that establishes identity, authorization, isolation, and privilege. Map:
1. Each lower-trust principal and the actions it has by design.
2. Authentication or peer identity at each entry surface.
3. Per-resource authorization and tenant/owner scope.
4. Process, browser, workload, CI, plugin, model/tool, device, or local-IPC authority.
5. Privilege changes, confirmation, revocation, recovery, and fallback paths.
6. Which controls are source-visible and which depend on an unobserved deployment fact.
Return trust boundaries and control locations with repository-relative file:line references. Do not infer live reachability.
```
**Agent 1c: Entry surfaces, copies, and sinks**
```text
Inventory every source-visible place external or lower-trust input enters:
- HTTP/browser, RPC/message/protocol, files/archive/document, CLI/env/config, plugins/dependencies/CI, cloud events/IAM selectors, model context/tool arguments, mobile/deep-link/webview, and local IPC.
For each surface, follow major transformations, stored or derived copies, and security-relevant sinks. Record source-visible limits and parallel paths to the same effect.
Return repository-relative paths and line numbers. Be complete, but do not execute or send inputs.
```
**Agent 1d: Local execution and deployment visibility**
```text
Read tests, build definitions, manifests, packaging, and maintained environment overlays. Return:
1. Small offline tests or existing fixtures that could validate trust boundaries with dummy data inside the required OS-enforced sandbox.
2. Processes that could use an isolated loopback network namespace without external or shared dependencies.
3. Commands that would fetch dependencies, publish artifacts, contact paid/provider APIs, or affect shared state; mark them prohibited for this run.
4. Deployed controls and attachments that source cannot establish and therefore require needs_validation if decisive.
5. The final active source path for each deployment mode only where the repository selects it deterministically.
6. Whether the local platform can enforce an empty allowlisted environment, no external network, read-only target/toolchain mounts, scratch-only writes, and explicit CPU, memory, process, file-size, disk, and wall-clock limits. Missing controls block target-controlled execution.
7. Whether trusted parent-side code can promote predeclared scratch files with path-confined no-follow descriptor traversal, nonblocking regular-file checks, no-follow traversal of every destination parent, exclusive regular-file destination creation, and explicit per-file and cumulative size bounds. Missing promotion controls block use of scratch files as evidence.
```
Add focused reconnaissance agents for materially distinct deployment modes or subsystems that these four do not map. Do not silently omit them: if the budget gate in `SKILL.md` blocks a focused agent, launch nothing for it, seed the unmapped area as a `deferred` ledger unit with reason `budget_cannot_reserve_critics_and_validation`, and disclose the gap in the report.
## Prior-run input
Before selecting work, the parent reads every available prior `coverage-ledger.json` and `findings.json` for the same repo:
- Compare the source locations, controls, conditions, and source-derived identity for every prior record and unit against the current source.
- Carry an unchanged prior `confirmed` record into the current candidate set, with the same fingerprint, only when its relevant source, conditions, and qualifying evidence still apply. Link it to a current `planned` unit with `prior_status: "prior_confirmed_same_source"` and put only that root cause on the hunter exclusion list. The Phase 3 verifier that re-verifies the carried record becomes that unit's assignment owner; its source re-check is the unit's first check and moves the unit to `candidate` with the carried fingerprint.
- Build a current planned `prior_confirmed_changed_source` revalidation unit when any relevant source or condition changed. Do not exclude that root cause from hunting or assume the prior verdict still applies.
- Build current work units for every prior `needs_validation`, `deferred`, `blocked`, `out_of_scope`, and changed-source unit. These states are priority input, never deduplication or suppression keys.
- Carry a still-blocked prior `needs_validation` record with the same fingerprint only after current source supports its trace. Link it to a current `planned` unit with `prior_status: "prior_needs_validation"`; the record keeps the unresolved blocker. The Phase 3 verifier that re-checks the carried record becomes that unit's assignment owner; its re-check is the unit's first check and moves the unit to `candidate` with the carried fingerprint. Include the record in final verification.
- Treat prior rejected records as stale claims unless current evidence changes the failed trace or missing condition. An unchanged rejection suppresses only that exact claim, not review of the coverage unit.
- Record missing or incompatible ledgers instead of treating them as empty coverage.
State paths and source refs used in `run-metadata.json`. Summarize only the coverage consequences in `architecture.md`.
## Architecture summary and companion selection
The parent synthesizes `<output-dir>/architecture.md`, with a hard cap of about 1,000 words. Include:
1. Product, principals, normal authority, and protected resources.
2. The comparable-software baseline from Agent 1a, when one is source-grounded: what security trade-offs the comparable accepts. Use it to calibrate effort and severity, never to dismiss a demonstrated finding; if the comparable shares a defect pattern that has mattered in practice, that strengthens the finding. Omit this line when no meaningful comparable exists.
3. Tech stack, source-visible deployment paths, and offline build/test limits.
4. Entry surfaces and the important source-to-sink or lifecycle paths.
5. Trust boundaries and the strongest source-visible control on each.
6. Repository-relative starting paths.
7. Prior coverage gaps, changed-source and blocked revalidation targets, and same-source confirmed exclusions.
8. A short companion-selection summary derived from [ATTACK-CLASSES.md](ATTACK-CLASSES.md): selected files and the source-visible boundaries that require them.
Keep the assignment-level ordinary block, selected companion blocks, and excluded blocks with reasons in each ledger unit, not in `architecture.md`. This keeps the architecture cap valid for large runs and makes the exact hunter prompt map machine-checkable.
Do not select a companion file merely because the language or dependency name appears. Select it because reconnaissance found the trust-sensitive boundary described by its `When to use this file` section. Do not exclude a visible boundary just because another agent will review a related class.
## Deterministic coverage ledger
The parent writes `<output-dir>/coverage-ledger.json` as a top-level JSON array. Derive one unit for every material combination of entry surface, trust boundary, subsystem, and applicable ordinary or companion attack class at the granularity the run profile sets (`quick` uses one all-in-scope subsystem identity; `deep` adds lifecycle modes). For a scoped run, seed in-scope surfaces for assignment and retain discovered excluded surfaces as `out_of_scope` units so later full runs can turn them into current work.
Each dimension has a human label and a stable source-derived value in `canonical_refs`. Use the same canonical reference for the same source object across runs even if its display label changes. Suitable references include a repository-relative entry path plus exported scope, a route or message identity defined in source, the source control that defines a boundary, a repository package path, and the exact attack-class block reference. A block reference is `FILE.md#` plus the exact class name as written in bold or as a heading in that file — a stable identifier matched against the file text, not a rendered HTML anchor. For companion section blocks, use the heading text before any parenthetical qualifier (for example `Core discipline`). Do not derive references by lowercasing or slugging display labels.
Derive `coverage_id` without lossy slugs:
1. Require every reference to be Unicode NFC with valid scalar values, visible content, no control, format, line/paragraph separator, or default-ignorable code point, and no surrounding whitespace.
2. Encode its UTF-8 bytes with RFC 3986 percent encoding: leave only `A-Z a-z 0-9 - . _ ~` unescaped and use uppercase `%HH` for every other byte.
3. Join encoded `surface`, `boundary`, `subsystem`, and `attack_class` references with `::`; append encoded `lifecycle` when present.
Use the fixed canonical value `profile/quick/all-in-scope-subsystems` for the quick profile's coarsened subsystem dimension. Do not include wave number, agent, verdict, severity, or line number in a reference or ID. Sort units lexicographically by `coverage_id` before each assignment. Fail on every duplicate ID. If duplicate IDs have different semantic fields, treat that as a canonical identity collision; never merge or silently overwrite them. The validator also rejects one semantic tuple represented by different canonical references.
Each unit records:
```json
{
"coverage_id": "...",
"canonical_refs": {
"surface": "src/router.ts#POST /users/:id",
"boundary": "src/authz.ts#requireOwner",
"subsystem": "packages/api",
"attack_class": "ATTACK-CLASSES.md#Access control"
},
"surface": "...",
"boundary": "...",
"subsystem": "...",
"attack_class": "...",
"starting_paths": ["repo/relative/path"],
"ordinary_attack_class_block": "ATTACK-CLASSES.md#Access control",
"selected_companion_blocks": ["FILE.md#section"],
"excluded_blocks": [{"block": "FILE.md#section", "reason": "..."}],
"prior_status": "new|prior_confirmed_same_source|prior_confirmed_changed_source|prior_needs_validation|prior_deferred|prior_blocked|prior_out_of_scope|prior_covered_same_source|prior_covered_changed_source|prior_rejected_claim_changed|none",
"attempts": [],
"wave": 1,
"status": "planned",
"agent_id": null,
"reviewed_paths": [],
"local_checks": [],
"result_fingerprints": [],
"unresolved": []
}
```
When `lifecycle` is material, add both `canonical_refs.lifecycle` and a human `lifecycle` field. `ordinary_attack_class_block` is null only when no ordinary block applies. The selected companion list includes each applicable class plus its companion `Core discipline`, `Universal moves`, and `Validation rules`; `excluded_blocks` records every considered but unselected block and the source fact that excludes it.
The parent may add bookkeeping fields but keeps the semantic fields above stable. In `prior_status`, `new` marks a surface first seen in this run when compatible prior ledgers exist; `none` marks a unit seeded when no compatible prior ledger is available. Prior `deferred`, `blocked`, `out_of_scope`, and changed-source units initialize as current `planned` work when now in scope. A prior same-source covered unit remains visible in the current ledger; assign changed source, important lifecycle paths, and exact conflicts first, then use the coverage critic to decide whether it needs another pass.
`attempts` is an append-only archive for evidence-bearing assignments that a coverage critic reopens. Before reassignment, append the prior unit's exact `wave`, `status`, `agent_id`, `reviewed_paths`, `local_checks`, `result_fingerprints`, and `unresolved`, plus the critic's source-backed `reassignment_reason`. Only `blocked`, `covered`, and `candidate` states can be archived. Archived attempts retain the same state and evidence invariants as live units, use strictly increasing waves below the current wave, and retain their producing owners and artifacts. The next assignment increments `wave`, uses a fresh owner, and starts with empty live evidence. If the profile or budget prevents another assignment, increment `wave` and use live `deferred` state with null owner, empty evidence, and the stop reason. Never copy an archived owner's checks or artifacts into the live state. A later live terminal state contains only the new attempt's evidence; the archive remains unchanged.
Enforce this state table exactly:
| Status | Unit `agent_id` | `reviewed_paths` / `local_checks` | `result_fingerprints` | `unresolved` |
|---|---|---|---|---|
| `planned` | null | empty | empty | empty |
| `not_applicable`, `out_of_scope`, `deferred` | null | empty | empty | nonempty reason |
| `in_progress` | canonical owner | empty | empty | empty |
| `blocked` | canonical owner | both nonempty owned partial evidence | empty | nonempty blocker |
| `covered` | canonical owner | both nonempty | empty | empty |
| `candidate` | canonical owner | both nonempty | nonempty | optional |
Canonical agent IDs match `^[a-z0-9][a-z0-9_-]{0,63}$` and are not Windows device names. Lowercase is mandatory, so one ledger cannot contain case-fold aliases. The unit `agent_id` records the assignment owner. Every check records its own `agent_id` and nonempty `reviewed_paths`; the unit-level `reviewed_paths` is exactly their union. A source-only check uses `artifact: null`. A local check requires a regular file promoted only by trusted parent-side code under exactly `agents/<check.agent_id>/artifacts/`. This lets hunter and verifier checks coexist in one unit. Scratch paths, output-root files, symlinks, special files, and another check owner's artifacts are not evidence.
The ledger is the coverage claim. An architecture summary, agent count, or generic "auth reviewed" sentence is not coverage evidence. Phase 2 closes units only from the paths and checks in a hunter's structured result.
Run `node <skill-dir>/validate-coverage-ledger.cjs <output-dir>/coverage-ledger.json` after seeding, after every parent update, and before Phase 6. The validator rejects input beyond 5 MiB, 64 nesting levels, 10,000 units, 1,000 entries in a nested collection, or 500,000 traversed values, and caps reported validation errors at 100. In practice the 5 MiB byte limit holds roughly 2,000-5,000 realistic units, so it binds before the 10,000-unit cap. Fix every error before assigning work or making a coverage claim.

View File

@@ -0,0 +1,78 @@
# Resource Exhaustion and Availability Hunting
#### When to use this file
Reach for this file when untrusted requests, messages, files, tenant state, or agent work can consume CPU, memory, disk, connections, worker slots, paid APIs, or queue capacity, or can deadlock/crash a shared service. This domain distinguishes a source-reviewable availability vulnerability from a general performance issue. Never validate by stressing a shared or live service.
Use `MEMORY-SAFETY-AND-BINARY.md` for memory-integrity defects and `PROTOCOLS-RPC-AND-MESSAGING.md` for broker delivery logic. A reachable fatal error belongs here for shared impact even when the underlying parser is covered elsewhere.
## Core discipline (include in every agent prompt for this domain)
```
- Require an input-to-cost path, a missing effective bound, and impact on another user, shared service, safety function, or operator-owned spend. Self-limiting work in the requester's own process is not a service vulnerability.
- A missing rate limit is not enough. Check body/message/file caps, concurrency, queues, deadlines, database constraints, upstream gateways, and per-tenant quotas before calling a path unbounded.
- Do not run stress, saturation, or production tests. Use asymptotic analysis, small boundary fixtures, mocked paid calls, strict local resource limits, and deterministic cancellation tests.
- State attacker cost, service work, persistence, scope, and recovery. One bounded input with superlinear or persistent shared effect is materially different from sustained volume.
- Use `confirmed` for source-visible bounds failures demonstrated safely. Use `needs_validation` when upstream caps, deployed topology, autoscaling, paid quota, or recovery behavior is outside the repository.
```
## Computational amplification attack classes (subagent_type: `general`)
**Superlinear parsing, matching, or evaluation**
Small accepted input drives catastrophic regex backtracking, nested parsing, recursive validation, symbolic evaluation, graph traversal, template expansion, or adversarial sort/hash behavior. Derive accepted depth/cardinality and complexity, then demonstrate a bounded growth curve locally.
**Decompression and representation amplification**
Compressed, sparse, nested, aliased, or encoded input expands far beyond the checked transfer or file size. Verify limits after every expansion and across parser stages, including archives, images, fonts, structured documents, and protocol compression tables.
**Database and downstream query amplification**
A small request creates broad scans, pathological joins, fan-out, unbounded sort/aggregation, or many downstream calls because query depth, filter cardinality, pagination, or expansion fields are not bounded. Confirm authorization does not intentionally permit the same resource scope.
## Resource accumulation attack classes (subagent_type: `general`)
**Unbounded buffering and cardinality**
Bodies, out-of-order streams, uploads, sessions, unique cache keys, metrics labels, log fields, subscriptions, or pending jobs accumulate without per-item and aggregate limits. Find cleanup and expiration on disconnect, timeout, cancellation, and partial parse.
**File descriptor, handle, and temporary-resource leaks**
Malformed or canceled work misses cleanup and retains sockets, files, database cursors, timers, subprocesses, temporary files, or object references. Confirm the leak repeats through bounded local iterations and affects a shared pool.
**Detached work after cancellation**
Client timeout, disconnect, canceled job, or failed authorization returns control but leaves database, model, network, or worker work running. Trace cancellation and deadline propagation through every layer.
## Quota and scheduling attack classes (subagent_type: `general`)
**Pre-authentication work imbalance**
Expensive parsing, key lookup, cryptography, decompression, or external requests happen before authentication and the earliest size/rate gate. Compare minimal requester effort to shared service cost and check upstream limits.
**Quota-accounting scope and reset gaps**
Accounting uses attacker-influenceable IP, route, tenant, key prefix, task ID, or other dimension, allowing one principal's work to escape its intended budget or consume another principal's allocation. Review integer overflow, distributed races, retries, reconnects, and account switching.
**Worker, pool, and priority starvation**
Low-priority or attacker-controlled jobs hold shared locks, workers, database pools, event-loop turns, or scheduler priority needed by unrelated users. Require a path that bypasses queue/concurrency fairness or retains a slot beyond its deadline.
## Failure and recovery attack classes (subagent_type: `general`)
**Reachable fatal error or deadlock**
An untrusted input reaches `panic`, abort, fatal assertion, unhandled exception, process exit, lock cycle, or infinite loop in a shared process. Confirm supervisor scope and whether one worker or the whole service becomes unavailable. A restarted isolated worker may reduce impact but does not erase the defect.
**Retry storm and fail-open amplification**
Timeouts, dependency errors, partially processed messages, or health-check failures trigger synchronized or unbounded retries without jitter, ceilings, circuit breaking, or deduplication. Verify one bounded failure source can create persistent aggregate work.
**Poison-record and head-of-line blocking**
One malformed record or message repeatedly fails at the front of a shared queue, partition, startup scan, migration, or recovery loop. Review skip/quarantine policy, offsets, and whether other tenants share the blocked unit.
**Unsafe recovery and capacity rollback**
A restart, restore, fallback, or cleanup path rebuilds unbounded state, ignores current quotas, or restores the input that immediately repeats failure. Recovery correctness is part of availability.
## Universal moves (apply across the above)
- Build an input-to-resource table: earliest accepted size/cardinality, work before auth, downstream fan-out, persistence, shared pool, limit and cleanup owner, recovery.
- Compare aggregate limits with per-object limits. Ten thousand valid one-byte items may evade a per-message cap while exhausting tenant-wide or process-wide state.
- Validate only in an isolated fixture with strict CPU/memory/time limits and small growth points. Mock external and paid calls and stop once the missing bound or cancellation is observable.
## Validation rules (apply before reporting ANY finding here)
1. Name untrusted input, requester work, service amplification or retained resource, shared blast radius, and recovery. Missing limits without concrete shared impact are hardening.
2. Confirm no source-visible upstream, parser, queue, tenant, or framework bound prevents the path. Unknown deployed controls require `needs_validation`.
3. For superlinear behavior, establish the accepted complexity and bounded local growth. For leaks, show repeatable retention after cleanup should occur. For fatal paths, identify process/supervisor isolation.
4. Prioritize by low requester work, unauthenticated reachability, cross-tenant scope, persistence, and poor recovery; do not validate with availability impact.
5. Return `confirmed` only with safe local proof and meaningful shared effect. Return `needs_validation` with the exact upstream limit, topology, quota, or recovery observation an owner must check.

View File

@@ -0,0 +1,192 @@
---
name: security-audit
description: Security guidance and vulnerability review for codebases, APIs, services, CLI tools, libraries, and daemons. Use for security questions, focused reviews, vulnerability research, security audits, or pen tests. Run the complete workflow only for explicit codebase audit or pen-test requests, full/comprehensive/end-to-end reviews, or requested report artifacts.
---
# Security Audit
Find vulnerabilities that violate a real trust boundary, then give owners the source evidence, safe reproduction, priority, and smallest effective fix. This is a defensive, source-first workflow. A candidate without a concrete affected principal, resource, or security outcome is not a confirmed finding.
## Operating modes
This skill is guidance by default. Loading it does not authorize the complete audit workflow or file creation.
- **Guidance mode**: For security questions, focused reviews, methodology, triage, or investigation of specific findings, use only the relevant parts of this skill. Do not automatically run all six phases, create an output directory, or write audit artifacts. You may launch focused agents when useful; they return results to the current task.
- **Full audit mode**: Use the complete workflow when the user explicitly asks to audit or pen-test a codebase, asks for a full, comprehensive, or end-to-end security review, or requests report artifacts. Run all six phases and write the files defined below.
If the request could mean either mode, ask one focused question before creating files or starting the complete workflow.
## Platform terminology
This skill is agent-neutral:
- **Parent** is the agent that coordinates the run and owns shared state.
- **Task tool** is the platform's delegation or sub-agent mechanism.
- **`research` agent** is a delegated agent for focused source exploration and factual verification.
- **`general` agent** is a delegated agent for broad investigation and bounded local execution.
- **`subagent_type:`** in a heading names which of these two delegated agent roles runs that work.
Use equivalent platform capabilities while preserving role, write-isolation, prompt, and independence boundaries.
## Universal execution safety
These rules apply in both operating modes. Source inspection is read-only. Run target-controlled builds, tests, processes, browsers, emulators, fuzzers, and fixture processing only inside an OS-enforced sandbox that provides all of these controls:
- no external network; use only an isolated loopback namespace when the check needs local client/server traffic;
- an empty environment populated from an explicit allowlist with safe values, with scratch-local `HOME`, temporary directories, and caches;
- a read-only target and toolchain, with the target-controlled process able to write only inside its assigned `scratch/` directory; and
- explicit low CPU, memory, process, file-size, disk, and wall-clock limits.
The agent, outside the target-controlled process, may make a disposable source copy in an assigned `scratch/` directory when a build must write beside source. In guidance mode, do not retain target-controlled files. In full audit mode, only trusted parent-side code may promote the minimum non-secret result to retained `artifacts/` using the procedure under Write isolation. Never expose a retained output directory (other than the agent's own assigned `scratch/`), another agent's directory, the host home directory, credentials, sockets, or shared services to target code. Do not install dependencies or let builds fetch them. Use only tools and dependencies already available locally. If every control cannot be enforced, do not execute target code: report the missing sandbox capability as a needs-validation blocker and give a safe validation plan.
Use dummy principals, fixtures, and secrets. Do not probe deployed endpoints, external services, shared infrastructure, production identities, other users' data, or live control planes. Do not test availability against a live or shared process, publish artifacts, alter releases, spend paid API quota, or continue beyond the minimum local effect needed to establish a defect. If the decisive fact is outside source or the sandboxed fixture, report it as needing validation.
## Full audit setup
In full audit mode, resolve these values before reconnaissance:
- **Skill directory**: the absolute directory containing this `SKILL.md`.
- **Target**: the absolute repository root under review.
- **Repo name**: a stable repository identifier from the directory or local Git remote.
- **Output directory**: a new writable directory outside the target, defaulting to `~/security-audit-skill/<repo-name>/run-<N>`, where `<N>` is the next unused integer. Use a directory inside the target only when the user explicitly selects it and the parent verifies that version control ignores the whole directory. Otherwise stop and request an external path.
- **Source ref**: the reviewed commit and whether the worktree is dirty. Do not treat unreviewed generated or modified files as another revision.
### Write isolation
The parent creates and is the only writer of shared run files:
- `run-metadata.json`
- `architecture.md`
- `coverage-ledger.json`
- `findings.json`
- `REPORT.md`
- `FINDINGS-DETAIL.md`
- `NEEDS-VALIDATION.md`
Each hunter or verifier receives a unique root under `<output-dir>/agents/<agent-id>/`, with separate `scratch/` and `artifacts/` directories. Canonical agent IDs match `^[a-z0-9][a-z0-9_-]{0,63}$` and must not equal a Windows device name such as `con`, `prn`, `aux`, `nul`, `com1` through `com9`, or `lpt1` through `lpt9`. Lowercase IDs prevent case-fold collisions. The agent and every target-controlled process may write only to `scratch/`; retained `artifacts/` is parent-owned, is never exposed to the sandbox, and is writable only by trusted parent-side promotion code. Agents may not change shared files, target source, retained artifacts, or another agent's directory. Do not use `/tmp` or the host home directory as a writable fallback.
Before execution, the parent opens and retains trusted, non-inheritable directory descriptors for the agent's `scratch/` and `artifacts/` roots, and records an allowlist of expected scratch-relative artifact files plus explicit per-file and cumulative byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox and all its processes terminate, trusted parent-side code promotes each allowlisted file separately:
1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or symlinked components.
2. Walk each parent component from the retained scratch-root descriptor with no-follow directory-relative operations; never reopen by path.
3. Open the leaf no-follow and nonblocking.
4. Verify with `fstat` that it is a regular file with link count exactly one and within the recorded per-file and cumulative byte limits.
5. Enforce those limits again while reading from that descriptor.
6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity, type, link count, or size.
7. For the destination, walk every parent component from the retained artifacts-root descriptor with no-follow directory-relative operations; require each existing component to be a real directory, and create any missing directory exclusively before reopening and verifying it no-follow.
8. Create the leaf exclusively without following links, verify that the opened destination is a regular file with link count exactly one, and copy from the verified source descriptor without reopening either path.
9. Use equivalent race-safe APIs on non-POSIX systems.
10. Never recursively copy or glob scratch, extract an archive into artifacts, or open or promote a symlink, FIFO, socket, device, directory, hard-linked file, changing file, or file that exceeds its bound.
11. If any check is unavailable, cannot be enforced, or fails, discard the scratch entry; if it is decisive evidence, retain `needs_validation` with the exact promotion blocker.
[HUNTING.md](HUNTING.md) and [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) carry this procedure as one identical fenced block for hunter and verifier prompts; it states the same rules in the same order as this list.
For a reproduced check, record the command, exact test input, sandbox limits, and only the allowlisted environment variable names plus safe non-secret values needed to reproduce it. Never capture or copy the ambient environment, inherited variables, credential values, authentication state, or unrelated host paths. Launch from an empty environment rather than trying to redact one after execution.
Before delegation, the parent writes `run-metadata.json` with at least `run_id`, `repo`, `target`, `source_ref`, `profile`, `scope_paths`, `budget` (null if unset), `execution_policy: "sandboxed-source-and-local-only"`, selected companion files, prior-run paths, shared-file owners, and `run_status: "in_progress"`. Update metadata only when those facts change; candidate state belongs in the coverage ledger and `findings.json`.
## Full audit planning
The coverage, prior-run, profile, and budget requirements in this section apply only in full audit mode.
### Coverage and prior runs
No one pass is complete. Build a deterministic coverage plan before hunting and update it after every agent result. [RECONNAISSANCE.md](RECONNAISSANCE.md) defines the stable coverage units and [HUNTING.md](HUNTING.md) defines coverage-critic waves. The parent alone updates the ledger.
If prior runs exist, read every compatible `coverage-ledger.json` and `findings.json` before planning the current run:
1. Compare the relevant current source with each prior record and unit. A prior source ref alone is not evidence that a path is unchanged.
2. Carry a prior `confirmed` record into the current candidate set only when its relevant source and conditions are unchanged and its evidence still meets the current contract. Link it to a current ledger unit seeded `planned`, preserve its fingerprint, exclude only that carried root cause from hunters, and send the carried record through the current final verification path; the Phase 3 verifier that re-checks it becomes that unit's assignment owner and moves it to `candidate`.
3. When relevant source for a prior `confirmed` record changed, create a current planned revalidation unit. Do not put that record on the hunter exclusion list. It remains confirmed only if current independent validation establishes the current path and result.
4. Make prior `needs_validation`, `deferred`, `blocked`, `out_of_scope`, and any changed-source unit current work. A still-external `needs_validation` record may be carried only after the current source trace is checked and linked by fingerprint to a current `planned` unit whose verifier re-check supplies its owner and evidence; the record keeps the unresolved blocker. These prior states never suppress a current unit.
5. A prior same-source covered unit may inform priority, but it remains visible in the current ledger. A prior `rejected` record suppresses only the unchanged failed claim, not coverage of its unit; changed evidence creates current work.
6. Read the prior profile and scope. A prior `quick` or scoped ledger contributes only its recorded evidence and gaps, never an implied "rest is fine."
If no prior ledger exists, say so in the final coverage statement. Never imply that one run exhausts the target.
### Run profiles and scope
During full audit setup, pick a profile from the user's request or propose one from the target's size and stakes. Record it in `run-metadata.json` (`profile`, `scope_paths`) and state it in the report. The default is `standard`.
- **`quick`** — a bounded pass for small targets, re-runs, or a fast first look. Coarsen ledger units to surface × boundary × attack class (subsystem uses the fixed canonical `profile/quick/all-in-scope-subsystems` identifier), run exactly one hunter wave followed by exactly one final coverage-critic pass, and use one fresh verifier per candidate for both candidate validation and final record verification. Do not launch a follow-up hunter wave: record the critic's accepted discoveries and reassignments as `deferred`.
- **`standard`** — the workflow as written.
- **`deep`** — for high-stakes or large targets. Split ledger units per subsystem and lifecycle mode, run critic waves to a clean pass, keep candidate validation and final record verification as separate fresh agents, and give `prior_covered_same_source` units an independent second pass.
A **scoped run** audits a subset: named paths, one subsystem, one companion domain, or the diff between two source refs. Seed ledger units only for in-scope surfaces and record everything else as `out_of_scope` — never as `covered`. A scoped or `quick` run must present itself as partial coverage.
Profiles change breadth and redundancy, never the evidence bar. Do not scale away the candidate gate, the source/local execution boundary, `needs_validation` discipline, schema validation, or independent verification of `confirmed` records.
#### Cost budget
The ledger makes spend countable: one unit is roughly one hunter assignment, and one surviving candidate is one or two verifier assignments depending on profile. When the user sets a budget — or the parent proposes one for a large target — record `budget` in `run-metadata.json` as a maximum number of agent invocations across all phases.
Apply the strict budget gate before launching any reconnaissance agent. Reserve the four baseline reconnaissance calls, one final post-wave critic for `quick` or one post-wave plus one distinct final-clean critic for `standard`/`deep`, and at least one verifier call. Add focused reconnaissance only after repeating this gate for each extra call. If the requested budget cannot fund that minimum, launch no agent: ask for a larger budget, narrower scope, or different profile. If the request remains unchanged, set `run_status: "incomplete"` with `incomplete_reason: "budget_cannot_fund_reconnaissance_and_reserves"` and report that no audit pass ran.
Spend it in this order:
1. Count reconnaissance, every post-wave critic, and the separate final-clean critic as agent invocations.
2. **Reserve critics and validation before hunting.** For `quick`, reserve its one post-wave final critic. Before every `standard` or `deep` hunter wave, reserve one immediate post-wave critic plus one distinct final-clean critic. Also reserve verifier cost from the profile (about 1 or 2 agents per expected candidate; when in doubt reserve 30% of the balance after critic reservation). Never assign hunters into either reserve.
3. Assign hunters to units in priority order until the hunting allowance is spent. Spend the reserved post-wave critic immediately after that wave; keep the final-clean and validation reserves intact.
4. Before a later wave, reserve its new post-wave critic again. If the remaining budget cannot cover the required critic calls and validation reserve, launch no hunters from that wave, mark its planned units `deferred` with reason `budget_cannot_reserve_critics_and_validation`, and use the retained final-clean critic to record the resulting gap.
Before wave 1, update the pre-recon estimate with seeded units, implied hunter count, mandatory critic calls, validation reserve, and whether the remaining budget covers the plan. If it clearly cannot, say so and propose either a tighter scope or a coarser profile instead of silently thinning evidence. If later facts consume the required final-critic reserve, launch no hunters, mark all planned work deferred, set the run incomplete with reason `critic_budget_exhausted`, and make no complete-coverage claim.
A strict total-agent budget can still be exceeded by an unexpectedly large candidate set or by a material Phase 5 replacement that needs another independent verifier. If the remaining budget cannot validate every candidate, stop hunting, validate candidates in fingerprint order while the budget permits, and set `run_status: "incomplete"` plus `incomplete_reason: "validation_budget_exhausted"`. Keep each unvalidated fingerprint linked to a `candidate` ledger unit with that unresolved reason. Do not put an unvalidated candidate in `findings.json`, relabel it `needs_validation`, or report the run as complete. Phase 6 may produce a partial report only if its first section states that candidate validation is incomplete and lists the affected fingerprints and units. Never exceed a user-set strict budget silently.
## Core principles
### Require a boundary and result
For every candidate, name the lower-trust principal, accepted input or action, intended control, crossed boundary, affected principal or resource, and concrete observed or owner-observable result. Do not elevate a missing best practice, guessed deployment behavior, generic parser crash, or self-impact into a security finding.
### Use bounded local evidence
Static analysis establishes the source path. Sandboxed local tests resolve behavior when all execution controls are available: a minimal function harness, existing unit test, small parser fixture, dummy-tenant integration test, locally rendered configuration, or bounded isolated-loopback client. Stop at a wrong return value, unauthorized dummy record, sanitizer finding, policy difference, or other minimum effect. Do not extend the local check beyond the minimum boundary result or produce persistence, post-fault, or concealment material.
### Respect source visibility
Deployment controls, proxy behavior, provider settings, browser headers, identity policy, broker ACLs, packaging, and topology are real controls. If they are required and absent from the repository, do not assume either presence or absence. Use `needs_validation` with the exact missing fact and a safe owner-observed or local plan.
### Separate priority from certainty
Only `confirmed` records receive severity. Likelihood and impact must reflect the demonstrated conditions and result; overall severity cannot exceed demonstrated impact. `needs_validation` means a specific source-grounded boundary hypothesis is blocked, not a low-confidence confirmed vulnerability, and it has no severity.
Calibrate overall severity with these anchors:
- **critical** — an unauthenticated actor gains code execution, full data-store access, or takeover of arbitrary accounts.
- **high** — an actor fully defeats an explicit security control with real consequences: authentication bypass, cross-tenant read or write, stored script execution affecting other users, authenticated code execution, or an unauthenticated remote stop of a shared service.
- **medium** — a real boundary violation with limited blast radius, uncommon preconditions, or consequences confined to a narrow resource set.
- **low** — disclosure of non-secret internals, or an effect requiring sustained effort for minimal gain.
- **informational** — a confirmed but minimal-impact observation, useful mainly as a prerequisite inside a larger finding.
The high/medium discriminator: does the demonstrated result fully defeat an explicit control for an action with real consequences, or only weaken it? If you cannot state the concrete damage, the severity is lower than it feels.
### Recommend the smallest effective source fix
For each confirmed finding, identify the invariant the code must enforce and the narrowest source change that enforces it at the last trusted decision point. Prefer specific repository-relative changes and regression tests over generic hardening advice. The audit describes fixes; it does not modify target source.
## Full audit workflow
In full audit mode, follow all six phases in order:
1. **Reconnaissance** — map the source, trust boundaries, local build paths, companion selections, prior evidence, and initial deterministic coverage ledger with [RECONNAISSANCE.md](RECONNAISSANCE.md).
2. **Coverage-led hunting waves** — assign isolated hunters from the ledger and collect structured candidate results with [HUNTING.md](HUNTING.md), [ATTACK-CLASSES.md](ATTACK-CLASSES.md), and the selected domain companions.
3. **Candidate validation** — consolidate fingerprints and give every candidate to a fresh source verifier as defined in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md).
4. **Structured output** — write all final `confirmed`, `needs_validation`, and `rejected` records to `findings.json`; validate it with `report-schema.json` and `validate-findings.cjs`, and validate the coverage claim with `validate-coverage-ledger.cjs`.
5. **Independent record verification** — use fresh agents to verify final source claims and reconcile corrections or state changes.
6. **Target-neutral report** — derive `REPORT.md`, `FINDINGS-DETAIL.md`, and `NEEDS-VALIDATION.md` from the final records, with no live-probe instructions.
Do not end the run before one of exactly two terminal states: (a) all Phase 6 artifacts are written and both validators pass, or (b) `run_status: "incomplete"` is recorded with its exact reason and the gap is disclosed in the report. Never stop mid-phase.
## Anti-patterns
1. Checklist deviations presented as vulnerabilities.
2. Defense-in-depth advice with no reachable boundary violation.
3. Live or shared-environment testing where bounded local evidence is insufficient.
4. Guessing provider, proxy, browser, identity, or deployment behavior not present in source.
5. Treating intended same-principal authority or self-impact as a cross-boundary result.
6. Reporting a parser or runtime effect stronger than the observed effect.
7. Emitting prose-only hunter results that cannot be deduplicated or verified.
8. Re-reporting carried same-source prior confirmed records or using them as exemplars that anchor the hunt.
9. Assigning severity to `needs_validation` records.
10. Writing the report before independent verification or letting prose and JSON disagree.

View File

@@ -0,0 +1,73 @@
# Supply Chain and Release Hunting
#### When to use this file
Reach for this file when the target resolves dependencies, builds from untrusted contributions, runs CI, creates release artifacts, signs or promotes builds, loads plugins, or updates deployed software. This domain covers trust handoffs from source and dependency to the artifact a user runs. Use `MEMORY-SAFETY-AND-BINARY.md` for flaws inside a local binary loader and `CLOUD-AND-DEPLOYMENT.md` for runtime workload authority.
Split large targets into dependency resolution, CI isolation, artifact provenance, release authorization, and updater/plugin trust.
## Core discipline (include in every agent prompt for this domain)
```
- A mutable or known-vulnerable dependency is not a finding by itself. Show who can influence resolution, which build consumes it, and what execution or release boundary follows.
- Follow integrity across every handoff: source identity, resolved inputs, build worker, artifact identity, test result, signature/attestation, promotion, and update consumer.
- CI configuration is authorization code. Establish which event triggered a workflow, whose code runs, which secrets and tokens exist, and what it may publish or mutate.
- A checksum fetched from the same untrusted location as the artifact does not establish independent integrity. Identify the trusted root and failure behavior.
- Use `confirmed` for in-repo control-flow failures with bounded local validation. Use `needs_validation` for branch protection, hosted-runner, registry, signing-service, or production promotion facts that are not observable.
```
## Dependency and build-input attack classes (subagent_type: `general`)
**Dependency source and namespace confusion**
Resolver configuration can select an unintended public/private namespace, fallback registry, mirror, repository, or source URL. Review package names, source priority, lockfile and checksum use, alternate build files, platform-specific resolution, and first-install versus update behavior.
**Mutable and unbound build inputs**
Builds consume branches, tags, unverified submodules, downloaded tools, generated assets, remote includes, floating CI actions, or container tags whose content can change without source review. Require a lower-trust writer and a path into trusted build output; reproducibility by itself does not prove authenticity.
**Generated-source and codegen provenance gaps**
Schemas, vendored archives, generated clients, localization, documentation examples, or binary blobs produce executable or shipped content without the same review and integrity gate as source. Compare local regeneration with committed output and verify who controls input and generator.
**Build-context inclusion**
Secrets, local configuration, repository metadata, test fixtures, or developer artifacts enter a package or image because the build context and ignore rules exceed intended release inputs. Confirm that the resulting artifact exposes a real credential, private data, or privileged configuration.
## CI and automation attack classes (subagent_type: `general`)
**Untrusted code in a privileged workflow**
A pull request, issue comment, fork, dependency update, or external event runs contributor-controlled code with protected secrets, write tokens, deployment authority, or a trusted runner. Compare trigger type, checkout ref, approval gate, environment protection, and permission narrowing. Do not assume repository-host defaults that are not in source.
**Workflow command and expression confusion**
Attacker-controlled branch names, commit messages, issue fields, artifact names, matrix values, or generated output enter shell commands, template expressions, paths, or privileged workflow inputs without canonical validation.
**Cache, artifact, and workspace trust mixing**
A lower-trust job can populate a cache, artifact, shared workspace, or output that a higher-trust job later restores and executes or releases. Review cache keys and namespaces, artifact producer identity, digest binding, retention, and whether promotion re-resolves by mutable name.
**Automation identity overreach**
CI jobs receive permissions beyond the operation, repository, environment, or duration needed, and untrusted job inputs can select the affected resource. Missing least privilege alone is hardening; require a reachable privileged action.
## Release and update attack classes (subagent_type: `general`)
**Build-to-promotion substitution**
Tests, review, signature, and publication refer to mutable tags, filenames, channels, or artifact IDs rather than the same immutable digest. Check every copy, repack, architecture merge, and provenance step between build and release.
**Release authorization and signing-policy gaps**
A release or signature is accepted from the wrong workflow, repository, branch, environment, key role, or threshold. Review identity claims inside attestations and verify the consumer validates them, not just a valid signature. Rotation, expiry, and revocation must fail closed where policy requires.
**Update metadata and rollback confusion**
An updater authenticates payload bytes but not version, product, platform, channel, target path, expiry, or rollback state, or it accepts metadata and payload from different authorized transactions. Verify atomic installation and recovery behavior. A signature API call without policy binding is incomplete.
**Plugin and extension trust expansion**
An extension package gains host authority beyond its declared scope, a lower-trust publisher can replace another publisher's identity, or install/update hooks run before authenticity and capability checks. Intended installation of arbitrary same-user plugins is not a privilege boundary.
## Universal moves (apply across the above)
- Walk backward from a released digest or installed update to every source, generated input, credential, worker, cache, test result, and authorization decision.
- Compare untrusted and protected workflow events side by side. Mark each persisted channel crossing between them and require an immutable identity plus producer trust.
- Review revoked key, failed download, missing attestation, partial platform release, rollback, and registry outage paths. The failure policy is part of release integrity.
## Validation rules (apply before reporting ANY finding here)
1. Name the lower-trust actor, controllable source/cache/artifact/metadata, consuming trusted job or updater, and resulting unauthorized publication, code inclusion, secret disclosure, or privileged execution.
2. Prove artifact identity across the broken handoff. A different mutable name or unbound digest must reach a real consumer.
3. Verify built-in package-manager, repository-host, registry, and signing defaults for the pinned version. Unknown hosted controls require `needs_validation`.
4. Keep local validation bounded: use a harmless fixture repository, dummy credential marker, local registry/config, and non-production artifact namespace. Do not publish or alter a real release.
5. Return `confirmed` only with a complete source-visible handoff and meaningful result. Return `needs_validation` with the precise branch, runner, registry, signing, or deployment fact an owner must observe.

View File

@@ -0,0 +1,186 @@
# Validation, Structured Output, Verification, and Reporting
### Phase 3: Independently validate every candidate
After the clean coverage-critic pass or an explicitly recorded early stop, consolidate Phase 2 candidates and carried same-source prior confirmations by stable fingerprint and root cause. Give every unique proposed `confirmed` and `needs_validation` candidate to a fresh `general` verifier that did not hunt it. A carried prior confirmation follows the same current verification path even though hunters exclude that unchanged root cause. A verifier may read hunter or prior artifacts but must re-read every cited current source location and independently run any decisive check it can reproduce safely.
Assign each verifier a canonical lowercase unique ID and `<output-dir>/agents/<verifier-id>/scratch/` plus parent-owned `artifacts/`. The verifier writes only to `scratch/` and never writes retained artifacts. It receives only the candidate, its linked coverage-unit checks and artifact paths, architecture facts needed to interpret the path, exact relevant companion validation blocks, the promotion procedure block below, the source/local execution boundary, the `confirmed`, `needs_validation`, and `rejected` branches of `report-schema.json` copied verbatim, and prior records with the same fingerprint. It must not receive another verifier's conclusion.
#### Candidate-verifier prompt
```text
You did not write this candidate. Try to refute it from repository source and bounded
local evidence. Do not contact deployed endpoints or external/shared services. Run
target-controlled code only inside the approved OS-enforced sandbox: no external
network, empty allowlisted environment, read-only target and tools, scratch-only
writes, and explicit low resource and wall-clock limits. If any control is unavailable,
do not execute; retain the exact missing capability as a needs_validation blocker.
Treat every scratch entry as target-controlled after execution. After the sandbox and
all its processes terminate, only trusted parent-side code may promote a predeclared
scratch-relative file, following the promotion procedure block included verbatim in
this prompt. You and target code never write retained artifacts. If promotion is
unavailable or fails, do not use that file as evidence.
1. Verify every trace and evidence file, positive line number, scope, and description.
Confirm the first entry is a real lower-trust entrypoint and the last is the
claimed sink or boundary effect.
2. Reconstruct the strongest source-visible validation, identity, authorization,
normalization, lifecycle, framework, and containment controls on the path.
Where the architecture summary names a comparable baseline, note whether it
shares the pattern — as calibration, never as grounds to dismiss.
3. For a proposed confirmed candidate, independently reproduce the minimum observed
result when possible. Verify inputs, interface shape, conditions, and affected
dummy principal/resource. Do not infer a stronger result or continue after it.
4. Verify that likelihood, impact, confidence, and the proposed source fix match only
what the evidence establishes.
5. For a proposed needs_validation candidate, decide whether the blocker is genuinely
outside source/local observation. If source refutes the trace, reject it. If the
missing fact remains decisive, keep needs_validation and make the local and
owner-observed plans exact and non-destructive.
6. Preserve the fingerprint for the same source-derived root cause across every state.
Return exactly one JSON object and no surrounding prose:
{"decision": "confirmed|needs_validation|rejected", "record": { ... }}
where record exactly matches the decision's verdict branch of the schema included
in this prompt. A corrected record replaces the hunter's wording.
```
Copy this promotion procedure verbatim into every candidate-verifier prompt:
```text
Artifact promotion procedure (trusted parent-side code only):
Reference only for you: the parent performs these steps; you never perform them.
Before execution, the parent opens and retains trusted, non-inheritable directory
descriptors for the agent's scratch/ and artifacts/ roots, and records an allowlist
of expected scratch-relative artifact files plus explicit per-file and cumulative
byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox
and all its processes terminate, trusted parent-side code promotes each allowlisted
file separately:
1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or
symlinked components.
2. Walk each parent component from the retained scratch-root descriptor with
no-follow directory-relative operations; never reopen by path.
3. Open the leaf no-follow and nonblocking.
4. Verify with `fstat` that it is a regular file with link count exactly one and
within the recorded per-file and cumulative byte limits.
5. Enforce those limits again while reading from that descriptor.
6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity,
type, link count, or size.
7. For the destination, walk every parent component from the retained
artifacts-root descriptor with no-follow directory-relative operations; require
each existing component to be a real directory, and create any missing directory
exclusively before reopening and verifying it no-follow.
8. Create the leaf exclusively without following links, verify that the opened
destination is a regular file with link count exactly one, and copy from the
verified source descriptor without reopening either path.
9. Use equivalent race-safe APIs on non-POSIX systems.
10. Never recursively copy or glob scratch, extract an archive into artifacts, or
open or promote a symlink, FIFO, socket, device, directory, hard-linked file,
changing file, or file that exceeds its bound.
11. If any check is unavailable, cannot be enforced, or fails, discard the scratch
entry; if it is decisive evidence, retain `needs_validation` with the exact
promotion blocker.
```
A verifier can promote `needs_validation` to `confirmed` only after independently establishing the complete path and bounded observed result. Demote proposed confirmation to `needs_validation` when a specific deployment or runtime fact remains unknown. Use `rejected` when source, local behavior, a visible control, missing meaningful impact, or an impossible prerequisite refutes the claim. `needs_validation` is never a parking place for a speculative idea.
The parent checks that each verifier returned the same fingerprint unless it identified a genuinely different root cause. Merge corrections, record the decision in every linked coverage unit, and ensure there is one final record per fingerprint. Discard a malformed or prose-wrapped verifier result without repairing it; re-run that candidate with a fresh verifier when the budget permits, otherwise it remains an unvalidated ledger candidate under the incomplete-run rule.
When verifier evidence updates a ledger check, set that check's `agent_id` to the verifier's canonical ID and list its nonempty repository-relative `reviewed_paths`. Keep the unit-level `reviewed_paths` equal to the union across checks. Use `method: "source"` with `artifact: null` for source-only review. Use `method: "local"` only with a file successfully promoted by trusted parent-side code below `agents/<check.agent_id>/artifacts/`. The unit retains its original assignment owner, so independently owned hunter and verifier checks can coexist. For a carried prior record's seeded `planned` unit there is no prior owner: the verifier that re-checks it becomes the unit's assignment owner, and its re-check is the unit's first check, moving the unit to `candidate` with the carried fingerprint.
If a strict total-agent budget cannot cover every candidate, set the run status to incomplete and follow the deterministic budget rule in `SKILL.md`. An unvalidated candidate remains only in the ledger. It does not enter `findings.json` under any verdict.
### Phase 4: Write and validate `findings.json`
The parent writes all independently decided records to `<output-dir>/findings.json`, sorted by fingerprint. Include:
- `confirmed`: source-grounded vulnerabilities with complete local execution evidence, conditions, specific remediation, likelihood/impact/overall severity, and confidence.
- `needs_validation`: source-grounded candidates with an exact unresolved blocker and at least one applicable local or owner-observed deployment plan.
- `rejected`: source-grounded candidates disproved during validation, retained so future runs do not repeat the unsupported claim without changed evidence.
Read `report-schema.json` immediately before writing. It uses `additionalProperties: false`; do not carry hunter wrapper fields into a record. Keep these verdict contracts distinct:
- A `confirmed` record uses `root_cause`, `intended_behavior`, `conditions`, `execution`, `remediation`, `severity`, and `confidence`. It must not use `claimed_root_cause`, `blockers`, `validation_plan`, or `reason`. `execution` is target-neutral and uses the target's native interface: API/HTTP input, CLI call, library call, message, file fixture, browser action, rendered policy, or local harness as applicable. `observed_result` is nonempty and factual.
- A `needs_validation` record uses `claimed_root_cause`, `trace`, `evidence`, `blockers`, and at least one nonempty `validation_plan.local` or `validation_plan.deployment` field. Include both only when both contexts can resolve distinct facts. It must not use severity, execution, remediation, reason, or confirmed root cause.
- A `rejected` record uses `claimed_root_cause`, `trace`, `evidence`, and `reason`. It must not use severity, execution, remediation, blockers, validation plan, or confirmed root cause.
Every record has a stable fingerprint, title, description, and repository-relative source paths. A multi-step trace begins with `entrypoint`, ends with `sink`, and uses `propagation` only between them. One-entry traces use `entrypoint` or `sink`. Overall severity cannot exceed demonstrated impact.
Run:
```sh
node <skill-dir>/validate-findings.cjs <output-dir>/findings.json
node <skill-dir>/validate-coverage-ledger.cjs <output-dir>/coverage-ledger.json
```
Fix every structural and semantic error before continuing. The findings validator rejects input beyond 5 MiB, 1,000 top-level findings, or 64 nesting levels, and caps reported error output at 100 messages. Validator success proves format and ledger consistency only.
### Phase 5: Verify the final records with fresh eyes
Launch one fresh `research` verifier per final `confirmed` and `needs_validation` record, in parallel. This verifier checks the structured record, not the hunter write-up, and remains inside source/local boundaries.
In a `quick` run, Phase 3 and Phase 5 merge: the Phase 3 verifier also performs these record checks and returns the final schema-shaped record, so each candidate gets one fresh independent reviewer instead of two. Every other profile keeps the two passes separate. Never skip independent review of a `confirmed` record in any profile.
For `confirmed`, require it to check:
1. Every repository-relative trace/evidence path, line, scope, and described operation.
2. Real entry interface and exact local input shape.
3. Every condition, parser/policy step, source-visible preventing layer, and observed local result.
4. Affected principal/resource and demonstrated impact.
5. Severity separation: realistic likelihood, demonstrated impact, overall no greater than impact.
6. Remediation strategy and any `code_changes`, including whether the fix enforces the invariant without merely moving trust.
For `needs_validation`, require it to check:
1. The source path is real and supports only the `claimed_root_cause` stated.
2. Every listed blocker is decisive and not already answerable locally.
3. The candidate names a boundary and a possible concrete result rather than a generic concern.
4. At least one validation-plan field is present and exact. `local` uses a bounded fixture; `deployment` asks an owner to observe a configuration, identity, route, policy, or runtime fact. Do not invent a plan for an inapplicable context, and never send audit traffic to a deployment.
5. The fingerprint matches prior/current records for the same root cause.
Each verifier returns exactly one JSON object: `{"decision":"verified","fingerprint":"..."}` or `{"decision":"replace","reason":"...","record":{...}}`, with no surrounding prose. A replacement record must match its `confirmed`, `needs_validation`, or `rejected` schema branch. Treat a malformed or prose-wrapped Phase 5 result the same way as in Phase 3: discard it without repairing it and re-run with a fresh verifier when the budget permits.
Do not apply a Phase 5 replacement as final when it promotes a record to a stronger verdict, including any promotion to `confirmed`, or materially changes the root cause, trace, execution input or observed result, demonstrated impact, or severity. Give that complete replacement to a new independent verifier that did not hunt, perform Phase 3 validation, or propose the Phase 5 replacement. The new verifier rechecks the current source and independently reproduces any decisive local result under the execution boundary, then returns `verified` or another replacement. Apply a material replacement only after this fresh verification. If another material replacement results, repeat with a fresh verifier. If budget or independence is unavailable, remove the disputed record from `findings.json`, keep its ledger unit as an unresolved candidate, and set `run_status: "incomplete"` with an exact `incomplete_reason`. Non-material wording or repository-line corrections may be applied directly when they do not change meaning or evidence.
After every applied replacement, rerun both validators and update linked ledger decisions. If a final verifier identifies a separate root cause, assign a new fingerprint and send it through independent candidate validation before inclusion. Set `run_status: "complete"` only when every ledger candidate has an independent final disposition and every retained record passes Phase 5.
Do not verify only `confirmed` records. A misleading `needs_validation` handoff wastes owner time and can preserve a false premise.
### Phase 6: Produce target-neutral reports from final records
Only after Phase 5 passes for every record retained in `findings.json`, derive prose from the final records, the ledger, and the hunter `hardening` notes retained in ledger bookkeeping. An incomplete run may report independently verified records, but it must identify each unresolved ledger candidate and must not present it as a finding. The prose files never change a verdict, severity, blocker, or demonstrated impact.
#### `REPORT.md`
Write:
1. Run profile, scope, budget (if set) with agents spent versus planned, source ref, sandboxed source-and-local-only execution statement, prior-run use, and explicit deferred and out-of-scope coverage. Name carried same-source confirmations and changed-source revalidations. A `quick`, scoped, budget-limited, or incomplete run states plainly that it is a partial pass. If candidate validation exhausted a strict budget, state that the run is incomplete and list every unvalidated fingerprint and linked unit; do not describe those candidates as findings. If the budget prevented a mandatory critic, state which critic did not run and make no clean-coverage claim.
2. One short security posture summary.
3. A confirmed-findings table: severity, title, affected boundary, and one-line observed result.
4. Each confirmed finding: repository source location, lower-trust principal, target-native bounded reproduction, conditions, actual result, impact, priority rationale, and smallest source fix.
5. A separate `NEEDS VALIDATION` table. Give each lead's title, repository trace, exact blocker, bounded local next step, and safe owner-observed deployment check. Do not assign severity or call it a confirmed vulnerability.
6. Separate hardening notes and positive source patterns.
7. Coverage summary from the ledger: covered, candidate, blocked, and deferred counts, plus important exclusions and the final critic result.
Do not describe rejected records as findings. Mention their fingerprints only when they explain a prior disagreement or coverage decision.
#### `FINDINGS-DETAIL.md`
For each confirmed `medium`, `high`, or `critical` record, copy the complete source path and target-neutral local reproduction:
- ordered repository-relative trace and evidence;
- dummy attacker/principal and affected dummy resource;
- native input, invocation, or fixture and exact bounded instructions;
- observed output and the security invariant it proves;
- conditions and containment;
- source-level remediation and regression case.
#### `NEEDS-VALIDATION.md`
For every unresolved record, copy the source trace, verified evidence, exact blocker, affected boundary, and each applicable bounded local or owner-observed resolution plan. Keep these as prioritized leads without severity. Do not turn them into live test guidance or assume the missing deployment fact.
HTTP is one possible native interface, not the default. A library finding may use a function call, a parser a fixture, a CLI a command, a desktop app an IPC or file action, and infrastructure a locally rendered policy. Do not require an endpoint, external account, or live environment that the target does not have.
Keep the report proportional to the evidence. A clean run may have zero confirmed records. State that result and the remaining coverage/validation limits without inventing LOW findings.

View File

@@ -0,0 +1,105 @@
# HTTP-Protocol and Authentication Hunting
#### When to use this file
Reach for this file when the target speaks HTTP at a parsing, caching, browser-authentication, or identity boundary: web applications, APIs, reverse proxies, CDNs, gateways, custom HTTP servers, and services implementing sessions, JWT, OAuth/OIDC, SAML, password recovery, MFA, passkeys, API keys, or mTLS. Use this with `ATTACK-CLASSES.md`: access-control review asks whether a principal may perform an operation; this file asks whether the HTTP or identity layer can confuse which principal, request, assurance level, or token the operation belongs to.
Pick classes from Phase 1. Split a large target into request framing and cache policy, browser authentication, federated identity, strong authentication and recovery, service credentials, and session lifecycle. A single server behind an unobserved managed proxy has little source-confirmable smuggling surface; a proxy or custom parser has much more.
## Core discipline (include in every agent prompt for this domain)
```
- Framing and cache findings require two interpretations of the same request, response, or key. Name both components and the exact normalized value on each side.
- For every credential, find the signature or secret verification and every binding required for its role: issuer, audience, origin, RP, client, session, principal, resource, assurance, expiry, and one-time state.
- Host, Forwarded, X-Forwarded-*, Origin, Referer, redirect targets, callback state, and request-derived URLs are trust decisions. Trace each to the affected identity or response.
- A missing header, cookie attribute, MFA prompt, or rate limit is not a finding alone. Require an accepted invalid request, cross-principal impact, assurance downgrade, or credential disclosure.
- Classify `confirmed` only from complete source evidence and bounded local request/token tests. Use `needs_validation` when proxy, IdP, browser, certificate, secret, or deployed configuration is required but not visible.
```
## HTTP framing and cache attack classes (subagent_type: `general`)
**Request framing and desynchronization**
Front end and back end disagree on request length or header normalization. Review multiple `Content-Length` values, `Transfer-Encoding`, HTTP/2 or HTTP/3 downgrade, header-name normalization, forbidden connection headers, and CR/LF conversion. Confirm which bytes one component assigns to a request and which bytes its peer assigns to the next request.
**Web cache poisoning through unkeyed input**
A request value changes cached content or security-relevant headers but is absent from the cache key. Compare cache key construction with every response variant, including forwarded host/scheme, selected cookies, query normalization, language/device headers, and authorization state.
**Cache deception and private-response caching**
Cache routing treats a private dynamic path as a public static asset, or caches a response whose identity and authorization inputs are missing from policy. Compare edge cacheability with application route parsing, suffix/path-parameter normalization, and response cache directives.
**Host and forwarded-header trust**
Untrusted host/proxy metadata determines absolute URLs, tenant routing, callbacks, reset links, cache keys, or the client address used by authorization. Confirm who can supply the header and whether trusted ingress removes client-provided copies.
**Response-header injection**
Untrusted data reaches `Location`, `Set-Cookie`, CSP, or another response header with unsafe control characters or normalization. Verify framework rejection before reporting and require a security-relevant response change.
## Browser-session attack classes (subagent_type: `general`)
**Ordinary CSRF**
A browser sends ambient credentials to a state-changing endpoint that accepts a cross-site request without an effective anti-CSRF token, same-site request binding, or strict Origin/Referer validation. Inventory every cookie-authenticated mutation, including form, JSON-like, multipart, method-override, and legacy routes. SameSite is effective only for the cookie and browser contexts actually used; login CSRF and cross-site subresource requests can have different requirements.
**Session fixation and invalidation**
Session identifiers are not rotated on login, account switch, MFA completion, impersonation, or other privilege changes, or remain valid after logout, password change, revocation, and account disable. Check server sessions, refresh tokens, signed cookies, websocket state, cache copies, and fallback endpoints.
**Cookie scope and transport**
A sensitive cookie has an over-broad `Domain` or `Path`, can cross an insecure transport, or conflicts with a sibling cookie that another component selects differently. Bare missing flags remain hardening notes unless a realistic less-trusted origin, network position, or browser path can gain or replace the credential.
## Federated-identity attack classes (subagent_type: `general`)
First establish role. Authorization-server controls such as redirect allowlisting and code issuance do not belong to a relying-party client. Verification and binding defects belong to the component consuming the artifact.
**JWT verification and claim binding**
Check signature verification, server-pinned algorithm and key source, then `exp`, `nbf`, `aud`, and `iss`. Review `kid`, `jku`, and `x5u` as untrusted key selectors, duplicate/header normalization, and decode-without-verify paths. A valid token for another service is invalid here even when signed by a trusted issuer.
**OAuth/OIDC request and callback binding**
Validate exact `redirect_uri` ownership where the target is the authorization server; session-bound `state`; PKCE and authorization-code binding where applicable; ID-token issuer/audience/signature/nonce; and selected-IdP binding in multi-provider flows. Compare initial callback, retry, mobile/deep-link, and account-link routes.
**SAML signed-object and assertion binding**
Ensure the element whose signature is validated is the element used as identity. Review unsigned/fallback paths, safe XML parser configuration, canonicalization differences, and freshness/binding fields such as validity windows, audience/recipient, request correlation, and replay state.
## MFA, passkey, and account-transition attack classes (subagent_type: `general`)
**MFA enrollment and assurance downgrade**
Enrollment, replacement, disablement, recovery-code generation, trusted-device creation, and fallback login require the intended prior assurance. Check that a valid first factor cannot enroll or replace the second factor without policy-required fresh authentication, and that disabled or stale factors stop authorizing sessions.
**Step-up binding and bypass**
A successful challenge upgrades the wrong session, account, tenant, action, or API request, or an alternate route omits the assurance check. Bind the challenge to principal, current session, assurance target, operation or resource when required, expiry, and one-time completion. Compare UI, API, batch, recovery, and resumed-flow paths.
**WebAuthn and passkey verification**
At registration, bind challenge, RP ID, expected origin, credential, user/userHandle, algorithm, and policy-required user verification to the initiating session. At authentication, verify challenge, RP/origin, credential membership, signature, and intended user presence/verification. Check account-discovery and linking flows for userHandle or credential-to-account confusion. Signature-counter handling is meaningful only when the product treats regressions as a clone signal.
**Account linking and identity collision**
Adding an IdP, passkey, email, phone, device, or external account to an existing account must require a current authenticated session, verified ownership of the new identity, policy-required step-up, and callback state bound to the account that initiated linking. Review unlink/relink and invite-acceptance paths for verified-identifier or tenant collisions.
**Password reset and broader recovery**
Recovery tokens, support/admin recovery, backup codes, device migration, and email or phone change often become the weakest authentication path. Verify token randomness, user/action binding, expiry, one-time state, rate/accounting controls, delivery URL trust, and invalidation of prior tokens and sessions. Different responses that only reveal public account existence are not automatically security findings.
## API-key and mTLS attack classes (subagent_type: `general`)
**API-key scope and resource binding**
A key authenticates to broader tenants, resources, actions, or environments than its server-side record grants, or request parameters override those bindings. Review key lookup, prefix/full-secret verification, type confusion between publishable and secret keys, scope checks, rotation, revocation caches, and bulk endpoints.
**API-key exposure and unsafe transport**
Keys appear in client bundles, URLs, redirects, logs, error paths, build artifacts, or responses accessible to a lower-trust principal. A public identifier called a key is not a secret. Confirm key type and the authority gained by disclosure.
**mTLS peer and application-identity confusion**
A process trusts client-certificate identity headers from any network peer, verifies a chain but maps attacker-influenceable subject text to an account incorrectly, or accepts a certificate for the wrong trust domain, extended usage, audience, or validity policy. Where a trusted proxy terminates mTLS, verify only that proxy can connect, it removes incoming identity headers, and the backend binds the sanitized identity to the request.
**Certificate lifecycle fallback**
Expired, revoked, missing, or renewal-failed certificates cause silent fallback to bearer-only or anonymous operation, or long-lived pooled connections retain authorization after revocation. Missing deployment revocation data makes the result `needs_validation`; an in-repo fail-open branch is source-confirmable.
## Universal moves (apply across the above)
- Walk issue → store → transmit → consume → refresh → revoke for every credential and challenge. Compare normal, error, retry, migration, legacy, and account-switch paths.
- Enumerate every door to the same identity and every route to the same sensitive operation. The effective policy is the weakest parallel path, not the most polished UI.
- Diff parser, proxy, router, cache, and application normalization side by side. For local validation, feed identical bounded request fixtures into each component rather than sending traffic to a live deployment.
- For recovery and linking, draw the account before/after graph. Each edge must name the current principal, proof of the new identity, required assurance, callback/session binding, and revocation effect.
## Validation rules (apply before reporting ANY finding here)
1. Apply a source-visibility gate. Proxy chains, edge cache keys, IdP policy, certificate trust, browser cookie behavior, secrets, and deployed auth modes may be outside the repository. Record a precise `needs_validation` candidate instead of asserting missing infrastructure behavior.
2. For framing and cache findings, name both components and the divergent parse/key. Confirm cross-request, cross-user, or private-response impact with bounded local fixtures.
3. For token, MFA, passkey, account-link, recovery, API-key, and mTLS findings, cite the verification line and missing principal/session/resource/origin/audience/action/assurance binding. Prove the server accepts the invalid transition or credential.
4. For CSRF, name the ambient credential, state-changing route, accepted cross-site request shape, browser cookie policy, and missing effective check. Read-only actions and routes requiring a non-ambient bearer token do not qualify.
5. Verify framework and library defaults. If version or configuration is unknown, use `needs_validation`; do not turn an unverified critical claim into a lower-severity confirmed finding.
6. Return `confirmed` only with a complete source trace and observable unauthorized identity, state, or disclosure. For `needs_validation`, name the missing fact and safe local or owner-observed check that resolves it.

View File

@@ -0,0 +1,461 @@
{
"$comment": "Top-level contract for findings.json. validate-findings.cjs interprets and checks this schema directly.",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"description": "A source-grounded vulnerability that was independently demonstrated.",
"properties": {
"verdict": {
"type": "string",
"const": "confirmed"
},
"fingerprint": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$",
"description": "A stable source-derived identifier that does not change between validation states."
},
"title": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"root_cause": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"intended_behavior": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"trace": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["entrypoint", "propagation", "sink"]
},
"file": {
"type": "string",
"minLength": 1
},
"line": {
"type": "integer",
"minimum": 1
},
"scope": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["kind", "file", "line", "scope", "description"],
"additionalProperties": false
}
},
"evidence": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"file": {
"type": "string",
"minLength": 1
},
"line": {
"type": "integer",
"minimum": 1
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["file", "line", "description"],
"additionalProperties": false
}
},
"conditions": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["authentication_level", "authorization_role", "user_interaction", "system_configuration", "network_routing", "environmental_dependency", "data_state", "timing_dependency", "third_party_dependency"]
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["kind", "description"],
"additionalProperties": false
}
},
"execution": {
"type": "object",
"description": "Target-neutral reproduction in the target's native interface.",
"properties": {
"attacker_perspective": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"payloads": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string"
}
},
"instructions": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"observed_result": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["attacker_perspective", "payloads", "instructions", "observed_result"],
"additionalProperties": false
},
"remediation": {
"type": "object",
"properties": {
"strategy": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"code_changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file_name": {
"type": "string",
"minLength": 1
},
"fixed_code": {
"type": "string"
}
},
"required": ["file_name", "fixed_code"],
"additionalProperties": false
}
}
},
"required": ["strategy"],
"additionalProperties": false
},
"severity": {
"type": "object",
"properties": {
"likelihood": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": ["informational", "low", "medium", "high", "critical"]
},
"reason": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["score", "reason"],
"additionalProperties": false
},
"impact": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": ["informational", "low", "medium", "high", "critical"]
},
"reason": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["score", "reason"],
"additionalProperties": false
},
"overall_severity": {
"type": "string",
"enum": ["informational", "low", "medium", "high", "critical"]
}
},
"required": ["likelihood", "impact", "overall_severity"],
"additionalProperties": false
},
"confidence": {
"type": "object",
"properties": {
"score": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"reason": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["score", "reason"],
"additionalProperties": false
}
},
"required": ["verdict", "fingerprint", "title", "description", "root_cause", "intended_behavior", "trace", "evidence", "conditions", "execution", "remediation", "severity", "confidence"],
"additionalProperties": false
},
{
"type": "object",
"description": "A source-grounded candidate whose decisive validation is blocked.",
"properties": {
"verdict": {
"type": "string",
"const": "needs_validation"
},
"fingerprint": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$"
},
"title": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"claimed_root_cause": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"trace": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["entrypoint", "propagation", "sink"]
},
"file": {
"type": "string",
"minLength": 1
},
"line": {
"type": "integer",
"minimum": 1
},
"scope": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["kind", "file", "line", "scope", "description"],
"additionalProperties": false
}
},
"evidence": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"file": {
"type": "string",
"minLength": 1
},
"line": {
"type": "integer",
"minimum": 1
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["file", "line", "description"],
"additionalProperties": false
}
},
"blockers": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"validation_plan": {
"type": "object",
"properties": {
"local": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"deployment": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"additionalProperties": false
}
},
"required": ["verdict", "fingerprint", "title", "description", "claimed_root_cause", "trace", "evidence", "blockers", "validation_plan"],
"additionalProperties": false
},
{
"type": "object",
"description": "A source-grounded candidate refuted during validation.",
"properties": {
"verdict": {
"type": "string",
"const": "rejected"
},
"fingerprint": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$"
},
"title": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"claimed_root_cause": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"trace": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["entrypoint", "propagation", "sink"]
},
"file": {
"type": "string",
"minLength": 1
},
"line": {
"type": "integer",
"minimum": 1
},
"scope": {
"type": "string",
"minLength": 1,
"visibleContent": true
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["kind", "file", "line", "scope", "description"],
"additionalProperties": false
}
},
"evidence": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"file": {
"type": "string",
"minLength": 1
},
"line": {
"type": "integer",
"minimum": 1
},
"description": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["file", "line", "description"],
"additionalProperties": false
}
},
"reason": {
"type": "string",
"minLength": 1,
"visibleContent": true
}
},
"required": ["verdict", "fingerprint", "title", "description", "claimed_root_cause", "trace", "evidence", "reason"],
"additionalProperties": false
}
]
}
}

View File

@@ -0,0 +1,872 @@
#!/usr/bin/env node
/**
* Validates coverage-ledger.json and its canonical coverage IDs.
* Usage: node validate-coverage-ledger.cjs <path-to-coverage-ledger.json>
*/
const fs = require("node:fs");
const path = require("node:path");
const { TextDecoder } = require("node:util");
// Conservative bounds apply before JSON.parse and again to the parsed document.
const LIMITS = Object.freeze({
inputBytes: 5 * 1024 * 1024,
units: 10000,
collectionItems: 1000,
objectFields: 1000,
nestingDepth: 64,
preflightValues: 500000,
validationErrors: 100,
});
const MAX_INPUT_BYTES = LIMITS.inputBytes;
const MAX_UNITS = LIMITS.units;
const MAX_LIST_ITEMS = LIMITS.collectionItems;
const MAX_TEXT_LENGTH = 4096;
const REQUIRED_FIELDS = [
"coverage_id",
"canonical_refs",
"surface",
"boundary",
"subsystem",
"attack_class",
"starting_paths",
"ordinary_attack_class_block",
"selected_companion_blocks",
"excluded_blocks",
"prior_status",
"attempts",
"wave",
"status",
"agent_id",
"reviewed_paths",
"local_checks",
"result_fingerprints",
"unresolved",
];
const REF_FIELDS = ["surface", "boundary", "subsystem", "attack_class"];
const STATUSES = new Set([
"planned",
"not_applicable",
"out_of_scope",
"in_progress",
"covered",
"candidate",
"blocked",
"deferred",
]);
const ATTEMPT_STATUSES = new Set(["covered", "candidate", "blocked"]);
const ATTEMPT_FIELDS = [
"wave",
"status",
"agent_id",
"reviewed_paths",
"local_checks",
"result_fingerprints",
"unresolved",
"reassignment_reason",
];
const PRIOR_STATUSES = new Set([
"new",
"prior_confirmed_same_source",
"prior_confirmed_changed_source",
"prior_needs_validation",
"prior_deferred",
"prior_blocked",
"prior_out_of_scope",
"prior_covered_same_source",
"prior_covered_changed_source",
"prior_rejected_claim_changed",
"none",
]);
const FINGERPRINT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/;
const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const WINDOWS_RESERVED_AGENT_ID = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
const VISIBLE_CONTENT = /[^\p{White_Space}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
const PATH_FORBIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
const WINDOWS_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/iu;
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
const UNSAFE_DIAGNOSTIC_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
const MAX_DIAGNOSTIC_STRING_LENGTH = 256;
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
class JsonStructureError extends Error {}
class SafeInputError extends Error {}
function escapeUnsafeDiagnosticCharacters(value) {
return String(value).replace(UNSAFE_DIAGNOSTIC_CHARACTER, (character) => {
const codePoint = character.codePointAt(0);
return codePoint <= 0xffff
? `\\u${codePoint.toString(16).padStart(4, "0")}`
: `\\u{${codePoint.toString(16)}}`;
});
}
function safeQuote(value) {
let serialized;
if (typeof value === "string") {
const clipped = value.length > MAX_DIAGNOSTIC_STRING_LENGTH
? `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...`
: value;
serialized = JSON.stringify(clipped);
} else if (value === null || typeof value === "boolean") {
serialized = String(value);
} else if (typeof value === "number" && Number.isFinite(value)) {
serialized = String(value);
} else {
serialized = `"<${Array.isArray(value) ? "array" : typeof value}>"`;
}
return escapeUnsafeDiagnosticCharacters(serialized);
}
function createErrorList() {
const errors = [];
Object.defineProperty(errors, "push", {
value(...messages) {
const remaining = LIMITS.validationErrors - this.length;
if (remaining > 0) {
Array.prototype.push.apply(this, messages.slice(0, remaining).map(escapeUnsafeDiagnosticCharacters));
}
return this.length;
},
});
return errors;
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function preflightJsonText(contents) {
const containers = [];
let rootState = "value";
let inString = false;
let escaped = false;
let totalValues = 0;
function fail(message) {
throw new JsonStructureError(`input ${message}`);
}
function currentContainer() {
return containers[containers.length - 1];
}
function countValue() {
totalValues++;
if (totalValues > LIMITS.preflightValues) {
fail(`exceeds ${LIMITS.preflightValues} total value limit`);
}
}
function beginValue() {
const container = currentContainer();
if (!container) {
if (rootState !== "value") fail("has malformed JSON structure");
rootState = "end";
} else if (container.type === "array") {
if (container.state !== "firstValueOrEnd" && container.state !== "value") {
fail("has malformed JSON array structure");
}
container.items++;
const limit = container.topLevel ? MAX_UNITS : LIMITS.collectionItems;
if (container.items > limit) {
fail(container.topLevel
? `exceeds ${limit} top-level unit limit`
: `exceeds ${limit} item array limit`);
}
container.state = "commaOrEnd";
} else {
if (container.state !== "value") fail("has malformed JSON object structure");
container.state = "commaOrEnd";
}
countValue();
}
function beginString() {
const container = currentContainer();
if (container && container.type === "object" &&
(container.state === "firstKeyOrEnd" || container.state === "key")) {
container.fields++;
if (container.fields > LIMITS.objectFields) {
fail(`exceeds ${LIMITS.objectFields} field object limit`);
}
container.state = "colon";
} else {
beginValue();
}
inString = true;
}
function beginContainer(type) {
beginValue();
if (containers.length >= LIMITS.nestingDepth) {
fail(`exceeds nesting depth limit ${LIMITS.nestingDepth}`);
}
containers.push(type === "array"
? { type, state: "firstValueOrEnd", items: 0, topLevel: containers.length === 0 }
: { type, state: "firstKeyOrEnd", fields: 0 });
}
function closeContainer(type) {
const container = currentContainer();
if (!container || container.type !== type) fail("has mismatched JSON containers");
const canClose = type === "array"
? container.state === "firstValueOrEnd" || container.state === "commaOrEnd"
: container.state === "firstKeyOrEnd" || container.state === "commaOrEnd";
if (!canClose) fail(`has malformed JSON ${type} structure`);
containers.pop();
}
function isWhitespace(character) {
return character === " " || character === "\t" || character === "\r" || character === "\n";
}
function isTokenDelimiter(character) {
return isWhitespace(character) || character === "," || character === ":" ||
character === "[" || character === "]" || character === "{" ||
character === "}" || character === "\"";
}
for (let index = 0; index < contents.length; index++) {
const character = contents[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === "\"") {
inString = false;
}
continue;
}
if (isWhitespace(character)) continue;
if (character === "\"") {
beginString();
} else if (character === "[") {
beginContainer("array");
} else if (character === "{") {
beginContainer("object");
} else if (character === "]") {
closeContainer("array");
} else if (character === "}") {
closeContainer("object");
} else if (character === ":") {
const container = currentContainer();
if (!container || container.type !== "object" || container.state !== "colon") {
fail("has malformed JSON object structure");
}
container.state = "value";
} else if (character === ",") {
const container = currentContainer();
if (!container || container.state !== "commaOrEnd") fail("has malformed JSON collection structure");
container.state = container.type === "array" ? "value" : "key";
} else {
beginValue();
while (index + 1 < contents.length && !isTokenDelimiter(contents[index + 1])) index++;
}
}
if (inString) fail("has an unterminated JSON string");
if (containers.length > 0) fail("has truncated JSON structure");
if (rootState !== "end") fail("has no JSON value");
}
function preflightDocument(root) {
const errors = createErrorList();
const stack = [{ value: root, depth: 0, location: "$" }];
let visited = 0;
while (stack.length > 0) {
const { value, depth, location } = stack.pop();
visited++;
if (visited > LIMITS.preflightValues) {
errors.push(`$: exceeds ${LIMITS.preflightValues} total values`);
return errors;
}
if (value === null || typeof value !== "object") continue;
if (depth >= LIMITS.nestingDepth) {
errors.push(`${location}: exceeds nesting depth limit ${LIMITS.nestingDepth}`);
return errors;
}
if (Array.isArray(value)) {
const limit = location === "$" ? MAX_UNITS : MAX_LIST_ITEMS;
if (value.length > limit) {
errors.push(`${location}: exceeds ${limit} entries`);
return errors;
}
for (let index = value.length - 1; index >= 0; index--) {
stack.push({ value: value[index], depth: depth + 1, location: `${location}[${index}]` });
}
continue;
}
const keys = Object.keys(value);
if (keys.length > LIMITS.objectFields) {
errors.push(`${location}: exceeds ${LIMITS.objectFields} object fields`);
return errors;
}
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index];
stack.push({ value: value[key], depth: depth + 1, location: `${location}{${index}}` });
}
}
return errors;
}
function hasValidUnicodeScalarValues(value) {
let index = 0;
while (index < value.length) {
const first = value.charCodeAt(index++);
if (first >= 0xd800 && first <= 0xdbff) {
if (index >= value.length) return false;
const second = value.charCodeAt(index++);
if (second < 0xdc00 || second > 0xdfff) return false;
} else if (first >= 0xdc00 && first <= 0xdfff) {
return false;
}
}
return true;
}
function hasVisibleProse(value) {
return hasValidUnicodeScalarValues(value) && VISIBLE_CONTENT.test(value);
}
function isVisibleText(value, maxLength = MAX_TEXT_LENGTH) {
return typeof value === "string" &&
value.length > 0 &&
value.length <= maxLength &&
value.trim() === value &&
hasVisibleProse(value);
}
function isCanonicalRef(value) {
return isVisibleText(value, 1024) &&
!PATH_FORBIDDEN_CHARACTER.test(value) &&
value.normalize("NFC") === value;
}
function encodeCanonicalRef(value) {
if (!isCanonicalRef(value)) throw new TypeError("invalid canonical reference");
let encoded = "";
for (const byte of Buffer.from(value, "utf8")) {
const unreserved =
(byte >= 0x41 && byte <= 0x5a) ||
(byte >= 0x61 && byte <= 0x7a) ||
(byte >= 0x30 && byte <= 0x39) ||
byte === 0x2d || byte === 0x2e || byte === 0x5f || byte === 0x7e;
encoded += unreserved ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
return encoded;
}
function canonicalCoverageId(refs) {
if (!isObject(refs)) throw new TypeError("canonical_refs must be an object");
const fields = hasOwn(refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS;
if (Object.keys(refs).length !== fields.length || fields.some((field) => !hasOwn(refs, field))) {
throw new TypeError("canonical_refs has missing or unexpected fields");
}
return fields.map((field) => encodeCanonicalRef(refs[field])).join("::");
}
function isSafeRelativePath(value) {
if (typeof value !== "string" || value.length === 0 || !hasValidUnicodeScalarValues(value) || value.trim() !== value || PATH_FORBIDDEN_CHARACTER.test(value) || value.includes("\\") || value.includes(":")) return false;
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("~")) return false;
return value.split("/").every((segment) =>
segment !== "" &&
segment !== "." &&
segment !== ".." &&
!/[ .]$/u.test(segment) &&
!WINDOWS_RESERVED_COMPONENT.test(segment));
}
function isSafeAgentId(value) {
return typeof value === "string" &&
AGENT_ID_PATTERN.test(value) &&
!WINDOWS_RESERVED_AGENT_ID.test(value);
}
function isOwnedArtifactPath(value, agentId) {
if (!isSafeAgentId(agentId) || !isSafeRelativePath(value)) return false;
const prefix = `agents/${agentId}/artifacts/`;
return value.startsWith(prefix) && value.length > prefix.length;
}
function validateStringArray(value, location, errors, options = {}) {
const { allowEmpty = true, fingerprint = false, pathValue = false } = options;
if (!Array.isArray(value)) {
errors.push(`${location}: expected array`);
return;
}
if (!allowEmpty && value.length === 0) errors.push(`${location}: must not be empty`);
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
const seen = new Set();
value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => {
const entryLocation = `${location}[${index}]`;
const valid = pathValue ? isSafeRelativePath(entry) : isVisibleText(entry);
if (!valid) errors.push(`${entryLocation}: invalid ${pathValue ? "repository-relative path" : "text"}`);
if (fingerprint && typeof entry === "string" && !FINGERPRINT_PATTERN.test(entry)) {
errors.push(`${entryLocation}: invalid fingerprint`);
}
if (typeof entry === "string" && seen.has(entry)) errors.push(`${entryLocation}: duplicate entry`);
if (typeof entry === "string") seen.add(entry);
});
}
function validateChecks(value, location, errors) {
if (!Array.isArray(value)) {
errors.push(`${location}: expected array`);
return;
}
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
value.slice(0, MAX_LIST_ITEMS).forEach((check, index) => {
const base = `${location}[${index}]`;
if (!isObject(check)) {
errors.push(`${base}: expected object`);
return;
}
for (const field of ["agent_id", "reviewed_paths", "invariant", "method", "result", "artifact"]) {
if (!hasOwn(check, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`);
}
if (!isSafeAgentId(check.agent_id)) errors.push(`${base}.agent_id: expected a canonical lowercase agent ID`);
validateStringArray(check.reviewed_paths, `${base}.reviewed_paths`, errors, { allowEmpty: false, pathValue: true });
if (!isVisibleText(check.invariant)) errors.push(`${base}.invariant: invalid text`);
if (check.method !== "source" && check.method !== "local") errors.push(`${base}.method: expected "source" or "local"`);
if (!isVisibleText(check.result)) errors.push(`${base}.result: invalid text`);
if (check.method === "source" && check.artifact !== null) {
errors.push(`${base}.artifact: source-only check must use null`);
} else if (check.method === "local") {
if (!isOwnedArtifactPath(check.artifact, check.agent_id)) {
errors.push(`${base}.artifact: local check requires an artifact owned by agent ${safeQuote(isSafeAgentId(check.agent_id) ? check.agent_id : "<agent-id>")}`);
}
} else if (check.method !== "source" && check.method !== "local" && check.artifact !== null && !isSafeRelativePath(check.artifact)) {
errors.push(`${base}.artifact: expected null or a safe output-relative path`);
}
});
}
function validateReviewedPathOwnership(unit, base, errors) {
if (!Array.isArray(unit.reviewed_paths) || !Array.isArray(unit.local_checks)) return;
const aggregatePaths = new Set(unit.reviewed_paths.filter((value) => typeof value === "string"));
const ownedPaths = new Set();
for (const check of unit.local_checks) {
if (!isObject(check) || !Array.isArray(check.reviewed_paths)) continue;
for (const reviewedPath of check.reviewed_paths) {
if (typeof reviewedPath === "string") ownedPaths.add(reviewedPath);
}
}
for (const reviewedPath of aggregatePaths) {
if (!ownedPaths.has(reviewedPath)) errors.push(`${base}.reviewed_paths: ${safeQuote(reviewedPath)} has no check owner`);
}
for (const reviewedPath of ownedPaths) {
if (!aggregatePaths.has(reviewedPath)) errors.push(`${base}.local_checks: owned path ${safeQuote(reviewedPath)} is absent from aggregate reviewed_paths`);
}
}
function validateExcludedBlocks(value, location, errors) {
if (!Array.isArray(value)) {
errors.push(`${location}: expected array`);
return;
}
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
const seen = new Set();
value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => {
const base = `${location}[${index}]`;
if (!isObject(entry)) {
errors.push(`${base}: expected object`);
return;
}
if (!isVisibleText(entry.block)) errors.push(`${base}.block: invalid text`);
if (!isVisibleText(entry.reason)) errors.push(`${base}.reason: invalid text`);
if (typeof entry.block === "string" && seen.has(entry.block)) errors.push(`${base}.block: duplicate entry`);
if (typeof entry.block === "string") seen.add(entry.block);
});
}
function semanticKey(unit) {
return JSON.stringify([
unit.surface,
unit.boundary,
unit.subsystem,
unit.attack_class,
hasOwn(unit, "lifecycle") ? unit.lifecycle : null,
]);
}
function hasValidSemanticFields(unit) {
return ["surface", "boundary", "subsystem", "attack_class"].every((field) => isVisibleText(unit[field])) &&
(!hasOwn(unit, "lifecycle") || isVisibleText(unit.lifecycle));
}
function requireEmptyArray(unit, field, base, errors) {
if (Array.isArray(unit[field]) && unit[field].length > 0) {
errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} must keep this array empty`);
}
}
function requireNonemptyArray(unit, field, base, errors) {
if (!Array.isArray(unit[field]) || unit[field].length === 0) {
errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} requires entries`);
}
}
function validateStateInvariants(unit, base, errors) {
const emptyEvidence = () => {
requireEmptyArray(unit, "reviewed_paths", base, errors);
requireEmptyArray(unit, "local_checks", base, errors);
};
const requireOwner = () => {
if (!isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} requires a canonical lowercase agent ID`);
};
if (unit.status !== "candidate") requireEmptyArray(unit, "result_fingerprints", base, errors);
switch (unit.status) {
case "planned":
if (unit.agent_id !== null) errors.push(`${base}.agent_id: planned unit must be unassigned`);
emptyEvidence();
requireEmptyArray(unit, "unresolved", base, errors);
break;
case "not_applicable":
case "out_of_scope":
case "deferred":
if (unit.agent_id !== null) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} must be unassigned`);
emptyEvidence();
requireNonemptyArray(unit, "unresolved", base, errors);
break;
case "in_progress":
requireOwner();
emptyEvidence();
requireEmptyArray(unit, "unresolved", base, errors);
break;
case "blocked":
requireOwner();
requireNonemptyArray(unit, "reviewed_paths", base, errors);
requireNonemptyArray(unit, "local_checks", base, errors);
requireNonemptyArray(unit, "unresolved", base, errors);
break;
case "covered":
requireOwner();
requireNonemptyArray(unit, "reviewed_paths", base, errors);
requireNonemptyArray(unit, "local_checks", base, errors);
requireEmptyArray(unit, "unresolved", base, errors);
break;
case "candidate":
requireOwner();
requireNonemptyArray(unit, "reviewed_paths", base, errors);
requireNonemptyArray(unit, "local_checks", base, errors);
requireNonemptyArray(unit, "result_fingerprints", base, errors);
break;
}
}
function validateAttempts(value, unit, base, errors) {
if (!Array.isArray(value)) {
errors.push(`${base}.attempts: expected array`);
return;
}
if (value.length > MAX_LIST_ITEMS) errors.push(`${base}.attempts: exceeds ${MAX_LIST_ITEMS} entries`);
const priorOwners = new Set();
const priorArtifacts = new Set();
let previousWave = 0;
value.slice(0, MAX_LIST_ITEMS).forEach((attempt, index) => {
const attemptBase = `${base}.attempts[${index}]`;
if (!isObject(attempt)) {
errors.push(`${attemptBase}: expected object`);
return;
}
for (const field of ATTEMPT_FIELDS) {
if (!hasOwn(attempt, field)) errors.push(`${attemptBase}: missing required field ${safeQuote(field)}`);
}
if (!Number.isInteger(attempt.wave) || attempt.wave < 1) {
errors.push(`${attemptBase}.wave: expected a positive integer`);
} else {
if (attempt.wave <= previousWave) errors.push(`${attemptBase}.wave: archived attempt waves must be strictly increasing`);
if (Number.isInteger(unit.wave) && attempt.wave >= unit.wave) {
errors.push(`${attemptBase}.wave: archived attempt wave must precede current wave ${safeQuote(unit.wave)}`);
}
previousWave = attempt.wave;
}
if (!ATTEMPT_STATUSES.has(attempt.status)) {
errors.push(`${attemptBase}.status: expected "covered", "candidate", or "blocked"`);
}
let hasFreshOwner = false;
if (!isSafeAgentId(attempt.agent_id)) {
errors.push(`${attemptBase}.agent_id: archived attempt requires a canonical lowercase agent ID`);
} else if (priorOwners.has(attempt.agent_id)) {
errors.push(`${attemptBase}.agent_id: assignment owner must be fresh for each attempt`);
} else {
hasFreshOwner = true;
}
validateStringArray(attempt.reviewed_paths, `${attemptBase}.reviewed_paths`, errors, { pathValue: true });
validateChecks(attempt.local_checks, `${attemptBase}.local_checks`, errors);
validateReviewedPathOwnership(attempt, attemptBase, errors);
validateStringArray(attempt.result_fingerprints, `${attemptBase}.result_fingerprints`, errors, { fingerprint: true });
validateStringArray(attempt.unresolved, `${attemptBase}.unresolved`, errors);
if (!isVisibleText(attempt.reassignment_reason)) errors.push(`${attemptBase}.reassignment_reason: invalid text`);
validateStateInvariants(attempt, attemptBase, errors);
if (Array.isArray(attempt.local_checks)) {
attempt.local_checks.forEach((check, checkIndex) => {
if (!isObject(check)) return;
if (priorOwners.has(check.agent_id)) {
errors.push(`${attemptBase}.local_checks[${checkIndex}].agent_id: prior assignment owner evidence must remain in its earlier attempt`);
}
if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) {
errors.push(`${attemptBase}.local_checks[${checkIndex}].artifact: artifact from an earlier attempt cannot be reused`);
}
if (check.method === "local" && typeof check.artifact === "string") priorArtifacts.add(check.artifact);
});
}
if (hasFreshOwner) priorOwners.add(attempt.agent_id);
});
if (isSafeAgentId(unit.agent_id) && priorOwners.has(unit.agent_id)) {
errors.push(`${base}.agent_id: current assignment owner must be fresh after reassignment`);
}
if (Array.isArray(unit.local_checks)) {
unit.local_checks.forEach((check, index) => {
if (!isObject(check)) return;
if (priorOwners.has(check.agent_id)) {
errors.push(`${base}.local_checks[${index}].agent_id: prior assignment owner evidence must remain in its archived attempt`);
}
if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) {
errors.push(`${base}.local_checks[${index}].artifact: artifact from an archived attempt cannot be reused`);
}
});
}
}
function collectUnitErrors(unit, index) {
const errors = createErrorList();
const base = `$[${index}]`;
if (!isObject(unit)) return [`${base}: expected object`];
for (const field of REQUIRED_FIELDS) {
if (!hasOwn(unit, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`);
}
for (const field of ["surface", "boundary", "subsystem", "attack_class"]) {
if (!isVisibleText(unit[field])) errors.push(`${base}.${field}: invalid text`);
}
if (hasOwn(unit, "lifecycle") && !isVisibleText(unit.lifecycle)) errors.push(`${base}.lifecycle: invalid text`);
let expectedId = null;
if (!isObject(unit.canonical_refs)) {
errors.push(`${base}.canonical_refs: expected object`);
} else {
const expectedFields = hasOwn(unit.canonical_refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS;
for (const field of expectedFields) {
if (!hasOwn(unit.canonical_refs, field)) {
errors.push(`${base}.canonical_refs: missing required field ${safeQuote(field)}`);
} else if (!isCanonicalRef(unit.canonical_refs[field])) {
errors.push(`${base}.canonical_refs.${field}: invalid canonical reference`);
}
}
if (Object.keys(unit.canonical_refs).some((field) => !expectedFields.includes(field))) {
errors.push(`${base}.canonical_refs: contains unexpected fields`);
}
if (hasOwn(unit, "lifecycle") !== hasOwn(unit.canonical_refs, "lifecycle")) {
errors.push(`${base}: lifecycle and canonical_refs.lifecycle must appear together`);
}
try {
expectedId = canonicalCoverageId(unit.canonical_refs);
} catch {
// The specific reference errors above are more useful.
}
}
if (!isVisibleText(unit.coverage_id, 65536)) {
errors.push(`${base}.coverage_id: invalid text`);
} else if (expectedId !== null && unit.coverage_id !== expectedId) {
errors.push(`${base}.coverage_id: expected canonical ID ${safeQuote(expectedId)}`);
}
validateStringArray(unit.starting_paths, `${base}.starting_paths`, errors, { allowEmpty: false, pathValue: true });
if (unit.ordinary_attack_class_block !== null && !isVisibleText(unit.ordinary_attack_class_block)) {
errors.push(`${base}.ordinary_attack_class_block: expected null or non-empty text`);
}
validateStringArray(unit.selected_companion_blocks, `${base}.selected_companion_blocks`, errors);
validateExcludedBlocks(unit.excluded_blocks, `${base}.excluded_blocks`, errors);
if (Array.isArray(unit.selected_companion_blocks) && Array.isArray(unit.excluded_blocks)) {
const selected = new Set(unit.selected_companion_blocks);
unit.excluded_blocks.forEach((entry, blockIndex) => {
if (isObject(entry) && selected.has(entry.block)) {
errors.push(`${base}.excluded_blocks[${blockIndex}].block: block is also selected`);
}
});
}
if (!PRIOR_STATUSES.has(unit.prior_status)) errors.push(`${base}.prior_status: invalid value ${safeQuote(unit.prior_status)}`);
if (!STATUSES.has(unit.status)) errors.push(`${base}.status: invalid value ${safeQuote(unit.status)}`);
if (!Number.isInteger(unit.wave) || unit.wave < 1) errors.push(`${base}.wave: expected a positive integer`);
if (unit.agent_id !== null && !isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: expected null or a safe agent ID`);
validateAttempts(unit.attempts, unit, base, errors);
validateStringArray(unit.reviewed_paths, `${base}.reviewed_paths`, errors, { pathValue: true });
validateChecks(unit.local_checks, `${base}.local_checks`, errors);
validateReviewedPathOwnership(unit, base, errors);
validateStringArray(unit.result_fingerprints, `${base}.result_fingerprints`, errors, { fingerprint: true });
validateStringArray(unit.unresolved, `${base}.unresolved`, errors);
validateStateInvariants(unit, base, errors);
return errors;
}
function readFileWithinLimit(file) {
const noFollow = fs.constants.O_NOFOLLOW;
const nonBlock = fs.constants.O_NONBLOCK;
if (!Number.isInteger(noFollow) || noFollow === 0 || !Number.isInteger(nonBlock) || nonBlock === 0) {
// Node exposes no race-safe fallback on these platforms, so reject all inputs.
throw new SafeInputError("OS no-follow and nonblocking input protection is unavailable");
}
let descriptor;
try {
descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock);
} catch (error) {
if (error && (error.code === "ELOOP" || error.code === "EMLINK")) throw new SafeInputError("input must not be a symlink");
throw error;
}
try {
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) throw new SafeInputError("input must be a regular file");
if (stat.size > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`);
const chunks = [];
const buffer = Buffer.allocUnsafe(64 * 1024);
let bytesRead = 0;
while (true) {
const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
if (count === 0) break;
bytesRead += count;
if (bytesRead > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`);
chunks.push(Buffer.from(buffer.subarray(0, count)));
}
try {
return UTF8_DECODER.decode(Buffer.concat(chunks, bytesRead));
} catch {
throw new SafeInputError("input is not valid UTF-8");
}
} finally {
fs.closeSync(descriptor);
}
}
function validateDocument(ledger) {
const errors = createErrorList();
if (!Array.isArray(ledger)) {
errors.push("$: expected a top-level array");
return errors;
}
if (ledger.length > MAX_UNITS) {
errors.push(`$: exceeds ${MAX_UNITS} coverage units`);
return errors;
}
errors.push(...preflightDocument(ledger));
if (errors.length > 0) return errors;
const ids = new Map();
const semantics = new Map();
let previousId = null;
for (let index = 0; index < ledger.length && errors.length < LIMITS.validationErrors; index++) {
const unit = ledger[index];
errors.push(...collectUnitErrors(unit, index));
if (errors.length >= LIMITS.validationErrors) break;
if (!isObject(unit) || typeof unit.coverage_id !== "string") continue;
const key = hasValidSemanticFields(unit) ? semanticKey(unit) : null;
if (ids.has(unit.coverage_id)) {
const previous = ids.get(unit.coverage_id);
const qualifier = key !== null && previous.key !== null && previous.key !== key
? "canonical identity collision with different semantic fields"
: "duplicate coverage ID";
errors.push(`$[${index}].coverage_id: ${qualifier} at $[${previous.index}]`);
} else {
ids.set(unit.coverage_id, { index, key });
}
if (key !== null && semantics.has(key) && semantics.get(key).id !== unit.coverage_id) {
const previous = semantics.get(key);
errors.push(`$[${index}].canonical_refs: semantic tuple already uses coverage ID ${safeQuote(previous.id)} at $[${previous.index}]`);
} else if (key !== null) {
semantics.set(key, { id: unit.coverage_id, index });
}
if (previousId !== null && previousId > unit.coverage_id) {
errors.push(`$[${index}].coverage_id: units must be sorted lexicographically`);
}
previousId = unit.coverage_id;
}
return errors;
}
function run(file) {
if (!file) {
console.error("Usage: node validate-coverage-ledger.cjs <path-to-coverage-ledger.json>");
return 1;
}
let contents;
try {
contents = readFileWithinLimit(file);
} catch (error) {
const reason = error instanceof SafeInputError ? error.message : "input could not be opened or read safely";
console.error(`Failed to read coverage ledger: ${reason}`);
return 1;
}
let ledger;
try {
preflightJsonText(contents);
} catch (error) {
const reason = error instanceof JsonStructureError ? error.message : "invalid JSON structure";
console.error(`Failed to parse coverage ledger: ${reason}`);
return 1;
}
try {
ledger = JSON.parse(contents);
} catch {
console.error("Failed to parse coverage ledger: invalid JSON syntax");
return 1;
}
let errors;
try {
errors = validateDocument(ledger);
} catch {
console.error("Failed to validate coverage ledger: unexpected validation error");
return 1;
}
for (const message of errors) console.error("ERROR:", message);
if (errors.length > 0) {
const cap = errors.length === LIMITS.validationErrors ? `; output capped at ${LIMITS.validationErrors}` : "";
console.error(`FAIL: ${errors.length} validation error(s)${cap}`);
return 1;
}
console.log(`PASS: ${ledger.length} coverage units valid`);
return 0;
}
module.exports = {
LIMITS,
PATH_FORBIDDEN_CHARACTER,
UNSAFE_DIAGNOSTIC_CHARACTER,
VISIBLE_CONTENT,
WINDOWS_RESERVED_COMPONENT,
canonicalCoverageId,
encodeCanonicalRef,
hasVisibleProse,
isSafeAgentId,
isSafeRelativePath,
preflightJsonText,
readFileWithinLimit,
safeQuote,
validateDocument,
};
if (require.main === module) process.exit(run(process.argv[2]));

View File

@@ -0,0 +1,740 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const test = require("node:test");
const {
LIMITS,
canonicalCoverageId,
encodeCanonicalRef,
isSafeAgentId,
isSafeRelativePath,
preflightJsonText,
validateDocument,
} = require("./validate-coverage-ledger.cjs");
const validatorPath = path.join(__dirname, "validate-coverage-ledger.cjs");
const CLI_TIMEOUT_MS = 5000;
const HOSTILE_CLI_TIMEOUT_MS = 15000;
const HAS_SAFE_INPUT_OPEN = Number.isInteger(fs.constants.O_NOFOLLOW) &&
fs.constants.O_NOFOLLOW !== 0 &&
Number.isInteger(fs.constants.O_NONBLOCK) &&
fs.constants.O_NONBLOCK !== 0;
function unit(overrides = {}) {
const canonicalRefs = overrides.canonical_refs || {
surface: "src/router.ts#POST /users/:id",
boundary: "src/authz.ts#requireOwner",
subsystem: "packages/api",
attack_class: "ATTACK-CLASSES.md#Access control",
};
const value = {
coverage_id: canonicalCoverageId(canonicalRefs),
canonical_refs: canonicalRefs,
surface: "Update-user route",
boundary: "Object ownership",
subsystem: "API",
attack_class: "Access control",
starting_paths: ["src/router.ts", "src/authz.ts"],
ordinary_attack_class_block: "ATTACK-CLASSES.md#Access control",
selected_companion_blocks: [],
excluded_blocks: [{ block: "WEB-PROTOCOL-AND-AUTH.md#Cache behavior", reason: "The route is not cached." }],
prior_status: "new",
attempts: [],
wave: 1,
status: "planned",
agent_id: null,
reviewed_paths: [],
local_checks: [],
result_fingerprints: [],
unresolved: [],
};
return Object.assign(value, overrides, { canonical_refs: canonicalRefs });
}
function errorsFor(value) {
return validateDocument(value);
}
function runCli(contents, options = {}) {
const { nodeArgs = [], timeout = CLI_TIMEOUT_MS } = options;
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-"));
const ledgerPath = path.join(directory, "coverage-ledger.json");
try {
fs.writeFileSync(ledgerPath, contents);
return spawnSync(process.execPath, [...nodeArgs, validatorPath, ledgerPath], {
encoding: "utf8",
timeout,
});
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
function cliOutput(result) {
return `${result.stdout}${result.stderr}`;
}
const TERMINAL_CONTROL_PAYLOAD = "\u001b\u0007\u0085\u202e";
const TERMINAL_CONTROL_BYTES = [
Buffer.from([0x1b]),
Buffer.from([0x07]),
Buffer.from("\u0085"),
Buffer.from("\u202e"),
];
function assertNoInjectedControlBytes(output) {
const bytes = Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8");
for (const marker of TERMINAL_CONTROL_BYTES) {
assert.equal(bytes.indexOf(marker), -1, `found raw control bytes ${marker.toString("hex")}`);
}
}
function sourceCheck(agentId = "hunter-1", overrides = {}) {
return {
agent_id: agentId,
reviewed_paths: ["src/router.ts"],
invariant: "The route checks object ownership.",
method: "source",
result: "The owner check applies before the update.",
artifact: null,
...overrides,
};
}
function localCheck(agentId = "hunter-1", overrides = {}) {
return sourceCheck(agentId, {
method: "local",
result: "The bounded fixture accepted the other owner's object.",
artifact: `agents/${agentId}/artifacts/result.txt`,
...overrides,
});
}
function archivedAttempt(overrides = {}) {
const value = {
wave: 1,
status: "blocked",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
result_fingerprints: [],
unresolved: ["The deployed policy is unavailable."],
reassignment_reason: "The critic found an unchecked parallel path.",
};
return Object.assign(value, overrides);
}
test("accepts an empty ledger and complete units", () => {
assert.deepEqual(errorsFor([]), []);
assert.deepEqual(errorsFor([unit()]), []);
const missingAttempts = unit();
delete missingAttempts.attempts;
assert(errorsFor([missingAttempts]).some((error) => error.includes('missing required field "attempts"')));
const covered = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
});
assert.deepEqual(errorsFor([covered]), []);
});
test("accepts a complete ledger through the CLI", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const result = runCli(JSON.stringify([unit()]));
assert.equal(result.status, 0, cliOutput(result));
assert.match(result.stdout, /PASS: 1 coverage units valid/);
});
test("text preflight ignores structural characters and escapes inside strings", () => {
const value = unit({
surface: "Route \\ slash [list] {object}, colon: quoted \"value\"",
excluded_blocks: [{
block: "COMPANION.md#Literal [brackets] {braces}",
reason: "The text contains a backslash \\ before an escaped \"quote\".",
}],
});
const contents = JSON.stringify([value]);
assert.doesNotThrow(() => preflightJsonText(contents));
assert.deepEqual(JSON.parse(contents), [value]);
if (HAS_SAFE_INPUT_OPEN) {
const result = runCli(contents);
assert.equal(result.status, 0, cliOutput(result));
}
});
test("text preflight enforces structural cardinality limits", () => {
const depthLimit = LIMITS.nestingDepth;
assert.doesNotThrow(() => preflightJsonText(`${"[".repeat(depthLimit)}0${"]".repeat(depthLimit)}`));
assert.throws(
() => preflightJsonText(`${"[".repeat(depthLimit + 1)}0${"]".repeat(depthLimit + 1)}`),
/exceeds nesting depth limit 64/,
);
const tooManyUnits = `[${"null,".repeat(LIMITS.units)}null]`;
assert.throws(() => preflightJsonText(tooManyUnits), /exceeds 10000 top-level unit limit/);
const tooManyItems = `[[${"null,".repeat(LIMITS.collectionItems)}null]]`;
assert.throws(() => preflightJsonText(tooManyItems), /exceeds 1000 item array limit/);
const objectFields = Array.from(
{ length: LIMITS.objectFields + 1 },
(_, index) => `"field${index}":null`,
).join(",");
assert.throws(() => preflightJsonText(`[{${objectFields}}]`), /exceeds 1000 field object limit/);
const fullArray = `[${"null,".repeat(LIMITS.collectionItems - 1)}null]`;
const arraysNeeded = Math.floor(LIMITS.preflightValues / (LIMITS.collectionItems + 1)) + 1;
const tooManyValues = `[${Array.from({ length: arraysNeeded }, () => fullArray).join(",")}]`;
assert.throws(() => preflightJsonText(tooManyValues), /exceeds 500000 total value limit/);
});
test("text preflight rejects malformed structural truncation cleanly", () => {
assert.throws(() => preflightJsonText("["), /truncated JSON structure/);
assert.throws(() => preflightJsonText("[\"unterminated"), /unterminated JSON string/);
assert.throws(() => preflightJsonText("[{\"field\":1]"), /mismatched JSON containers/);
});
test("derives collision-free canonical IDs from exact UTF-8 references", () => {
assert.equal(encodeCanonicalRef("route:POST /users"), "route%3APOST%20%2Fusers");
assert.notEqual(encodeCanonicalRef("route name"), encodeCanonicalRef("route-name"));
assert.equal(
canonicalCoverageId({ surface: "a", boundary: "b", subsystem: "c", attack_class: "d", lifecycle: "retry" }),
"a::b::c::d::retry",
);
assert.throws(() => encodeCanonicalRef("e\u0301"), /invalid canonical reference/);
assert.throws(() => encodeCanonicalRef("bad\u0000ref"), /invalid canonical reference/);
assert.throws(() => encodeCanonicalRef("hidden\u200bref"), /invalid canonical reference/);
});
test("rejects noncanonical, duplicate, and colliding IDs", () => {
const wrong = unit({ coverage_id: "display-label-slug" });
assert(errorsFor([wrong]).some((error) => error.includes("expected canonical ID")));
const duplicate = unit();
assert(errorsFor([duplicate, unit()]).some((error) => error.includes("duplicate coverage ID")));
const collision = unit();
const differentMeaning = unit({ surface: "Delete-user route" });
assert(errorsFor([collision, differentMeaning]).some((error) => error.includes("canonical identity collision")));
});
test("requires canonical references to be own properties", () => {
const inherited = Object.create(unit().canonical_refs);
const value = unit();
value.canonical_refs = inherited;
assert(errorsFor([value]).some((error) => error.includes("missing required field")));
});
test("rejects aliases for one semantic tuple", () => {
const first = unit();
const refs = { ...first.canonical_refs, surface: "src/alias.ts#updateUser" };
const alias = unit({ canonical_refs: refs });
const ledger = [first, alias].sort((left, right) => left.coverage_id.localeCompare(right.coverage_id));
assert(errorsFor(ledger).some((error) => error.includes("semantic tuple already uses coverage ID")));
});
test("requires lexicographic order", () => {
const secondRefs = {
surface: "zzz",
boundary: "src/authz.ts#requireOwner",
subsystem: "packages/api",
attack_class: "ATTACK-CLASSES.md#Access control",
};
assert(errorsFor([unit({ canonical_refs: secondRefs }), unit()])
.some((error) => error.includes("sorted lexicographically")));
});
test("validates assignment block maps", () => {
const overlap = unit({
selected_companion_blocks: ["AI-AND-LLM.md#Tool calls"],
excluded_blocks: [{ block: "AI-AND-LLM.md#Tool calls", reason: "Claimed irrelevant." }],
});
assert(errorsFor([overlap]).some((error) => error.includes("also selected")));
const noReason = unit({ excluded_blocks: [{ block: "AI-AND-LLM.md#Tool calls", reason: "" }] });
assert(errorsFor([noReason]).some((error) => error.includes("reason")));
});
test("requires owned artifacts for local checks and null artifacts for source checks", () => {
const local = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [localCheck()],
});
assert.deepEqual(errorsFor([local]), []);
const independentlyVerified = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts", "src/authz.ts"],
local_checks: [sourceCheck(), localCheck("verifier-1", { reviewed_paths: ["src/authz.ts"] })],
});
assert.deepEqual(errorsFor([independentlyVerified]), []);
const unownedPath = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts", "src/authz.ts"],
local_checks: [sourceCheck()],
});
assert(errorsFor([unownedPath]).some((error) => error.includes("has no check owner")));
for (const [checkAgentId, artifact] of [
[null, "agents/hunter-1/artifacts/result.txt"],
["hunter-1", null],
["hunter-1", "result.txt"],
["hunter-1", "agents/hunter-2/artifacts/result.txt"],
["../hunter", "agents/../hunter/artifacts/result.txt"],
]) {
const value = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [localCheck(checkAgentId, { artifact })],
});
assert.notEqual(errorsFor([value]).length, 0, `${checkAgentId}: ${artifact}`);
}
const unownedSource = unit({
status: "blocked",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
unresolved: ["The boundary behavior is not source-visible."],
});
assert(errorsFor([unownedSource]).some((error) => error.includes("unit with status \"blocked\" requires a canonical lowercase agent ID")));
const sourceWithArtifact = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck("hunter-1", { artifact: "agents/hunter-1/artifacts/source.txt" })],
});
assert(errorsFor([sourceWithArtifact]).some((error) => error.includes("source-only check must use null")));
});
test("requires canonical lowercase filesystem-safe agent IDs", () => {
for (const value of ["hunter-1", "verifier_2", "a0"]) assert.equal(isSafeAgentId(value), true, value);
for (const value of ["Hunter-1", "hunter.1", "hunter-1.", "hunter ", "con", "prn", "aux", "nul", "com1", "lpt9", "../hunter"]) {
assert.equal(isSafeAgentId(value), false, value);
}
const caseAlias = unit({ status: "in_progress", agent_id: "Hunter-1" });
assert(errorsFor([caseAlias]).some((error) => error.includes("canonical lowercase agent ID")));
});
test("enforces state evidence", () => {
assert(errorsFor([unit({ status: "in_progress" })]).some((error) => error.includes("unit with status \"in_progress\" requires")));
assert(errorsFor([unit({ status: "blocked" })]).some((error) => error.includes("unresolved")));
assert(errorsFor([unit({ status: "candidate" })]).some((error) => error.includes("reviewed_paths")));
assert.deepEqual(errorsFor([unit({ status: "in_progress", agent_id: "hunter-1" })]), []);
const inProgressEvidence = unit({
status: "in_progress",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
});
assert(errorsFor([inProgressEvidence]).some((error) => error.includes("must keep this array empty")));
const assignedPlanned = unit({
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
});
assert(errorsFor([assignedPlanned]).some((error) => error.includes("planned unit must be unassigned")));
const candidate = unit({
status: "candidate",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck("hunter-1", { invariant: "Ownership is required.", result: "No check exists." })],
result_fingerprints: ["src-router-missing-owner-check"],
unresolved: ["validation_budget_exhausted"],
});
assert.deepEqual(errorsFor([candidate]), []);
const blocked = unit({
status: "blocked",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
unresolved: ["The deployed policy is unavailable."],
});
assert.deepEqual(errorsFor([blocked]), []);
const blockedFingerprint = { ...blocked, result_fingerprints: ["forbidden-fingerprint"] };
assert(errorsFor([blockedFingerprint]).some((error) => error.includes("result_fingerprints")));
for (const status of ["not_applicable", "out_of_scope", "deferred"]) {
assert.deepEqual(errorsFor([unit({ status, unresolved: ["Reason recorded."] })]), []);
const invalid = unit({
status,
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
result_fingerprints: ["forbidden-fingerprint"],
unresolved: ["Reason recorded."],
});
const errors = errorsFor([invalid]);
assert(errors.some((error) => error.includes("must be unassigned")), status);
assert(errors.some((error) => error.includes("reviewed_paths")), status);
assert(errors.some((error) => error.includes("result_fingerprints")), status);
}
const coveredFingerprint = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
result_fingerprints: ["forbidden-fingerprint"],
});
assert(errorsFor([coveredFingerprint]).some((error) => error.includes("result_fingerprints")));
const coveredUnresolved = unit({
status: "covered",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck()],
unresolved: ["Unexpected unresolved claim."],
});
assert(errorsFor([coveredUnresolved]).some((error) => error.includes("unresolved")));
});
test("archives prior evidence when a critic assigns a fresh owner", () => {
const reassigned = unit({
attempts: [archivedAttempt()],
wave: 2,
status: "in_progress",
agent_id: "hunter-2",
});
assert.deepEqual(errorsFor([reassigned]), []);
const finalClosure = unit({
attempts: [archivedAttempt()],
wave: 2,
status: "covered",
agent_id: "hunter-2",
reviewed_paths: ["src/authz.ts"],
local_checks: [sourceCheck("hunter-2", { reviewed_paths: ["src/authz.ts"] })],
});
assert.deepEqual(errorsFor([finalClosure]), []);
});
test("preserves candidate provenance when reassignment must be deferred", () => {
const candidateAttempt = archivedAttempt({
status: "candidate",
result_fingerprints: ["src-router-missing-owner-check"],
unresolved: ["validation_budget_exhausted"],
});
const deferred = unit({
attempts: [candidateAttempt],
wave: 2,
status: "deferred",
unresolved: ["quick_profile_final_critic"],
});
assert.deepEqual(errorsFor([deferred]), []);
});
test("rejects reassignment owner reuse and evidence mixing", () => {
const reusedOwner = unit({
attempts: [archivedAttempt()],
wave: 2,
status: "in_progress",
agent_id: "hunter-1",
});
assert(errorsFor([reusedOwner]).some((error) => error.includes("current assignment owner must be fresh")));
const mixedEvidence = unit({
attempts: [archivedAttempt({ local_checks: [localCheck()] })],
wave: 2,
status: "covered",
agent_id: "hunter-2",
reviewed_paths: ["src/router.ts"],
local_checks: [localCheck()],
});
const errors = errorsFor([mixedEvidence]);
assert(errors.some((error) => error.includes("prior assignment owner evidence must remain")));
assert(errors.some((error) => error.includes("artifact from an archived attempt cannot be reused")));
const mixedHistory = unit({
attempts: [
archivedAttempt({ local_checks: [localCheck()] }),
archivedAttempt({
wave: 2,
agent_id: "hunter-2",
local_checks: [localCheck()],
}),
],
wave: 3,
status: "in_progress",
agent_id: "hunter-3",
});
const historyErrors = errorsFor([mixedHistory]);
assert(historyErrors.some((error) => error.includes("prior assignment owner evidence must remain in its earlier attempt")));
assert(historyErrors.some((error) => error.includes("artifact from an earlier attempt cannot be reused")));
const unordered = unit({
attempts: [archivedAttempt(), archivedAttempt({
wave: 1,
agent_id: "hunter-2",
local_checks: [sourceCheck("hunter-2")],
})],
wave: 3,
status: "in_progress",
agent_id: "hunter-3",
});
assert(errorsFor([unordered]).some((error) => error.includes("strictly increasing")));
});
test("rejects unsafe paths and malformed fingerprints", () => {
for (const value of [
"/etc/passwd",
"../src/file.js",
"src/../file.js",
"src/con.txt",
"src/PRN",
"src/AUX.c",
"src/NUL",
"src/CLOCK$.txt",
"src/conin$.txt",
"src/conout$",
"src/COM1.log",
"src/lpt9",
"src/COM\u00b9.log",
"src/COM\u00b2.log",
"src/COM\u00b3.log",
"src/lpt\u00b9",
"src/lpt\u00b2",
"src/lpt\u00b3",
"src/file.js.",
"C:/src/file.js",
"src/file\n.js",
"src/file\u0085.js",
"src/file\u2028.js",
"src/file\u200b.js",
"src/file\u034f.js",
"src/file\ufe0f.js",
]) {
assert.equal(isSafeRelativePath(value), false, value);
}
assert.equal(isSafeRelativePath("src/handler.js"), true);
assert.equal(isSafeRelativePath("src/caf\u00e9/handler.js"), true);
assert(errorsFor([unit({ starting_paths: ["../src/router.ts"] })]).some((error) => error.includes("repository-relative path")));
assert(errorsFor([unit({
status: "candidate",
agent_id: "hunter-1",
reviewed_paths: ["src/router.ts"],
local_checks: [sourceCheck("hunter-1", { invariant: "Ownership is required.", result: "No check exists." })],
result_fingerprints: ["not stable"],
})]).some((error) => error.includes("invalid fingerprint")));
});
test("rejects format, default-ignorable, and invalid-scalar prose", () => {
for (const invisible of ["\u200b", "\u034f", "\ufe0f", "\ud800"]) {
assert(errorsFor([unit({ surface: invisible })]).some((error) => error.includes("surface")), JSON.stringify(invisible));
assert(errorsFor([unit({
excluded_blocks: [{ block: "ATTACK-CLASSES.md#Access control", reason: invisible }],
})]).some((error) => error.includes("reason")), JSON.stringify(invisible));
}
});
test("quotes input-derived controls in direct validation errors", () => {
const invalidStatus = `invalid-${TERMINAL_CONTROL_PAYLOAD}`;
const invalidPath = `src/${TERMINAL_CONTROL_PAYLOAD}.js`;
const value = unit({
status: invalidStatus,
agent_id: "hunter-1",
reviewed_paths: [invalidPath],
local_checks: [sourceCheck()],
result_fingerprints: ["force-state-error"],
});
const output = errorsFor([value]).join("\n");
assert.match(output, /\$\[0\]\.status/);
assert.match(output, /\$\[0\]\.reviewed_paths/);
assert.match(output, /\\u001b/);
assert.match(output, /\\u0007/);
assert.match(output, /\\u0085/);
assert.match(output, /\\u202e/);
assertNoInjectedControlBytes(output);
});
test("quotes input-derived controls in CLI validation errors", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const value = unit({
status: `invalid-${TERMINAL_CONTROL_PAYLOAD}`,
result_fingerprints: ["force-state-error"],
});
const result = runCli(JSON.stringify([value]));
assert.equal(result.status, 1, cliOutput(result));
assert.match(result.stderr, /\$\[0\]\.status/);
assert.match(result.stderr, /\\u001b/);
assertNoInjectedControlBytes(result.stderr);
});
test("returns a generic syntax error without parser-supplied controls", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const malformed = Buffer.concat([
Buffer.from("["),
Buffer.from(TERMINAL_CONTROL_PAYLOAD),
Buffer.from("]"),
]);
const result = runCli(malformed);
assert.equal(result.status, 1, cliOutput(result));
assert.equal(result.stderr, "Failed to parse coverage ledger: invalid JSON syntax\n");
assertNoInjectedControlBytes(result.stderr);
});
test("does not reflect controls from a failed CLI input path", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-path-"));
const missingPath = path.join(directory, `missing-${TERMINAL_CONTROL_PAYLOAD}.json`);
try {
const result = spawnSync(process.execPath, [validatorPath, missingPath], {
encoding: "utf8",
timeout: CLI_TIMEOUT_MS,
});
assert.equal(result.status, 1, cliOutput(result));
assert.match(result.stderr, /Failed to read coverage ledger:/);
assertNoInjectedControlBytes(result.stderr);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("rejects invalid UTF-8 through the CLI", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const encoded = Buffer.from(JSON.stringify([unit()]));
const marker = Buffer.from("Update-user route");
const markerOffset = encoded.indexOf(marker);
assert.notEqual(markerOffset, -1);
const malformed = Buffer.concat([
encoded.subarray(0, markerOffset),
Buffer.from([0x80]),
encoded.subarray(markerOffset + marker.length),
]);
const result = runCli(malformed);
const output = cliOutput(result);
assert.equal(result.status, 1, output);
assert.match(output, /input is not valid UTF-8/);
assert.doesNotMatch(output, /TypeError|stack|at validate-coverage-ledger/i);
});
test("rejects a FIFO through the CLI without blocking", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-fifo-"));
const fifoPath = path.join(directory, "coverage-ledger.json");
try {
const created = spawnSync("mkfifo", [fifoPath], { encoding: "utf8", timeout: CLI_TIMEOUT_MS });
assert.equal(created.status, 0, cliOutput(created));
const result = spawnSync(process.execPath, [validatorPath, fifoPath], {
encoding: "utf8",
timeout: CLI_TIMEOUT_MS,
});
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /input must be a regular file/);
assert.doesNotMatch(output, /stack|at validate-coverage-ledger/i);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("rejects a symlink through the CLI", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-symlink-"));
const targetPath = path.join(directory, "target.json");
const symlinkPath = path.join(directory, "coverage-ledger.json");
try {
fs.writeFileSync(targetPath, JSON.stringify([unit()]));
fs.symlinkSync(targetPath, symlinkPath);
const result = spawnSync(process.execPath, [validatorPath, symlinkPath], {
encoding: "utf8",
timeout: CLI_TIMEOUT_MS,
});
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /input must not be a symlink/);
assert.doesNotMatch(output, /stack|at validate-coverage-ledger/i);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("rejects deeply nested input without recursion failure", () => {
let nested = 0;
for (let depth = 0; depth < 20000; depth++) nested = [nested];
assert(errorsFor(nested).some((error) => error.includes("exceeds nesting depth limit 64")));
if (!HAS_SAFE_INPUT_OPEN) return;
const result = runCli(`${"[".repeat(20000)}0${"]".repeat(20000)}`);
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /exceeds nesting depth limit 64/);
assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-coverage-ledger/i);
});
test("rejects multi-megabyte nesting under a constrained Node heap", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const openContainers = "[".repeat(2000000);
const cases = [
openContainers,
`${openContainers}0${"]".repeat(2000000)}`,
];
for (const contents of cases) {
const result = runCli(contents, {
nodeArgs: ["--max-old-space-size=64"],
timeout: HOSTILE_CLI_TIMEOUT_MS,
});
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /exceeds nesting depth limit 64/);
assert.doesNotMatch(output, /heap out of memory|allocation failed|RangeError|Maximum call stack|stack|at validate-coverage-ledger/i);
}
});
test("caps malformed 10000-unit validation output", () => {
assert.equal(errorsFor(Array.from({ length: LIMITS.units }, () => null)).length, LIMITS.validationErrors);
if (!HAS_SAFE_INPUT_OPEN) return;
const result = runCli(JSON.stringify(Array.from({ length: LIMITS.units }, () => null)));
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /output capped at 100/);
assert(output.length < 20000, `unexpected output length ${output.length}`);
assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-coverage-ledger/i);
});
test("rejects malformed top-level data and excessive unit counts", () => {
assert.deepEqual(errorsFor({ units: [] }), ["$: expected a top-level array"]);
const tooMany = Array.from({ length: 10001 }, () => null);
const errors = errorsFor(tooMany);
assert.deepEqual(errors, ["$: exceeds 10000 coverage units"]);
const oversizedCollection = unit({ extra: Array.from({ length: LIMITS.collectionItems + 1 }, () => null) });
assert(errorsFor([oversizedCollection]).some((error) => error.includes("exceeds 1000 entries")));
});
test("accepts a canonical ID derived from near-maximum multibyte references", () => {
const canonicalRefs = {
surface: "\u6f22".repeat(1024),
boundary: "\u00e9".repeat(1024),
subsystem: "packages/api",
attack_class: "\u6f22".repeat(1023) + "\u00e9",
};
const value = unit({ canonical_refs: canonicalRefs });
assert(value.coverage_id.length > 16384, `coverage_id length ${value.coverage_id.length}`);
assert(value.coverage_id.length <= 65536, `coverage_id length ${value.coverage_id.length}`);
assert.deepEqual(errorsFor([value]), []);
if (HAS_SAFE_INPUT_OPEN) {
const result = runCli(JSON.stringify([value]));
assert.equal(result.status, 0, cliOutput(result));
}
});

View File

@@ -0,0 +1,773 @@
#!/usr/bin/env node
/**
* Validates findings.json against report-schema.json.
* Usage: node validate-findings.cjs <path-to-findings.json>
*
* This is a dependency-free interpreter for the JSON Schema keywords used by
* report-schema.json, plus finding-specific checks that are clearer in code.
*/
const fs = require("fs");
const path = require("path");
const { TextDecoder } = require("util");
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const SUPPORTED_TYPES = new Set(["object", "array", "string", "integer", "number", "boolean", "null"]);
const SUPPORTED_KEYWORDS = new Set([
"$comment",
"additionalProperties",
"const",
"description",
"enum",
"items",
"minimum",
"minItems",
"minLength",
"oneOf",
"pattern",
"properties",
"required",
"type",
"uniqueItems",
"visibleContent",
]);
const SEVERITY_RANK = new Map([
["informational", 0],
["low", 1],
["medium", 2],
["high", 3],
["critical", 4],
]);
const LIMITS = Object.freeze({
inputBytes: 5 * 1024 * 1024,
nestingDepth: 64,
arrayItems: 1000,
canonicalKeyBytes: 1024 * 1024,
uniqueSetBytes: 5 * 1024 * 1024,
validationErrors: 100,
});
const VISIBLE_CONTENT = /[^\p{White_Space}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
const PATH_FORBIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
const WINDOWS_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/iu;
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
const UNSAFE_DIAGNOSTIC_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
const MAX_DIAGNOSTIC_STRING_LENGTH = 256;
class JsonStructureError extends Error {}
class SafeInputError extends Error {}
function escapeUnsafeDiagnosticCharacters(value) {
return String(value).replace(UNSAFE_DIAGNOSTIC_CHARACTER, (character) => {
const codePoint = character.codePointAt(0);
return codePoint <= 0xffff
? `\\u${codePoint.toString(16).padStart(4, "0")}`
: `\\u{${codePoint.toString(16)}}`;
});
}
function safeQuote(value) {
let serialized;
if (typeof value === "string") {
const clipped = value.length > MAX_DIAGNOSTIC_STRING_LENGTH
? `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...`
: value;
serialized = JSON.stringify(clipped);
} else if (value === null || typeof value === "boolean") {
serialized = String(value);
} else if (typeof value === "number" && Number.isFinite(value)) {
serialized = String(value);
} else {
serialized = `"<${Array.isArray(value) ? "array" : typeof value}>"`;
}
return escapeUnsafeDiagnosticCharacters(serialized);
}
function propertyPath(base, key) {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
? `${base}.${key}`
: `${base}[${safeQuote(key)}]`;
}
function createErrorList() {
const errors = [];
Object.defineProperty(errors, "push", {
value(...messages) {
const remaining = LIMITS.validationErrors - this.length;
if (remaining > 0) {
Array.prototype.push.apply(this, messages.slice(0, remaining).map(escapeUnsafeDiagnosticCharacters));
}
return this.length;
},
});
return errors;
}
function typeOf(value) {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
function deepEqual(left, right) {
if (left === right) return true;
if (typeOf(left) !== typeOf(right)) return false;
if (Array.isArray(left)) {
return left.length === right.length && left.every((value, index) => deepEqual(value, right[index]));
}
if (left !== null && typeof left === "object") {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return leftKeys.length === rightKeys.length &&
leftKeys.every((key) => hasOwn(right, key) && deepEqual(left[key], right[key]));
}
return false;
}
function codePointLength(value) {
let length = 0;
let index = 0;
while (index < value.length) {
const first = value.charCodeAt(index++);
if (first >= 0xd800 && first <= 0xdbff && index < value.length) {
const second = value.charCodeAt(index);
if (second >= 0xdc00 && second <= 0xdfff) index++;
}
length++;
}
return length;
}
function hasValidUnicodeScalarValues(value) {
let index = 0;
while (index < value.length) {
const first = value.charCodeAt(index++);
if (first >= 0xd800 && first <= 0xdbff) {
if (index >= value.length) return false;
const second = value.charCodeAt(index++);
if (second < 0xdc00 || second > 0xdfff) return false;
} else if (first >= 0xdc00 && first <= 0xdfff) {
return false;
}
}
return true;
}
function hasVisibleProse(value) {
return hasValidUnicodeScalarValues(value) && VISIBLE_CONTENT.test(value);
}
function canonicalKey(value) {
const chunks = [];
let bytes = 0;
function append(chunk) {
bytes += Buffer.byteLength(chunk);
if (bytes > LIMITS.canonicalKeyBytes) {
throw new Error(`canonical key exceeds ${LIMITS.canonicalKeyBytes} byte limit`);
}
chunks.push(chunk);
}
function encode(item) {
const type = typeOf(item);
if (type === "null") {
append("null");
} else if (type === "string") {
append(`string:${JSON.stringify(item)}`);
} else if (type === "number") {
append(`number:${Object.is(item, -0) ? "0" : String(item)}`);
} else if (type === "boolean") {
append(`boolean:${item ? "true" : "false"}`);
} else if (type === "array") {
append("array:[");
item.forEach((entry, index) => {
if (index > 0) append(",");
encode(entry);
});
append("]");
} else if (type === "object") {
append("object:{");
Object.keys(item).sort().forEach((key, index) => {
if (index > 0) append(",");
append(JSON.stringify(key));
append(":");
encode(item[key]);
});
append("}");
} else {
append(`${type}:${String(item)}`);
}
}
encode(value);
return { key: chunks.join(""), bytes };
}
function collectDataLimitErrors(value, location = "$data") {
const stack = [{ value, location, depth: value !== null && typeof value === "object" ? 1 : 0 }];
const seen = new WeakSet();
while (stack.length > 0) {
const current = stack.pop();
if (current.value === null || typeof current.value !== "object") continue;
if (current.depth > LIMITS.nestingDepth) {
return [escapeUnsafeDiagnosticCharacters(`${current.location}: exceeds ${LIMITS.nestingDepth} level nesting depth limit`)];
}
if (seen.has(current.value)) {
return [escapeUnsafeDiagnosticCharacters(`${current.location}: input must not contain repeated or cyclic object references`)];
}
seen.add(current.value);
if (Array.isArray(current.value)) {
if (current.value.length > LIMITS.arrayItems) {
return [escapeUnsafeDiagnosticCharacters(`${current.location}: exceeds ${LIMITS.arrayItems} item array limit`)];
}
for (let index = current.value.length - 1; index >= 0; index--) {
const child = current.value[index];
if (child !== null && typeof child === "object") {
stack.push({ value: child, location: `${current.location}[${index}]`, depth: current.depth + 1 });
}
}
} else {
const keys = Object.keys(current.value);
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index];
const child = current.value[key];
if (child !== null && typeof child === "object") {
stack.push({ value: child, location: propertyPath(current.location, key), depth: current.depth + 1 });
}
}
}
}
return [];
}
function collectSchemaErrors(schema, location = "schema") {
const errors = createErrorList();
function check(node, p) {
if (node === null || typeof node !== "object" || Array.isArray(node)) {
errors.push(`${p}: schema must be an object`);
return;
}
for (const key of Object.keys(node)) {
if (!SUPPORTED_KEYWORDS.has(key)) errors.push(`${p}: unsupported schema keyword ${safeQuote(key)}`);
}
if (hasOwn(node, "$comment") && typeof node.$comment !== "string") {
errors.push(`${p}.$comment: expected string`);
}
if (hasOwn(node, "description") && typeof node.description !== "string") {
errors.push(`${p}.description: expected string`);
}
if (hasOwn(node, "type") && (!SUPPORTED_TYPES.has(node.type))) {
errors.push(`${p}.type: unsupported type ${safeQuote(node.type)}`);
}
if (hasOwn(node, "properties")) {
if (node.properties === null || typeof node.properties !== "object" || Array.isArray(node.properties)) {
errors.push(`${p}.properties: expected object`);
} else {
for (const key of Object.keys(node.properties)) check(node.properties[key], propertyPath(`${p}.properties`, key));
}
}
if (hasOwn(node, "required")) {
if (!Array.isArray(node.required) || node.required.some((key) => typeof key !== "string")) {
errors.push(`${p}.required: expected an array of strings`);
} else if (new Set(node.required).size !== node.required.length) {
errors.push(`${p}.required: entries must be unique`);
}
}
if (hasOwn(node, "additionalProperties") && typeof node.additionalProperties !== "boolean") {
errors.push(`${p}.additionalProperties: only boolean values are supported`);
}
if (hasOwn(node, "enum")) {
if (!Array.isArray(node.enum) || node.enum.length === 0) {
errors.push(`${p}.enum: expected a non-empty array`);
} else {
const seen = new Set();
for (const value of node.enum) {
let key;
try {
key = canonicalKey(value).key;
} catch (error) {
errors.push(`${p}.enum: ${error.message}`);
break;
}
if (seen.has(key)) {
errors.push(`${p}.enum: entries must be unique`);
break;
}
seen.add(key);
}
}
}
if (hasOwn(node, "items")) check(node.items, `${p}.items`);
for (const keyword of ["minItems", "minLength"]) {
if (hasOwn(node, keyword) && (!Number.isInteger(node[keyword]) || node[keyword] < 0)) {
errors.push(`${p}.${keyword}: expected a non-negative integer`);
}
}
if (hasOwn(node, "minimum") && (typeof node.minimum !== "number" || !Number.isFinite(node.minimum))) {
errors.push(`${p}.minimum: expected a finite number`);
}
if (hasOwn(node, "pattern")) {
if (typeof node.pattern !== "string") {
errors.push(`${p}.pattern: expected string`);
} else {
try {
new RegExp(node.pattern);
} catch (error) {
errors.push(`${p}.pattern: invalid regular expression`);
}
}
}
if (hasOwn(node, "uniqueItems") && typeof node.uniqueItems !== "boolean") {
errors.push(`${p}.uniqueItems: expected boolean`);
}
if (hasOwn(node, "visibleContent")) {
if (typeof node.visibleContent !== "boolean") {
errors.push(`${p}.visibleContent: expected boolean`);
} else if (node.visibleContent === true && node.type !== "string") {
errors.push(`${p}.visibleContent: requires type "string"`);
}
}
if (hasOwn(node, "oneOf")) {
if (!Array.isArray(node.oneOf) || node.oneOf.length === 0) {
errors.push(`${p}.oneOf: expected a non-empty array`);
} else {
node.oneOf.forEach((branch, index) => check(branch, `${p}.oneOf[${index}]`));
}
}
}
check(schema, location);
return errors;
}
function findDiscriminator(schema) {
if (!hasOwn(schema, "properties") || typeof schema.properties !== "object") return null;
for (const key of Object.keys(schema.properties)) {
const subSchema = schema.properties[key];
if (subSchema && typeof subSchema === "object" && hasOwn(subSchema, "const")) {
return { key, value: subSchema.const };
}
}
return null;
}
function validate(value, schema, p, errors) {
if (errors.length >= LIMITS.validationErrors) return;
if (hasOwn(schema, "oneOf")) {
const results = schema.oneOf.map((branch) => collectUnchecked(value, branch, p));
const passingIndexes = results
.map((branchErrors, index) => branchErrors.length === 0 ? index : -1)
.filter((index) => index !== -1);
if (passingIndexes.length !== 1) {
errors.push(`${p}: must match exactly one schema in oneOf; matched ${passingIndexes.length}`);
if (passingIndexes.length === 0 && value !== null && typeof value === "object" && !Array.isArray(value)) {
const matchingDiscriminators = schema.oneOf
.map((branch, index) => ({ discriminator: findDiscriminator(branch), index }))
.filter(({ discriminator }) => discriminator && hasOwn(value, discriminator.key) && deepEqual(value[discriminator.key], discriminator.value));
if (matchingDiscriminators.length === 1) {
errors.push(...results[matchingDiscriminators[0].index]);
}
}
}
}
if (hasOwn(schema, "const") && !deepEqual(value, schema.const)) {
errors.push(`${p}: must equal ${safeQuote(schema.const)}, got ${safeQuote(value)}`);
}
if (hasOwn(schema, "enum") && !schema.enum.some((allowed) => deepEqual(value, allowed))) {
const allowed = schema.enum.map(safeQuote).join(", ");
errors.push(`${p}: invalid value ${safeQuote(value)} (expected one of ${allowed})`);
}
if (hasOwn(schema, "type") && typeOf(value) !== schema.type && !(schema.type === "integer" && typeOf(value) === "number" && Number.isInteger(value))) {
errors.push(`${p}: expected ${schema.type}, got ${typeOf(value)}`);
return;
}
if (typeOf(value) === "object") {
for (const req of hasOwn(schema, "required") ? schema.required : []) {
if (!hasOwn(value, req)) errors.push(`${p}: missing required field ${safeQuote(req)}`);
}
for (const key of Object.keys(value)) {
if (hasOwn(schema, "properties") && hasOwn(schema.properties, key)) {
validate(value[key], schema.properties[key], propertyPath(p, key), errors);
} else if (hasOwn(schema, "additionalProperties") && schema.additionalProperties === false) {
errors.push(`${p}: unexpected field ${safeQuote(key)}`);
}
}
}
if (Array.isArray(value)) {
if (hasOwn(schema, "minItems") && value.length < schema.minItems) {
errors.push(`${p}: must have at least ${schema.minItems} item(s), got ${value.length}`);
}
if (hasOwn(schema, "uniqueItems") && schema.uniqueItems === true) {
const seen = new Set();
let setBytes = 0;
for (let i = 0; i < value.length; i++) {
let canonical;
try {
canonical = canonicalKey(value[i]);
} catch (error) {
errors.push(`${p}[${i}]: ${error.message}`);
break;
}
if (seen.has(canonical.key)) {
errors.push(`${p}: items must be unique; duplicate at index ${i}`);
continue;
}
setBytes += canonical.bytes;
if (setBytes > LIMITS.uniqueSetBytes) {
errors.push(`${p}: canonical uniqueness set exceeds ${LIMITS.uniqueSetBytes} byte limit`);
break;
}
seen.add(canonical.key);
}
}
if (hasOwn(schema, "items")) {
value.forEach((item, index) => validate(item, schema.items, `${p}[${index}]`, errors));
}
}
if (typeof value === "string") {
if (hasOwn(schema, "minLength") && codePointLength(value) < schema.minLength) {
errors.push(`${p}: must have at least ${schema.minLength} character(s)`);
}
if (schema.visibleContent === true) {
if (!hasValidUnicodeScalarValues(value)) {
errors.push(`${p}: must contain only valid Unicode scalar values`);
} else if (!VISIBLE_CONTENT.test(value)) {
errors.push(`${p}: must contain a visible character`);
}
}
if (hasOwn(schema, "pattern") && !(new RegExp(schema.pattern).test(value))) {
errors.push(`${p}: must match pattern ${JSON.stringify(schema.pattern)}`);
}
}
if (typeof value === "number" && hasOwn(schema, "minimum") && value < schema.minimum) {
errors.push(`${p}: must be at least ${schema.minimum}, got ${value}`);
}
}
function collectUnchecked(value, schema, p) {
const errors = createErrorList();
validate(value, schema, p, errors);
return errors;
}
function collect(value, schema, p = "$data") {
const limitErrors = collectDataLimitErrors(value, p);
if (limitErrors.length > 0) return limitErrors;
return collectUnchecked(value, schema, p);
}
function isSafeRelativeSourcePath(value) {
if (typeof value !== "string" || value.length === 0 || !hasValidUnicodeScalarValues(value) || value.trim() !== value || PATH_FORBIDDEN_CHARACTER.test(value) || value.includes("\\") || value.includes(":")) return false;
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("~")) return false;
const segments = value.split("/");
return segments.every((segment) =>
segment !== "" &&
segment !== "." &&
segment !== ".." &&
!/[ .]$/u.test(segment) &&
!WINDOWS_RESERVED_COMPONENT.test(segment));
}
function collectFindingSemanticErrors(findings) {
const errors = createErrorList();
if (!Array.isArray(findings)) return errors;
const fingerprints = new Map();
let previousFingerprint = null;
findings.forEach((finding, index) => {
if (errors.length >= LIMITS.validationErrors) return;
if (!finding || typeof finding !== "object" || Array.isArray(finding)) return;
const base = `$[${index}]`;
if (hasOwn(finding, "fingerprint") && typeof finding.fingerprint === "string") {
if (fingerprints.has(finding.fingerprint)) {
errors.push(`${base}.fingerprint: duplicate of $[${fingerprints.get(finding.fingerprint)}].fingerprint`);
} else {
fingerprints.set(finding.fingerprint, index);
}
if (previousFingerprint !== null && previousFingerprint > finding.fingerprint) {
errors.push(`${base}.fingerprint: findings must be sorted lexicographically`);
}
previousFingerprint = finding.fingerprint;
}
for (const field of ["trace", "evidence"]) {
if (!hasOwn(finding, field) || !Array.isArray(finding[field])) continue;
finding[field].forEach((entry, entryIndex) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
if (hasOwn(entry, "line") && (!Number.isInteger(entry.line) || entry.line < 1)) {
errors.push(`${base}.${field}[${entryIndex}].line: must be a positive integer`);
}
if (hasOwn(entry, "file") && !isSafeRelativeSourcePath(entry.file)) {
errors.push(`${base}.${field}[${entryIndex}].file: must be a safe repository-relative source path`);
}
});
}
if (finding.remediation && Array.isArray(finding.remediation.code_changes)) {
finding.remediation.code_changes.forEach((change, changeIndex) => {
if (change && hasOwn(change, "file_name") && !isSafeRelativeSourcePath(change.file_name)) {
errors.push(`${base}.remediation.code_changes[${changeIndex}].file_name: must be a safe repository-relative source path`);
}
});
}
if (Array.isArray(finding.trace) && finding.trace.length === 1) {
const kind = finding.trace[0] && finding.trace[0].kind;
if (kind !== "entrypoint" && kind !== "sink") {
errors.push(`${base}.trace[0].kind: a one-line trace must be "entrypoint" or "sink"`);
}
} else if (Array.isArray(finding.trace) && finding.trace.length > 1) {
const last = finding.trace.length - 1;
if (finding.trace[0] && finding.trace[0].kind !== "entrypoint") {
errors.push(`${base}.trace[0].kind: must be "entrypoint", got ${safeQuote(finding.trace[0].kind)}`);
}
if (finding.trace[last] && finding.trace[last].kind !== "sink") {
errors.push(`${base}.trace[${last}].kind: must be "sink", got ${safeQuote(finding.trace[last].kind)}`);
}
for (let traceIndex = 1; traceIndex < last; traceIndex++) {
if (finding.trace[traceIndex] && finding.trace[traceIndex].kind !== "propagation") {
errors.push(`${base}.trace[${traceIndex}].kind: must be "propagation", got ${safeQuote(finding.trace[traceIndex].kind)}`);
}
}
}
const verdict = finding.verdict;
if (verdict === "confirmed") {
for (const forbidden of ["claimed_root_cause", "blockers", "validation_plan", "reason"]) {
if (hasOwn(finding, forbidden)) errors.push(`${base}: confirmed finding must not contain ${safeQuote(forbidden)}`);
}
if (!finding.execution || typeof finding.execution !== "object" || typeof finding.execution.observed_result !== "string" || !hasVisibleProse(finding.execution.observed_result)) {
errors.push(`${base}: confirmed finding requires a visible execution observed_result`);
}
if (!finding.remediation || typeof finding.remediation !== "object" || typeof finding.remediation.strategy !== "string" || !hasVisibleProse(finding.remediation.strategy)) {
errors.push(`${base}: confirmed finding requires visible remediation`);
}
const overall = finding.severity && finding.severity.overall_severity;
const impact = finding.severity && finding.severity.impact && finding.severity.impact.score;
if (SEVERITY_RANK.has(overall) && SEVERITY_RANK.has(impact) && SEVERITY_RANK.get(overall) > SEVERITY_RANK.get(impact)) {
errors.push(`${base}.severity.overall_severity: cannot exceed demonstrated impact ${safeQuote(impact)}`);
}
} else if (verdict === "needs_validation") {
if (hasOwn(finding, "severity")) errors.push(`${base}: needs_validation finding must not contain "severity"`);
for (const forbidden of ["execution", "remediation", "reason", "root_cause"]) {
if (hasOwn(finding, forbidden)) errors.push(`${base}: needs_validation finding must not contain ${safeQuote(forbidden)}`);
}
const plan = finding.validation_plan;
const hasLocalPlan = plan && typeof plan.local === "string" && hasVisibleProse(plan.local);
const hasDeploymentPlan = plan && typeof plan.deployment === "string" && hasVisibleProse(plan.deployment);
if (!hasLocalPlan && !hasDeploymentPlan) {
errors.push(`${base}.validation_plan: requires at least one visible local or deployment plan`);
}
} else if (verdict === "rejected") {
for (const forbidden of ["severity", "execution", "remediation", "blockers", "validation_plan", "root_cause"]) {
if (hasOwn(finding, forbidden)) errors.push(`${base}: rejected finding must not contain ${safeQuote(forbidden)}`);
}
}
});
return errors;
}
function validateDocument(findings, schema) {
const schemaErrors = collectSchemaErrors(schema);
if (schemaErrors.length > 0) return schemaErrors;
const limitErrors = collectDataLimitErrors(findings, "$");
if (limitErrors.length > 0) return limitErrors;
const errors = collectUnchecked(findings, schema, "$");
if (errors.length < LIMITS.validationErrors) {
errors.push(...collectFindingSemanticErrors(findings));
}
return errors;
}
function loadSchema(schemaPath) {
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
const errors = collectSchemaErrors(schema);
if (errors.length > 0) throw new Error(`unsupported or invalid report schema:\n${errors.join("\n")}`);
return schema;
}
function readFileWithinLimit(file) {
const noFollow = fs.constants.O_NOFOLLOW;
const nonBlock = fs.constants.O_NONBLOCK;
if (!Number.isInteger(noFollow) || noFollow === 0 || !Number.isInteger(nonBlock) || nonBlock === 0) {
throw new SafeInputError("OS no-follow and nonblocking input protection is unavailable");
}
let descriptor;
try {
descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock);
} catch (error) {
if (error && (error.code === "ELOOP" || error.code === "EMLINK")) {
throw new SafeInputError("input must not be a symlink");
}
throw error;
}
try {
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
throw new SafeInputError("input must be a regular file");
}
if (stat.size > LIMITS.inputBytes) {
throw new SafeInputError(`input exceeds ${LIMITS.inputBytes} byte limit`);
}
const chunks = [];
const buffer = Buffer.allocUnsafe(64 * 1024);
let bytesRead = 0;
while (true) {
const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
if (count === 0) break;
bytesRead += count;
if (bytesRead > LIMITS.inputBytes) {
throw new SafeInputError(`input exceeds ${LIMITS.inputBytes} byte limit`);
}
chunks.push(Buffer.from(buffer.subarray(0, count)));
}
try {
return UTF8_DECODER.decode(Buffer.concat(chunks, bytesRead));
} catch {
throw new SafeInputError("input is not valid UTF-8");
}
} finally {
fs.closeSync(descriptor);
}
}
function enforceJsonTextLimits(contents) {
const containers = [];
let inString = false;
let escaped = false;
function markArrayItem() {
const container = containers[containers.length - 1];
if (!container || container.type !== "array" || !container.expectsItem) return;
container.expectsItem = false;
container.items++;
if (container.items > LIMITS.arrayItems) {
throw new JsonStructureError(`input exceeds ${LIMITS.arrayItems} item array limit`);
}
}
for (let index = 0; index < contents.length; index++) {
const character = contents[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === "\"") {
inString = false;
}
continue;
}
if (character === "\"") {
markArrayItem();
inString = true;
} else if (character === "[" || character === "{") {
markArrayItem();
if (containers.length >= LIMITS.nestingDepth) {
throw new JsonStructureError(`input exceeds ${LIMITS.nestingDepth} level nesting depth limit`);
}
containers.push({
type: character === "[" ? "array" : "object",
expectsItem: character === "[",
items: 0,
});
} else if (character === "]" || character === "}") {
containers.pop();
} else if (character === ",") {
const container = containers[containers.length - 1];
if (container && container.type === "array") container.expectsItem = true;
} else if (!/\s/.test(character)) {
markArrayItem();
}
}
}
function run(file) {
if (!file) {
console.error("Usage: node validate-findings.cjs <path-to-findings.json>");
return 1;
}
let schema;
try {
schema = loadSchema(path.join(__dirname, "report-schema.json"));
} catch (error) {
console.error("Failed to load report-schema.json:", error.message);
return 1;
}
let contents;
try {
contents = readFileWithinLimit(file);
} catch (error) {
const reason = error instanceof SafeInputError ? error.message : "input could not be opened or read safely";
console.error(`Failed to read findings JSON: ${reason}`);
return 1;
}
try {
enforceJsonTextLimits(contents);
} catch (error) {
const reason = error instanceof JsonStructureError ? error.message : "invalid JSON structure";
console.error(`Failed to parse findings JSON: ${reason}`);
return 1;
}
let findings;
try {
findings = JSON.parse(contents);
} catch {
console.error("Failed to parse findings JSON: invalid JSON syntax");
return 1;
}
let errors;
try {
errors = validateDocument(findings, schema);
} catch {
console.error("Failed to validate findings JSON: unexpected validation error");
return 1;
}
for (const message of errors) console.error("ERROR:", escapeUnsafeDiagnosticCharacters(message));
if (errors.length > 0) {
const cap = errors.length === LIMITS.validationErrors ? `; output capped at ${LIMITS.validationErrors}` : "";
console.error(`FAIL: ${errors.length} validation error(s)${cap}`);
return 1;
}
console.log(`PASS: ${findings.length} findings valid`);
return 0;
}
module.exports = {
LIMITS,
PATH_FORBIDDEN_CHARACTER,
UNSAFE_DIAGNOSTIC_CHARACTER,
VISIBLE_CONTENT,
WINDOWS_RESERVED_COMPONENT,
collect,
collectFindingSemanticErrors,
collectSchemaErrors,
hasVisibleProse,
isSafeRelativeSourcePath,
validateDocument,
};
if (require.main === module) process.exit(run(process.argv[2]));

View File

@@ -0,0 +1,652 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const test = require("node:test");
const schema = require("./report-schema.json");
const {
LIMITS,
collect,
collectSchemaErrors,
validateDocument,
} = require("./validate-findings.cjs");
const validatorPath = path.join(__dirname, "validate-findings.cjs");
const CLI_TIMEOUT_MS = 5000;
const HOSTILE_CLI_TIMEOUT_MS = 15000;
const HAS_SAFE_INPUT_OPEN = Number.isInteger(fs.constants.O_NOFOLLOW) &&
fs.constants.O_NOFOLLOW !== 0 &&
Number.isInteger(fs.constants.O_NONBLOCK) &&
fs.constants.O_NONBLOCK !== 0;
const TERMINAL_CONTROL_PAYLOAD = "\u001b\u0007\u0085\u202e\u034f\ufe0f";
const TERMINAL_CONTROL_BYTES = [
Buffer.from([0x1b]),
Buffer.from([0x07]),
Buffer.from("\u0085"),
Buffer.from("\u202e"),
Buffer.from("\u034f"),
Buffer.from("\ufe0f"),
];
function source(kind = "entrypoint", file = "src/handler.c", line = 10) {
return { kind, file, line, scope: "handle", description: "Attacker data reaches the operation." };
}
function evidence(file = "src/handler.c", line = 10) {
return { file, line, description: "The source performs the operation without the required check." };
}
function confirmed() {
return {
verdict: "confirmed",
fingerprint: "src-handler-missing-check",
title: "Missing ownership check",
description: "An attacker can reach an operation without the intended ownership check.",
root_cause: "handle omits the ownership check before changing the object.",
intended_behavior: "Only the object's owner can change it.",
trace: [source("entrypoint"), source("propagation", "src/model.c", 20), source("sink", "src/store.c", 30)],
evidence: [evidence()],
conditions: [],
execution: {
attacker_perspective: "An unprivileged remote user with their own account.",
payloads: ["An object identifier owned by another user."],
instructions: ["Submit the identifier through the public operation."],
observed_result: "The other user's object changes.",
},
remediation: { strategy: "Check ownership before the state change." },
severity: {
likelihood: { score: "medium", reason: "The operation is directly reachable." },
impact: { score: "medium", reason: "The attacker changes one protected object." },
overall_severity: "medium",
},
confidence: { score: "high", reason: "The source path and result were reproduced." },
};
}
function needsValidation() {
return {
verdict: "needs_validation",
fingerprint: "src-parser-size-hypothesis",
title: "Unchecked parsed size",
description: "A parsed size may reach an allocation without a limit.",
claimed_root_cause: "parse_size may pass an unbounded value to allocate.",
trace: [source()],
evidence: [evidence()],
blockers: ["The generated parser source is absent from this checkout."],
validation_plan: {
local: "Generate the parser and submit the smallest input that exceeds the documented limit.",
deployment: "In an approved test deployment, confirm the request reaches the generated parser and record the bounded observable result.",
},
};
}
function rejected() {
return {
verdict: "rejected",
fingerprint: "src-router-auth-bypass",
title: "Authorization bypass in router",
description: "The candidate claimed a route bypassed authorization.",
claimed_root_cause: "dispatch was claimed to skip the authorization wrapper.",
trace: [source("sink")],
evidence: [evidence()],
reason: "All routes pass through the authorization wrapper before dispatch.",
};
}
function errorsFor(value) {
return validateDocument(value, schema);
}
function runCli(contents, options = {}) {
const { nodeArgs = [], timeout = CLI_TIMEOUT_MS } = options;
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-"));
const findingsPath = path.join(directory, "findings.json");
try {
fs.writeFileSync(findingsPath, contents);
return spawnSync(process.execPath, [...nodeArgs, validatorPath, findingsPath], {
encoding: "utf8",
timeout,
});
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
function cliOutput(result) {
return `${result.stdout}${result.stderr}`;
}
function assertNoInjectedControlBytes(output) {
const bytes = Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8");
for (const marker of TERMINAL_CONTROL_BYTES) {
assert.equal(bytes.indexOf(marker), -1, `found raw control bytes ${marker.toString("hex")}`);
}
}
function producerShapedFindings() {
const demonstrated = confirmed();
demonstrated.conditions = [{
kind: "authentication_level",
description: "The attacker needs a normal account.",
}];
demonstrated.execution.payloads = ["", " \t\r\n", "\u0000\u001f\u007f", "\u034f", "\ufe0f", "\ud800", "\udc00", "[{,}]\\\""];
demonstrated.remediation.code_changes = [{
file_name: "src/handler.c",
fixed_code: "",
}];
const blocked = needsValidation();
delete blocked.validation_plan.deployment;
return [demonstrated, blocked, rejected()];
}
function rejectMutation(factory, mutate) {
const value = factory();
mutate(value);
assert.notEqual(errorsFor([value]).length, 0);
}
test("schema is an actual top-level array with exactly three branches", () => {
assert.equal(schema.type, "array");
assert.equal(schema.items.oneOf.length, 3);
assert.deepEqual(schema.items.oneOf.map((branch) => branch.properties.verdict.const), [
"confirmed", "needs_validation", "rejected",
]);
const confirmedSchema = schema.items.oneOf[0].properties;
assert.equal(confirmedSchema.title.visibleContent, true);
assert.equal(confirmedSchema.execution.properties.payloads.items.minLength, undefined);
assert.equal(confirmedSchema.execution.properties.payloads.items.visibleContent, undefined);
assert.equal(confirmedSchema.remediation.properties.code_changes.items.properties.fixed_code.minLength, undefined);
});
test("accepts a producer-shaped findings document through the CLI", () => {
const result = runCli(JSON.stringify(producerShapedFindings()));
assert.equal(result.status, 0, cliOutput(result));
assert.match(result.stdout, /PASS: 3 findings valid/);
});
test("accepts empty output and each complete branch", () => {
assert.deepEqual(errorsFor([]), []);
assert.deepEqual(errorsFor([confirmed(), needsValidation(), rejected()]), []);
const localOnly = needsValidation();
delete localOnly.validation_plan.deployment;
assert.deepEqual(errorsFor([localOnly]), []);
const deploymentOnly = needsValidation();
delete deploymentOnly.validation_plan.local;
assert.deepEqual(errorsFor([deploymentOnly]), []);
});
test("allows a one-line finding trace", () => {
const finding = confirmed();
finding.trace = [source("entrypoint")];
assert.deepEqual(errorsFor([finding]), []);
});
test("rejects empty required content", () => {
const cases = [
[confirmed, (finding) => { finding.title = ""; }],
[confirmed, (finding) => { finding.title = " "; }],
[confirmed, (finding) => { finding.evidence = []; }],
[confirmed, (finding) => { finding.execution.payloads = []; }],
[confirmed, (finding) => { finding.execution.instructions = []; }],
[confirmed, (finding) => { finding.execution.observed_result = ""; }],
[confirmed, (finding) => { finding.remediation.strategy = ""; }],
[needsValidation, (finding) => { finding.blockers = []; }],
[needsValidation, (finding) => { finding.validation_plan = {}; }],
[needsValidation, (finding) => { finding.validation_plan = { local: " " }; }],
[rejected, (finding) => { finding.claimed_root_cause = ""; }],
];
for (const [factory, mutate] of cases) rejectMutation(factory, mutate);
});
test("preserves exact payload and replacement-code strings", () => {
const finding = confirmed();
const payloads = ["", " \t\r\n", "\u0000\u001f\u007f", "\u034f", "\ufe0f", "\ud800", "\udc00"];
const fixedCode = "\u0000 \t\r\n\u001f\u007f\u034f\ufe0f\ud800x\udc00";
finding.execution.payloads = payloads.slice();
finding.remediation.code_changes = [{ file_name: "src/handler.c", fixed_code: fixedCode }];
assert.deepEqual(errorsFor([finding]), []);
assert.deepEqual(finding.execution.payloads, payloads);
assert.equal(finding.remediation.code_changes[0].fixed_code, fixedCode);
});
test("rejects invalid scalars and whitespace, control, format, or default-ignorable prose", () => {
for (const invisible of ["\u0000\t\r\n\u001f\u007f\u200b", "\u034f", "\ufe0f", "\ud800", "\udc00", "visible\ud800"]) {
const cases = [
[confirmed, (finding) => { finding.title = invisible; }],
[confirmed, (finding) => { finding.trace[0].scope = invisible; }],
[confirmed, (finding) => { finding.evidence[0].description = invisible; }],
[confirmed, (finding) => { finding.execution.instructions = [invisible]; }],
[confirmed, (finding) => { finding.remediation.strategy = invisible; }],
[confirmed, (finding) => { finding.severity.impact.reason = invisible; }],
[confirmed, (finding) => { finding.confidence.reason = invisible; }],
[needsValidation, (finding) => { finding.blockers = [invisible]; }],
[needsValidation, (finding) => { finding.validation_plan = { local: invisible }; }],
[rejected, (finding) => { finding.reason = invisible; }],
];
for (const [factory, mutate] of cases) rejectMutation(factory, mutate);
}
});
test("quotes input-derived controls in direct validation values and paths", () => {
const finding = confirmed();
finding.trace[0].kind = `invalid-${TERMINAL_CONTROL_PAYLOAD}`;
finding.execution[`extra-${TERMINAL_CONTROL_PAYLOAD}`] = "value";
const cyclic = {};
cyclic[`path-${TERMINAL_CONTROL_PAYLOAD}`] = cyclic;
const output = [
...errorsFor([finding]),
...collect(cyclic, { type: "object" }, "$input"),
].join("\n");
for (const escaped of ["\\u001b", "\\u0007", "\\u0085", "\\u202e", "\\u034f", "\\ufe0f"]) {
assert(output.includes(escaped), `missing escaped diagnostic ${escaped}`);
}
assertNoInjectedControlBytes(output);
});
test("rejects line zero", () => {
rejectMutation(confirmed, (finding) => { finding.trace[0].line = 0; });
rejectMutation(rejected, (finding) => { finding.evidence[0].line = 0; });
});
test("does not treat inherited or Object-prototype properties as schema properties", () => {
rejectMutation(confirmed, (finding) => { finding.constructor = "not allowed"; });
const inherited = Object.create({ verdict: "confirmed" });
assert(errorsFor([inherited]).some((error) => error.includes("exactly one")));
assert(collect(Object.create({ constructor: "inherited" }), {
type: "object",
properties: { constructor: { type: "string" } },
required: ["constructor"],
additionalProperties: false,
}).some((error) => error.includes("missing required")));
});
test("oneOf requires exactly one passing branch", () => {
assert(collect("value", { oneOf: [{ type: "string" }, { minLength: 1 }] }, "$test")
.some((error) => error.includes("matched 2")));
assert(collect(7, { oneOf: [{ type: "string" }, { minimum: 10 }] }, "$test")
.some((error) => error.includes("matched 0")));
});
test("rejects duplicate fingerprints and unique array entries", () => {
const first = confirmed();
const second = rejected();
second.fingerprint = first.fingerprint;
assert(errorsFor([first, second]).some((error) => error.includes("duplicate of")));
rejectMutation(needsValidation, (finding) => { finding.blockers = [finding.blockers[0], finding.blockers[0]]; });
});
test("uses canonical Set uniqueness for structured entries at the array limit", () => {
const entries = Array.from({ length: LIMITS.arrayItems }, (_, id) => ({ id, label: String(id) }));
assert.deepEqual(collect(entries, { type: "array", uniqueItems: true }), []);
const duplicate = entries.slice(0, -1);
duplicate.push({ label: "0", id: 0 });
assert(collect(duplicate, { type: "array", uniqueItems: true })
.some((error) => error.includes(`duplicate at index ${LIMITS.arrayItems - 1}`)));
});
test("bounds canonical uniqueness keys and Set storage", () => {
const oversizedKey = "x".repeat(LIMITS.canonicalKeyBytes + 1);
assert(collect([oversizedKey], { type: "array", uniqueItems: true })
.some((error) => error.includes("canonical key exceeds")));
const itemLength = Math.floor(LIMITS.uniqueSetBytes / 6);
const largeUniqueItems = Array.from({ length: 6 }, (_, index) => `${index}${"x".repeat(itemLength)}`);
assert(collect(largeUniqueItems, { type: "array", uniqueItems: true })
.some((error) => error.includes("canonical uniqueness set exceeds")));
});
test("requires findings to be sorted by fingerprint", () => {
const first = confirmed();
const second = rejected();
assert(errorsFor([second, first]).some((error) => error.includes("sorted lexicographically")));
});
test("rejects severity above demonstrated impact", () => {
rejectMutation(confirmed, (finding) => {
finding.severity.overall_severity = "high";
finding.severity.impact.score = "medium";
});
});
test("rejects unsafe source paths", () => {
const badPaths = [
"/etc/passwd",
"../src/file.c",
"src/../file.c",
"src//file.c",
"C:\\src\\file.c",
"src/file:name.c",
"src/file\nname.c",
"src/file\u0001name.c",
"src/file\u0085name.c",
"src/file\u2028name.c",
"src/file\u202ename.c",
"src/file\u2066name.c",
"src/file\u200dname.c",
"src/file\u034fname.c",
"src/file\ufe0fname.c",
"src/file\ud800name.c",
"src/file\udc00name.c",
"CON",
"src/con.txt",
"src/PRN",
"src/AUX.c",
"src/NUL",
"src/COM1.log",
"src/lpt9",
"src/CONIN$",
"src/CONOUT$.txt",
"src/CLOCK$.txt",
"src/COM\u00b9.log",
"src/LPT\u00b2.log",
"src /file.c",
"src./file.c",
"src/file.c ",
"src/file.c.",
];
for (const badPath of badPaths) {
rejectMutation(confirmed, (finding) => { finding.trace[0].file = badPath; });
}
rejectMutation(rejected, (finding) => { finding.evidence[0].file = "NUL.txt"; });
rejectMutation(confirmed, (finding) => {
finding.remediation.code_changes = [{ file_name: "src/file:name.c", fixed_code: "replacement" }];
});
});
test("accepts legitimate Unicode source paths and prose", () => {
const finding = confirmed();
finding.title = "Finding \ud83d\ude00 cafe\u0301";
finding.trace[0].file = "src/日本語/cafe\u0301-\ud83d\ude00.ts";
finding.evidence[0].file = "src/mañana/файл.ts";
finding.remediation.code_changes = [{
file_name: "src/修正/éxito.ts",
fixed_code: "replacement",
}];
assert.deepEqual(errorsFor([finding]), []);
});
test("CLI rejects input above the byte limit without an exception trace", () => {
const result = runCli(Buffer.alloc(LIMITS.inputBytes + 1, 0x20));
const output = cliOutput(result);
assert.equal(result.status, 1, output);
assert.match(output, new RegExp(`input exceeds ${LIMITS.inputBytes} byte limit`));
assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i);
});
test("CLI rejects invalid UTF-8 without replacement or an exception trace", () => {
const findings = producerShapedFindings();
findings[0].execution.payloads = ["INVALID_UTF8"];
const encoded = Buffer.from(JSON.stringify(findings));
const marker = Buffer.from("INVALID_UTF8");
const markerOffset = encoded.indexOf(marker);
assert.notEqual(markerOffset, -1);
const malformed = Buffer.concat([
encoded.subarray(0, markerOffset),
Buffer.from([0x80]),
encoded.subarray(markerOffset + marker.length),
]);
const result = runCli(malformed);
const output = cliOutput(result);
assert.equal(result.status, 1, output);
assert.match(output, /input is not valid UTF-8/);
assert.doesNotMatch(output, /TypeError|stack|at validate-findings/i);
});
test("quotes input-derived controls in CLI validation errors", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const finding = confirmed();
finding.trace[0].kind = `invalid-${TERMINAL_CONTROL_PAYLOAD}`;
finding.execution[`extra-${TERMINAL_CONTROL_PAYLOAD}`] = "value";
const result = runCli(JSON.stringify([finding]));
assert.equal(result.status, 1, cliOutput(result));
assert.match(result.stderr, /\$\[0\]\.trace\[0\]\.kind/);
assert.match(result.stderr, /\\u001b/);
assert.match(result.stderr, /\\u202e/);
assertNoInjectedControlBytes(result.stderr);
});
test("returns a generic syntax error without parser-supplied controls", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const malformed = Buffer.concat([
Buffer.from("["),
Buffer.from(TERMINAL_CONTROL_PAYLOAD),
Buffer.from("]"),
]);
const result = runCli(malformed);
assert.equal(result.status, 1, cliOutput(result));
assert.equal(result.stderr, "Failed to parse findings JSON: invalid JSON syntax\n");
assertNoInjectedControlBytes(result.stderr);
});
test("does not reflect controls from a failed CLI input path", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-path-"));
const missingPath = path.join(directory, `missing-${TERMINAL_CONTROL_PAYLOAD}.json`);
try {
const result = spawnSync(process.execPath, [validatorPath, missingPath], {
encoding: "utf8",
timeout: CLI_TIMEOUT_MS,
});
assert.equal(result.status, 1, cliOutput(result));
assert.match(result.stderr, /Failed to read findings JSON:/);
assertNoInjectedControlBytes(result.stderr);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("CLI rejects lone-surrogate prose without changing payload semantics", () => {
const findings = producerShapedFindings();
findings[0].title = "\ud800";
const result = runCli(JSON.stringify(findings));
const output = cliOutput(result);
assert.equal(result.status, 1, output);
assert.match(output, /must contain only valid Unicode scalar values/);
assert.doesNotMatch(output, /stack|at validate-findings/i);
});
test("CLI rejects Unicode format controls in source paths", () => {
const findings = producerShapedFindings();
findings[0].trace[0].file = "src/file\u202ename.c";
const result = runCli(JSON.stringify(findings));
const output = cliOutput(result);
assert.equal(result.status, 1, output);
assert.match(output, /must be a safe repository-relative source path/);
assert.doesNotMatch(output, /stack|at validate-findings/i);
});
test("CLI rejects a FIFO without blocking", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-fifo-"));
const fifoPath = path.join(directory, "findings.json");
try {
const created = spawnSync("mkfifo", [fifoPath], { encoding: "utf8", timeout: CLI_TIMEOUT_MS });
assert.equal(created.status, 0, cliOutput(created));
const result = spawnSync(process.execPath, [validatorPath, fifoPath], {
encoding: "utf8",
timeout: CLI_TIMEOUT_MS,
});
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /input must be a regular file/);
assert.doesNotMatch(output, /stack|at validate-findings/i);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("CLI rejects a symlink without following it", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-symlink-"));
const targetPath = path.join(directory, "target.json");
const symlinkPath = path.join(directory, "findings.json");
try {
fs.writeFileSync(targetPath, JSON.stringify(producerShapedFindings()));
fs.symlinkSync(targetPath, symlinkPath);
const result = spawnSync(process.execPath, [validatorPath, symlinkPath], {
encoding: "utf8",
timeout: CLI_TIMEOUT_MS,
});
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /input must not be a symlink/);
assert.doesNotMatch(output, /stack|at validate-findings/i);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("CLI rejects input above the nesting-depth limit without an exception trace", () => {
const levels = LIMITS.nestingDepth + 1;
const result = runCli(`${"[".repeat(levels)}0${"]".repeat(levels)}`);
const output = cliOutput(result);
assert.equal(result.status, 1, output);
assert.match(output, new RegExp(`${LIMITS.nestingDepth} level nesting depth limit`));
assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i);
});
test("CLI rejects an oversized array without an exception trace", () => {
const result = runCli(JSON.stringify(Array(LIMITS.arrayItems + 1).fill(null)));
const output = cliOutput(result);
assert.equal(result.status, 1, output);
assert.match(output, new RegExp(`${LIMITS.arrayItems} item array limit`));
assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i);
});
test("checks pattern and branch invariants", () => {
rejectMutation(rejected, (finding) => { finding.fingerprint = "not stable"; });
rejectMutation(needsValidation, (finding) => {
finding.severity = { impact: { score: "low" }, overall_severity: "low" };
});
});
test("rejects unsupported and malformed schema keywords", () => {
assert(collectSchemaErrors({ type: "string", format: "uuid" }).some((error) => error.includes("format")));
assert(collectSchemaErrors({ type: "string", pattern: "[" }).some((error) => error.includes("regular expression")));
assert(collectSchemaErrors({ type: "string", visibleContent: "yes" }).some((error) => error.includes("expected boolean")));
assert(collectSchemaErrors({ type: "array", visibleContent: true }).some((error) => error.includes("requires type")));
assert.notEqual(validateDocument([], { type: "array", maxItems: 1 }).length, 0);
});
test("caps malformed 1000-finding validation output", () => {
assert.equal(errorsFor(Array.from({ length: LIMITS.arrayItems }, () => null)).length, LIMITS.validationErrors);
if (!HAS_SAFE_INPUT_OPEN) return;
const result = runCli(JSON.stringify(Array.from({ length: LIMITS.arrayItems }, () => null)));
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /output capped at 100/);
assert(output.length < 20000, `unexpected output length ${output.length}`);
assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-findings/i);
});
test("caps amplified in-limit findings output under a constrained Node heap", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
const findings = Array.from({ length: 750 }, () => ({
verdict: "confirmed",
trace: Array.from({ length: LIMITS.arrayItems }, () => 0),
evidence: Array.from({ length: LIMITS.arrayItems }, () => 0),
}));
const contents = JSON.stringify(findings);
assert(contents.length > 3 * 1000 * 1000, `hostile input too small: ${contents.length}`);
assert(contents.length <= LIMITS.inputBytes, `hostile input over limit: ${contents.length}`);
const result = runCli(contents, {
nodeArgs: ["--max-old-space-size=64"],
timeout: HOSTILE_CLI_TIMEOUT_MS,
});
const output = cliOutput(result);
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
assert.equal(result.status, 1, output);
assert.match(output, /output capped at 100/);
assert(output.length < 20000, `unexpected output length ${output.length}`);
assert.doesNotMatch(output, /heap out of memory|allocation failed|RangeError|Maximum call stack/i);
});
test("keeps shared helpers aligned with the coverage-ledger validator", () => {
const findingsModule = require("./validate-findings.cjs");
const ledgerModule = require("./validate-coverage-ledger.cjs");
for (const name of [
"VISIBLE_CONTENT",
"PATH_FORBIDDEN_CHARACTER",
"WINDOWS_RESERVED_COMPONENT",
"UNSAFE_DIAGNOSTIC_CHARACTER",
]) {
assert.equal(findingsModule[name].source, ledgerModule[name].source, `${name} source`);
assert.equal(findingsModule[name].flags, ledgerModule[name].flags, `${name} flags`);
}
const sharedLimitKeys = Object.keys(findingsModule.LIMITS)
.filter((key) => Object.prototype.hasOwnProperty.call(ledgerModule.LIMITS, key))
.sort();
assert.deepEqual(sharedLimitKeys, ["inputBytes", "nestingDepth", "validationErrors"]);
for (const key of sharedLimitKeys) {
assert.equal(findingsModule.LIMITS[key], ledgerModule.LIMITS[key], `LIMITS.${key}`);
}
const pathCorpus = [
"src/handler.js",
"src/caf\u00e9/handler.js",
"src/\u65e5\u672c\u8a9e/\u0444\u0430\u0439\u043b.ts",
"src/cloc\u212a$.txt",
"src/CLOCK$.txt",
"src/con.txt",
"CON",
"src/COM\u00b9.log",
"src/lpt\u00b3",
"/etc/passwd",
"../src/file.c",
"src/../file.c",
"src//file.c",
"src\\file.c",
"src/file:name.c",
"~home/file.c",
"C:/file.c",
"src/file.c ",
"src/file.c.",
"src/file\u202ename.c",
"src/file\u200b.js",
"src/file\u034f.js",
"src/file\ufe0f.js",
"src/file\ud800name.c",
"src/file\udc00name.c",
];
for (const value of pathCorpus) {
assert.equal(
findingsModule.isSafeRelativeSourcePath(value),
ledgerModule.isSafeRelativePath(value),
`path verdict diverges for ${JSON.stringify(value)}`,
);
}
assert.equal(findingsModule.isSafeRelativeSourcePath("src/cloc\u212a$.txt"), false);
assert.equal(ledgerModule.isSafeRelativePath("src/cloc\u212a$.txt"), false);
const proseCorpus = [
"Valid prose.",
"caf\u00e9",
"",
" \t\r\n",
"\u200b",
"\u034f",
"\ufe0f",
"\ud800",
"\udc00",
"visible\ud800",
];
for (const value of proseCorpus) {
assert.equal(
findingsModule.hasVisibleProse(value),
ledgerModule.hasVisibleProse(value),
`prose verdict diverges for ${JSON.stringify(value)}`,
);
}
});

9
.claude/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(ffprobe -v error *)",
"Bash(env)",
"mcp__outline__read_document"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(rtk grep *)",
"Bash(rtk read *)",
"Bash(rtk git *)"
],
"additionalDirectories": [
"/config/.claude/skills/security-audit",
"/config/security-audit-skill",
"/config/.cargo/registry"
]
}
}

View File

@@ -0,0 +1 @@
../../.agents/skills/security-audit

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
target/
.git/
*.md
tests/

3
.gitignore vendored
View File

@@ -1 +1,4 @@
/target /target
/node_modules
/test-results
/playwright-report

13
.rtk/filters.toml Normal file
View File

@@ -0,0 +1,13 @@
# Project-local RTK filters — commit this file with your repo.
# Filters here override user-global and built-in filters.
# Docs: https://github.com/rtk-ai/rtk#custom-filters
schema_version = 1
# Example: suppress build noise from a custom tool
# [filters.my-tool]
# description = "Compact my-tool output"
# match_command = "^my-tool\\s+build"
# strip_ansi = true
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
# max_lines = 30
# on_empty = "my-tool: ok"

385
CHANGELOG.md Normal file
View File

@@ -0,0 +1,385 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The long form, with what was wrong before and how it was found, is in
[docs/history.md](docs/history.md).
## [Unreleased]
## [0.5.5] - 2026-09-14
### Changed
- A feed whose site sends a message instead of the feed, such as "Unable to establish a DB
connection", now shows that message and is flagged as the publisher's problem, instead of two
parser errors about reaching the end of input.
### Removed
- An unused icon glyph (`minus`) left over from before Unsubscribe settled on `circleMinus`.
### Fixed
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
"Loading…", and no longer cuts the Directory behind it down to ten.
## [0.5.4] - 2026-09-14
### Added
- Currently Listening, below Popular: episodes you started and have not finished, across every
feed you subscribe to. Tap one to pick up where you left off.
- Theme has an Auto option, alongside Dark, Light and Classic, that follows your system's
light/dark setting. All four are now also in Settings, as a dropdown next to the header
button's one-click-at-a-time toggle -- the same setting either way.
### Changed
- The feed (or Directory/Popular/All Subscriptions) and the tab you had open are remembered
across a reload or a new visit. A feed you no longer subscribe to, or a first visit with
nothing remembered yet, lands on All Subscriptions instead of the first feed alphabetically.
### Fixed
- On iOS, the topbar (the hamburger menu included) could stop responding to taps until a hard
refresh. The page sized itself with `100vh`, which iOS Safari measures against the address
bar's collapsed state rather than what is actually visible; `100dvh` tracks the real viewport
as the bar shows and hides.
## [0.5.3] - 2026-09-14
### Added
- A feed that has been failing for a day shows a plain-English reason in the sidebar and on its
own page, sorted from a 404, a 401/403, a 402, a name that no longer resolves, or a web page in
place of the feed -- with Unsubscribe or, when the page links its new feed, Use the new address.
A feed that fails once and reads fine again within a day is never flagged.
### Changed
- Unsubscribing from the last person's OPML or Patreon subscription now retires the feeds it
listed, the same as a feed the list itself drops: removed if nothing was downloaded, kept and
marked orphaned otherwise. Until now they stayed in the database and kept being scanned hourly
with auto-download on, which is how 922 defunct `davewiner` feeds outlived the OPML that listed
them.
### Fixed
- A feed whose XML uses a bare `&` instead of `&amp;` (kcpw, both feedland feeds) is now read
instead of refused.
- A feed URL that now serves a web page says so, and names the feed the page links to when it has
one, instead of a raw XML parser error.
- A publisher answering with an empty body (British Antarctic Survey's 202) is read as nothing new
to report, not a parse failure.
- A link in an item's show notes opens in a new tab instead of navigating away from ipx.
- A video file plays as video, in a small floating pane above the player bar, instead of silently
as sound only.
- On the Unread tab, opening an item no longer makes it disappear from the list -- it stays until
you open a different one, even if a scan finishes and refreshes the list while it is open.
- Subscribe and Unsubscribe have their own icons (a circled check and a circled minus) instead of
sharing the generic plus and minus used for adding feeds, users and imports.
- Settings no longer disappears for a non-admin account. It was hiding the whole Settings modal
along with the log and the users screen, but a non-admin has settings of their own in there --
their subscriptions' Export and Import, and the schedule and quota are worth seeing even without
a say in them. Only the log and the users screen, which the server also refuses them, are gone.
## [0.5.2] - 2026-09-12
### Added
- Settings → Users and `ipx user list` show when each account was added and when it last signed
in, to the hour.
- `ipx user rename <name> <new name>` renames an account and keeps its feeds, read state and admin
rights. An account made before the proxy was set up can take the name the proxy signs it in as.
### Changed
- Directory and Popular list the feeds inside an OPML one by one, and no longer the OPML itself,
so you can subscribe to just the shows you want.
- The database no longer records when subscriptions and sign-in sessions were created. Nothing
ever read it, and an existing database drops the columns on its next start.
### Fixed
- Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of
HTML: the item's other copy of its notes is used instead, from the next time the feed changes.
Daily Meditation Podcast had 57.
- Docker no longer shows ipodderx as starting, or calls it unhealthy, while it scans or downloads:
`ipx status` answers at once instead of waiting for the job in progress to finish.
- Signing out after signing in through Cloudflare Access no longer lands on ipodderx's own password
page. With the new `sign_out_url` set, Sign out ends the Access session, and the password page
sends anyone the proxy signs in straight to their feeds.
- The sign-in guide, `docs/sso.md`, describes the setup ipodderx.sdf1.net really runs: Authentik as
Cloudflare Access's identity provider, and how to find the address ipx has to trust. It had never
been checked against a real setup, and pointed at the wrong address.
## [0.5.1] - 2026-09-12
### Fixed
- The triangle that opens an OPML or Patreon folder was cramped against the folder's art. It has
more room now, and a wider target to click.
## [0.5.0] - 2026-09-12
### Added
- A Patreon token pasted into Add feed, or a creator's RSS link without `&show=`, becomes a folder
of that creator's shows, kept in step on every scan like a subscribed OPML. A creator with only
one show stays a plain feed. One already added as a single long feed is split into its shows on
its next scan, keeping its files and what you had read.
- Add feed has an "Allow items marked explicit" box, so a new feed's first scan no longer skips
every explicit item.
### Changed
- Unread counts, unread dots and download progress are amber, the colour of the icon's EQ bars.
Blue is kept for the primary action and links, so a count no longer looks like a button.
- What is playing is marked by small EQ bars, in its row and in the player. They move only while it
plays.
- A folder in the sidebar shows its first four shows' art as a mosaic, and its shows sit under its
title. Only folders have a triangle, so every feed lines up with Directory and Popular above.
- Feeds without art get initials in a colour of their own, instead of all the same grey.
- The Flagged tab is called Kept, as the Keep button and Settings already said.
- A feed's header is one short line; when it checks next is in its tooltip.
- The item list takes more of the window, and the Files pane shows only when the item has files.
- Column headings and tags are in sentence case, and fewer things are bold.
- Unsubscribe is a round button beside the feed's other actions.
- Export and Import in Settings say what they do.
- The sign-in page shows the original icon large.
- Nothing animates when your system asks for reduced motion.
- The pages are about 90 KB smaller: the icon is served once instead of written into each.
- A web token generated for a new install is 64 characters instead of 32.
- The README is a short overview of what ipx does and how to run it, and points into `docs/` for
the rest. It still described the layout from before 0.4.0.
### Removed
- The systemd units in `contrib/`. Run ipx with Docker, or point a unit of your own at
`ipx daemon`.
- Upgrading from before 0.3.0 directly: what was read, kept or part-played before accounts is no
longer carried over to the admin, and OPML feeds that old versions wrote into `config.toml` are
no longer moved out of it. Upgrade through 0.4.0 first.
- `interval_mins` in `config.toml` is ignored; use `schedule`.
### Fixed
- The feed list works from the keyboard: Tab reaches every feed, Enter opens it, and Right and Left
open and close a folder. Every button shows where the focus is, including in the toolbar, which
used to clip the ring.
- "1 items" reads "1 item".
- A selected feed without art no longer loses its initials tile in Dark and Light.
- The player shows the feed's initials when there is no art, not the episode's.
- Turning on Allow explicit, or changing keywords or auto-download, brings back what those settings
had skipped on the feed's next scan. Before, an item was judged once, when first seen, and a
skipped one stayed skipped whatever you changed.
- Feeds inside an OPML or a Patreon creator follow your settings on the folder unless you set their
own, as the folder's settings dialog said they did. Before, the folder's settings reached nothing
inside it.
- A new feed no longer takes the name of one you removed earlier and shows that feed's old items.
Re-adding the same feed still gets its old name, and its history, back.
## [0.4.0] - 2026-09-11
### Added
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
through all three and remembers the choice.
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
- Items show as a table (unread, kept, title, feed, file, size, published) with a Files pane beside
it, and a status bar with the totals.
- Mark everything read from All Subscriptions, across every feed you subscribe to
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
published); click again to reverse. The server sorts, so it covers the whole list, not just the
fifty shown, and the choice is remembered.
### Changed
- Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it,
marked Subscribed, and clicking one opens it.
- Adding a feed scans it straight away, and an OPML import that added feeds scans them, so their
items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
label, just the Download button. The retention summary says "deleted", not "reaped".
- Buttons are icons, with the words in their tooltips: the Files pane (save, delete, view,
download), an item's own buttons (mark read, keep, open the original), the feed header (scan,
download latest, mark all read, settings, unsubscribe), and the Settings, feed settings and
Download latest dialogs (save, download, cancel). The icons are Font Awesome Free, embedded as
SVG: only the ones used, no font to download, and nothing fetched from anyone else. They
replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to
font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope.
- One meaning per icon. Minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
dialog's confirm button carries the icon of what it does. The feed header's unsubscribe was an x
and read as closing the page. The remaining word buttons are icons too:
- Log, Add feed, Users, Unsubscribe and OPML.
- Popular's Subscribe, Copy and Sign out.
- The Subscribed label in Popular, the Directory and Add feed, which is now a green check.
- The player's back, play, forward and close, which were font characters, and the folder arrow.
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
- The item table's size has its own column, apart from the file's type, and shows KB for small
files instead of "0 MB". The Item heading is now Title.
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
downloaded and red when the download failed, with the details in its tooltip. One icon per
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
### Fixed
- Playing a file from the Files pane played it twice at once, in the pane and in the player bar.
The pane has a play button now, and the player bar is the only player.
- The password box in Manage users was white in the dark theme.
- An item with no date showed a stray dot in its details.
- Escape did not close a dialog while the cursor was in one of its boxes, so Add feed, which opens
in its URL box, could not be closed with Escape.
### Security
- Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never
listed in Popular or the Directory. A Supercast feed, which keeps its key in the URL's path
rather than the query, was being listed.
## [0.3.0] - 2026-09-11
### Added
- Add feed lists what other people on this server subscribe to, most subscribers first, and
subscribes you by id (`GET /api/popular`, `POST /api/popular/{id}`). Feeds from an OPML, and
feeds with a login or a key in their URL, are never listed.
- Upload an OPML file to import, beside the paste box. The page checks it looks like OPML before
sending it and clears the picker afterwards.
- Settings → Manage users: add and remove accounts, and choose who is an admin
(`GET`/`POST /api/users`, `PATCH`/`DELETE /api/users/{id}`).
- Per-user subscriptions, and per-user read, starred and playback state. The existing library is
adopted by the admin on first start.
- Subscribing to a feed someone else already has costs no second fetch and no second copy. Scanning
merges every subscriber's wants.
- Delete on a shared feed reads **Delete for everyone**, and the server answers `409` while anyone
else has starred the item or not played it (`?force=true` overrides).
- A shared feed's header says how many other people read it.
- `docs/` for configuration, the CLI, users, SSO and architecture, and `CLAUDE.md` for anyone
working on the code.
- Browser tests for OPML import and export by every route, user admin, unread ordering, and
`ipx import`/`ipx export`.
### Changed
- Feeds inside an OPML subscription list the ones with unread items first.
- OPML import subscribes you to every feed in the file. `ipx import` subscribes the first admin.
- OPML export lists only your own subscriptions.
- Production runs as a Docker image pushed to `192.168.1.130:5000` and recreated with
`docker compose`.
- This changelog follows Keep a Changelog. The long-form entries moved to `docs/history.md`.
### Fixed
- Importing another account's OPML export subscribed nobody and reported "Imported 0 feed(s)".
Feeds it added had no subscriber, so they were never scanned.
- Importing something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
refused before anything changes.
- Starring stopped protecting a file from the quota and age sweeps once read state became per-user.
### Security
- The log is admin-only (`GET /api/logs` answers `403`, and the Log button is hidden). It names
every account, every feed and every failed sign-in.
- OPML export no longer hands anyone signed in the whole catalogue, including other people's
private feed URLs.
## [0.2.0] - 2026-09-10
### Added
- Web UI served by the daemon: plain HTML and JS compiled into the binary, with feeds, items,
filters, search, sanitised show notes and live progress over SSE.
- Player bar with resume, speed, keyboard shortcuts and lock-screen controls.
- Three-pane layout: feeds beside, items above, and the selected item's text and files below.
- Phone layout.
- Accounts and sign-in: Argon2id passwords, session cookies, `ipx user add|list|passwd|rm`, and a
trusted proxy header for Cloudflare Zero Trust or Authentik (`docs/sso.md`).
- Subscribing to an OPML: it is re-read every scan and its feeds show as a folder. A feed dropped
from it is removed unless it has downloads.
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`).
- `[general] media_types`, default audio and video, and `max_new_per_check`, default 3.
- Every enclosure of an item, a View link for files that are not audio or video, and per-item
artwork.
- In-app log view with Daemon I/O, Scans and HTTP tabs.
- Mark all read on an OPML subscription.
- Editable feed URL with a copy button.
- Docker image whose healthcheck goes through the control socket.
- The iPodderX name, icon, and a colour scheme taken from the icon.
- `tests/page-smoke.js` and a Playwright browser suite.
### Changed
- Global settings and scan schedules are admin-only. The per-feed schedule picker is gone.
- Feeds from an OPML live in the database, not `config.toml`.
- Opening an item marks it read. Playing it marks it read only at the end or past 90%.
- "Episodes" became "items", since half the library is text.
- A burst of scan events causes one refresh, not one per feed.
- All is the default filter. OPML import and export, Settings and Log moved out of the header.
- Torrents run detached, two at a time.
### Fixed
- Download fetched the next queued episodes instead of the one clicked.
- Pressing play made an item vanish from the Unread list.
- Clearing a folder, schedule or cap from the UI did nothing.
- The daemon ignored SIGTERM until the current download finished.
- A missing function stopped the page script and left the whole UI dead.
- One download painted progress on every pending row.
- A torrent could freeze scanning for up to an hour.
- Downloading from an OPML feed failed with "belongs to unsubscribed feed".
- Image enclosures were downloaded, counted as episodes and given a play button.
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
- An item with several enclosures kept only the last.
- OPML folders rendered open by default.
- Mark read in the text pane recursed until the stack overflowed.
- The Unread, Downloaded and Flagged filters answered `500` without a search term.
- An OPML subscription always showed 0 unread.
- Folder names kept doubled spaces where separators were stripped.
- Sidebar rows had four different left edges.
### Security
- The web UI needs a token or a sign-in. The token is compared in constant time, and an empty
token refuses to serve.
- Show notes are sanitised with `ammonia`.
- A proxy's user header is honoured only from an address in `trusted_proxies`.
- A feed URL must be http(s), so `file:///etc/passwd` is refused.
- Download folders are sanitised per path segment, so `../../etc/Show` cannot climb out.
## [0.1.0] - 2026-09-09
### Added
- `ipx`, a Rust rewrite of the iPodderX engine: TOML config, SQLite state, and `ipx list`, `add`,
`rm` and `fetch`.
- RSS and Atom parsing with conditional GET, `<ttl>` and basic auth.
- Streaming downloads with explicit, keyword and per-scan filters, deduplicated by enclosure URL.
- Quota and age retention that never touches a starred file, and `ipx reap [--dry-run]`.
- `ipx daemon` with a JSON-lines Unix socket. CLI commands proxy to a running daemon.
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...main
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0

167
CLAUDE.md Normal file
View File

@@ -0,0 +1,167 @@
# Working on ipodderx-rs
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
thing is built; this file is about working on it without repeating mistakes that have already been
made here.
## Where things are
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
| | Host | In the container |
|---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
Deploying a change is: build and push the image, then pull it and recreate the container.
```sh
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
docker logs --tail 20 iPodderX
```
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
this host. There is no Docker Hub login here, and the build asks about `debian:bookworm-slim` and
`rust:1-slim-bookworm` every time unless they are already stored locally. Pull them from Google's
mirror and tag them; the build then uses the local copies without asking Docker Hub:
```sh
docker pull mirror.gcr.io/library/debian:bookworm-slim
docker tag mirror.gcr.io/library/debian:bookworm-slim debian:bookworm-slim
docker pull mirror.gcr.io/library/rust:1-slim-bookworm
docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm
```
Run those again now and then, or the local copies go stale.
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
the daemon answers there and can read its database, not just that the web port is up. The socket
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or
download does not fail the check; it also means a worker stuck on one job would still pass. The
container restarts on its own after a reboot.
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
the shell running the command and kills the session (exit 144). This has happened more than once.
## Before you touch the page
`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before
it is visible. It is one file: markup, CSS and script.
After any edit to it:
```sh
node tests/page-smoke.js
```
That loads the script against a stub DOM and checks every selector it wires at load actually
exists. It exists because a patch once anchored on a deleted function, `String.replace` silently
matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
## Tests
```sh
cargo test # ~51 tests: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js
npx playwright test # 16 browser tests against a real daemon on fixture feeds
```
Things about the browser suite that have cost time:
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
re-imports the config in every worker, so `prepare()` guards on `TEST_WORKER_INDEX` — without
that guard a worker deleted the database out from under the running daemon, which then kept
serving from the unlinked inode while everything else saw an empty file.
* Tests **share that daemon and run in order**. A test that opens an item marks it read and changes
what later tests see. Write assertions that do not depend on what ran before, or normalise the
state first.
* Fixture feeds must not share an enclosure URL, because `enclosures.url` is globally unique and
whichever feed is scanned first claims it.
* `webServer` starts **before** `globalSetup`, which is why the fixture config is written at
config-load time instead.
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
`matches_keywords`, `parse_interval`) are the easiest place to put it.
## Things that are true and easy to get wrong
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
and `position` columns from before accounts; two bugs came from queries still reading them
(retention, and the entry pruner), and `migrate()` now drops them.
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
derived and never written to config.
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
Anyone wanting an item is enough to fetch it.
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
handler and return `403`.
* **A `tokio::select!` only races its branches at the point of selection.** A long download has to
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
fighting over the database, with the stale one still holding the port.
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
different task. `ipx status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log.
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait
for `daemon started` in the log. Copy `state.db` aside before deploying one.
## House style
Comments explain **why**, not what. If a line looks odd, the comment says what went wrong without
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
addressed to the person using it.
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
say, such as what was wrong before or what it cost to find out, write it up at the top of
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
than once.
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
compare links at the bottom of the changelog.
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
## Known gaps
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
(documented in [docs/sso.md](docs/sso.md)).
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
Substack-style subtitles.
<!-- rtk-instructions v2 -->
# Command output
Command output here is condensed to save tokens, keeping every signal and
dropping costly noise. Treat it as the complete result: run commands
normally, and batch related commands into one call to avoid extra turns.
Truncated results state their recovery path in their own output. Re-run a
command as `rtk proxy <cmd>` only when its result is unusable: empty when
output was clearly expected, contradicting its exit code, or garbled.
<!-- /rtk-instructions -->

361
Cargo.lock generated
View File

@@ -23,6 +23,18 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser",
"html5ever",
"maplit",
"url",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.6" version = "0.1.6"
@@ -97,6 +109,18 @@ dependencies = [
"rustversion", "rustversion",
] ]
[[package]]
name = "argon2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.8" version = "0.7.8"
@@ -175,7 +199,7 @@ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",
"diligent-date-parser", "diligent-date-parser",
"quick-xml", "quick-xml 0.41.0",
] ]
[[package]] [[package]]
@@ -222,6 +246,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"bytes", "bytes",
"form_urlencoded",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
@@ -235,6 +260,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"serde_core", "serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tower", "tower",
@@ -310,6 +338,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -334,6 +368,24 @@ dependencies = [
"wyz", "wyz",
] ]
[[package]]
name = "blake2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "bs58" name = "bs58"
version = "0.5.1" version = "0.5.1"
@@ -384,17 +436,6 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cfb"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3"
dependencies = [
"fnv",
"uuid",
"web-time",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -492,6 +533,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -590,6 +637,35 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"dtoa-short",
"itoa",
"smallvec",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@@ -751,6 +827,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"crypto-common",
"ctutils",
]
[[package]] [[package]]
name = "diligent-date-parser" name = "diligent-date-parser"
version = "0.1.5" version = "0.1.5"
@@ -769,15 +856,6 @@ dependencies = [
"dirs-sys", "dirs-sys",
] ]
[[package]]
name = "dirs"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
dependencies = [
"dirs-sys",
]
[[package]] [[package]]
name = "dirs-sys" name = "dirs-sys"
version = "0.5.0" version = "0.5.0"
@@ -813,6 +891,21 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@@ -1206,6 +1299,16 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.5.0" version = "1.5.0"
@@ -1239,6 +1342,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -1251,6 +1360,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.11.1" version = "1.11.1"
@@ -1470,15 +1588,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "infer"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4"
dependencies = [
"cfb",
]
[[package]] [[package]]
name = "intervaltree" name = "intervaltree"
version = "0.2.7" version = "0.2.7"
@@ -1496,18 +1605,20 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.1.0" version = "0.5.5"
dependencies = [ dependencies = [
"ammonia",
"anyhow", "anyhow",
"argon2",
"atom_syndication", "atom_syndication",
"axum",
"chrono", "chrono",
"clap", "clap",
"dirs",
"futures-util", "futures-util",
"infer",
"librqbit", "librqbit",
"opml", "opml",
"percent-encoding", "percent-encoding",
"quick-xml 0.42.0",
"reqwest", "reqwest",
"rss", "rss",
"rusqlite", "rusqlite",
@@ -1515,6 +1626,8 @@ dependencies = [
"serde_json", "serde_json",
"tokio", "tokio",
"toml", "toml",
"tower",
"tower-http 0.7.1",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
@@ -1974,7 +2087,7 @@ dependencies = [
"httparse", "httparse",
"librqbit-dualstack-sockets", "librqbit-dualstack-sockets",
"network-interface", "network-interface",
"quick-xml", "quick-xml 0.41.0",
"reqwest", "reqwest",
"serde", "serde",
"serde_derive", "serde_derive",
@@ -2045,6 +2158,23 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -2161,6 +2291,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.30.1" version = "0.30.1"
@@ -2314,12 +2450,72 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "password-hash"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"getrandom 0.4.3",
"phc",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phc"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
dependencies = [
"base64ct",
"ctutils",
"getrandom 0.4.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -2371,6 +2567,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -2406,6 +2608,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.11" version = "0.11.11"
@@ -2666,7 +2877,7 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http 0.6.11",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
@@ -2727,7 +2938,7 @@ checksum = "f505d3e5e7b06b4dc0245b13294f8ef9a1a0f70284708be1e11c5b7b7441034e"
dependencies = [ dependencies = [
"atom_syndication", "atom_syndication",
"derive_builder", "derive_builder",
"quick-xml", "quick-xml 0.41.0",
] ]
[[package]] [[package]]
@@ -3092,6 +3303,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "size_format" name = "size_format"
version = "1.0.2" version = "1.0.2"
@@ -3151,6 +3368,30 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -3243,6 +3484,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tendril"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -3474,6 +3724,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -3499,6 +3750,31 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tower-http"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"bitflags 2.13.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@@ -3517,6 +3793,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@@ -3790,6 +4067,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web_atoms"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]] [[package]]
name = "webpki-root-certs" name = "webpki-root-certs"
version = "1.0.9" version = "1.0.9"

View File

@@ -1,19 +1,21 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.1.0" version = "0.5.5"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
ammonia = "4.1.4"
anyhow = "1.0.104" anyhow = "1.0.104"
argon2 = "0.6.0"
atom_syndication = "0.12.10" atom_syndication = "0.12.10"
axum = "0.8.9"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
dirs = "7.0.0"
futures-util = { version = "0.3.34", default-features = false, features = ["std"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
infer = "0.22.0"
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6" opml = "1.1.6"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
quick-xml = "0.42.0"
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1" rss = "2.1.1"
rusqlite = { version = "0.40.2", features = ["bundled"] } rusqlite = { version = "0.40.2", features = ["bundled"] }
@@ -21,6 +23,8 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] } tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
toml = "1.1.5" toml = "1.1.5"
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["fs"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5.8" url = "2.5.8"

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
FROM rust:1-slim-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Dependencies first, so editing the source does not rebuild librqbit every time.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src
COPY src ./src
COPY web ./web
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
RUN touch src/main.rs && cargo build --release --locked
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates gosu \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV IPX_CONFIG=/config/config.toml \
IPX_DATA_DIR=/data \
IPX_LOG=ipx=info \
PUID=99 \
PGID=100
VOLUME ["/config", "/data", "/downloads"]
# Web UI, and the BitTorrent peer port (TCP and UDP -- DHT needs the UDP side).
EXPOSE 8099/tcp 6881/tcp 6881/udp
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["ipx", "daemon"]

View File

@@ -1,262 +0,0 @@
# Progress
Running record of what has actually landed. Newest entry first.
The full design and step list live in the plan file at
`/config/.claude/plans/i-want-to-create-playful-quiche.md`.
## Build order
- [x] **1. Repo skeleton** — git init (`main`), `cargo init --name ipx`, deps pinned, LICENSE,
README, this file.
- [x] **2. `config.rs` + `db.rs`** — TOML config structs + SQLite schema.
- [x] **3. `feed.rs`** — conditional GET, RSS-then-Atom parse, persist entries.
- [x] **4. `download.rs`** — downloads, filters, dedupe.
- [x] **5. `retention.rs`** — oldest-first quota + age reaper.
- [x] **6. `ipc.rs` + daemon** — UDS JSON-lines server, TTL scheduler, CLI-proxies-to-daemon.
- [x] **7. `torrent.rs`** — librqbit, seed to ratio/time, stall abort. (swarm download unverified —
see the step 7 entry)
- [x] **8. OPML + polish** — import/export, add/rm/status, tracing setup, systemd units, README.
## Smoke tests
1. `ipx add <feed>` + `ipx fetch` → file in `download_dir/<Show>/`, row in `enclosures`.
2. `ipx fetch` again → no re-download, feed skipped for TTL.
3. `ipx daemon &` + `nc -U $XDG_RUNTIME_DIR/ipx.sock`, send `{"cmd":"fetch"}` → JSON events;
a concurrent `ipx fetch` proxies to the daemon instead of downloading in parallel.
4. Delete a downloaded file by hand, `ipx fetch` → NOT re-downloaded.
5. Torrent enclosure → downloads, moves, stops seeding at the configured ratio/time.
6. `ipx reap --dry-run` under quota pressure → oldest-first hit list; real run flips rows to
`reaped`.
---
## 2026-09-09 — Step 8: OPML and polish
`ipx add <url>` fetches the feed to name it from its own title (`Accidental Tech Podcast` ->
`accidental-tech-podcast`); a feed that cannot be reached is still added, named from its URL, rather
than refused. `ipx rm` leaves downloads and history alone, so re-adding a feed does not re-pull its
back catalogue. `ipx import`/`export` walk nested OPML folder outlines and skip URLs already
subscribed. `tracing` logs to stderr, `IPX_LOG` sets the filter. `contrib/` has a systemd user unit
for the daemon, plus a timer and one-shot service for the no-daemon style (with the caveat that
without a daemon there is no socket for a UI).
Verified: `cargo test` 27/27. Round-trip — exported 3 feeds with real titles, removed one,
re-imported: exactly the missing one came back, no duplicates, config still mode 0600. Quickstart
from a genuinely empty home with no env overrides: `list` on a missing config, `add`, `fetch`
(1 downloaded, 1 explicit skipped, 1 torrent 404), `list`; every path resolved under `$HOME`.
---
## 2026-09-09 — Step 7: torrent.rs
`src/torrent.rs`: a librqbit `Session` started lazily on first torrent (binding ports and starting a
DHT for a config that has never seen a torrent would be rude). `.torrent` URLs and magnets both go
through `AddTorrent::from_url`. Progress is polled once a second and reported through the same
`Progress` events HTTP downloads use.
Downloads go **straight into the feed folder** rather than staging and moving. The plan said move
then seed, which cannot work — seeding serves the files it downloaded, so moving them first breaks
it. In-place also removes a copy the original had to do.
Seeding stops at `seed_ratio` or `seed_time_mins`, whichever comes first, then the torrent is
released from the session (files kept).
**Bug the smoke test caught:** the stall budget only covered the download loop, but resolving a
magnet's metadata happens inside `add_torrent`, which against a dead swarm never returns — a
torrent nobody seeds wedged the scan indefinitely. `add_torrent` is now wrapped in the same budget.
Verified: `cargo test` 27/27 (ratio incl. the divide-by-zero case, stall_mins = 0 not meaning
"abort instantly"). Routing: with `enabled = false` a torrent enclosure is marked
`skipped/torrents disabled` and never attempted. Session startup works here. Stall abort measured
end to end: with `stall_mins = 1`, a dead magnet failed at 20:58:23 -> 20:59:24, exactly 61s, and
the row recorded `error / no metadata after 1 minutes, gave up`.
**Not verified: an actual successful swarm download.** This sandbox has no reachable peers, so
smoke 5's happy path — payload lands, seeding stops at the ratio — has not been run. The code paths
either side of it are tested; the swarm itself needs a real network. Worth running once against a
live torrent feed before trusting it.
---
## 2026-09-09 — Step 6: ipc.rs + daemon
`src/ipc.rs`: `Event` and `Command` as serde-tagged enums (`{"ev":...}` / `{"cmd":...}`), one JSON
object per line over a Unix socket. `Emitter` is the single output path — it broadcasts to socket
clients, prints the human rendering to a terminal, or both, so the scan code no longer knows how it
is being watched. That replaces `printMSG` and its `;;1;;1;;100.00;;42.31` sentinels.
`main.rs` restructured around a `Ctx` (config, db, client, emitter). `ipx daemon` binds the socket,
serves clients, and runs a 60s ticker that defers to each feed's TTL. Commands from every client
funnel through one mpsc queue into a single worker, which is what stops two scans overlapping.
Any CLI subcommand with a wire form probes the socket first and proxies to a running daemon;
`--local` forces the work to happen in-process. SIGINT/SIGTERM remove the socket on the way out.
**Bug the smoke test caught:** `fetch` runs a retention sweep first, and that sweep was emitting the
terminal `ReapDone`. A UI waiting for its fetch to finish would have stopped reading before the scan
started. Only a standalone `ipx reap` emits it now.
Verified: `cargo test` 24/24. Smoke 3 in full — daemon starts and binds; a raw socket client sending
`{"cmd":"fetch","force":true}` gets `feed_start` → 14 throttled `progress` events → `download_done`
→ `feed_done` → `scan_done`; `{"cmd":"status"}` answers `{"ev":"status","feeds":1,...}`. With the
daemon up, `ipx fetch` from the CLI logged `command from a client cmd=Fetch { .. }` in the daemon
and rendered the streamed events, so it proxied rather than downloading in parallel. Socket removed
on SIGTERM; with no daemon the same command runs locally.
Deferred: `{"cmd":"cancel","enclosure":N}` from the plan's protocol is **not implemented** —
downloads run sequentially in one worker, so there is nothing to cancel concurrently yet. It wants
a per-download cancellation token, which is worth doing when downloads go parallel. Say if you want
it sooner.
Note: the event stream is a broadcast, so a CLI client seeing a busy daemon also sees that other
work. Fine for a terminal; a UI wanting strict request/response would want per-request ids.
Also note: the daemon reads config once at startup — changing `config.toml` needs a restart.
Next: step 7 — `torrent.rs`.
---
## 2026-09-09 — Step 5: retention.rs
`src/retention.rs`: reconcile pass (rows claiming a file that is gone become `reaped`, fixing the
step-4 wart), age sweep, quota sweep keeping the original's 50 MB headroom pad, and entry pruning.
`ipx reap [--dry-run]`; a sweep also runs before every `fetch`, as the Python did per download.
`pick()` and `aged()` are pure so the ordering rules are testable without touching a disk.
**Judgement call worth Ray's eye.** The Python meant to reap only `read = 1 AND flagged = 0` but
never managed it — a missing `plistlib` import and an `EntreiesData` typo made that filter throw on
every candidate, so with a `.ipxd` present nothing was ever deleted. Requiring `read = 1` here would
be equally dead, because nothing marks episodes read until a UI exists. So: **`flagged` is the
keep-forever marker, and `read` only decides what goes first** (`ORDER BY read DESC, downloaded_at
ASC`). Quota therefore actually reclaims space headless. Say the word if you would rather unread
episodes were never touched.
Second call: `max_age_days` deletes *files* older than the cutoff, not just fileless entries as the
plan's wording had it — "keep 30 days of episodes" is what the setting reads like on a NAS.
Verified: `cargo test` 21/21, including the two tests encoding the exact bug the Python had —
flagged files are never offered, and read sort ahead of unread. Smoke 6 with three 30 MB episodes
against a 0.1 GB quota (52.4 MB ceiling after the pad): dry run listed ep1+ep2 and deleted nothing
(3 files still on disk), the real run deleted exactly those two oldest, left ep3, flipped both rows
to `reaped` with `path = NULL`. A full re-parse with the conditional-GET headers cleared then
re-downloaded nothing.
Next: step 6 — `ipc.rs` + daemon.
---
## 2026-09-09 — Step 4: download.rs
`src/download.rs`: streaming download to `<download_dir>/.ipx-incomplete/` (same filesystem as the
destination, so filing it is a rename, not the original's copy-then-unlink), content sniffing,
then `place()`. Filename comes from the URL's last path segment, percent-decoded, unless
`Content-Disposition` names one (RFC 5987 `filename*=` preferred). The sanitizer keeps UTF-8 —
`latin1_to_ascii` existed because 2004 filesystems demanded ASCII — strips the same characters
`stringCleaning()` did plus control chars, and adds a real 255-byte cap the Python never had,
preserving the extension across truncation.
Sniffing replaces `detectFileType()`, which called a `typeFile` module that was already missing in
2008 and so always answered `'data'`. Two rules survive: an HTML body is a failed download (login
wall/error page), and a torrent body is a torrent whatever the MIME claimed.
Filters run once at discovery and are recorded in `enclosures.state`; the download queue is then
just "everything still `pending`", so an enclosure held back by `max_new_per_check` is picked up by
the next scan instead of being lost. Keywords are OR'd across keywords and AND'd within one — the
original's nested loop let a later keyword silently undo an earlier miss.
Verified: `cargo test` 16/16. Smoke against a local server, five enclosures, each filter path hit:
`ep1.mp3 -> done`, `ep2.mp3 -> skipped (explicit)`, `ep2.mp3?v=3 -> skipped (no keyword match)`,
`paywall.html -> error (HTML page, not media)`, `ep5.torrent -> torrent (deferred to step 7)`.
Smoke 2 and 4 pass, and because a plain rerun 304s before parsing, dedupe was proved separately by
clearing the stored etag/last-modified and re-parsing all five entries: 0 downloaded, hand-deleted
file not refetched, `.ipx-incomplete` left empty.
Known wart: `ipx list` counts `path IS NOT NULL`, so a hand-deleted file still reads as downloaded.
Reconciling rows against the filesystem belongs in step 5.
Next: step 5 — `retention.rs`.
---
## 2026-09-09 — Step 3: feed.rs
`src/feed.rs`: conditional GET (`If-None-Match` + `If-Modified-Since`, optional basic auth) and a
RSS-first / Atom-fallback parser normalising both into `ParsedFeed`/`Entry`/`Enclosure`. Feed-level
`itunes:explicit` overrides the entry level, as the original did. `<ttl>` is captured. RSS
`content:encoded` wins over `description`. Atom enclosures come only from `rel="enclosure"` links.
GUID: the original hashed the title or description when no guid existed; here the chain is
guid → permalink → enclosure URL → title, all stable identifiers, so no hashing and no MD5
dependency. An entry with none of them has nothing to download and is dropped.
`db.rs` gained `http_state`, `record_feed`, `touch_feed`, `set_feed_error`, `record_entry`,
`record_enclosure`. A changed title/description flips `read` back to 0 — what the original's
textDiff was ultimately for, minus the `<ins>`/`<del>` markup, which belongs in the UI.
`main.rs` gained `ipx fetch [FEED] [--force]`. A failing feed records its error and the scan
continues.
Verified: `cargo test` 10/10. Gate met against a local `python3 -m http.server` serving the
fixtures — first run inserted 4 entries + 4 enclosures across an RSS and an Atom feed; second run
showed both skip paths, `atomcast: not modified` (304) and `testcast: not due for 45m` (the feed's
own ttl=45 beating `interval_mins = 0`).
Deferred: nothing downloads yet — enclosure rows land in state `pending`. That is step 4.
Next: step 4 — `download.rs`.
---
## 2026-09-09 — Step 2: config.rs + db.rs
`src/config.rs`: serde structs for `[general]`, `[torrent]` and `[feeds.<id>]` with defaults, `~`
expansion, `IPX_CONFIG` / `IPX_DATA_DIR` overrides, `save()` at mode 0600, `Feed::password()`
(`password_env` beats a literal `password`), `Torrent::ports()` parsing `"6881-6889"`. A missing
config file loads as an empty one so a fresh install works. Retention defaults are 0/0
(unlimited, keep forever) — nothing gets deleted until Ray asks for it.
`src/db.rs`: schema exactly as planned, WAL + `busy_timeout`, `Db::open()` idempotent, connection
behind a `Mutex`, `feed_summary()` for `list`, `now()` helper. `enclosures.url` is UNIQUE — the
dedupe key that replaces `history.dat`.
`src/main.rs`: clap skeleton with `ipx list`. Only the subcommands that work exist; the rest arrive
with their steps.
Verified: `cargo test` 5/5 green (config defaults, port-range fallback incl. reversed range,
password_env precedence, schema idempotency, enclosure-url uniqueness). Gate met — `ipx --config
<scratch> list` printed both feeds and created `state.db` once across two runs.
Deferred: nothing. Two dead-code warnings (`Config::save`, `Feed::password`) are expected; steps 3
and 8 consume them.
Next: step 3 — `feed.rs`.
Also added `chrono` 0.4 (std, clock) for RFC-2822 pubDate parsing in step 3.
---
## 2026-09-09 — Step 1: repo skeleton
Repo created at `/src/ipodderx-rs`, default branch `main`, `cargo init --name ipx` (edition 2024,
rustc 1.95.0). LICENSE (MIT, carrying the 2010 copyright), README with the lineage note, and this
file. Hello-world `main.rs` builds.
Dependency versions pinned today — the later steps are written against these APIs:
| crate | version | notes |
|---|---|---|
| tokio | 1.53.1 | rt-multi-thread, macros, fs, io-util, net, sync, time, signal |
| reqwest | 0.13.5 | `default-features = false`; features **`rustls`** (not `rustls-tls` — renamed in 0.13), http2, gzip, stream, json, charset, **`system-proxy`** (env-var proxy pickup is opt-in in 0.13) |
| rss | 2.1.1 | default features; the `with-syndication` feature name in my notes does not exist — Atom is handled by the separate crate |
| atom_syndication | 0.12.10 | |
| rusqlite | 0.40.2 | `bundled` |
| librqbit | 9.0.1 | `default-features = false`; features `rust-tls`, `http-api-client`. The default `default-tls` feature pulls reqwest/native-tls **and** an OpenSSL sha1 backend (`crypto-hash`), which fails to build without pkg-config/OpenSSL headers. API not yet exercised — step 7 |
| serde 1.0.229 / serde_json 1.0.151 / toml 1.1.5 | | |
| clap | 4.6.6 | derive |
| infer 0.22.0 / dirs 7.0.0 / anyhow 1.0.104 / opml 1.1.6 | | |
| tracing 0.1.44 / tracing-subscriber 0.3.23 | | env-filter |
Deferred: nothing.
Gotcha worth keeping: three feature names in the plan were wrong against current crate versions —
`reqwest/rustls-tls` is now `rustls`, env-var proxy support moved behind `system-proxy`, and
`rss/with-syndication` does not exist. `librqbit`'s default features drag in OpenSSL; `rust-tls`
is the fix. Whole tree is rustls-only now, no C TLS dependency.
Next: step 2 — `config.rs` + `db.rs`. (done)

150
README.md
View File

@@ -1,111 +1,83 @@
# ipodderx-rs # ipodderx-rs
A headless podcatcher: scans RSS/Atom feeds, downloads enclosures (HTTP and BitTorrent), A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
files them into per-feed folders, and reaps old episodes to stay under a disk quota. a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
Runs as a one-shot CLI or as a daemon with a Unix-socket JSON event stream for a UI to attach to. the files. One Rust binary, `ipx`, is both the daemon and the command line.
## Lineage It is a rewrite of [ipodderx-core](https://git.sdf1.net/rays/ipodderx-core), the Python engine
behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
This is a modern Rust rewrite of [ipodderx-core](https://git.sdf1.net/rays/ipodderx-core), the ## What it does
Python 2 engine behind **iPodderX** (2004-2008, Ray Slakinski & August Trometer), which was
open-sourced under the MIT License in 2010.
What carries over: the feed scan and TTL handling, GUID/URL dedupe, per-feed and per-date download - **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
folders, keyword filters, the explicit-content filter, torrent enclosures, and "SmartSpace" -- the Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
oldest-first disk quota reaper. comes in Dark, Light and Classic themes, and works on a phone.
- **Several people, one copy.** Each person has their own subscriptions and their own read, kept
and playback state. There is one file on disk per episode, however many people want it. People
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
manage accounts and settings.
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a cap
on new downloads per scan.
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
an item someone has kept.
- **OPML.** You can import and export your own subscriptions. You can also subscribe to an OPML
URL, which keeps a whole list in step as a folder.
What does not: iTunes and iPhoto export via AppleScript, text-to-speech enclosures, the Windows ## Run it
WMP/COM paths, XML plists and Python pickles for state, the `directory.iPodderX.com` survey ping,
3DES-encrypted preferences, and the `printMSG` stdout protocol (replaced by a JSON-lines socket).
## Quickstart With Docker:
```sh ```sh
cargo install --path . docker build -t ipodderx .
docker compose up -d
ipx add https://atp.fm/rss # names the feed from its own title
ipx list
ipx fetch # scan now
ipx daemon # or run continuously, honouring each feed's <ttl>
``` ```
Config lives at `~/.config/ipx/config.toml` (mode 0600, since it may hold feed passwords); `docker-compose.yml` is set up for the author's own server. Point its `image` and its three volumes
state at `~/.local/share/ipx/state.db`. Override with `IPX_CONFIG` and `IPX_DATA_DIR`. (`/config`, `/data` and `/downloads`) at yours first. The UI is on port 8099. BitTorrent uses 6881
Set `IPX_LOG=ipx=debug` for verbose logging on stderr. over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
## Commands From source:
| command | what it does | ```sh
cargo build --release
./target/release/ipx daemon
```
The first start creates **admin / ipodderx**. Sign in at `/login`, then change it:
```sh
echo -n 'a good password' | ipx user passwd admin
```
The UI is plain HTTP, so put TLS in front of it if it is reachable from outside your network.
## Documentation
| | |
|---|---| |---|---|
| `ipx add <url> [--folder X] [--keywords a,b]` | subscribe; the id comes from the feed title | | [docs/configuration.md](docs/configuration.md) | Every config key, path and environment variable |
| `ipx rm <feed>` | unsubscribe; downloads and history are kept | | [docs/cli.md](docs/cli.md) | Every command, including `ipx user` |
| `ipx list` / `ipx status` | subscriptions and their state | | [docs/users.md](docs/users.md) | Accounts, and what several people share |
| `ipx fetch [FEED] [--force]` | scan; `--force` ignores the TTL | | [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
| `ipx reap [--dry-run]` | run retention now | | [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
| `ipx import/export <file.opml>` | move subscriptions in or out | | [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
| `ipx daemon` | scheduler plus the control socket | | [docs/history.md](docs/history.md) | How it was built, with what was wrong and why |
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
Any command with a wire form probes the socket first: if a daemon is running it does the work, ## Tests
and the CLI just renders the events it streams back. `--local` forces in-process execution.
## Configuration
```toml
[general]
download_dir = "~/Podcasts"
socket = "/run/user/1000/ipx.sock" # default: $XDG_RUNTIME_DIR/ipx.sock
interval_mins = 60 # default poll; a feed's own <ttl> wins when longer
organize = "feed" # "feed" | "date"
max_total_gb = 50 # 0 = unlimited
max_age_days = 30 # 0 = keep forever
[torrent]
enabled = true
seed_ratio = 1.0 # stop seeding at this ratio ...
seed_time_mins = 60 # ... or after this long, whichever comes first
port_range = "6881-6889"
stall_mins = 30 # give up on a torrent making no progress
[feeds.atp]
url = "https://atp.fm/rss"
folder = "Accidental Tech Podcast" # default: the feed title
keywords = ["deep dive"] # OR across keywords, AND within one
allow_explicit = false
auto_download = true
max_new_per_check = 3 # the rest wait for the next scan
username = "ray" # optional HTTP basic auth
password_env = "IPX_ATP_PASS" # or a literal `password`
```
Retention keeps files that are `flagged` in the database, and deletes read episodes before unread
ones, oldest first.
## Socket protocol
Newline-delimited JSON over a Unix socket, both directions.
```sh ```sh
$ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
{"ev":"feed_start","feed":"atp"} node tests/page-smoke.js # the page script loads without throwing
{"ev":"progress","feed":"atp","url":"...","file":"ep1.mp3","done":8192,"total":3000000} npx playwright test # a real browser against a real daemon on fixture feeds
{"ev":"download_done","feed":"atp","url":"...","path":"...","bytes":3000000}
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
{"ev":"scan_done","feeds":1}
``` ```
Commands: `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `status`. `npm install` gets the test runner, and `npx playwright install --with-deps chromium` gets the
Events: `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`, browser.
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
`scan_done`, `reap_done` and `status` are terminal -- a client that asked for work stops there.
Progress is throttled to whole percents. The stream is a broadcast, so a client attached to a busy
daemon also sees that daemon's other work.
## Running it as a service
`contrib/` has a systemd user unit for the daemon, and a timer plus one-shot service if you would
rather run periodic scans with no daemon (in which case there is no socket for a UI to attach to).
## License ## License
MIT. See [LICENSE](LICENSE). MIT, see [LICENSE](LICENSE). The icons are [Font Awesome Free](https://fontawesome.com) 7.3.1 by
@fontawesome, under [CC BY 4.0](https://fontawesome.com/license/free), embedded as SVG.

View File

@@ -1,7 +0,0 @@
[Unit]
Description=ipx feed scan (one shot)
[Service]
Type=oneshot
ExecStart=%h/.cargo/bin/ipx fetch
Environment=IPX_LOG=ipx=info

View File

@@ -1,18 +0,0 @@
# User unit: install to ~/.config/systemd/user/ipx.service, then
# systemctl --user enable --now ipx
# The socket lands in $XDG_RUNTIME_DIR/ipx.sock by default, so a UI running as the
# same user can attach without extra configuration.
[Unit]
Description=ipx podcatcher
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/.cargo/bin/ipx daemon
Restart=on-failure
RestartSec=30
Environment=IPX_LOG=ipx=info
[Install]
WantedBy=default.target

View File

@@ -1,16 +0,0 @@
# Alternative to the daemon: a periodic one-shot scan, closer to how the original
# iPodderX agent was driven. Use this OR ipx.service, not both -- with no daemon
# running there is no socket, so a UI cannot attach.
#
# Install ipx-scan.service and ipx.timer to ~/.config/systemd/user/, then
# systemctl --user enable --now ipx.timer
[Unit]
Description=Periodic ipx feed scan
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Persistent=true
[Install]
WantedBy=timers.target

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
ipodderx:
image: 192.168.1.130:5000/ipodderx:latest
container_name: iPodderX
restart: unless-stopped
environment:
PUID: "99"
PGID: "100"
TZ: "America/Toronto"
IPX_LOG: "ipx=info"
ports:
- "8099:8099" # web UI
- "6881:6881/tcp" # BitTorrent peers
- "6881:6881/udp" # DHT
volumes:
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
- /mnt/user/ipodderx/:/data # state.db
- /mnt/user/ipodderx/downloads:/downloads
healthcheck:
test: ["CMD", "ipx", "status"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

40
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/sh
set -e
# A container's loopback is not reachable from outside it, so the default bind of
# 127.0.0.1 would leave the UI unreachable. Write a starter config that binds 0.0.0.0
# on first run; after that the file is yours and is never rewritten.
if [ ! -f "$IPX_CONFIG" ]; then
mkdir -p "$(dirname "$IPX_CONFIG")"
cat > "$IPX_CONFIG" <<TOML
[general]
download_dir = "/downloads"
schedule = "every 60m"
max_total_gb = 0
max_age_days = 0
[torrent]
enabled = true
port_range = "6881-6889"
[web]
enabled = true
bind = "0.0.0.0:8099"
token = ""
TOML
echo "ipx: wrote a starter config to $IPX_CONFIG"
fi
mkdir -p "$IPX_DATA_DIR" /downloads
# Unraid shares expect 99:100. Running as root would leave root-owned downloads.
if [ "$(id -u)" = "0" ] && [ -n "$PUID" ] && [ -n "$PGID" ]; then
if ! getent group ipx >/dev/null 2>&1; then addgroup --gid "$PGID" ipx 2>/dev/null || true; fi
if ! getent passwd ipx >/dev/null 2>&1; then
adduser --uid "$PUID" --gid "$PGID" --disabled-password --gecos "" ipx 2>/dev/null || true
fi
chown -R "$PUID:$PGID" "$IPX_DATA_DIR" "$(dirname "$IPX_CONFIG")" 2>/dev/null || true
exec gosu "$PUID:$PGID" "$@"
fi
exec "$@"

146
docs/architecture.md Normal file
View File

@@ -0,0 +1,146 @@
# How it works
One binary, `ipx`. `ipx daemon` runs three things in one process: a scheduler, a Unix-socket
control server, and the web UI. Everything else is a CLI that either does the work itself or hands
it to a running daemon.
## Modules
| File | Responsibility | What it replaced in the Python |
|---|---|---|
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
| `src/db.rs` | SQLite schema, migrations, every query | `.ipxd` plists, `history.dat`, `qmcache.dat` |
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
| `src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
| `src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
| `web/index.html` | The whole front end, `include_str!`d into the binary | — |
The page is compiled in, so **editing `web/index.html` needs a rebuild**.
## A scan
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there.
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
re-derived into the database each scan, never written to config.toml. A Patreon creator link
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
shows come from Patreon's web API and each becomes a derived feed.
4. Record entries. A changed title or description flips the item back to unread.
5. Record enclosures. `enclosures.url` is `UNIQUE`, which is the dedupe key and subsumes the
original's `history.dat` pickle: a reaped file keeps its row so it is never fetched twice.
6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
a reason. What a filter skipped is judged again every scan, so a change of settings brings it
back. A feed in a group takes your settings on the group for anything you have not set on it.
7. Download what is still pending, newest first, up to the per-scan cap. A `.torrent` body goes to
the torrent path whatever its advertised type; an HTML body is a failed download — a login wall
or an error page — and is deleted.
## Data model
```
feeds id, url, title, image, etag, last_modified, last_checked, ttl_mins,
last_error, orphaned, group_id, managed
entries feed_id, guid, title, link, published, description, first_seen,
image, duration, episode, season PK (feed_id, guid)
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
bytes_done, downloaded_at, last_error
users id, name, pass_hash, is_admin, created, last_login
sessions token, user_id, seen
subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
max_new_per_check PK (user_id, feed_id)
entry_state user_id, feed_id, guid, read, flagged, position
PK (user_id, feed_id, guid)
```
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
older database.
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same
path as `Db::open`, so a migration cannot pass the tests while missing in production.
## Control socket
Newline-delimited JSON, both directions, over `$XDG_RUNTIME_DIR/ipx.sock`.
```sh
printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
{"ev":"feed_start","feed":"atp"}
{"ev":"progress","feed":"atp","enclosure":42,"file":"ep1.mp3","done":8192,"total":3000000}
{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
{"ev":"scan_done","feeds":1}
```
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
(`enclosure`), `status`.
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
there. Commands run one at a time, in the order they arrive, except `status`: the socket answers it
straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
answers only the client that asked, since `status` would end any other client's session.
Progress carries the enclosure id, without which a UI cannot tell one download from another and
ends up animating every pending row. It is throttled to whole percents. The stream is a broadcast,
so a client attached to a busy daemon also sees that daemon's other work.
Inside the process the same events go over a `tokio::broadcast`; commands arrive on an `mpsc` and
are handled by a single worker, so nothing races over the same download. Shutdown is a `watch`
channel raced *inside* each job — `tokio::select!` only races branches at the point of selection,
so a long download had to be able to notice the signal itself.
## HTTP API
Everything below `/api` needs a signed-in user; the browser gets a redirect to `/login`, anything
else a `401`.
| Route | |
|---|---|
| `GET /` | the app |
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
| `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe |
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
| `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) |
| `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | |
| `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) |
| `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, kept, position |
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
| `GET /api/events` | SSE, the same broadcast the socket carries |
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
| `GET /media/{id}` | the file, with Range support so seeking works |
Show notes are feed-supplied HTML from an untrusted source, sanitized with `ammonia` server-side
before they reach the page.
## Testing
```sh
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
npx playwright test # a real browser against a real daemon on fixture feeds
```
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that
renders empty — which is what has actually reached users. Each Playwright case maps to a bug that
did.
The suite starts its own daemon and database under `/tmp/ipx-ui-test`, wiped once per run. Tests
share that daemon and run in order, so a test that marks something read changes what later tests
see — make assertions that do not depend on earlier ones.

91
docs/cli.md Normal file
View File

@@ -0,0 +1,91 @@
# Command line
```
ipx [--config PATH] [--local] <command>
```
Every command that has a wire form probes the control socket first: if a daemon is running, the
daemon does the work and the CLI just renders the events it streams back. That is deliberate — two
processes must never download the same thing. `--local` forces the work to happen in-process.
| Command | What it does |
|---|---|
| `ipx list` | Subscriptions and their state |
| `ipx status` | Counts: feeds, pending, downloaded |
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
| `ipx add <url> [--folder X] [--keywords a,b]` | Subscribe; the id comes from the feed title |
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
| `ipx reap [--dry-run]` | Run retention now |
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI |
## Accounts
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.
```sh
echo -n 'a good password' | ipx user add ray # local account
ipx user add ray@example.com --no-password # signs in through the proxy only
echo -n 'a good password' | ipx user passwd admin # change a password
ipx user list # who exists, and how each signs in
ipx user rm sam # account, subscriptions and read state
```
The first account created is an admin; later ones are ordinary users. A database with no accounts
at all gets **admin / ipodderx** on the next daemon start, announced in the log — change it.
To avoid even the command line, read it interactively:
```sh
read -s PW && echo -n "$PW" | ipx user passwd admin
```
## Scanning
```sh
ipx fetch # everything due
ipx fetch atp --force # one feed, ignoring its TTL and schedule
```
A scan: conditional GET (`If-None-Match` / `If-Modified-Since`), parse, record new entries, apply
the filters, then download up to the per-scan cap, newest first. A feed nothing has changed in
answers `304` and costs one request.
## Retention
```sh
ipx reap --dry-run # what would go, oldest first
ipx reap # actually delete
```
Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days`
with no file are pruned from the database. **An item anyone kept keeps its file**, and one only counts
as read when everyone subscribed has read it. The enclosure row survives as `reaped`, which is what
stops the next scan fetching it again.
## The daemon
```sh
ipx daemon # scheduler + socket + web UI
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
```
One daemon per socket; a second refuses to start rather than fight over the database. It shuts down
cleanly on SIGTERM, including mid-download.
To kill it, match the binary exactly:
```sh
pkill -x ipx
```
`pkill -f ipx` matches the shell running the command too, and kills your own session.
## Talking to it directly
```sh
printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
```
See [architecture.md](architecture.md#control-socket) for the protocol.

120
docs/configuration.md Normal file
View File

@@ -0,0 +1,120 @@
# Configuration
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
| What | Where | Override |
|---|---|---|
| Config | `~/.config/ipx/config.toml` | `--config`, `$IPX_CONFIG` |
| Database | `~/.local/share/ipx/state.db` | `$IPX_DATA_DIR` |
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
| Downloads | `[general] download_dir` | — |
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db`
while the daemon is stopped, or with `sqlite3 state.db .backup`.
## `[general]`
```toml
[general]
download_dir = "~/Podcasts"
socket = "/run/user/1000/ipx.sock"
schedule = "every 1h" # "every 30m", "every 4h", "2d", "90" (minutes)
organize = "feed" # "feed" | "date"
max_total_gb = 50 # 0 = unlimited
max_age_days = 30 # 0 = keep forever
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"]
```
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
read it. `0` disables it entirely.
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
Kept ones stay. `0` disables it.
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
fetched 216 files and 22 GB in one scan.
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
without this the disk fills with artwork. Empty takes everything.
## `[torrent]`
```toml
[torrent]
enabled = true
seed_ratio = 1.0 # stop seeding at this ratio ...
seed_time_mins = 60 # ... or after this long, whichever comes first
port_range = "6881-6889"
stall_mins = 30 # give up on a torrent making no progress
```
A `.torrent` body is handed to the torrent path whatever MIME type it was advertised as. Torrents
run on their own tasks (two at a time) so a slow swarm never blocks a scan.
## `[web]`
```toml
[web]
enabled = true
bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
token = "" # generated and saved on first run
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1"]
auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30
```
* **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so
you paste it once per browser. It is what the Docker healthcheck and any scripts use.
* **`trusted_header`** — a header naming the signed-in user, set by whatever fronts ipx. Empty
disables that path. See [sso.md](sso.md).
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
for it. Name the proxy, never a subnet.
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
the proxy signs them straight back in.
* **`session_days`** — sign a session out after this long without a request.
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
itself carry a credential. Put TLS in front of it if that matters.
## `[feeds.<id>]`
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add`
derives it from the feed title.
```toml
[feeds.atp]
url = "https://atp.fm/rss"
folder = "Accidental Tech Podcast" # default: the feed title
schedule = "every 6h" # overrides [general] for this feed
media_types = ["audio"] # overrides [general] for this feed
username = "ray" # HTTP basic auth
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
```
With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
`max_new_per_check` live on each person's subscription in the database**, not here — the values in
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md).
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry.
## Environment
| Variable | Effect |
|---|---|
| `IPX_CONFIG` | Config file path |
| `IPX_DATA_DIR` | Directory holding `state.db` |
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |

1670
docs/history.md Normal file

File diff suppressed because it is too large Load Diff

203
docs/sso.md Normal file
View File

@@ -0,0 +1,203 @@
# Signing in through Cloudflare Access and Authentik
ipx can take the signed-in identity from whatever sits in front of it, instead of asking for a
password itself. The proxy authenticates the person and passes the result to ipx in a **header**;
ipx reads it, finds (or creates) the matching account, and gets on with it.
Read [How this is secured](#how-this-is-secured) before exposing anything. The short version: a
header is worth exactly as much as the hop that set it, so ipx only believes one from an address you
list.
---
## How ipodderx.sdf1.net does it
Checked end to end on 2026-09-12. An earlier version of this page had never been tried against a
real setup and pointed at the wrong address.
```
browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect)
─► tunnel "rays-unraid" (the cloudflared container on Tower)
─► http://192.168.1.130:8099 ─► ipx
```
Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access
then adds `Cf-Access-Authenticated-User-Email`, the email address Authentik gave it, to every
request it forwards through the tunnel, and ipx signs that person in.
| Piece | Where | Setting |
|---|---|---|
| Identity provider | Zero Trust → Settings → Authentication | `Authentik`, OpenID Connect; scopes `openid email profile` |
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
```toml
[web]
enabled = true
bind = "0.0.0.0:8099"
trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
auto_create_users = true
sign_out_url = "/cdn-cgi/access/logout"
session_days = 30
```
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
restart ipodderx`.
### What was missing
Cloudflare and Authentik were already right. Three things on the ipx side were not:
1. **`trusted_header` was empty**, which switches the whole proxy path off. ipx ignored the header
and asked for a password.
2. **`trusted_proxies` listed only `127.0.0.1`.** The tunnel's requests do not come from there;
see the next section.
3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty
account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything.
### The address to trust, and why it is 192.168.16.1
`cloudflared` runs in its own container and reaches ipx through the host's published port. Docker
(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's
requests arrive from the **gateway of ipx's own network**, `content_default`:
```sh
docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
```
That was measured, not assumed. ipx does not log where a request came from, so the addresses were
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
lists them in hex.)
If the `content` project's network is ever recreated, its gateway can change. Check it again, and
update `trusted_proxies` to match.
### Names
The username is the email address, lower-cased: `rays@sdf1.net`. To sign in at `/login` with a
password from the LAN, use that name too.
To let someone else in, add their address to the Access policy; they need an Authentik account with
that email. With `auto_create_users = true` they get an ipx account on their first visit, as an
ordinary user with no feeds. An account made before the proxy can be given the name the proxy will
send:
```sh
docker exec iPodderX ipx user rename <old name> <email address>
```
### Signing out
**Sign out** sends someone the proxy signed in to `sign_out_url`, here Cloudflare's
`/cdn-cgi/access/logout`. That ends your Access session for **every** Access application,
`code.sdf1.net` included: Cloudflare has no way to end just one, and its sign-out page does not send
you anywhere afterwards. The next visit goes back through Authentik, which lets you straight in if
you are still signed in there. Signing out of Authentik itself is Authentik's own sign-out.
ipx never shows its password page to someone the proxy vouches for: `/login` sends them on to their
feeds.
### The tile in Authentik's library
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
tile. Who actually gets in is still up to the Access policy.
### Check it
```sh
# From Tower itself: not a trusted address, so the header is ignored.
curl -s -H 'Accept: application/json' -H 'Cf-Access-Authenticated-User-Email: rays@sdf1.net' \
http://192.168.1.130:8099/api/me # -> sign in
# From a container on a Docker bridge, as cloudflared is: believed.
docker run --rm --network bridge mirror.gcr.io/library/busybox wget -qO- \
--header 'Accept: application/json' --header 'Cf-Access-Authenticated-User-Email: rays@sdf1.net' \
http://192.168.1.130:8099/api/me # -> {"admin":true,"name":"rays@sdf1.net"}
```
Then open `https://ipodderx.sdf1.net` in a private window. Authentik should ask who you are, and
ipx should show `rays@sdf1.net` in the sidebar footer without asking for a password.
---
## The ipx settings
| Key | What it does |
|---|---|
| `trusted_header` | The header the proxy sets. Empty, the default, turns the proxy path off. |
| `trusted_proxies` | The addresses allowed to set it. Nothing else is believed. |
| `auto_create_users` | Make an account the first time the proxy vouches for a name ipx has not seen. |
| `sign_out_url` | Where Sign out sends someone the proxy signed in: the proxy's own sign-out. Empty sends them to the sign-in page, where the proxy signs them straight back in. |
| `session_days` | How long a password sign-in lasts without use. |
The first account ever created is an admin. Every later one is an ordinary user, who cannot change
global settings, a feed's URL or folder, or how often feeds are scanned: the API refuses those with
a `403`, not just the UI. Everything else about a feed is theirs alone; see [users.md](users.md).
Local sign-in at `/login` keeps working alongside the proxy, which is how you get in from the LAN
when the tunnel is down. So does the shared `[web] token`, which signs in as the admin and is the
way back in if you lock yourself out. A brand new database starts with **admin / ipodderx**;
change it.
---
## Authentik in the request path instead
Not what ipodderx.sdf1.net uses, and **not verified**. Authentik can also sit in front of ipx
itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-username`:
- Applications → Providers → Create → Proxy Provider; mode **Proxy** (the outpost talks to ipx) or
**Forward auth** (an existing reverse proxy asks the outpost).
- Applications → Create, bound to that provider, with a policy; add the provider to an outpost.
- In ipx: `trusted_header = "X-authentik-username"`, and the outpost's or reverse proxy's address
in `trusted_proxies`. Measure that address as above rather than guessing it.
---
## How this is secured
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the
request falls through to a session cookie or the shared token. That is the whole security boundary.
With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
itself, arrive under their own addresses and cannot set the header; the checks above show both
sides. Never list a LAN address or range: anyone there could then send
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
boundary, and is the upgrade if that ever matters.
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
in with them until they are given a password (`ipx user passwd <name>`).
---
## Everyday administration
```sh
ipx user list # who exists, how each signs in, and when
echo -n 'secret123' | ipx user add sam # local account, password on stdin
ipx user add sam@example.com --no-password # proxy-only account, made ahead of time
ipx user rename sam sam@example.com # give an account the name the proxy sends
echo -n 'newsecret' | ipx user passwd sam # change a password
ipx user rm sam # remove the account
```
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
that read a password.
Set `auto_create_users = false` once everyone who should have an account has one. After that the
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
the exact name the header will carry.
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.

98
docs/users.md Normal file
View File

@@ -0,0 +1,98 @@
# Accounts, and what several people share
ipx serves any number of people from one copy of the data. The rule that decides everything else:
**there is one file on disk per enclosure URL.** Two people subscribed to the same show cost one
fetch, one parse and one file.
## What is yours, what is everyone's
| Yours alone | The same for everyone |
|---|---|
| Read, kept, playback position | The feed's URL |
| Which feeds you see at all | Its download folder |
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
| | The file on disk |
The right-hand column describes the feed and the file rather than a preference — two people wanting
different folders would mean two copies. Those three are **admin-only**, and the API returns `403`
for anyone else rather than merely hiding the controls.
## How the scanner merges everyone's wants
One fetch serves every subscriber, so the policy is a union:
* an item is downloaded if **anyone** wants it — one person's keyword set matching is enough, and
one person with no keywords removes the filter for that feed entirely
* auto-download is on if **anyone** has it on
* the per-scan cap is the **largest** anyone asked for
So "auto-download off" means *I don't cause downloads*, not *I never see them*. If someone else's
subscription pulls an item, you see it listed as downloaded and can play it, because the enclosure
is shared.
## Deleting
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
has kept the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
reason, and only `?force=true` goes through.
Retention follows the same rule: an item anyone kept keeps its file, and it counts as read only once
every subscriber has read it.
## Signing in
Three ways, tried in order of how specific the claim is:
1. **A proxy header** naming the user — Cloudflare Zero Trust or Authentik. Honoured only from an
address in `trusted_proxies`. See [sso.md](sso.md).
2. **A session cookie** from signing in at `/login`. Argon2id hashes, sessions in the database,
idle timeout `session_days`.
3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck
and any scripts use.
A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in
the log. Change it:
```sh
echo -n 'a good password' | ipx user passwd admin
```
## Adding someone
```sh
echo -n 'their password' | ipx user add sam
```
They sign in at `/login` and start with **no feeds**: subscriptions are per person. Adding a feed
someone else already has costs nothing — no second fetch, no second copy — it just appears on their
list with their own read state. Unsubscribing removes it from their list alone; only when the last
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
re-subscribing does not pull the back catalogue again.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
they pay for.
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
removed there, so there is always someone who can manage the rest.
## Admin
The first account is an admin. An admin can change global settings (scanning interval, quota,
retention, media types, download folder), a feed's URL, folder and schedule, and who has an account
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
```sh
ipx user list # the admin column says who
```

58
package-lock.json generated Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "ipx-ui-tests",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ipx-ui-tests",
"devDependencies": {
"@playwright/test": "^1.56.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "ipx-ui-tests",
"private": true,
"description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"smoke": "node tests/page-smoke.js"
},
"devDependencies": {
"@playwright/test": "^1.56.0"
}
}

43
playwright.config.js Normal file
View File

@@ -0,0 +1,43 @@
const { defineConfig } = require('@playwright/test');
const setup = require('./tests/ui/global-setup');
// Before anything else, including the servers below.
setup.prepare();
// Real browser against a real daemon. The stub-DOM smoke test catches a script that
// fails to load; it cannot catch a wrong selector, a handler that runs but does nothing,
// or a page that renders empty -- which is exactly what has slipped through before.
module.exports = defineConfig({
testDir: './tests/ui',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false, // one daemon, one database
workers: 1,
reporter: process.env.CI ? 'line' : [['list']],
use: {
baseURL: 'http://127.0.0.1:8791',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: [
{
command: 'node tests/ui/fixtures/serve.js',
port: 8792,
reuseExistingServer: false,
stdout: 'ignore',
},
{
// Build first so the tests always run against current source.
command: 'cargo build -q && exec ./target/debug/ipx daemon',
port: 8791,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
env: {
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
IPX_LOG: 'ipx=info',
},
},
],
});

11
skills-lock.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"security-audit": {
"source": "cloudflare/security-audit-skill",
"sourceType": "github",
"skillPath": "skills/security-audit/SKILL.md",
"computedHash": "98b97aad2873b3b9a8e064007c25e7827b493ba27fda8bfbdb41dab586767973"
}
}
}

92
src/auth.rs Normal file
View File

@@ -0,0 +1,92 @@
//! Who is asking. Sign-in is either a local password or a header set by whatever fronts
//! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated
//! address in `Cf-Access-Authenticated-User-Email`.
use anyhow::{Result, bail};
use argon2::Argon2;
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
/// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The
/// salt is generated per password by the hasher itself.
pub fn hash_password(password: &str) -> Result<String> {
if password.len() < 8 {
bail!("password must be at least 8 characters");
}
Argon2::default()
.hash_password(password.as_bytes())
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("could not hash the password: {e}"))
}
/// False for a wrong password *and* for a stored hash this build cannot parse; either way
/// the answer is no.
pub fn verify_password(password: &str, stored: &str) -> bool {
let Ok(parsed) = PasswordHash::new(stored) else {
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
pub fn new_session_token() -> String {
let mut bytes = [0u8; 32];
if getrandom(&mut bytes).is_err() {
// Falling back to the clock would be a predictable session id. Better to fail.
panic!("no source of randomness for a session token");
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
use std::io::Read;
std::fs::File::open("/dev/urandom")?.read_exact(buf)
}
/// A username taken from a proxy header. Cloudflare sends an email address; the local part
/// is what a person recognises, and the whole thing stays unique enough for one household.
pub fn name_from_header(raw: &str) -> Option<String> {
let name = raw.trim();
if name.is_empty() || name.len() > 190 {
return None;
}
// Anything that could confuse a lookup or a log line is not a name.
if name.chars().any(|c| c.is_control() || c == ',' || c == ';') {
return None;
}
Some(name.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_password_verifies_only_against_itself() {
let h = hash_password("correct horse battery").unwrap();
assert!(verify_password("correct horse battery", &h));
assert!(!verify_password("Correct horse battery", &h));
assert!(!verify_password("", &h));
// A hash from a different scheme, or a truncated one, must not authenticate.
assert!(!verify_password("correct horse battery", "not-a-hash"));
assert!(hash_password("short").is_err());
}
#[test]
fn session_tokens_are_long_and_distinct() {
let a = new_session_token();
let b = new_session_token();
assert_eq!(a.len(), 64);
assert_ne!(a, b);
}
#[test]
fn a_header_name_is_cleaned_or_refused() {
assert_eq!(name_from_header(" Ray@Example.COM "), Some("ray@example.com".into()));
assert_eq!(name_from_header(""), None);
assert_eq!(name_from_header("ray\nadmin"), None);
assert_eq!(name_from_header("ray;admin"), None);
}
}

View File

@@ -5,29 +5,40 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize, Serialize)] #[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct Config { pub struct Config {
#[serde(default)] #[serde(default)]
pub general: General, pub general: General,
#[serde(default)] #[serde(default)]
pub torrent: Torrent, pub torrent: Torrent,
#[serde(default)]
pub web: Web,
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL). /// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
#[serde(default)] #[serde(default)]
pub feeds: BTreeMap<String, Feed>, pub feeds: BTreeMap<String, Feed>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
pub struct General { pub struct General {
pub download_dir: PathBuf, pub download_dir: PathBuf,
pub socket: PathBuf, pub socket: PathBuf,
/// Default poll interval; a feed's own <ttl> wins when it is longer. /// How often to re-check feeds: "every 30m", "every 4h", "90" (minutes), "1d".
pub interval_mins: u64, /// A feed's own `schedule` overrides this.
pub schedule: String,
pub organize: Organize, pub organize: Organize,
/// 0 = unlimited. /// 0 = unlimited.
pub max_total_gb: f64, pub max_total_gb: f64,
/// 0 = keep forever. /// 0 = keep forever.
pub max_age_days: u64, pub max_age_days: u64,
/// How many new enclosures a single scan may take, when a feed does not say.
/// Unlimited by default was a trap: subscribing to an OPML of 80 feeds then pulled
/// every back-catalogue episode at once. 0 means unlimited, deliberately chosen.
pub max_new_per_check: usize,
/// Top-level media types worth downloading. Blog feeds put each article's header
/// image in an <enclosure>, so taking everything filled the disk with artwork and
/// counted it as episodes. Empty means take anything.
pub media_types: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -39,7 +50,7 @@ pub enum Organize {
Date, Date,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
pub struct Torrent { pub struct Torrent {
pub enabled: bool, pub enabled: bool,
@@ -51,6 +62,53 @@ pub struct Torrent {
pub stall_mins: u64, pub stall_mins: u64,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Web {
pub enabled: bool,
/// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token.
pub bind: String,
/// Shared secret. Generated and written back on first run when left empty. It signs
/// in as the admin, which is what keeps the healthcheck and any scripts working.
pub token: String,
/// A header naming the signed-in user, set by whatever fronts this -- Cloudflare Zero
/// Trust sends `Cf-Access-Authenticated-User-Email`. Empty disables the whole path.
pub trusted_header: String,
/// Addresses allowed to assert that header. A header is only as trustworthy as the
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
pub trusted_proxies: Vec<String>,
/// Create an account the first time the proxy vouches for a name it has not seen.
pub auto_create_users: bool,
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
/// while the proxy still vouches for them, so this is the proxy's own sign-out:
/// `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to /login.
pub sign_out_url: String,
/// Sign a session out after this long without a request.
pub session_days: i64,
}
impl Default for Web {
fn default() -> Self {
Self {
enabled: false,
bind: "127.0.0.1:8080".into(),
token: String::new(),
trusted_header: String::new(),
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
auto_create_users: true,
sign_out_url: String::new(),
session_days: 30,
}
}
}
impl Web {
pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Feed { pub struct Feed {
pub url: String, pub url: String,
@@ -65,7 +123,17 @@ pub struct Feed {
pub allow_explicit: bool, pub allow_explicit: bool,
#[serde(default = "yes")] #[serde(default = "yes")]
pub auto_download: bool, pub auto_download: bool,
/// Cap on new downloads per scan. None = unlimited. /// Set on feeds that came from a subscribed OPML: the id of the OPML feed they
/// belong to. The OPML is re-read on every scan and this list kept in step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// Overrides the global schedule for this feed. Same forms: "every 6h", "2d".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<String>,
/// Media types for this feed. None follows `[general]`; an empty list takes anything.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_types: Option<Vec<String>>,
/// Cap on new downloads per scan for this feed. None follows `[general]`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub max_new_per_check: Option<usize>, pub max_new_per_check: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -86,10 +154,12 @@ impl Default for General {
Self { Self {
download_dir: home().join("Podcasts"), download_dir: home().join("Podcasts"),
socket: default_socket(), socket: default_socket(),
interval_mins: 60, schedule: "every 60m".into(),
organize: Organize::Feed, organize: Organize::Feed,
max_total_gb: 0.0, max_total_gb: 0.0,
max_age_days: 0, max_age_days: 0,
max_new_per_check: 3,
media_types: vec!["audio".into(), "video".into()],
} }
} }
} }
@@ -106,6 +176,49 @@ impl Default for Torrent {
} }
} }
impl General {
/// Minutes between checks, or an hour when `schedule` is empty or unreadable. A malformed
/// value warns rather than stopping the daemon.
pub fn interval(&self) -> u64 {
if let Some(n) = parse_interval(&self.schedule) {
return n;
}
if !self.schedule.trim().is_empty() {
tracing::warn!(schedule = %self.schedule, "unrecognised schedule; using the default");
}
60
}
}
/// Parses a check interval into minutes.
///
/// Accepts "every 30m", "30m", "4h", "1d", "2w", "every 4 hours", or a bare number of
/// minutes.
/// Returns None for anything it cannot read, or for zero.
pub fn parse_interval(s: &str) -> Option<u64> {
let s = s.trim().to_lowercase();
let s = s.strip_prefix("every").unwrap_or(&s).trim();
if s.is_empty() {
return None;
}
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
let n: u64 = digits.parse().ok()?;
let unit = s[digits.len()..].trim();
let mins = match unit {
"" | "m" | "min" | "mins" | "minute" | "minutes" => n,
"h" | "hr" | "hrs" | "hour" | "hours" => n.checked_mul(60)?,
"d" | "day" | "days" => n.checked_mul(1440)?,
"w" | "week" | "weeks" => n.checked_mul(10080)?,
_ => return None,
};
(mins > 0).then_some(mins)
}
impl Torrent { impl Torrent {
/// Inclusive listen port range. Falls back to the BitTorrent default on garbage input. /// Inclusive listen port range. Falls back to the BitTorrent default on garbage input.
pub fn ports(&self) -> (u16, u16) { pub fn ports(&self) -> (u16, u16) {
@@ -166,9 +279,7 @@ pub fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("IPX_CONFIG") { if let Ok(p) = std::env::var("IPX_CONFIG") {
return PathBuf::from(p); return PathBuf::from(p);
} }
dirs::config_dir() xdg("XDG_CONFIG_HOME", ".config").join("ipx/config.toml")
.unwrap_or_else(|| home().join(".config"))
.join("ipx/config.toml")
} }
/// `$IPX_DATA_DIR`, else `$XDG_DATA_HOME/ipx`. /// `$IPX_DATA_DIR`, else `$XDG_DATA_HOME/ipx`.
@@ -176,9 +287,7 @@ pub fn data_dir() -> PathBuf {
if let Ok(p) = std::env::var("IPX_DATA_DIR") { if let Ok(p) = std::env::var("IPX_DATA_DIR") {
return PathBuf::from(p); return PathBuf::from(p);
} }
dirs::data_dir() xdg("XDG_DATA_HOME", ".local/share").join("ipx")
.unwrap_or_else(|| home().join(".local/share"))
.join("ipx")
} }
fn default_socket() -> PathBuf { fn default_socket() -> PathBuf {
@@ -188,6 +297,25 @@ fn default_socket() -> PathBuf {
} }
} }
/// Whether an enclosure's type is one we want.
///
/// An unknown type is allowed: the real type is only known after downloading, and
/// refusing everything untyped would drop feeds that simply omit the attribute.
pub fn wanted_media(mime: Option<&str>, wanted: &[String]) -> bool {
if wanted.is_empty() {
return true;
}
let Some(mime) = mime.map(str::trim).filter(|m| !m.is_empty()) else {
return true;
};
let top = mime.split('/').next().unwrap_or(mime).to_ascii_lowercase();
// A .torrent is a container for media, not media itself; judge it once unpacked.
if mime.to_ascii_lowercase().contains("torrent") {
return true;
}
wanted.iter().any(|w| w.trim().eq_ignore_ascii_case(&top) || w.trim().eq_ignore_ascii_case(mime))
}
/// Feed ids are the TOML table key, so they must be readable and punctuation-free. /// Feed ids are the TOML table key, so they must be readable and punctuation-free.
pub fn slug(text: &str) -> String { pub fn slug(text: &str) -> String {
let mut out = String::new(); let mut out = String::new();
@@ -215,8 +343,16 @@ pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap() (2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap()
} }
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
fn xdg(var: &str, fallback: &str) -> PathBuf {
std::env::var_os(var)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home().join(fallback))
}
fn home() -> PathBuf { fn home() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."))
} }
fn expand_tilde(p: &Path) -> PathBuf { fn expand_tilde(p: &Path) -> PathBuf {
@@ -236,6 +372,8 @@ mod tests {
r#" r#"
[general] [general]
download_dir = "/tmp/pods" download_dir = "/tmp/pods"
# A key older versions read. An old config that still has it has to load.
interval_mins = 45
[feeds.example] [feeds.example]
url = "https://example.com/feed.xml" url = "https://example.com/feed.xml"
@@ -245,7 +383,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(cfg.general.download_dir, PathBuf::from("/tmp/pods")); assert_eq!(cfg.general.download_dir, PathBuf::from("/tmp/pods"));
assert_eq!(cfg.general.interval_mins, 60); assert_eq!(cfg.general.interval(), 60);
assert_eq!(cfg.general.organize, Organize::Feed); assert_eq!(cfg.general.organize, Organize::Feed);
assert!(cfg.torrent.enabled); assert!(cfg.torrent.enabled);
@@ -256,6 +394,36 @@ mod tests {
assert_eq!(feed.keywords, vec!["deep dive"]); assert_eq!(feed.keywords, vec!["deep dive"]);
} }
#[test]
fn intervals_parse_from_the_forms_people_actually_type() {
for (input, want) in [
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
] {
assert_eq!(parse_interval(input), Some(want), "{input:?}");
}
for bad in ["", " ", "every", "soon", "-5m", "0", "0h", "every 0 minutes", "5 fortnights"] {
assert_eq!(parse_interval(bad), None, "{bad:?} should not parse");
}
}
#[test]
fn interval_falls_back_to_an_hour() {
let mut g = General::default();
assert_eq!(g.interval(), 60, "the default schedule");
g.schedule = "every 15m".into();
assert_eq!(g.interval(), 15);
// Empty or garbage must not stop the daemon.
g.schedule = String::new();
assert_eq!(g.interval(), 60);
g.schedule = "whenever".into();
assert_eq!(g.interval(), 60);
}
#[test] #[test]
fn port_range_falls_back_when_malformed() { fn port_range_falls_back_when_malformed() {
let mut t = Torrent::default(); let mut t = Torrent::default();
@@ -268,6 +436,26 @@ mod tests {
assert_eq!(t.ports(), (6881, 6889), "reversed range is not a range"); assert_eq!(t.ports(), (6881, 6889), "reversed range is not a range");
} }
#[test]
fn media_types_keep_article_artwork_out() {
let want = vec!["audio".to_string(), "video".to_string()];
assert!(wanted_media(Some("audio/mpeg"), &want));
assert!(wanted_media(Some("audio/mp4"), &want));
assert!(wanted_media(Some("video/quicktime"), &want));
assert!(!wanted_media(Some("image/jpeg"), &want), "a blog header image is not an episode");
assert!(!wanted_media(Some("text/html"), &want));
// A torrent is a container; what is inside is judged after unpacking.
assert!(wanted_media(Some("application/x-bittorrent"), &want));
// Unknown type: only discoverable by downloading, so do not refuse it outright.
assert!(wanted_media(None, &want));
assert!(wanted_media(Some(""), &want));
// An empty list means take anything, which is how it behaved before.
assert!(wanted_media(Some("image/jpeg"), &[]));
// A full type can be named exactly.
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
}
#[test] #[test]
fn slugs_are_readable_and_unique() { fn slugs_are_readable_and_unique() {
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast"); assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
@@ -279,7 +467,7 @@ mod tests {
let mut taken = BTreeMap::new(); let mut taken = BTreeMap::new();
taken.insert("the-daily".to_string(), Feed { taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, keywords: vec![], allow_explicit: false, url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None, auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, password: None, password_env: None,
}); });
@@ -291,6 +479,9 @@ mod tests {
let mut f = Feed { let mut f = Feed {
url: "https://x/y".into(), url: "https://x/y".into(),
folder: None, folder: None,
group: None,
media_types: None,
schedule: None,
keywords: vec![], keywords: vec![],
allow_explicit: false, allow_explicit: false,
auto_download: true, auto_download: true,

1488
src/db.rs

File diff suppressed because it is too large Load Diff

View File

@@ -7,19 +7,56 @@ use tokio::io::AsyncWriteExt;
use crate::config::{Config, Feed as FeedCfg, Organize}; use crate::config::{Config, Feed as FeedCfg, Organize};
/// Characters the original's stringCleaning() stripped, plus the control range and the /// Forbidden characters that were separating words: they become "-" so the words stay
/// trailing dots/spaces it left in. A real length cap is new -- the Python had none. /// apart. The original's stringCleaning() deleted them, turning "Show | Series" into
const FORBIDDEN: &[char] = &['/', '\\', '?', '*', ':', '<', '>', '|', '"', '\'']; /// "Show Series".
const SEPARATORS: &[char] = &['/', '\\', '|', ':'];
/// Forbidden characters that were never separators: they just go.
const STRIPPED: &[char] = &['?', '*', '<', '>', '"', '\''];
/// A real length cap is new -- the Python had none.
const MAX_NAME_BYTES: usize = 255; const MAX_NAME_BYTES: usize = 255;
/// Keeps UTF-8: the original transliterated to ASCII via latin1_to_ascii because 2004 /// Keeps UTF-8: the original transliterated to ASCII via latin1_to_ascii because 2004
/// filesystems demanded it. Ours do not. /// filesystems demanded it. Ours do not.
pub fn sanitize(name: &str) -> String { pub fn sanitize(name: &str) -> String {
let mut out: String = name let mapped: String = name
.chars() .chars()
.filter(|c| !c.is_control() && !FORBIDDEN.contains(c)) .map(|c| if c.is_control() { ' ' } else { c })
.filter(|c| !STRIPPED.contains(c))
.map(|c| if SEPARATORS.contains(&c) { '-' } else { c })
.collect(); .collect();
out = out.trim().trim_matches('.').trim().to_owned();
// Collapse each run of dashes and spaces into one thing. A run containing a dash
// becomes " - " when it also had whitespace ("Show | Series" -> "Show - Series",
// "Ep 12: One" -> "Ep 12 - One") and a bare "-" when it did not ("AC/DC" -> "AC-DC").
// A run of plain whitespace collapses to a single space.
let mut out = String::with_capacity(mapped.len());
let mut chars = mapped.chars().peekable();
while let Some(c) = chars.next() {
if !(c == '-' || c.is_whitespace()) {
out.push(c);
continue;
}
let mut has_dash = c == '-';
let mut has_space = c.is_whitespace();
while let Some(&next) = chars.peek() {
if next == '-' {
has_dash = true;
} else if next.is_whitespace() {
has_space = true;
} else {
break;
}
chars.next();
}
match (has_dash, has_space) {
(true, true) => out.push_str(" - "),
(true, false) => out.push('-'),
_ => out.push(' '),
}
}
// Leading/trailing separators and dots are noise, and a leading "-" trips up CLI tools.
out = out.trim().trim_matches(|c| c == '.' || c == '-').trim().to_owned();
if out.len() > MAX_NAME_BYTES { if out.len() > MAX_NAME_BYTES {
// Truncate on a char boundary, keeping the extension if there is a plausible one. // Truncate on a char boundary, keeping the extension if there is a plausible one.
@@ -186,7 +223,7 @@ enum Sniffed {
/// 2008 and so always answered 'data'. /// 2008 and so always answered 'data'.
async fn sniff(path: &Path) -> Result<Sniffed> { async fn sniff(path: &Path) -> Result<Sniffed> {
let head = read_head(path, 512).await?; let head = read_head(path, 512).await?;
if infer::is(&head, "torrent") || head.starts_with(b"d8:announce") || head.starts_with(b"d7:") { if head.starts_with(b"d8:announce") || head.starts_with(b"d7:") {
return Ok(Sniffed::Torrent); return Ok(Sniffed::Torrent);
} }
let text = String::from_utf8_lossy(&head); let text = String::from_utf8_lossy(&head);
@@ -231,17 +268,27 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
} }
/// Download folder for a feed: per-feed name, or per-day when organize = "date". /// Download folder for a feed: per-feed name, or per-day when organize = "date".
///
/// A folder may name more than one level ("Subscriptions/Some Show") -- feeds from a
/// subscribed OPML nest under it -- so each segment is sanitized separately rather than
/// letting the sanitizer eat the separator.
pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str>) -> String { pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str>) -> String {
match cfg.general.organize { match cfg.general.organize {
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(), Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
Organize::Feed => sanitize( Organize::Feed => {
feed_cfg let raw = feed_cfg
.folder .folder
.as_deref() .as_deref()
.or(title) .or(title)
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.unwrap_or(id), .unwrap_or(id);
), raw.split('/')
.map(str::trim)
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
.map(sanitize)
.collect::<Vec<_>>()
.join("/")
}
} }
} }
@@ -264,12 +311,26 @@ mod tests {
#[test] #[test]
fn sanitize_strips_path_and_control_characters() { fn sanitize_strips_path_and_control_characters() {
assert_eq!(sanitize("../../etc/passwd"), "etcpasswd"); assert_eq!(sanitize("../../etc/passwd"), "etc-passwd");
assert_eq!(sanitize("Ep 12: The \"Best\" One?"), "Ep 12 The Best One"); assert_eq!(sanitize("Ep 12: The \"Best\" One?"), "Ep 12 - The Best One");
assert_eq!(sanitize("bad\u{0}name\u{7}.mp3"), "badname.mp3"); assert_eq!(sanitize("bad\u{0}name\u{7}.mp3"), "bad name .mp3");
assert_eq!(sanitize(" spaced.mp3 "), "spaced.mp3"); assert_eq!(sanitize(" spaced.mp3 "), "spaced.mp3");
} }
#[test]
fn sanitize_turns_separators_into_dashes() {
// A real Patreon feed title; the pipes are forbidden characters.
assert_eq!(
sanitize("Get in the Trunk | Anthology Series | Delta Green"),
"Get in the Trunk - Anthology Series - Delta Green"
);
assert_eq!(sanitize("Ep 12: The One"), "Ep 12 - The One");
assert_eq!(sanitize("a b"), "a b", "plain whitespace stays whitespace");
assert_eq!(sanitize("AC/DC"), "AC-DC", "no spaces around it, so no spaces added");
assert_eq!(sanitize("well-known.mp3"), "well-known.mp3", "existing dashes survive");
assert_eq!(sanitize("Show -- Thing"), "Show - Thing");
}
#[test] #[test]
fn sanitize_never_yields_an_empty_or_dot_name() { fn sanitize_never_yields_an_empty_or_dot_name() {
assert_eq!(sanitize(""), "download"); assert_eq!(sanitize(""), "download");
@@ -319,6 +380,22 @@ mod tests {
); );
} }
#[test]
fn a_folder_can_nest_without_the_sanitizer_eating_the_separator() {
let mut cfg = Config::default();
cfg.general.download_dir = "/tmp".into();
let mut f = crate::config::Feed {
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
max_new_per_check: None, username: None, password: None, password_env: None,
};
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
// A traversal in a folder name must not climb out of the download directory.
f.folder = Some("../../etc/Show".into());
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
}
#[test] #[test]
fn keyword_matching_is_or_across_keywords_and_and_within_one() { fn keyword_matching_is_or_across_keywords_and_and_within_one() {
let kws = vec!["deep dive".to_string(), "interview".to_string()]; let kws = vec!["deep dive".to_string(), "interview".to_string()];

View File

@@ -10,6 +10,7 @@ use crate::config::Feed as FeedCfg;
pub struct ParsedFeed { pub struct ParsedFeed {
pub title: Option<String>, pub title: Option<String>,
pub ttl_mins: Option<u64>, pub ttl_mins: Option<u64>,
pub image: Option<String>,
pub entries: Vec<Entry>, pub entries: Vec<Entry>,
} }
@@ -22,10 +23,16 @@ pub struct Entry {
pub description: Option<String>, pub description: Option<String>,
pub categories: Vec<String>, pub categories: Vec<String>,
pub explicit: bool, pub explicit: bool,
/// Episode artwork; falls back to the feed's in the UI.
pub image: Option<String>,
/// Seconds.
pub duration: Option<i64>,
pub episode: Option<i64>,
pub season: Option<i64>,
pub enclosures: Vec<Enclosure>, pub enclosures: Vec<Enclosure>,
} }
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, Clone, PartialEq)]
pub struct Enclosure { pub struct Enclosure {
pub url: String, pub url: String,
pub mime: Option<String>, pub mime: Option<String>,
@@ -80,18 +87,385 @@ pub async fn fetch(
Ok(Fetched::Body { bytes, etag, last_modified }) Ok(Fetched::Body { bytes, etag, last_modified })
} }
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
/// it is, and whether there is a new address to switch to.
pub struct Failure {
pub reason: &'static str,
pub new_url: Option<String>,
}
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
/// comes back `None`: transient by nature, or with nothing more useful to say than the raw
/// text already shown once a feed is open.
///
/// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP
/// {status}")`, and `parse`'s "got a web page" and "the site sent") plus the substrings a DNS failure
/// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing
/// nothing extra, so a miss costs a clearer message, not a wrong one.
pub fn explain_failure(msg: &str) -> Option<Failure> {
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
let new_url = rest
.strip_prefix("; it links ")
.and_then(|r| r.strip_suffix(" as its feed"))
.map(str::to_owned);
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
}
if msg.contains("the site sent ") {
return Some(Failure { reason: "The site sent a message instead of the feed; the publisher has to fix it.", new_url: None });
}
let low = msg.to_ascii_lowercase();
if low.contains("http 404") {
return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None });
}
if low.contains("http 401") || low.contains("http 403") {
return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None });
}
if low.contains("http 402") {
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
}
if low.contains("dns error")
|| low.contains("failed to lookup address")
|| low.contains("no address associated")
{
return Some(Failure { reason: "This address no longer resolves; the site is gone.", new_url: None });
}
None
}
/// True when a body is an OPML document rather than a feed.
///
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
/// OPML served from a URL without that extension. Sniffing the body catches both.
pub fn is_opml(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(1024)];
let text = String::from_utf8_lossy(head).to_lowercase();
text.contains("<opml")
}
/// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
let text = String::from_utf8_lossy(bytes);
let doc = opml::OPML::from_str(&text)
.map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
let mut out = vec![];
crate::collect_outlines(&doc.body.outlines, &mut out);
Ok(out)
}
/// The <head><title> of an OPML document.
pub fn opml_title(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let doc = opml::OPML::from_str(&text).ok()?;
doc.head
.and_then(|h| h.title)
.map(|t| t.trim().to_owned())
.filter(|t| !t.is_empty())
}
/// The token and show of a Patreon feed link, or None for any other URL.
///
/// Patreon gives each patron one token per creator. With no show it stands for the creator,
/// whose feed carries every show at once.
fn patreon_parts(url: &str) -> Option<(String, Option<String>)> {
let u = url::Url::parse(url).ok()?;
if !matches!(u.host_str()?, "patreon.com" | "www.patreon.com") || !u.path().starts_with("/rss") {
return None;
}
let param = |name: &str| u.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned());
Some((param("auth")?, param("show")))
}
/// A Patreon link naming a creator but no show.
pub fn is_patreon_creator(url: &str) -> bool {
matches!(patreon_parts(url), Some((_, None)))
}
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
/// feed, since the token alone says whose it is.
pub fn expand_input(input: &str) -> String {
let s = input.trim();
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if token { format!("https://www.patreon.com/rss?auth={s}") } else { s.to_owned() }
}
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
/// creator's name, by number, or with no creator at all -- and the token and show are what
/// identify it.
pub fn same_feed(a: &str, b: &str) -> bool {
a == b || patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b))
}
/// A Patreon creator's name and shows, each show as (title, feed URL).
///
/// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes,
/// finding shows stops and the show feeds already found keep working. The documented API
/// needs an OAuth client per install and does not list shows.
pub async fn patreon_shows(
client: &reqwest::Client,
url: &str,
) -> Result<(Option<String>, Vec<(String, String)>)> {
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
let mut resp = client.get(url).send().await.context("connecting")?;
if !resp.status().is_success() {
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
}
let mut head = Vec::new();
while patreon_campaign(&head).is_none() && head.len() < 64 * 1024 {
let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break };
head.extend_from_slice(&chunk);
}
let campaign = patreon_campaign(&head)
.ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
let api = format!(
"https://www.patreon.com/api/campaigns/{campaign}\
?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title"
);
let resp = client.get(api).send().await.context("asking Patreon for the shows")?;
if !resp.status().is_success() {
return Err(anyhow!("Patreon would not list the shows: HTTP {}", resp.status()));
}
let (name, shows) = parse_patreon_shows(&resp.bytes().await.context("reading the shows")?)?;
Ok((name, shows.into_iter().map(|(id, title)| (title, format!("{url}&show={id}"))).collect()))
}
/// The campaign number in the start of a Patreon feed.
fn patreon_campaign(head: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(head);
text.match_indices("patreon.com/rss/").find_map(|(i, m)| {
let id: String = text[i + m.len()..].chars().take_while(char::is_ascii_digit).collect();
(!id.is_empty()).then_some(id)
})
}
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, String)>)> {
let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?;
// Missing is not the same as none. Read as no shows, the creator feed would be scanned as
// a plain feed, claim every show's files, and leave the shows empty once the list returned.
let ids = v["data"]["relationships"]["shows"]["data"]
.as_array()
.ok_or_else(|| anyhow!("Patreon's answer does not list the shows"))?;
let title = |id: &str| -> Option<String> {
let show = v["included"].as_array()?.iter().find(|x| x["type"] == "collection" && x["id"] == id)?;
show["attributes"]["title"].as_str().map(|t| t.trim().to_owned())
};
let shows = ids
.iter()
.filter_map(|s| s["id"].as_str())
.map(|id| (id.to_owned(), title(id).unwrap_or_else(|| format!("Show {id}"))))
.collect();
Ok((v["data"]["attributes"]["name"].as_str().map(str::to_owned), shows))
}
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`. /// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> { pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
match rss::Channel::read_from(bytes) { match rss::Channel::read_from(bytes) {
Ok(ch) => Ok(from_rss(ch)), Ok(ch) => Ok(from_rss(ch, bytes)),
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) { Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
Ok(feed) => Ok(from_atom(feed)), Ok(feed) => Ok(from_atom(feed)),
Err(atom_err) => Err(anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})")), Err(atom_err) => {
if let Some(said) = plain_text(bytes) {
return Err(anyhow!("the site sent {said} instead of a feed"));
}
// Some publishers (kcpw, feedland) write a bare "&" in a URL instead of
// "&amp;". Strict XML parsers refuse it; browsers don't. Retry once with
// every offending "&" escaped rather than fail outright.
let escaped = escape_bare_ampersands(bytes);
if escaped != bytes {
if let Ok(ch) = rss::Channel::read_from(escaped.as_slice()) {
return Ok(from_rss(ch, &escaped));
}
if let Ok(feed) = atom_syndication::Feed::read_from(escaped.as_slice()) {
return Ok(from_atom(feed));
}
}
Err(match alternate_feed_link(bytes) {
Some(href) if looks_like_html(bytes) => {
anyhow!("got a web page, not a feed; it links {href} as its feed")
}
None if looks_like_html(bytes) => anyhow!("got a web page, not a feed"),
_ => anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})"),
})
}
}, },
} }
} }
fn from_rss(ch: rss::Channel) -> ParsedFeed { /// Whether a body is a web page rather than a feed: most of the errors traced back to a feed
/// that moved or a domain that lapsed, with the old URL now serving the site instead (or a
/// redirect to it). `is_opml` already sniffs the other "not actually a feed" case.
fn looks_like_html(bytes: &[u8]) -> bool {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]).to_lowercase();
head.contains("<!doctype html") || head.contains("<html")
}
/// What a site sent when it sent a sentence instead of markup. doghouse's feed answered 200 with
/// "Unable to establish a DB connection", and the two parsers' errors about end of input buried
/// it. Anything starting with `<` is markup, however broken, and keeps the parsers' errors.
fn plain_text(bytes: &[u8]) -> Option<String> {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(512)]);
let text = head.trim_start_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
if text.starts_with('<') {
return None;
}
let line = text.lines().next().unwrap_or("").trim_end();
if line.is_empty() {
return Some("an empty reply".into());
}
let mut said: String = line.chars().take(80).collect();
if said.len() < line.len() {
said.push('…');
}
Some(format!("\"{said}\""))
}
/// The feed a web page names as its own via `<link rel="alternate" type="application/rss+xml"
/// href="...">` (or the Atom equivalent) -- how the new address was found for om.co, ms.now,
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let lower = text.to_lowercase();
let mut pos = 0;
while let Some(rel) = lower[pos..].find("<link") {
let start = pos + rel;
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
pos = end + 1;
let tag = &text[start..end];
let tag_lower = &lower[start..end];
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
if is_alternate && is_feed_type
&& let Some(href) = tag_attr(tag, "href")
{
return Some(href);
}
}
None
}
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
fn tag_attr(tag: &str, name: &str) -> Option<String> {
let key = format!("{name}=");
let idx = tag.to_lowercase().find(&key)?;
let after = &tag[idx + key.len()..];
let quote = after.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = &after[1..];
let close = rest.find(quote)?;
Some(rest[..close].trim().to_owned())
}
/// Escapes every `&` that does not already start a recognized XML entity
/// (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, or a numeric reference like `&#39;`).
fn escape_bare_ampersands(bytes: &[u8]) -> Vec<u8> {
fn is_entity_start(rest: &[u8]) -> bool {
for named in [&b"amp;"[..], b"lt;", b"gt;", b"quot;", b"apos;"] {
if rest.starts_with(named) {
return true;
}
}
let digits = if rest.starts_with(b"#x") || rest.starts_with(b"#X") {
&rest[2..]
} else if rest.starts_with(b"#") {
&rest[1..]
} else {
return false;
};
let len = digits.iter().take_while(|b| b.is_ascii_alphanumeric()).count();
len > 0 && digits.get(len) == Some(&b';')
}
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'&' && !is_entity_start(&bytes[i + 1..]) {
out.extend_from_slice(b"&amp;");
} else {
out.push(bytes[i]);
}
i += 1;
}
out
}
/// Every `<enclosure>` of every `<item>`, in document order.
///
/// The `rss` crate models an item as having at most one enclosure -- which is what RSS 2.0
/// says -- and when a feed carries several it keeps only the *last*, silently losing the
/// rest. Feeds do ship several, so read them from the XML directly.
fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_reader(bytes);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut out: Vec<Vec<Enclosure>> = Vec::new();
let mut current: Option<Vec<Enclosure>> = None;
let read_enclosure = |e: &quick_xml::events::BytesStart| -> Option<Enclosure> {
let (mut url, mut mime, mut length) = (String::new(), None, None);
for attr in e.attributes().flatten() {
// Values arrive escaped: a feed URL's "&" is "&amp;" in the document.
let val = quick_xml::escape::unescape(&attr.value)
.map(|v| v.trim().to_string())
.unwrap_or_default();
match attr.key.local_name().as_ref() {
"url" => url = val,
"type" => mime = Some(val).filter(|v| !v.is_empty()),
"length" => length = val.parse().ok(),
_ => {}
}
}
(!url.is_empty()).then_some(Enclosure { url, mime, length })
};
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => match e.name().local_name().as_ref() {
"item" => current = Some(Vec::new()),
"enclosure" => {
if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) {
list.push(enc);
}
}
_ => {}
},
Ok(Event::Empty(e)) => match e.name().local_name().as_ref() {
// <item/> with no children still counts, so the indexes stay aligned.
"item" => out.push(Vec::new()),
"enclosure" => {
if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) {
list.push(enc);
}
}
_ => {}
},
Ok(Event::End(e)) => {
if e.name().local_name().as_ref() == "item"
&& let Some(list) = current.take()
{
out.push(list);
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
if let Some(list) = current.take() {
out.push(list);
}
out
}
fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
let per_item = enclosures_by_item(bytes);
let explicit = ch let explicit = ch
.itunes_ext() .itunes_ext()
.and_then(|it| it.explicit()) .and_then(|it| it.explicit())
@@ -100,17 +474,21 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
let entries = ch let entries = ch
.items() .items()
.iter() .iter()
.filter_map(|item| { .enumerate()
let enclosures: Vec<Enclosure> = item .filter_map(|(idx, item)| {
.enclosure() // Straight from the XML, so an item with several keeps all of them. Falls
.into_iter() // back to the parsed one if the scan and the parser disagree on item count.
.map(|e| Enclosure { let enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
url: e.url().trim().to_owned(), item.enclosure()
mime: non_empty(Some(e.mime_type())), .into_iter()
length: e.length().parse().ok(), .map(|e| Enclosure {
}) url: e.url().trim().to_owned(),
.filter(|e| !e.url.is_empty()) mime: non_empty(Some(e.mime_type())),
.collect(); length: e.length().parse().ok(),
})
.filter(|e| !e.url.is_empty())
.collect()
});
let guid = pick_guid( let guid = pick_guid(
item.guid().map(|g| g.value()), item.guid().map(|g| g.value()),
@@ -123,6 +501,7 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
.itunes_ext() .itunes_ext()
.and_then(|it| it.explicit()) .and_then(|it| it.explicit())
.is_some_and(is_yes); .is_some_and(is_yes);
let it = item.itunes_ext();
Some(Entry { Some(Entry {
guid, guid,
@@ -130,7 +509,7 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
link: non_empty(item.link()), link: non_empty(item.link()),
published: item.pub_date().and_then(parse_date), published: item.pub_date().and_then(parse_date),
// Content wins over description, as __getEntries preferred entry.content. // Content wins over description, as __getEntries preferred entry.content.
description: non_empty(item.content()).or_else(|| non_empty(item.description())), description: body(item.content(), item.description()),
categories: item categories: item
.categories() .categories()
.iter() .iter()
@@ -138,6 +517,10 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
.filter(|c| !c.is_empty() && !c.starts_with("http")) .filter(|c| !c.is_empty() && !c.starts_with("http"))
.collect(), .collect(),
explicit: explicit || entry_explicit, explicit: explicit || entry_explicit,
image: item_image(item, &enclosures),
duration: it.and_then(|i| i.duration()).and_then(parse_duration),
episode: it.and_then(|i| i.episode()).and_then(|e| e.trim().parse().ok()),
season: it.and_then(|i| i.season()).and_then(|e| e.trim().parse().ok()),
enclosures, enclosures,
}) })
}) })
@@ -146,6 +529,12 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
ParsedFeed { ParsedFeed {
title: non_empty(Some(ch.title())), title: non_empty(Some(ch.title())),
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()), ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
image: ch
.itunes_ext()
.and_then(|i| i.image())
.map(str::to_owned)
.or_else(|| ch.image().map(|i| i.url().to_owned())),
entries, entries,
} }
} }
@@ -186,13 +575,13 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
title: non_empty(Some(e.title().as_str())), title: non_empty(Some(e.title().as_str())),
link: alt.map(str::to_owned), link: alt.map(str::to_owned),
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()), published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
description: e description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
.content()
.and_then(|c| c.value())
.or_else(|| e.summary().map(|s| s.as_str()))
.map(str::to_owned),
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(), categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
explicit: false, explicit: false,
image: None,
duration: None,
episode: None,
season: None,
enclosures, enclosures,
}) })
}) })
@@ -201,6 +590,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
ParsedFeed { ParsedFeed {
title: non_empty(Some(feed.title().as_str())), title: non_empty(Some(feed.title().as_str())),
ttl_mins: None, ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
entries, entries,
} }
} }
@@ -230,6 +620,74 @@ fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned) s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
} }
/// An item's show notes: its full body when that is whole, else its description.
///
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
/// pasted from a web app (`[&:has([data-writing-block])>*]:pointer-events-auto`), so the body
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
/// `description` was whole. With no description to fall back on, a damaged body beats none.
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
non_empty(content)
.filter(|c| !starts_mid_tag(c))
.or_else(|| non_empty(description))
.or_else(|| non_empty(content))
}
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
/// start was cut off.
fn starts_mid_tag(html: &str) -> bool {
html[..html.find('<').unwrap_or(html.len())].contains("\">")
}
/// The picture to show beside an item, in order of how deliberate it is:
/// `itunes:image`, then Media RSS `media:thumbnail`, then a `media:content` that is an
/// image, and finally an image enclosure -- which is how a blog's article picture arrives
/// (Substack puts it there), so those entries get artwork rather than a blank square.
fn item_image(item: &rss::Item, enclosures: &[Enclosure]) -> Option<String> {
if let Some(url) = item.itunes_ext().and_then(|i| i.image()) {
return non_empty(Some(url));
}
let media = item.extensions().get("media");
let attr = |name: &str, want_image: bool| -> Option<String> {
media?.get(name)?.iter().find_map(|e| {
if want_image {
// media:content carries anything; only take it when it says it is a picture.
let is_image = e.attrs.get("type").is_some_and(|t| t.starts_with("image/"))
|| e.attrs.get("medium").is_some_and(|m| m == "image");
if !is_image {
return None;
}
}
non_empty(e.attrs.get("url").map(String::as_str))
})
};
attr("thumbnail", false)
.or_else(|| attr("content", true))
.or_else(|| {
enclosures
.iter()
.find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/")))
.map(|e| e.url.clone())
})
}
/// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
fn parse_duration(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() {
return None;
}
if !s.contains(':') {
return s.parse().ok().filter(|n| *n > 0);
}
let mut total: i64 = 0;
for part in s.split(':') {
total = total * 60 + part.trim().parse::<i64>().ok()?;
}
Some(total).filter(|n| *n > 0)
}
/// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead. /// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead.
fn parse_date(s: &str) -> Option<i64> { fn parse_date(s: &str) -> Option<i64> {
let s = s.trim(); let s = s.trim();
@@ -274,6 +732,18 @@ mod tests {
); );
} }
#[test]
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
}
#[test] #[test]
fn feed_level_explicit_overrides_entries() { fn feed_level_explicit_overrides_entries() {
let xml = br#"<?xml version="1.0"?> let xml = br#"<?xml version="1.0"?>
@@ -290,6 +760,62 @@ mod tests {
); );
} }
#[test]
fn explain_failure_translates_the_errors_the_ui_should_flag() {
assert_eq!(
explain_failure("HTTP 404 Not Found").unwrap().reason,
"The publisher took this feed down, or moved it."
);
assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests.");
assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests.");
assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan.");
let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap();
assert_eq!(dns.reason, "This address no longer resolves; the site is gone.");
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
let down = explain_failure("the site sent \"Unable to establish a DB connection\" instead of a feed").unwrap();
assert_eq!(down.reason, "The site sent a message instead of the feed; the publisher has to fix it.");
for transient in [
"HTTP 500 Internal Server Error",
"HTTP 429 Too Many Requests",
"operation timed out",
"not RSS (reached end of input without finding a complete channel) and not Atom (unexpected end of input)",
] {
assert!(explain_failure(transient).is_none(), "{transient} must not be flagged");
}
}
#[test]
fn a_web_page_says_so_and_names_the_feed_it_links() {
let html = br#"<!doctype html><html><head>
<link rel="alternate" type="application/rss+xml" href="https://x.example/feed">
</head><body>not a feed</body></html>"#;
let err = parse(html).unwrap_err().to_string();
assert_eq!(err, "got a web page, not a feed; it links https://x.example/feed as its feed");
}
#[test]
fn a_web_page_with_no_feed_link_still_says_so() {
let html = b"<!doctype html><html><body>moved</body></html>";
assert_eq!(parse(html).unwrap_err().to_string(), "got a web page, not a feed");
}
#[test]
fn malformed_xml_gets_the_original_parser_errors() {
let err = parse(b"<rss><channel><title>cut off").unwrap_err().to_string();
assert!(err.starts_with("not RSS ("), "{err}");
}
#[test]
fn a_body_with_no_markup_says_what_the_site_sent() {
let err = parse(b"\xef\xbb\xbf\r\n Unable to establish a DB connection\nmore").unwrap_err().to_string();
assert_eq!(err, "the site sent \"Unable to establish a DB connection\" instead of a feed");
let long = parse("x".repeat(200).as_bytes()).unwrap_err().to_string();
assert_eq!(long, format!("the site sent \"{}…\" instead of a feed", "x".repeat(80)));
assert_eq!(parse(b" \n").unwrap_err().to_string(), "the site sent an empty reply instead of a feed");
}
#[test] #[test]
fn parses_atom_enclosure_links() { fn parses_atom_enclosure_links() {
let bytes = include_bytes!("../tests/data/atom.xml"); let bytes = include_bytes!("../tests/data/atom.xml");
@@ -312,6 +838,197 @@ mod tests {
); );
} }
#[test]
fn a_bare_ampersand_in_a_link_is_repaired_and_parsed() {
// kcpw.org: <link>https://kcpw.org/?post_type=post&p=125715</link> -- a bare "&"
// that strict XML rejects but browsers accept.
let xml = br#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>X</title><link>https://x</link><description>d</description>
<item><title>a</title><guid>g1</guid>
<link>https://kcpw.org/?post_type=post&p=125715</link>
<enclosure url="https://x/a.mp3?a=1&b=2" length="1" type="audio/mpeg"/></item>
</channel></rss>"#;
let feed = parse(xml).unwrap();
assert_eq!(feed.entries[0].link.as_deref(), Some("https://kcpw.org/?post_type=post&p=125715"));
assert_eq!(feed.entries[0].enclosures[0].url, "https://x/a.mp3?a=1&b=2");
}
#[test]
fn escape_bare_ampersands_leaves_real_entities_alone() {
let out = escape_bare_ampersands(b"a&amp;b &lt;x&gt; &#39; &#x2F; c&d");
assert_eq!(out, b"a&amp;b &lt;x&gt; &#39; &#x2F; c&amp;d");
}
#[test]
fn the_rss_title_always_wins_and_episode_numbers_stay_metadata() {
// Some feeds set a different itunes:title. The displayed title is always the RSS
// <title>, verbatim -- separators and all -- and season/episode are stored
// alongside it rather than folded into it.
let xml = br#"<?xml version="1.0"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel><title>Show</title><link>https://x</link><description>d</description>
<item>
<title>Music from a Darkened Room | Session Zero</title>
<itunes:title>Session Zero</itunes:title>
<guid>sz</guid>
<itunes:season>8</itunes:season>
<itunes:duration>6720</itunes:duration>
<enclosure url="https://x/sz.mp3" length="1" type="audio/mpeg"/>
</item>
<item>
<title>Music from a Darkened Room Part 1 | Murphy's Drawer</title>
<guid>p1</guid>
<itunes:season>8</itunes:season><itunes:episode>1</itunes:episode>
<enclosure url="https://x/p1.mp3" length="1" type="audio/mpeg"/>
</item>
</channel></rss>"#;
let feed = parse(xml).unwrap();
let sz = &feed.entries[0];
assert_eq!(
sz.title.as_deref(),
Some("Music from a Darkened Room | Session Zero"),
"itunes:title must not override the RSS title"
);
assert_eq!(sz.season, Some(8));
assert_eq!(sz.episode, None, "a missing episode number stays missing");
assert_eq!(sz.duration, Some(6720));
let p1 = &feed.entries[1];
assert_eq!(p1.title.as_deref(), Some("Music from a Darkened Room Part 1 | Murphy's Drawer"));
assert_eq!((p1.season, p1.episode), (Some(8), Some(1)));
}
#[test]
fn opml_is_recognised_and_its_feeds_listed() {
let xml = br#"<opml version="2.0"><head><title>My Subscriptions</title></head><body>
<outline text="Folder">
<outline type="rss" text="Alpha" xmlUrl="https://a.example/rss"/>
<outline type="rss" text="Beta" xmlUrl="https://b.example/rss"/>
</outline>
<outline text="Not a feed"/>
</body></opml>"#;
assert!(is_opml(xml));
assert_eq!(opml_title(xml).as_deref(), Some("My Subscriptions"));
let feeds = parse_opml(xml).unwrap();
assert_eq!(feeds.len(), 2, "nested folders are walked, non-feed outlines skipped");
assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into()));
// A feed must never be mistaken for a subscription list.
assert!(!is_opml(include_bytes!("../tests/data/rss2.xml")));
assert!(!is_opml(include_bytes!("../tests/data/atom.xml")));
}
#[test]
fn a_patreon_creator_is_a_list_of_its_shows() {
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
assert!(!is_patreon_creator(&format!("https://www.patreon.com/rss/x?auth={tok}&show=1")), "one show is a feed");
assert!(!is_patreon_creator(&format!("https://example.com/rss?auth={tok}")));
// The show you already have by name is the one a bare token would add by number.
assert!(same_feed(
&format!("https://www.patreon.com/rss/glasscannon?auth={tok}&show=2073588"),
&format!("https://www.patreon.com/rss?auth={tok}&show=2073588"),
));
assert!(!same_feed(
&format!("https://www.patreon.com/rss?auth={tok}&show=1"),
&format!("https://www.patreon.com/rss?auth={tok}&show=2"),
));
// The self link carries the campaign by number, whichever spelling was asked for.
let head = br#"<rss><channel><link>https://www.patreon.com/glasscannon</link>
<atom:link href="https://www.patreon.com/rss/369921?auth=t" rel="self"/>"#;
assert_eq!(patreon_campaign(head).as_deref(), Some("369921"));
assert_eq!(patreon_campaign(b"<rss><channel><title>T"), None);
let json = br#"{"data":{"id":"369921","type":"campaign","attributes":{"name":"The Glass Cannon Network"},
"relationships":{"shows":{"data":[{"id":"2073588","type":"collection"},{"id":"2073636","type":"collection"}]}}},
"included":[{"id":"2073588","type":"collection","attributes":{"title":"Get in the Trunk "}},
{"id":"2073636","type":"collection","attributes":{"title":"Shadowdark"}}]}"#;
let (name, shows) = parse_patreon_shows(json).unwrap();
assert_eq!(name.as_deref(), Some("The Glass Cannon Network"));
assert_eq!(shows, [("2073588".into(), "Get in the Trunk".into()), ("2073636".into(), "Shadowdark".into())]);
// An answer that stops naming the shows is an error, never "this creator has none".
assert!(parse_patreon_shows(br#"{"data":{"attributes":{"name":"X"}}}"#).is_err());
}
#[test]
fn an_item_may_carry_several_enclosures() {
// The rss crate keeps only one per item -- the last -- so these come from the XML.
let xml = br#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>M</title><link>https://x</link><description>d</description>
<item><title>Two files</title><guid>m1</guid>
<enclosure url="https://x/a.mp3?v=1&amp;t=2" length="111" type="audio/mpeg"/>
<enclosure url="https://x/b.mp4" length="222" type="video/mp4"/>
</item>
<item><title>One file</title><guid>m2</guid>
<enclosure url="https://x/c.mp3" length="333" type="audio/mpeg"/></item>
<item><title>None</title><guid>m3</guid></item>
</channel></rss>"#;
let f = parse(xml).unwrap();
assert_eq!(f.entries.len(), 3);
let two = &f.entries[0].enclosures;
assert_eq!(two.len(), 2, "both enclosures survive");
assert_eq!(
two[0].url, "https://x/a.mp3?v=1&t=2",
"document order, and the escaped ampersand is decoded"
);
assert_eq!(two[0].length, Some(111));
assert_eq!(two[1].url, "https://x/b.mp4");
assert_eq!(two[1].mime.as_deref(), Some("video/mp4"));
assert_eq!(f.entries[1].enclosures.len(), 1);
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
}
#[test]
fn an_items_picture_comes_from_the_most_deliberate_source() {
let xml = br#"<?xml version="1.0"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:media="http://search.yahoo.com/mrss/">
<channel><title>P</title><link>https://x</link><description>d</description>
<item><title>Has itunes</title><guid>a</guid>
<itunes:image href="https://x/itunes.jpg"/>
<media:thumbnail url="https://x/thumb.jpg"/>
<enclosure url="https://x/a.jpg" length="1" type="image/jpeg"/></item>
<item><title>Has thumbnail</title><guid>b</guid>
<media:thumbnail url="https://x/thumb.jpg"/>
<enclosure url="https://x/b.jpg" length="1" type="image/jpeg"/></item>
<item><title>Has media content</title><guid>c</guid>
<media:content url="https://x/pic.jpg" type="image/jpeg"/>
<media:content url="https://x/clip.mp4" type="video/mp4"/></item>
<item><title>Only an image enclosure</title><guid>d</guid>
<enclosure url="https://x/d.jpg" length="1" type="image/jpeg"/></item>
<item><title>Audio only</title><guid>e</guid>
<enclosure url="https://x/e.mp3" length="1" type="audio/mpeg"/></item>
</channel></rss>"#;
let f = parse(xml).unwrap();
let img = |i: usize| f.entries[i].image.as_deref();
assert_eq!(img(0), Some("https://x/itunes.jpg"), "itunes:image wins");
assert_eq!(img(1), Some("https://x/thumb.jpg"), "then media:thumbnail");
assert_eq!(img(2), Some("https://x/pic.jpg"), "media:content, and only the image one");
assert_eq!(img(3), Some("https://x/d.jpg"), "a blog's article picture arrives as an enclosure");
assert_eq!(img(4), None, "audio is not a picture");
}
#[test]
fn durations_parse_from_seconds_or_a_clock() {
assert_eq!(parse_duration("5649"), Some(5649));
assert_eq!(parse_duration("23:45"), Some(1425));
assert_eq!(parse_duration("1:34:09"), Some(5649));
assert_eq!(parse_duration("0"), None, "zero is not a duration");
assert_eq!(parse_duration(""), None);
assert_eq!(parse_duration("garbage"), None);
}
#[test] #[test]
fn rejects_html_masquerading_as_a_feed() { fn rejects_html_masquerading_as_a_feed() {
assert!(parse(b"<html><body>nope</body></html>").is_err()); assert!(parse(b"<html><body>nope</body></html>").is_err());
@@ -324,3 +1041,4 @@ mod tests {
assert_eq!(pick_guid(None, None, None, None), None); assert_eq!(pick_guid(None, None, None, None), None);
} }
} }

View File

@@ -17,14 +17,17 @@ pub enum Event {
FeedError { feed: String, msg: String }, FeedError { feed: String, msg: String },
Progress { Progress {
feed: String, feed: String,
/// Which enclosure this is about. Without it a UI cannot tell one download's
/// progress from another's and ends up animating every pending row.
enclosure: i64,
url: String, url: String,
file: String, file: String,
done: u64, done: u64,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
total: Option<u64>, total: Option<u64>,
}, },
DownloadDone { feed: String, url: String, path: String, bytes: u64 }, DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
DownloadError { feed: String, url: String, msg: String }, DownloadError { feed: String, enclosure: i64, url: String, msg: String },
TorrentDeferred { feed: String, url: String }, TorrentDeferred { feed: String, url: String },
Reaped { path: String, bytes: u64 }, Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here. /// Terminal: a client that asked for work stops reading here.
@@ -59,10 +62,10 @@ impl Event {
Event::DownloadDone { path, .. } => format!(" saved {path}"), Event::DownloadDone { path, .. } => format!(" saved {path}"),
Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"), Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"),
Event::Reaped { path, bytes } => { Event::Reaped { path, bytes } => {
format!("reap {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0) format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
} }
Event::ReapDone { files, bytes } => format!( Event::ReapDone { files, bytes } => format!(
"reaped {files} file(s), {:.1} MB", "deleted {files} old file(s), {:.1} MB",
*bytes as f64 / 1_048_576.0 *bytes as f64 / 1_048_576.0
), ),
Event::Status { feeds, pending, downloaded } => { Event::Status { feeds, pending, downloaded } => {
@@ -70,9 +73,9 @@ impl Event {
} }
Event::Error { msg } => format!("error: {msg}"), Event::Error { msg } => format!("error: {msg}"),
// Noise in a terminal; a UI still gets them on the socket. // Noise in a terminal; a UI still gets them on the socket.
Event::FeedStart { .. } | Event::TorrentDeferred { .. } | Event::ScanDone { .. } => { Event::FeedStart { feed } => format!("{feed}: checking"),
return None; Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
} Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
}) })
} }
} }
@@ -90,6 +93,12 @@ pub enum Command {
#[serde(default)] #[serde(default)]
dry_run: bool, dry_run: bool,
}, },
/// Fetch one specific enclosure now, ignoring max_new_per_check and the queue order.
/// A scan cannot express "this one, now": it takes the lowest-id pending rows up to
/// the per-scan cap, so an explicit request has to bypass both.
Download {
enclosure: i64,
},
Status, Status,
} }
@@ -110,7 +119,45 @@ impl Emitter {
} }
pub fn emit(&self, e: Event) { pub fn emit(&self, e: Event) {
// Also log it. Scans and downloads travel as events, not tracing calls, so
// without this the log view shows only startup and HTTP lines and none of the
// work the daemon is actually doing. Progress goes to debug: it fires on every
// whole percent and would otherwise crowd everything else out of the buffer.
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
// line per feed per tick for "not due yet" would push everything worth reading
// out of the buffer within a few minutes.
let routine = match &e {
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
_ => false,
};
let bad = matches!(
&e,
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
);
if let Some(line) = e.human() {
let line = line.trim();
if bad {
tracing::warn!(target: "ipx::scan", "{line}");
} else if routine {
tracing::debug!(target: "ipx::scan", "{line}");
} else {
tracing::info!(target: "ipx::scan", "{line}");
}
}
if let Some(tx) = &self.tx { if let Some(tx) = &self.tx {
// The outbound half of the protocol, as it goes on the wire. Progress is the
// high-volume one, so it sits at debug.
if let Ok(json) = serde_json::to_string(&e) {
if matches!(e, Event::Progress { .. }) {
tracing::debug!(target: "ipx::io", "<- {json}");
} else {
tracing::info!(target: "ipx::io", "<- {json}");
}
}
// An error here only means nobody is listening yet. // An error here only means nobody is listening yet.
let _ = tx.send(e.clone()); let _ = tx.send(e.clone());
} }
@@ -125,11 +172,18 @@ pub async fn daemon_is_live(path: &Path) -> bool {
UnixStream::connect(path).await.is_ok() UnixStream::connect(path).await.is_ok()
} }
/// Answers `status` for the socket, without the worker. The worker runs one job at a time, and a
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
pub type StatusFn = std::sync::Arc<dyn Fn() -> Event + Send + Sync>;
/// Accepts connections, feeding commands to `cmds` and events from `events` back out. /// Accepts connections, feeding commands to `cmds` and events from `events` back out.
pub async fn serve( pub async fn serve(
path: PathBuf, path: PathBuf,
events: broadcast::Sender<Event>, events: broadcast::Sender<Event>,
cmds: mpsc::Sender<Command>, cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> { ) -> Result<()> {
// A socket file left by a crashed daemon would block the bind; a live one was already // A socket file left by a crashed daemon would block the bind; a live one was already
// rejected by the caller's daemon_is_live() check. // rejected by the caller's daemon_is_live() check.
@@ -148,8 +202,9 @@ pub async fn serve(
let (stream, _) = listener.accept().await?; let (stream, _) = listener.accept().await?;
let rx = events.subscribe(); let rx = events.subscribe();
let cmds = cmds.clone(); let cmds = cmds.clone();
let status = status.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle(stream, rx, cmds).await { if let Err(e) = handle(stream, rx, cmds, status).await {
tracing::debug!(error = %e, "client gone"); tracing::debug!(error = %e, "client gone");
} }
}); });
@@ -160,12 +215,21 @@ async fn handle(
stream: UnixStream, stream: UnixStream,
mut rx: broadcast::Receiver<Event>, mut rx: broadcast::Receiver<Event>,
cmds: mpsc::Sender<Command>, cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> { ) -> Result<()> {
let (read, mut write) = stream.into_split(); let (read, mut write) = stream.into_split();
// Events out. // Events out: everything broadcast, and the answers meant for this client alone.
let (reply, mut replies) = mpsc::channel::<Event>(4);
let writer = tokio::spawn(async move { let writer = tokio::spawn(async move {
while let Ok(ev) = rx.recv().await { loop {
let ev = tokio::select! {
Some(ev) = replies.recv() => ev,
got = rx.recv() => match got {
Ok(ev) => ev,
Err(_) => break,
},
};
let mut line = serde_json::to_string(&ev).unwrap_or_default(); let mut line = serde_json::to_string(&ev).unwrap_or_default();
line.push('\n'); line.push('\n');
if write.write_all(line.as_bytes()).await.is_err() { if write.write_all(line.as_bytes()).await.is_err() {
@@ -182,6 +246,15 @@ async fn handle(
continue; continue;
} }
match serde_json::from_str::<Command>(line) { match serde_json::from_str::<Command>(line) {
// Answered here, not queued behind whatever the worker is on: see StatusFn.
Ok(Command::Status) => {
tracing::info!(target: "ipx::io", "-> {line}");
let ev = status();
if let Ok(json) = serde_json::to_string(&ev) {
tracing::info!(target: "ipx::io", "<- {json}");
}
let _ = reply.send(ev).await;
}
Ok(cmd) => { Ok(cmd) => {
if cmds.send(cmd).await.is_err() { if cmds.send(cmd).await.is_err() {
break; // Worker is gone; so are we. break; // Worker is gone; so are we.
@@ -234,6 +307,11 @@ mod tests {
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true })); assert!(matches!(got, Command::Reap { dry_run: true }));
// "Download this one now" is its own command precisely because a scan cannot
// express it: a scan takes the lowest-id pending rows up to max_new_per_check.
let got: Command = serde_json::from_str(r#"{"cmd":"download","enclosure":11}"#).unwrap();
assert!(matches!(got, Command::Download { enclosure: 11 }));
assert!(serde_json::from_str::<Command>(r#"{"cmd":"nope"}"#).is_err()); assert!(serde_json::from_str::<Command>(r#"{"cmd":"nope"}"#).is_err());
} }
@@ -241,6 +319,7 @@ mod tests {
fn events_serialise_to_the_documented_shape() { fn events_serialise_to_the_documented_shape() {
let ev = Event::Progress { let ev = Event::Progress {
feed: "atp".into(), feed: "atp".into(),
enclosure: 42,
url: "https://x/ep.mp3".into(), url: "https://x/ep.mp3".into(),
file: "ep.mp3".into(), file: "ep.mp3".into(),
done: 10_485_760, done: 10_485_760,
@@ -249,10 +328,12 @@ mod tests {
let json = serde_json::to_string(&ev).unwrap(); let json = serde_json::to_string(&ev).unwrap();
assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}"); assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}");
assert!(json.contains(r#""done":10485760"#)); assert!(json.contains(r#""done":10485760"#));
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
// total is omitted rather than null when the server sent no length. // total is omitted rather than null when the server sent no length.
let ev = Event::Progress { let ev = Event::Progress {
feed: "a".into(), feed: "a".into(),
enclosure: 1,
url: "u".into(), url: "u".into(),
file: "f".into(), file: "f".into(),
done: 1, done: 1,
@@ -274,4 +355,29 @@ mod tests {
} }
.is_terminal()); .is_terminal());
} }
#[tokio::test]
async fn status_is_answered_while_the_worker_is_busy() {
// The queue is full and nobody drains it, as when the worker is deep in a long download:
// anything sent to it would wait for ever.
let (cmds, _worker) = mpsc::channel::<Command>(1);
cmds.send(Command::Reap { dry_run: true }).await.unwrap();
let (events, _) = broadcast::channel::<Event>(8);
// Another client, watching a scan: it must not be handed someone else's answer, which
// would end its session.
let mut watcher = events.subscribe();
let status: StatusFn = std::sync::Arc::new(|| Event::Status { feeds: 1, pending: 2, downloaded: 3 });
let (client, server) = UnixStream::pair().unwrap();
tokio::spawn(handle(server, events.subscribe(), cmds, status));
let (read, mut write) = client.into_split();
write.write_all(b"{\"cmd\":\"status\"}\n").await.unwrap();
let line = tokio::time::timeout(std::time::Duration::from_secs(2), BufReader::new(read).lines().next_line())
.await
.expect("status waited behind the worker")
.unwrap()
.unwrap();
assert!(line.contains(r#""ev":"status""#), "{line}");
assert!(watcher.try_recv().is_err(), "the answer went to every client, not just the one asking");
}
} }

147
src/logbuf.rs Normal file
View File

@@ -0,0 +1,147 @@
//! In-process ring buffer of log lines, so the UI can show what the daemon is doing.
//!
//! Tailing a file would not survive Docker, where logs go to stdout and there is no file
//! to read. Capturing inside the tracing pipeline works the same either way.
use std::collections::VecDeque;
use std::sync::{LazyLock, Mutex};
use tracing::field::{Field, Visit};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
/// Kept small enough to be cheap to hold and to serialise in one response.
const CAPACITY: usize = 5000;
#[derive(Clone, Debug, serde::Serialize)]
pub struct LogLine {
/// Monotonic, so a client can ask for "everything after N" without duplicates.
pub seq: u64,
pub ts: i64,
pub level: String,
pub target: String,
pub msg: String,
}
struct Ring {
lines: VecDeque<LogLine>,
next_seq: u64,
}
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
});
pub fn push(level: &str, target: &str, msg: String) {
let mut ring = match BUF.lock() {
Ok(r) => r,
Err(p) => p.into_inner(), // a poisoned log buffer must not take the process down
};
let seq = ring.next_seq;
ring.next_seq += 1;
if ring.lines.len() == CAPACITY {
ring.lines.pop_front();
}
ring.lines.push_back(LogLine {
seq,
ts: crate::db::now(),
level: level.to_owned(),
target: target.to_owned(),
msg,
});
}
/// Lines newer than `after`, oldest first, plus the highest seq now held.
pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
let ring = match BUF.lock() {
Ok(r) => r,
Err(p) => p.into_inner(),
};
let latest = ring.next_seq.saturating_sub(1);
let mut out: Vec<LogLine> = ring
.lines
.iter()
.filter(|l| l.seq > after)
.cloned()
.collect();
// On a first load (after = 0) the tail is what matters, not the head.
if out.len() > limit {
out.drain(..out.len() - limit);
}
(out, latest)
}
/// A tracing layer that mirrors every event into the ring.
pub struct RingLayer;
impl<S: tracing::Subscriber> Layer<S> for RingLayer {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let mut v = Collect::default();
event.record(&mut v);
let meta = event.metadata();
push(meta.level().as_str(), meta.target(), v.finish());
}
}
#[derive(Default)]
struct Collect {
message: String,
fields: Vec<String>,
}
impl Collect {
fn finish(self) -> String {
if self.fields.is_empty() {
self.message
} else if self.message.is_empty() {
self.fields.join(" ")
} else {
format!("{} {}", self.message, self.fields.join(" "))
}
}
fn add(&mut self, field: &Field, value: String) {
if field.name() == "message" {
self.message = value;
} else {
self.fields.push(format!("{}={}", field.name(), value));
}
}
}
impl Visit for Collect {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.add(field, format!("{value:?}"));
}
// Numbers and bools reach record_debug through the trait's defaults, which prints them the
// same way. A string would print quoted there, hence its own method.
fn record_str(&mut self, field: &Field, value: &str) {
self.add(field, value.to_owned());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ring_drops_oldest_and_keeps_sequence_stable() {
for i in 0..(CAPACITY + 50) {
push("INFO", "t", format!("line {i}"));
}
let (all, latest) = since(0, CAPACITY * 2);
assert_eq!(all.len(), CAPACITY, "bounded");
assert!(latest >= (CAPACITY + 50) as u64);
assert!(
all.first().unwrap().seq < all.last().unwrap().seq,
"oldest first"
);
// "everything after the last one I saw" must return nothing new.
let (none, _) = since(latest, 100);
assert!(none.is_empty());
// A first load takes the tail, not the head.
let (tail, _) = since(0, 5);
assert_eq!(tail.len(), 5);
assert_eq!(tail.last().unwrap().seq, latest);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -150,33 +150,52 @@ mod tests {
} }
#[test] #[test]
fn query_never_offers_flagged_files_and_prefers_read_ones() { fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
// One file serves both subscribers, so it takes both of them to release it.
let db = Db::memory().unwrap(); let db = Db::memory().unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO entries (feed_id, guid, first_seen, read, flagged) VALUES "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
('f', 'keep', 0, 1, 1), INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
('f', 'unread', 0, 0, 0), INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'read', 0, 1, 0); ('f', 'keep', 0),
('f', 'half', 0),
('f', 'unread', 0),
('f', 'read', 0);
-- Starred by one of the two, so it stays whatever the other thinks.
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(1, 'f', 'keep', 1, 1),
(2, 'f', 'keep', 1, 0),
(1, 'f', 'half', 1, 0),
(1, 'f', 'read', 1, 0),
(2, 'f', 'read', 1, 0);
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10), (1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
(2, 'f', 'unread', 'u2', '/tmp/unread', 10, 'done', 20), (2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
(3, 'f', 'read', 'u3', '/tmp/read', 10, 'done', 30);", (3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
) )
.unwrap(); .unwrap();
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect(); let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
assert_eq!(got, vec![3, 2], "flagged excluded; read goes before unread"); assert_eq!(
got,
vec![4, 2, 3],
"starred by anyone is never offered; read by everyone goes first, and one \
person still having it unread keeps it back with the unread ones"
);
} }
#[test] #[test]
fn prune_keeps_entries_that_still_have_a_file() { fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().unwrap(); let db = Db::memory().unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO entries (feed_id, guid, first_seen, read, flagged) VALUES "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
('f', 'has-file', 100, 1, 0), INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
('f', 'no-file', 100, 1, 0), INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'flagged', 100, 1, 1), ('f', 'has-file', 100),
('f', 'recent', 900, 1, 0); ('f', 'no-file', 100),
('f', 'flagged', 100),
('f', 'recent', 900);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');", (1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
) )

1593
src/web.rs Normal file

File diff suppressed because it is too large Load Diff

115
tests/page-smoke.js Normal file
View File

@@ -0,0 +1,115 @@
// Executes web/index.html's script against a stub DOM and fails on anything thrown.
//
// This exists because a ReferenceError at load once blanked the whole UI: a patch
// anchored on a function that no longer existed, so `prefsModal` was referenced but
// never defined. `node --check` passes that happily -- it is a parse, not a run --
// and every server-side test passed too, because the server was fine.
//
// node tests/page-smoke.js
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8');
const script = html.split('<script>')[1].split('</script>')[0];
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
const document = {
querySelector(sel) {
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
return el(sel);
},
querySelectorAll: () => [],
createElement: () => el('created'),
addEventListener(){}, body: el('body'),
documentElement: { dataset: {} },
};
const ctx = {
document, console,
window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
fetch: (url) => Promise.resolve({
ok: true, status: 200, text: () => Promise.resolve(''),
json: () => Promise.resolve(
String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
Blob: function () {},
setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
ctx.globalThis = ctx;
ctx.window.location = { href: '' };
try {
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 });
} catch (e) {
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
process.exit(1);
}
// The modals are built on demand, so a load-time check never reaches them. Drive the
// ones that construct markup from live data, which is where a bad field reference hides.
const feed = {
id: 'f', url: 'https://x/rss', title: 'A Feed', image: null, folder: null,
keywords: ['a'], allow_explicit: false, auto_download: true, max_new_per_check: 3,
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
};
const drive = [
['settingsModal', () => ctx.settingsModal(feed)],
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
['removeFeed', () => ctx.removeFeed(feed)],
['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['logsModal', () => ctx.logsModal()],
// `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do.
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
];
for (const [name, fn] of drive) {
try {
const r = fn();
if (r && typeof r.catch === 'function') r.catch(e => {
console.error(`FAIL: ${name} rejected: ${e.message}`); process.exit(1);
});
} catch (e) {
console.error(`FAIL: ${name} threw: ${e.message}`);
process.exit(1);
}
}
if (missing.length) {
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
process.exit(1);
}
console.log('OK: page script loads clean, every selector it wires at load exists');
// logsModal arms a poll timer; without this the pending interval keeps node alive.
process.exit(0);

892
tests/ui/app.spec.js Normal file
View File

@@ -0,0 +1,892 @@
const { test, expect } = require('@playwright/test');
const { TOKEN } = require('./global-setup');
// The token sets a cookie, so every test starts by presenting it once.
test.beforeEach(async ({ page }) => {
await page.goto(`/?token=${TOKEN}`);
await expect(page.locator('#feedlist')).toBeVisible();
});
test('the page loads and lists the configured feeds', async ({ page }) => {
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
// empty, with every handler below the error dead. Server-side checks all passed.
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.reload();
await expect(page.locator('.feed').first()).toBeVisible();
expect(errors, 'the page script must not throw at load').toEqual([]);
});
test('the theme toggle actually changes the theme', async ({ page }) => {
// Regression: this button was wired after a line that threw, so it did nothing.
const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system');
await page.locator('#theme').click();
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.theme))
.not.toBe(before);
});
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
expect(await theme()).toBe('classic');
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
await page.reload();
await expect.poll(theme).toBe('classic');
// The 2004 Mac app set its type in Lucida Grande.
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
});
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
await page.locator('#prefs').click();
await expect(page.locator('#stheme')).toHaveValue(await theme());
await page.locator('#stheme').selectOption('auto');
await expect.poll(theme).toBe('auto');
// Auto follows the system; emulating a light system must show the light palette live,
// no reload needed, since it is a media query rather than something JS picks per click.
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
// The header button and the dropdown are the same one setting, not two.
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
await page.locator('#theme').click();
expect(await theme()).toBe('dark');
});
test('settings opens and saves the global schedule', async ({ page }) => {
await page.locator('#prefs').click();
await expect(page.locator('#modal.on')).toBeVisible();
await expect(page.locator('#gnum')).toBeVisible();
await page.locator('#gnum').fill('4');
await page.locator('#gunit').selectOption('h');
await page.locator('#gsave').click();
await expect(page.locator('#modal.on')).toBeHidden();
// It must survive a reload, i.e. actually reach the config.
await page.locator('#prefs').click();
await expect(page.locator('#gnum')).toHaveValue('4');
await expect(page.locator('#gunit')).toHaveValue('h');
});
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('First Episode')).toBeVisible();
// Newest first, so target the episode by name rather than by position.
const first = page.locator('.ep', { hasText: 'First Episode' });
await expect(first).toContainText('S1E1');
await expect(first).toContainText('30:30'); // itunes:duration 1830
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toContainText('15:00');
// Selecting an item shows its text in the pane below, not inline in the row.
await first.click();
await expect(first).toHaveClass(/sel/);
await expect(page.locator('#detail')).toContainText('Show notes for the first one');
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
});
test('the three panes are there and the item text lands in the bottom one', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('#list')).toBeVisible();
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
await expect(page.locator('#detail')).toContainText('Pick an item');
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
// The enclosure goes to the Files pane beside the list, as the original's did.
await expect(page.locator('#files')).toBeVisible();
await expect(page.locator('#files .encbox')).toHaveCount(1);
// Only the downloaded one gets a player, and max_new_per_check is 1, so find it by
// its chip rather than assuming which episode the daemon happened to fetch.
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
await downloaded.click();
await expect(page.locator('#files [data-a="play"]')).toBeVisible();
await expect(page.locator('#files .encbox [title="Save to this computer"]')).toBeVisible();
// Selecting another item replaces the pane rather than stacking.
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
});
test('a downloaded file that is not audio gets no player', async ({ page }) => {
// Regression: anything with a file got an <audio> element and a play button, so a blog's
// header image rendered as a broken player.
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
const row = page.locator('.ep', { hasText: 'An Article' });
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row.locator('[data-a="play"]')).toHaveCount(0);
await row.click();
await expect(page.locator('#detail .dt')).toHaveText('An Article');
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
// What it is and that it is here: one icon, green, with the words in its tooltip.
await expect(page.locator('#files .encbox .kind.here')).toHaveAttribute('title', 'image, downloaded');
// Still offered as a file, just not as an episode: viewable and keepable.
await expect(page.locator('#files [title="Save to this computer"]')).toBeVisible();
const view = page.locator('#files a[title="View in a new tab"]');
await expect(view).toHaveAttribute('target', '_blank');
await expect(view).toHaveAttribute('rel', /noopener/);
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
});
test('an item with several enclosures lists them all', async ({ page }) => {
await page.locator('.feed', { hasText: 'Multi Show' }).click();
const row = page.locator('.ep', { hasText: 'Two Files' });
await expect(row).toBeVisible({ timeout: 20_000 });
// The row says there is more than one without listing them.
await expect(row).toContainText('+1 more file');
await row.click();
// The Files pane lists every one: the audio and the image.
await expect(page.locator('#files .encbox')).toHaveCount(2);
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
});
test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => {
// Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
// test may have opened -- and so read -- this one already; reset it before relying on it.
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) }));
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row).toContainText('0:42 of 15:00');
await row.click();
await expect(page.locator('#player')).toBeVisible();
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
await page.locator('#pclose').click();
// Finished (read) drops it from the list, however far it got.
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
});
test('the filter tabs change what is listed', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
const all = await page.locator('.ep').count(); // All is the default tab
await expect(page.locator('#count')).toContainText('item');
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
await page.locator('.tabs button', { hasText: 'Kept' }).first().click();
await expect(page.locator('#count')).toContainText('0 items');
});
test('a feed URL is editable and has a copy button', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
await expect(page.locator('#scopy')).toBeVisible();
// navigator.clipboard is absent over plain http, so the button must not throw.
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.locator('#scopy').click();
await expect(page.locator('#scopy')).toHaveText(/Copied|Failed/);
expect(errors).toEqual([]);
});
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
await page.locator('#logs').click();
await expect(page.locator('#logbox')).toBeVisible();
await expect(page.locator('#logtabs button')).toHaveCount(4);
// Generate traffic, then check the Daemon I/O tab shows both directions.
await page.locator('#logtabs button', { hasText: 'Daemon I/O' }).click();
await page.evaluate(() =>
fetch('/api/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true }),
}));
await expect(page.locator('#logbox .l').first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#logbox')).toContainText('"cmd":"fetch"', { timeout: 15_000 });
await expect(page.locator('#logbox')).toContainText('"ev":', { timeout: 15_000 });
});
test('an OPML subscription is a collapsible folder', async ({ page }) => {
// Read the subscription so its feeds exist.
await page.evaluate(() =>
fetch('/api/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
}));
// Only a folder has a triangle, and it is a button that says whether the folder is open.
const chev = page.locator('.feed.group .chev');
await expect(chev).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
await expect(chev).toHaveAttribute('aria-expanded', 'false');
// Closed by default: the children are not listed until the folder is opened.
const before = await page.locator('.feed').count();
await chev.click();
await expect(page.locator('.feed')).toHaveCount(before + 2);
// Scoped to the sidebar: the name also appears as the page heading once selected.
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
// The subscription's own page lists what is inside it.
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
await expect(page.locator('.childrow')).toHaveCount(2);
});
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
await page.evaluate(() =>
fetch('/api/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
}));
const chev = page.locator('.feed.group .chev');
await expect(chev).toBeVisible({ timeout: 20_000 });
await chev.click();
const side = page.locator('#feedlist');
await expect(side.getByText('Aardvark Radio')).toBeVisible({ timeout: 20_000 });
// Other tests change read state, so set it here: Aardvark Radio read, Grouped Show not.
// Opening an item reads it; the toggle in the pane below flips it back.
await side.getByText('Aardvark Radio').click();
const aa = page.locator('.ep', { hasText: 'Aardvark Ep' });
await aa.click();
await expect(aa).toHaveClass(/read/);
await side.getByText('Grouped Show').click();
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
await gs.click();
await page.locator('#detail button[title="Mark unread"]').click();
await expect(gs).not.toHaveClass(/read/);
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
// Grouped Show above it. The folder stays open across the reload (localStorage).
await page.reload();
const want = ['Grouped Show', 'Aardvark Radio'];
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
await expect(page.locator('.childrow b')).toHaveText(want);
});
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
await page.evaluate(() =>
fetch('/api/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
}));
// The folder's own row has no entries, so anything it marks read came from its child.
const folder = page.locator('.feed', { hasText: 'Test Subscriptions' }).first();
await expect(folder).toBeVisible({ timeout: 20_000 });
await expect(folder.locator('.badge')).not.toHaveText('0');
await folder.click();
await page.locator('#content .acts [data-a="read"]').click();
await expect(folder.locator('.badge')).toHaveText('0');
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } });
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
// The burger used to live in the player bar, which is hidden until something plays --
// leaving no way to reach the feeds at all.
await expect(page.locator('#burger')).toBeVisible();
await expect(page.locator('#player')).not.toBeVisible();
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
await expect(page.locator('#sidebar')).not.toHaveClass(/open/);
// Nothing may push the page sideways at this width.
const over = await page.evaluate(() =>
document.documentElement.scrollWidth - window.innerWidth);
expect(over).toBeLessThanOrEqual(0);
// One pane at a time: the list, then the item over it, then back.
await expect(page.locator('#detail')).not.toBeVisible();
await page.locator('.ep').first().click();
await expect(page.locator('#detail')).toBeVisible();
await page.locator('#dback').click();
await expect(page.locator('#detail')).not.toBeVisible();
});
});
test('opening an item marks it read, and the toggle flips it back', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.locator('.feed', { hasText: 'Test Show' }).click();
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
await expect(row()).toBeVisible({ timeout: 20_000 });
// Another test may have opened this item already, so start from a known state: the
// toggle in the text below flips it back -- which used to recurse until the stack blew.
await row().click();
await page.locator('#detail button[title="Mark unread"]').click();
await expect(row()).not.toHaveClass(/read/);
await expect(page.locator('#detail button[title="Mark read"]')).toBeVisible();
// Opening it is reading it.
await row().click();
await expect(row()).toHaveClass(/read/);
expect(errors).toEqual([]);
});
test('the toolbar acts on the selected item', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
await expect(row()).toBeVisible({ timeout: 20_000 });
// Nothing selected, nothing to act on.
await expect(page.locator('#tbRead')).toBeDisabled();
await row().click(); // opening it reads it
await expect(row()).toHaveClass(/read/);
await page.locator('#tbRead').click();
await expect(row()).not.toHaveClass(/read/);
await page.locator('#tbFlag').click();
await expect(row().locator('.fl')).toHaveClass(/on/);
await page.locator('#tbFlag').click(); // and back, so later tests see it unkept
await expect(row().locator('.fl')).not.toHaveClass(/on/);
// Second Episode is the one the daemon downloaded, so it plays from the toolbar.
await expect(page.locator('#tbPlay')).toBeEnabled();
await page.locator('#tbPlay').click();
await expect(page.locator('#player')).toBeVisible();
await page.locator('#pclose').click();
});
test('a second person has their own feeds and their own read state', async ({ browser }) => {
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = {
...process.env,
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
};
try {
execFileSync('./target/debug/ipx', ['user', 'add', 'sam'], { input: 'sampassword', env });
} catch (e) {
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
}
// A fresh context, so none of the admin's cookies come along.
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
// The sign-in page shows the icon, so it has to load before anyone has signed in.
const icon = await page.request.get('/icon.png');
expect(icon.status()).toBe(200);
expect(icon.headers()['content-type']).toBe('image/png');
await page.locator('#name').fill('sam');
await page.locator('#pw').fill('sampassword');
await page.locator('button[type=submit]').click();
await expect(page.locator('#feedlist')).toBeVisible();
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
await expect(page.locator('#feedlist')).toContainText('No feeds.');
// Settings stays: Sam has their own subscriptions to export and import, and the
// schedule and quota are worth seeing even without a say in them. Only the log and the
// users screen -- and the server -- are an admin's alone.
await expect(page.locator('#prefs')).toBeVisible();
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#gsave')).toBeHidden();
await expect(page.locator('#gusers')).toBeHidden();
await page.locator('#modalCard .cardacts .btn').first().click();
// Hiding the button is not the guard; the server is.
expect((await page.request.get('/api/users')).status()).toBe(403);
await expect(page.locator('#logs')).toBeHidden();
expect((await page.request.get('/api/logs')).status()).toBe(403);
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
// files, but Sam's own read state.
await page.locator('#addFeed').click();
await page.locator('#nurl').fill('http://127.0.0.1:8792/show.xml');
await page.locator('#nsave').click();
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
// The admin read these earlier in this file; for Sam they are all still unread.
const rows = await page.locator('.ep').count();
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.ep')).toHaveCount(rows);
// Two people now share this feed, so the page says so and Delete is honest about it.
await expect(page.locator('#content .sub').first()).toContainText('shared with 1 other person');
await ctx.close();
});
test('deleting a shared file warns that it is everyone\'s copy', async ({ page }) => {
// Admin and Sam both subscribe to Test Show by now, and the daemon downloaded a file.
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
const row = page.locator('.ep').first();
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
// An icon now; what it does, and to whom, is in its tooltip.
const del = page.locator('#files button[data-a="del"]');
await expect(del).toHaveAttribute('title', /^Delete for everyone \(shared with 1 other person/);
// Two prompts: the page's own, then the server's, because someone else has not played
// it. Accept the first, decline the second, and the file must survive.
const seen = [];
page.on('dialog', d => {
seen.push(d.message());
if (seen.length === 1) d.accept();
else d.dismiss();
});
await del.click();
await expect.poll(() => seen.length, { timeout: 10_000 }).toBe(2);
expect(seen[0]).toContain('shared with 1 other person');
expect(seen[1]).toContain('one copy of this file');
await page.reload();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
});
// Every row says "Admin" on its checkbox, so match the name exactly.
const userRow = (page, name) =>
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
async function openUsers(page) {
await page.locator('#prefs').click();
await page.locator('#gusers').click();
await expect(userRow(page, 'admin')).toBeVisible();
}
test('an admin adds someone, makes them an admin, and removes them', async ({ page }) => {
await openUsers(page);
await page.locator('#uname').fill('pat');
await page.locator('#upass').fill('patpassword');
await page.locator('#uadd').click();
const row = userRow(page, 'pat');
await expect(row).toBeVisible();
// When each account was added and last signed in; the admin signed in with the token link.
await expect(row).toContainText(/Added .* never signed in/);
await expect(userRow(page, 'admin')).toContainText(/signed in \d+m ago/);
await expect(row.locator('[data-a="admin"]')).not.toBeChecked();
await row.locator('[data-a="admin"]').check();
// Not just the box: it has to have reached the database.
await expect.poll(async () =>
(await (await page.request.get('/api/users')).json()).find(u => u.name === 'pat')?.admin
).toBe(true);
page.once('dialog', d => d.accept());
await row.locator('[data-a="rm"]').click();
await expect(row).toHaveCount(0);
});
test('the only admin cannot be demoted or removed', async ({ page }) => {
await openUsers(page);
await userRow(page, 'admin').locator('[data-a="admin"]').click();
await expect(page.locator('.toast.bad')).toContainText('only admin');
// Redrawn from the server, so the box is back.
await expect(userRow(page, 'admin').locator('[data-a="admin"]')).toBeChecked();
const me = (await (await page.request.get('/api/users')).json()).find(u => u.name === 'admin');
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
});
test('Settings exports your OPML and imports a pasted one', async ({ page }) => {
await page.locator('#prefs').click();
const [dl] = await Promise.all([
page.waitForEvent('download'),
page.locator('#modalCard a[title="Export OPML"]').click(),
]);
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
const out = require('fs').readFileSync(await dl.path(), 'utf8');
for (const f of ['show.xml', 'pics.xml', 'multi.xml', 'subs.opml']) expect(out).toContain(f);
// A feed from an OPML subscription comes back with the OPML itself, not on its own.
expect(out).not.toContain('other.xml');
// One feed new to everyone, one the admin already has.
await page.locator('#gopml').click();
await page.locator('#opmlText').fill('<opml version="2.0"><head><title>t</title></head><body>' +
'<outline text="Imported Show" xmlUrl="http://127.0.0.1:8792/imported.xml"/>' +
'<outline text="Test Show" xmlUrl="http://127.0.0.1:8792/show.xml"/></body></opml>');
await page.locator('#oimp').click();
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
.toHaveText('Subscribed to 1 feed(s), 1 you already had');
// Named from the OPML's id until the first scan reads the feed's own title.
await expect(page.locator('#feedlist .feed', { hasText: /Imported Show|imported-show/ }))
.toBeVisible({ timeout: 20_000 });
});
test('an uploaded OPML file imports, and a file that is not OPML is refused', async ({ page }) => {
await page.locator('#prefs').click();
await page.locator('#gopml').click();
const pick = page.locator('#opmlFile');
// An RSS feed is XML but not OPML: refused in the page, and the picker lets go of it.
await pick.setInputFiles({
name: 'feed.xml', mimeType: 'application/xml',
buffer: require('fs').readFileSync(require('path').join(__dirname, 'fixtures', 'show.xml')),
});
await page.locator('#oimp').click();
await expect(page.locator('.toast.bad', { hasText: 'feed.xml is not an OPML file' })).toBeVisible();
expect(await pick.evaluate(i => i.files.length)).toBe(0);
// Something that gets past the page's quick look is still refused by the server, untouched.
const sneaky = await page.request.post('/api/opml', {
data: { xml: '<rss version="2.0"><channel><title>&lt;opml&gt;</title></channel></rss>' },
});
expect(sneaky.status()).toBe(400);
expect(await sneaky.text()).toContain('not an OPML file');
// A real one. Multi Show is already the admin's, so it counts as already had.
await pick.setInputFiles({
name: 'subs.opml', mimeType: 'text/x-opml',
buffer: Buffer.from('<opml version="2.0"><head><title>t</title></head><body>' +
'<outline text="Multi Show" xmlUrl="http://127.0.0.1:8792/multi.xml"/></body></opml>'),
});
await page.locator('#oimp').click();
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
.toHaveText('Subscribed to 0 feed(s), 1 you already had');
await expect(page.locator('#modal.on')).toBeHidden();
});
test('an export from one account imports into another', async ({ page, browser }) => {
// Regression: import only added URLs the catalogue lacked and subscribed nobody, so importing
// the admin's export into a second account did nothing at all.
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = {
...process.env,
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
};
try {
execFileSync('./target/debug/ipx', ['user', 'add', 'opal'], { input: 'opalpassword', env });
} catch (e) {
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
}
const ctx = await browser.newContext();
const opal = await ctx.newPage();
await opal.goto('/login');
await opal.locator('#name').fill('opal');
await opal.locator('#pw').fill('opalpassword');
await opal.locator('button[type=submit]').click();
await expect(opal.locator('#feedlist')).toContainText('No feeds.');
// Export used to hand anyone the whole catalogue. Opal has nothing yet, so gets nothing.
const empty = await opal.request.get('/api/opml');
expect(empty.status()).toBe(200);
expect(await empty.text()).not.toContain('xmlUrl');
const urlsIn = xml => [...xml.matchAll(/xmlUrl="([^"]+)"/g)].map(m => m[1]).sort();
const exported = await (await page.request.get('/api/opml')).text(); // the admin's
const urls = urlsIn(exported);
expect(urls.length).toBeGreaterThan(2);
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
.toEqual({ added: urls.length, already: 0 });
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
.toEqual({ added: 0, already: urls.length });
await opal.reload();
await expect(opal.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// The round trip closes: opal's own export now lists what the admin's did.
expect(urlsIn(await (await opal.request.get('/api/opml')).text())).toEqual(urls);
await ctx.close();
});
test('ipx import subscribes the admin, and ipx export writes the feeds out', async () => {
// Its own config and database. The CLI works in-process, and the suite's running daemon
// reads config.toml once at start, so it would not see what the CLI added anyway.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const dir = path.join(setup.root, 'cli');
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
fs.writeFileSync(path.join(dir, 'config.toml'),
`[general]\ndownload_dir = "${dir}/downloads"\nsocket = "${dir}/ipx.sock"\n`);
const env = { ...process.env, IPX_CONFIG: path.join(dir, 'config.toml'), IPX_DATA_DIR: path.join(dir, 'data') };
const ipx = (args, input) => execFileSync('./target/debug/ipx', args, { env, input, encoding: 'utf8' });
ipx(['user', 'add', 'boss'], 'bosspassword'); // the first account is the admin
const opml = path.join(dir, 'in.opml');
fs.writeFileSync(opml, '<opml version="2.0"><head><title>t</title></head><body>' +
'<outline text="One" xmlUrl="http://127.0.0.1:8792/one.xml"/>' +
'<outline text="Two" xmlUrl="http://127.0.0.1:8792/two.xml"/></body></opml>');
expect(ipx(['import', opml])).toContain('subscribed boss to 2 feed(s); 0 already there');
expect(ipx(['import', opml])).toContain('subscribed boss to 0 feed(s); 2 already there');
const out = path.join(dir, 'out.opml');
ipx(['export', out]);
const xml = fs.readFileSync(out, 'utf8');
expect(xml).toContain('http://127.0.0.1:8792/one.xml');
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
});
test('Popular lists what everyone here reads, but never a private feed', async ({ browser }) => {
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = {
...process.env,
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
};
try {
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
} catch (e) {
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
}
const ctx = await browser.newContext();
const piper = await ctx.newPage();
await piper.goto('/login');
await piper.locator('#name').fill('piper');
await piper.locator('#pw').fill('piperpassword');
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
const offered = piper.locator('#popular .childrow');
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
const listed = await (await piper.request.get('/api/popular')).text();
expect(listed).not.toContain('secret123');
expect(listed).not.toContain('.xml');
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
// Popular is the top ten of the directory, and the directory is every listed feed A to Z,
// with an OPML's feeds in place of the OPML in both.
const dir = await (await piper.request.get('/api/directory')).json();
const top = await (await piper.request.get('/api/popular')).json();
const names = dir.map(p => (p.title || p.id).toLowerCase());
expect(names).toEqual([...names].sort());
const ids = dir.map(p => p.id);
expect(ids).not.toContain('test-subscriptions');
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
expect(top.length).toBe(Math.min(10, dir.length));
expect(top.every(t => ids.includes(t.id))).toBe(true);
expect(ids).not.toContain('paid-show');
// Subscribe from the directory this time; the popular list shares the same rows.
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
// Add a feed opened over Directory fills its own list, not the pane behind it.
await piper.locator('#addFeed').click();
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible();
await expect(offered).toHaveCount(dir.length);
await piper.locator('#modalCard button[title="Cancel"]').click();
const row = async () =>
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
const before = await row();
expect(before.subscribed).toBe(false);
await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
// Blog is not among them.
await piper.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
const first = piper.locator('.ep', { hasText: 'First Episode' });
await expect(first).toBeVisible({ timeout: 20_000 });
await expect(first.locator('.fd')).toHaveText('Test Show');
await expect(piper.locator('.ep', { hasText: 'An Article' })).toHaveCount(0);
await expect(piper.locator('#count')).toContainText('All Subscriptions:');
await ctx.close();
});
test('adding a feed scans it straight away', async ({ page }) => {
await page.locator('#addFeed').click();
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
await page.locator('#nsave').click();
// Nobody pressed Scan. The scheduler's tick is a minute, so this is the add scanning it.
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
});
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
page.on('dialog', d => d.accept());
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
const row = page.locator('.ep', { hasText: 'An Article' });
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
await page.locator('#files button[data-a="del"]').click();
// No "reaped", no chip at all: just the way to get it again.
await expect(row.locator('.kind.here')).toHaveCount(0);
await expect(row).not.toContainText(/reaped/i);
await row.click();
await expect(page.locator('#files')).not.toContainText(/reaped/i);
await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible();
});
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
const icon = loc => loc.locator('svg path').first().getAttribute('d');
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
// The toolbar's read and keep show the selected item's state, as its own buttons do, and follow
// a change made from the toolbar.
await page.locator('.ep').first().click();
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
await icon(page.locator(`#detail [data-a="${a}"]`))];
for (const a of ['read', 'flag']) { const [tb, own] = await pair(a); expect(tb).toBe(own); }
const [kept] = await pair('flag');
await page.locator('#tbFlag').click();
await expect.poll(async () => { const [tb, own] = await pair('flag'); return tb === own && tb !== kept; }).toBe(true);
await page.locator('#tbFlag').click(); // leave it as it was
await expect.poll(async () => (await pair('flag'))[0]).toBe(kept);
// Every button in every dialog is an icon with its words in the tooltip.
const dialogs = [
() => page.locator('#addFeed').click(),
() => page.locator('#prefs').click(),
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
() => page.locator('#logs').click(),
() => page.locator('#content .acts [data-a="settings"]').click(),
() => page.locator('#content .acts [data-a="dl"]').click(),
() => page.locator('#content .acts [data-a="rm"]').click(),
];
for (const open of dialogs) {
await open();
const btns = page.locator('#modalCard .btn');
await expect(btns.first()).toBeVisible();
for (const b of await btns.all()) {
await expect(b.locator('svg')).toHaveCount(1);
await expect(b).toHaveAttribute('title', /\S/);
}
await page.keyboard.press('Escape');
await expect(page.locator('#modal.on')).toBeHidden();
}
});
test('All Subscriptions marks everything read, across every feed', async ({ page }) => {
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
await all.click();
// Earlier tests read things; make sure something is unread. Opening an item reads it, and
// its own button makes it unread again.
await page.locator('.ep').first().click();
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
await expect(all.locator('.badge')).not.toHaveText('0');
page.once('dialog', d => d.accept());
await page.locator('#content .acts [data-a="readall"]').click();
await expect(all.locator('.badge')).toHaveText('0');
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.ep')).toHaveCount(0);
});
test('the item table sorts by any column, both ways, and remembers', async ({ page }) => {
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
await all.click();
const head = k => page.locator(`#list .ephead [data-sort="${k}"]`);
const titles = () => page.locator('#eps .ep .t').allTextContents();
// Byte order on lower case, which is what SQLite gives for lower(...).
const cmp = (a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
const sorted = (t, dir) => JSON.stringify(t) === JSON.stringify([...t].sort((a, b) => cmp(a, b) * dir));
await expect(page.locator('#eps .ep').nth(2)).toBeVisible({ timeout: 20_000 });
expect(new Set(await titles()).size).toBeGreaterThan(2); // or both orders would prove nothing
await expect(head('title')).toHaveText('Title');
await head('title').click();
await expect.poll(async () => sorted(await titles(), 1)).toBe(true);
await head('title').click();
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
// Kept across a reload.
await page.reload();
await all.click();
await expect(head('title').locator('.arr.desc')).toBeVisible();
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
// Size has its own column; the file column is just what the file is.
await expect(page.locator('#eps .ep .size', { hasText: /\d/ }).first()).toBeVisible();
await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0);
});
test('the selected feed and tab are remembered across a reload', async ({ page }) => {
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
await page.reload();
await expect(page.locator('#content h2')).toHaveText('Test Show');
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
// A feed that is gone -- unsubscribed, or never visited on this browser -- lands on All
// Subscriptions, not the first feed alphabetically.
await page.evaluate(() => localStorage.setItem('ipx.feed', 'no-such-feed'));
await page.reload();
await expect(page.locator('#feedlist .place.sel')).toContainText('All Subscriptions');
});
test('play in the Files pane plays once, in the player bar', async ({ page }) => {
// Regression: the pane had an <audio> of its own, and playing it started the player bar too,
// so the same file played twice at once.
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('.ep', { has: page.locator('.kind.here') }).first().click();
await page.locator('#files [data-a="play"]').click();
await expect(page.locator('#player')).toBeVisible();
// The player bar's element doubles as a <video> so a video file has somewhere to show its
// picture (see #audio's own comment), but there is still exactly one of it, and nothing else.
await expect(page.locator('#audio')).toHaveCount(1);
await page.locator('#pclose').click();
});
test('someone the proxy signs in never sees the password page, and signs out through the proxy', async ({ page, browser }) => {
// Signed in with the token, not by the proxy: Sign out stays ipx's own.
expect((await (await page.request.get('/api/me')).json()).sign_out).toBeNull();
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': 'proxied@example.com' } });
const proxied = await ctx.newPage();
// Regression: after Sign out, the password form showed to someone the proxy still vouched for.
await proxied.goto('/login');
await expect(proxied).toHaveURL(/:8791\/$/);
await expect(proxied.locator('#who')).toContainText('proxied@example.com');
expect(await (await proxied.request.get('/api/me')).json())
.toMatchObject({ name: 'proxied@example.com', sign_out: '/signed-out-by-the-proxy' });
await proxied.locator('#signout').click();
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
await ctx.close();
});

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Aardvark Radio</title><link>http://127.0.0.1:8792/</link>
<description>Inside the OPML, and first in it and alphabetically.</description>
<item><title>Aardvark Ep</title><guid>aa-1</guid><description>x</description></item>
</channel></rss>

BIN
tests/ui/fixtures/art.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
tests/ui/fixtures/art2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
tests/ui/fixtures/ep1.mp3 Normal file

Binary file not shown.

BIN
tests/ui/fixtures/ep2.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Fresh Show</title><link>http://127.0.0.1:8792/</link>
<description>Added in the browser suite, and scanned by adding it.</description>
<item><title>Fresh Ep</title><guid>fresh-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Imported Show</title><link>http://127.0.0.1:8792/</link>
<description>Only ever arrives through an OPML import.</description>
<item><title>Imported Ep</title><guid>imp-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Multi Show</title><link>http://127.0.0.1:8792/</link>
<description>An item with more than one file.</description>
<item><title>Two Files</title><guid>mu-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<description>Audio and a picture.</description>
<enclosure url="http://127.0.0.1:8792/ep2.mp3" length="40000" type="audio/mpeg"/>
<enclosure url="http://127.0.0.1:8792/art2.jpg" length="3020" type="image/jpeg"/>
</item></channel></rss>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Grouped Show</title><link>http://127.0.0.1:8792/</link>
<description>Inside the OPML.</description>
<item><title>Grouped Ep</title><guid>g-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Paid Show</title><link>http://127.0.0.1:8792/</link>
<description>Subscribed with a key in its URL, so it must never be offered to anyone else.</description>
<item><title>Paid Ep</title><guid>paid-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Picture Blog</title><link>http://127.0.0.1:8792/</link>
<description>A text blog whose entries carry a header image, as Substack does.</description>
<item><title>An Article</title><guid>pic-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<description>&lt;p&gt;Words, not audio.&lt;/p&gt;</description>
<enclosure url="http://127.0.0.1:8792/art.jpg" length="3020" type="image/jpeg"/></item>
</channel></rss>

View File

@@ -0,0 +1,19 @@
// Serves the fixture feeds so the daemon under test has something real to scan.
const http = require('http');
const fs = require('fs');
const path = require('path');
const dir = __dirname;
const port = Number(process.env.FIXTURE_PORT || 8792);
http.createServer((req, res) => {
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
const file = path.join(dir, path.basename(name));
fs.readFile(file, (err, body) => {
if (err) { res.writeHead(404).end('no'); return; }
const type = file.endsWith('.mp3') ? 'audio/mpeg'
: file.endsWith('.opml') ? 'text/x-opml' : 'application/xml';
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
res.end(body);
});
}).listen(port, '127.0.0.1', () => console.log(`fixtures on ${port}`));

View File

@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
<itunes:image href="http://127.0.0.1:8792/art.png"/>
<item><title>First Episode</title><guid>ui-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<description>&lt;p&gt;Show notes for the first one.&lt;/p&gt;</description>
<itunes:duration>1830</itunes:duration><itunes:season>1</itunes:season><itunes:episode>1</itunes:episode>
<enclosure url="http://127.0.0.1:8792/ep1.mp3" length="40000" type="audio/mpeg"/></item>
<item><title>Second Episode</title><guid>ui-2</guid>
<pubDate>Mon, 08 Sep 2026 10:00:00 +0000</pubDate>
<description>Notes for the second.</description>
<itunes:duration>900</itunes:duration>
<enclosure url="http://127.0.0.1:8792/ep1.mp3?2" length="40000" type="audio/mpeg"/></item>
</channel></rss>

View File

@@ -0,0 +1,5 @@
<opml version="2.0"><head><title>Test Subscriptions</title></head>
<body><outline text="Folder">
<outline type="rss" text="Aardvark Radio" xmlUrl="http://127.0.0.1:8792/aardvark.xml"/>
<outline type="rss" text="Grouped Show" xmlUrl="http://127.0.0.1:8792/other.xml"/>
</outline></body></opml>

70
tests/ui/global-setup.js Normal file
View File

@@ -0,0 +1,70 @@
// Builds a scratch config and data dir so the browser tests drive a real daemon with
// known feeds, rather than whatever happens to be on the machine.
const fs = require('fs');
const path = require('path');
const os = require('os');
const root = path.join(os.tmpdir(), 'ipx-ui-test');
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts
// webServer *before* globalSetup, so a config written there does not exist yet when the
// daemon launches -- it would fall back to the real config and fight the live daemon.
// Playwright imports this config again in every worker process, so prepare() runs more
// than once per suite. Wiping on the second call deleted the data directory out from under
// the running daemon: it kept serving from the unlinked inode, while anything else opening
// that path -- the CLI, a query -- got a brand new empty database and disagreed with it.
function prepare() {
// Only the process that launches the run may wipe. A worker gets TEST_WORKER_INDEX.
if (process.env.TEST_WORKER_INDEX !== undefined || process.env.PW_WORKER_INDEX !== undefined) {
return;
}
fs.rmSync(root, { recursive: true, force: true });
for (const d of ['config', 'data', 'downloads']) {
fs.mkdirSync(path.join(root, d), { recursive: true });
}
fs.writeFileSync(path.join(root, 'config', 'config.toml'), `
[general]
download_dir = "${path.join(root, 'downloads')}"
socket = "${path.join(root, 'ipx.sock')}"
schedule = "every 60m"
max_new_per_check = 1
[torrent]
enabled = false
[web]
enabled = true
bind = "127.0.0.1:8791"
token = "${TOKEN}"
# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1.
trusted_header = "X-Test-User"
trusted_proxies = ["127.0.0.1"]
sign_out_url = "/signed-out-by-the-proxy"
[feeds.test-show]
url = "http://127.0.0.1:8792/show.xml"
auto_download = true
# Downloads its image, so the UI has a file that is not playable to deal with.
[feeds.picture-blog]
url = "http://127.0.0.1:8792/pics.xml"
auto_download = true
media_types = ["image"]
[feeds.multi-show]
url = "http://127.0.0.1:8792/multi.xml"
auto_download = true
[feeds.test-subscriptions]
url = "http://127.0.0.1:8792/subs.opml"
auto_download = false
# A key in its URL, like a Patreon feed: someone's paid subscription, never offered to others.
[feeds.paid-show]
url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
auto_download = false
`);
}
module.exports = { prepare, root, TOKEN };

2094
web/index.html Normal file

File diff suppressed because it is too large Load Diff

BIN
web/ipodderx-icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
web/ipodderx-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

93
web/login.html Normal file
View File

@@ -0,0 +1,93 @@
<title>Sign in — iPodderX</title>
<link rel="icon" href="/icon.png">
<style>
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
--panel2:#1c2431;
--raise:#25303f;
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
--good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */
--bad:#e2705f;
--shadow:0 8px 28px rgba(6,10,16,.55);
--r:10px;
}
:root[data-theme="light"] {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10;
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;display:grid;place-items:center;background:var(--bg);color:var(--fg);
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:20px;
}
form{
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
border-radius:14px;padding:22px;box-shadow:var(--shadow);
}
/* The one place the 2004 icon is shown at the size it was drawn for. */
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
.brand img{width:96px;height:auto}
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
input{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:9px 11px;font:inherit;margin-bottom:13px;
}
input:focus{outline:0;border-color:var(--accent)}
button{
width:100%;font:inherit;font-weight:600;cursor:pointer;color:var(--ink);
background:var(--accent);border:0;border-radius:8px;padding:10px;
}
.msg{color:var(--bad);font-size:13px;min-height:19px;margin:10px 0 0;text-align:center}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
<form id="f">
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="username" autofocus required>
<label for="pw">Password</label>
<input id="pw" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
<p class="msg" id="msg"></p>
</form>
<script>
document.getElementById('f').onsubmit = async e => {
e.preventDefault();
const msg = document.getElementById('msg');
msg.textContent = '';
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('name').value,
password: document.getElementById('pw').value,
}),
});
if (r.ok) location.href = '/';
else msg.textContent = await r.text() || 'Sign in failed';
};
</script>