Compare commits
517 Commits
v0.2.0
...
directory-
| Author | SHA1 | Date | |
|---|---|---|---|
| ed9f008c6c | |||
| b72e65711f | |||
| 62f8451700 | |||
| 00eb75472c | |||
| ecff2f8dcc | |||
| ff5d786602 | |||
| 4f4fd4c3fe | |||
| f22c1993b1 | |||
| d33d83043d | |||
| 41affc8d8e | |||
| 59c7f5f213 | |||
| 0be8eb5cdf | |||
| e1291c1627 | |||
| a11e3b6367 | |||
| cb4ee5a570 | |||
| c4878731c6 | |||
| 21fa0ae476 | |||
| aa430f6d50 | |||
| df643536c0 | |||
| 543d35f02c | |||
| 0de0fb4332 | |||
| 79b496bdfb | |||
| a20fade8ad | |||
| daf95a25cd | |||
| 91de1114d8 | |||
| 37c08195f1 | |||
| d1149e1236 | |||
| 46fe0dddbf | |||
| d35b8054b4 | |||
| 3bfd8f848b | |||
| fc04a741a6 | |||
| 80f12cda70 | |||
| 4f7dab9c04 | |||
| cc3b8742bc | |||
| c99f481124 | |||
| a20062d0f7 | |||
| 6d71613e19 | |||
| f398211fd6 | |||
| 708b69c083 | |||
| 62a2456dde | |||
| ec3dedf113 | |||
| 511246acb2 | |||
| ccc4766b03 | |||
| 78b81e43aa | |||
| 4af12363bc | |||
| 84b4203daf | |||
| 0696583547 | |||
| 6ec2c35291 | |||
| c8afcec73d | |||
| 5bad68670a | |||
| d87d024096 | |||
| 44afc08b9d | |||
| a21472730e | |||
| 995ee05557 | |||
| 7af6153d18 | |||
| c1d8983ca1 | |||
| 4c1b78408f | |||
| d850c02501 | |||
| 363a5a122d | |||
| 3d9b45ddd7 | |||
| b54b47897b | |||
| b89c371662 | |||
| 6daab6989e | |||
| 187729a278 | |||
| 3df7c974e8 | |||
| 59aa00e07c | |||
| b4c0048f68 | |||
| d5ffe5d042 | |||
| d16460267f | |||
| 21c786f48a | |||
| 3a8d564eb7 | |||
| f9246129f2 | |||
| 5cf7a28c44 | |||
| ac3808fe97 | |||
| 341d880e13 | |||
| cb4407b19d | |||
| 7f61250852 | |||
| c90a66a24f | |||
| 692a510217 | |||
| 2c4ba7896e | |||
| 36a820f283 | |||
| 41fc2e3933 | |||
| 4eba746ba4 | |||
| ce78599ab4 | |||
| 5130f754cc | |||
| 2077ea5810 | |||
| 026fbd1622 | |||
| 1aef6ba421 | |||
| 9a41641eaa | |||
| 33eb1f4db2 | |||
| 3b48f51a56 | |||
| 55b0192df5 | |||
| e07d27d9f2 | |||
| 3532710b3d | |||
| b56c97259d | |||
| 1025416181 | |||
| 894e5e4711 | |||
| 0e21195e8d | |||
| 3b41abd860 | |||
| 47ad68c7da | |||
| ae58e4653a | |||
| dc3ee016db | |||
| f916ca972a | |||
| 7627af5bc8 | |||
| fbe7629420 | |||
| e882904c11 | |||
| f1a156c974 | |||
| f34423735a | |||
| 15bdce486d | |||
| 0c2c3a379e | |||
| 3e4d67fd2f | |||
| 2ed8781604 | |||
| d0ed953e84 | |||
| 4f1e7e23f6 | |||
| b18c92d1f5 | |||
| 9e5dcbb8c4 | |||
| ce485a82ac | |||
| f8371f255d | |||
| 1ae91170fd | |||
| 20f53183ac | |||
| 8d93b58deb | |||
| 0fdd609791 | |||
| 13cad18857 | |||
| 70990c70c7 | |||
| 30622fd3e1 | |||
| 1949f3e493 | |||
| 3d94bce4f5 | |||
| cd7a9b8530 | |||
| ca73b8136f | |||
| 6fde4b73cd | |||
| cdf2c98f9f | |||
| 6640ca9612 | |||
| 62a3241139 | |||
| 8f19d88fc9 | |||
| dbfe7918ac | |||
| c957db7f61 | |||
| 4e0b5eed5d | |||
| a9991545f9 | |||
| 60007d4e6a | |||
| 1471b9b9d0 | |||
| ec2b9e87b8 | |||
| af6eae9baf | |||
| 78704e40f2 | |||
| f20660a7d5 | |||
| a5e64e520d | |||
| 7ecbe8293f | |||
| 8d7a30d1f9 | |||
| cece114d98 | |||
| f00adf5da5 | |||
| d787bd26ec | |||
| 382f51a480 | |||
| 45c9f9252b | |||
| e4b505564c | |||
| 4cf568b263 | |||
| 724764c8df | |||
| cc155274e9 | |||
| 02bfa9571b | |||
| 1b137d6969 | |||
| 25f4d23112 | |||
| 69958d6926 | |||
| 0051590c71 | |||
| 83e7ee817b | |||
| 68ac180c9a | |||
| 0875bb9e39 | |||
| 2dd6e50065 | |||
| 91125700d9 | |||
| c0f8424209 | |||
| 50ffc5c81b | |||
| 0045396efd | |||
| 8d5a86d02a | |||
| 4cb2426b49 | |||
| 700037c368 | |||
| 579fe27b15 | |||
| 2ee2382c9b | |||
| 0cb6c84abe | |||
| f23274284c | |||
| 1c6f4eb321 | |||
| bc650153ce | |||
| 954399ed5d | |||
| 10a78efcc3 | |||
| 38c1abba1d | |||
| fa949f467d | |||
| 4eab53bf5d | |||
| 44e75df99e | |||
| a3708769a4 | |||
| ee7ab36730 | |||
| 42f983f81a | |||
| 34a0cdf914 | |||
| 7ff73f1a8b | |||
| cd099380d1 | |||
| 751a45f132 | |||
| 7ff6f86e7b | |||
| 77a414d8df | |||
| 3bab6210c5 | |||
| 0c820bbde1 | |||
| 3e58447f12 | |||
| f9870938bf | |||
| c47aa57909 | |||
| ed82166745 | |||
| 83bda97cce | |||
| beded23530 | |||
| c2cea0fc5b | |||
| fca704f7ff | |||
| e459d1474c | |||
| 7c3d36b29a | |||
| 78eb255a78 | |||
| afd3cf448d | |||
| c28accb8e4 | |||
| 8dc6dd4ce6 | |||
| f032d50ed3 | |||
| 12fadbd0f8 | |||
| 0d930e75ee | |||
| 29f1cbce29 | |||
| a985cfafef | |||
| 973eeb67c7 | |||
| 9fc1e7e248 | |||
| b51df33bb6 | |||
| cd9aef36a1 | |||
| 35fca0a6a2 | |||
| 3d005da847 | |||
| 6c931d0485 | |||
| 9648a79f3f | |||
| e5b9b0cfdb | |||
| ba9759f44f | |||
| c214b3e79a | |||
| e56bc9e8dd | |||
| b2fe1c725d | |||
| 7bec82d053 | |||
| 4ccf2e5511 | |||
| 089202f72b | |||
| f29d493a0e | |||
| 9ea2a9250c | |||
| 95202ec411 | |||
| 17fbf79db8 | |||
| d6c0e3e7a6 | |||
| 527a90ed2d | |||
| a67b949960 | |||
| 845448a754 | |||
| 00bc039964 | |||
| 111a0e29a4 | |||
| 827cc7489f | |||
| 16b0686aab | |||
| 63e192767d | |||
| 8687e4ff1b | |||
| 1e7a858e9c | |||
| c37bcf816b | |||
| 4ac1e6dbbc | |||
| 22a99110e8 | |||
| cb92d5e355 | |||
| e3f0267814 | |||
| f4a8ba4ea8 | |||
| 4c1d230044 | |||
| ecbc253894 | |||
| b4c115d79a | |||
| 4e417389fe | |||
| 19f77a21d9 | |||
| 65ca481525 | |||
| 92cc0cf4ec | |||
| 6a69818ef0 | |||
| 2de5f81202 | |||
| 4db5ee5c44 | |||
| 6ddd6444f9 | |||
| e710103ba6 | |||
| dc340dd4e9 | |||
| ebecc12193 | |||
| 33e031dd13 | |||
| 60cbb7aff6 | |||
| ec55682c13 | |||
| 15291298aa | |||
| c426ab6314 | |||
| d5edead011 | |||
| c7eef3e516 | |||
| a12aa59970 | |||
| a553931c76 | |||
| 46f9370018 | |||
| 70c49921ab | |||
| 0f93d1bd0d | |||
| 4f4f2e8d95 | |||
| c1b73b3dbe | |||
| fc0d6bf98b | |||
| a44e2b4235 | |||
| 2098343621 | |||
| 5684a02e79 | |||
| 50c3b9db64 | |||
| 39ae43c80e | |||
| c9964778cb | |||
| a553d050ca | |||
| 9c2f3eaf1b | |||
| c63f70ad0c | |||
| bbdcbe213f | |||
| 8c4a5f396b | |||
| b43ba89778 | |||
| f1d360420c | |||
| 697e907c86 | |||
| 894dbbe31d | |||
| 552d20443e | |||
| 367f431e1f | |||
| ef8e2526f2 | |||
| 5058adc93e | |||
| 9e39f2d756 | |||
| b86062b97a | |||
| 95acde3046 | |||
| 0b4f5443b7 | |||
| e699baa22a | |||
| 00bd58ac9d | |||
| cc17b1ddab | |||
| 119336a20d | |||
| 02a46bb500 | |||
| 3e384f34f9 | |||
| d238681ec1 | |||
| 0395717c14 | |||
| 8ce68a881a | |||
| e7c59489ee | |||
| f7f4b466dc | |||
| ec8fd5dd86 | |||
| a387012c69 | |||
| a00516687a | |||
| 38ebc7b02b | |||
| 54d827f655 | |||
| e2593eaa50 | |||
| d52ecfcbd7 | |||
| 142610e8b8 | |||
| 43acc62259 | |||
| 65fdab8b13 | |||
| 4d312a87ec | |||
| ef5bdb4244 | |||
| c7f13eea2f | |||
| 7a134b810f | |||
| 79bc006a30 | |||
| 9084b61bb6 | |||
| 0cc956b7c6 | |||
| db87bc0f42 | |||
| 527777efdf | |||
| 4a26c73b82 | |||
| 98ed9b6498 | |||
| 57ab419718 | |||
| 2799704d30 | |||
| e8fd3fe9ed | |||
| 448e557272 | |||
| 36b16c7f5d | |||
| 928cbaf8f4 | |||
| 0945f3a9f4 | |||
| e9f2832e1e | |||
| d4e00be085 | |||
| 66e40e3c71 | |||
| 4f8b3d6a1d | |||
| 8ce0a4cb27 | |||
| 549ae33f06 | |||
| 9e7c93e149 | |||
| 1724346da7 | |||
| 8c5eddd783 | |||
| 469467bf04 | |||
| 95a8633877 | |||
| 00f6293b95 | |||
| bae22e553e | |||
| 7d55d99fac | |||
| a5de4ae06c | |||
| 4ed2d59311 | |||
| 2004da3459 | |||
| c6980c355d | |||
| ce221cee18 | |||
| f57f824535 | |||
| 7490a3a9ac | |||
| 5be629427a | |||
| bf785299b0 | |||
| 5967aa1e57 | |||
| 9e238bfc75 | |||
| 10b7ccd424 | |||
| d8db785681 | |||
| f9c9c2b7cc | |||
| 3cb36ab8b7 | |||
| 65b5eacdb4 | |||
| 4769a2ebe1 | |||
| 70e0341348 | |||
| e16dace9c0 | |||
| 9f56436033 | |||
| ae900b82ca | |||
| 40c92ad08d | |||
| 7796566dfc | |||
| 162665b010 | |||
| f2fde8cc75 | |||
| a573a07ed5 | |||
| 380a552913 | |||
| bdda9b3d2e | |||
| 868dd673f3 | |||
| 2930be37ad | |||
|
|
f9225f0d21 | ||
|
|
9d1492b388 | ||
| ad15ae3dfe | |||
| aedbe89654 | |||
| 321c9e014e | |||
| 73092e6ad7 | |||
| 1a3c8a6d4f | |||
| b81d44cfb6 | |||
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 | |||
| b5ff57ac0f | |||
| 35b57fa997 | |||
| 43ffafe7ac | |||
| 45cbd3a239 | |||
| 905dfa0b02 | |||
| 5f6bdbfbcb | |||
| 8849ba6bef | |||
| 1cafd8d6e3 | |||
| f09bb4a11c | |||
| 973ebdd33a | |||
| bc7491a377 | |||
| c8df148546 | |||
| c1c06229c8 | |||
| bc53e0f730 | |||
| bd8f6ab855 | |||
| 611716d8b7 | |||
| a68bfb179b | |||
| 6bf1ad6b31 | |||
| d7f8f2df1d | |||
| 4927677e66 | |||
| 484aaa1849 | |||
| c99e17bd80 | |||
| e312f11bb1 | |||
| a465fa8471 | |||
| aeb686163b | |||
| 2d158a4540 | |||
| fc425bffa6 | |||
| 42a1136e2e | |||
| 53341264a7 | |||
| 9c16408d04 | |||
| 9b2537761f | |||
| 3b00e2721a | |||
| fc09e8a6b7 | |||
| 5483355021 | |||
| e2969bcee8 | |||
| 26802d2b23 | |||
| fbba447ca6 | |||
| d7a4a0b663 | |||
| 0443177471 | |||
| 0a83c716eb | |||
| b9e0d9f3cb | |||
| 2dee3b722c | |||
| 8daa7a1991 | |||
| f89ca2aceb | |||
| 57a6fb5daa | |||
| 49dafedbbc | |||
| b83523fc92 | |||
| 8ae5c332c3 | |||
| 420f9eb9a0 | |||
| 4314b11237 | |||
| eaea520020 | |||
| b8d22904f1 | |||
| cbd16ce3f6 | |||
| 017cfd7e28 | |||
| 954173cacf | |||
| 97934d641a | |||
| afbe6367cc | |||
| af38583b53 | |||
| eeb72fd677 | |||
| 320cb48b5d | |||
| f2a61ad88c | |||
| 8d84f9fe8c | |||
| e38e3c563c | |||
| a30edff248 | |||
| be3820bbbd | |||
| 51ce0bf9eb | |||
| 6ec900e456 | |||
| d4304869b6 | |||
| 9aae3097e7 | |||
| 2ff2074755 | |||
| 1698cf8d1e | |||
| b94a74ef15 | |||
| 9a8a3c696f | |||
| bedf64e645 | |||
| 586d2c07a1 | |||
| 2ba83c3aed | |||
| 1352f0d54d | |||
| a958f7cb37 | |||
| 457a58dcc5 | |||
| 2af57065c6 | |||
| 564b011c7a | |||
| 9eb7aadced | |||
| c6bceaef37 | |||
| dc63d6acaf | |||
| 8937f35f00 | |||
| 0990f2a90d | |||
| 0cc002cdfa | |||
| 2c9e899762 | |||
| 2e416f96cf | |||
| 9269aa99f7 | |||
| ae8123250b | |||
| 2c34a144ba | |||
| 669e8b5124 | |||
| 5362436766 | |||
| 0efc49519c | |||
| 57dcba2d1a | |||
| e95cccc66f | |||
| 7295be8b25 | |||
| f1b0d97b81 | |||
| ae47e31a97 | |||
| 8a309eb652 | |||
| 1a2b0d87c6 | |||
| d7fac2d0e7 | |||
| df9b7645d6 | |||
| f3825cfc57 | |||
| c0f4b0bcb2 | |||
| 8b8b48302b | |||
| 5d3fdde4da | |||
| 8784d0a3fd | |||
| 5e95557cbb | |||
| 6114add4a6 | |||
| c47c224372 | |||
| 686851b448 | |||
| f0d03c79c8 | |||
| 7df4ee7dde | |||
| d46ec73261 |
83
.agents/skills/security-audit/AI-AND-LLM.md
Normal file
83
.agents/skills/security-audit/AI-AND-LLM.md
Normal 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.
|
||||
130
.agents/skills/security-audit/ATTACK-CLASSES.md
Normal file
130
.agents/skills/security-audit/ATTACK-CLASSES.md
Normal 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.
|
||||
83
.agents/skills/security-audit/CLIENT-SIDE.md
Normal file
83
.agents/skills/security-audit/CLIENT-SIDE.md
Normal 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.
|
||||
86
.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md
Normal file
86
.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
251
.agents/skills/security-audit/HUNTING.md
Normal file
251
.agents/skills/security-audit/HUNTING.md
Normal 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.
|
||||
101
.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md
Normal file
101
.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md
Normal 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.
|
||||
81
.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md
Normal file
81
.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md
Normal 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.
|
||||
156
.agents/skills/security-audit/RECONNAISSANCE.md
Normal file
156
.agents/skills/security-audit/RECONNAISSANCE.md
Normal 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.
|
||||
@@ -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.
|
||||
192
.agents/skills/security-audit/SKILL.md
Normal file
192
.agents/skills/security-audit/SKILL.md
Normal 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.
|
||||
73
.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md
Normal file
73
.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md
Normal 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.
|
||||
186
.agents/skills/security-audit/VALIDATION-AND-REPORTING.md
Normal file
186
.agents/skills/security-audit/VALIDATION-AND-REPORTING.md
Normal 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.
|
||||
105
.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md
Normal file
105
.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md
Normal 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.
|
||||
461
.agents/skills/security-audit/report-schema.json
Normal file
461
.agents/skills/security-audit/report-schema.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
872
.agents/skills/security-audit/validate-coverage-ledger.cjs
Executable file
872
.agents/skills/security-audit/validate-coverage-ledger.cjs
Executable 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]));
|
||||
740
.agents/skills/security-audit/validate-coverage-ledger.test.cjs
Normal file
740
.agents/skills/security-audit/validate-coverage-ledger.test.cjs
Normal 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));
|
||||
}
|
||||
});
|
||||
773
.agents/skills/security-audit/validate-findings.cjs
Executable file
773
.agents/skills/security-audit/validate-findings.cjs
Executable 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]));
|
||||
652
.agents/skills/security-audit/validate-findings.test.cjs
Normal file
652
.agents/skills/security-audit/validate-findings.test.cjs
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
22
.claude/settings.json
Normal file
22
.claude/settings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ffprobe -v error *)",
|
||||
"Bash(env)",
|
||||
"mcp__outline__read_document"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.rs) rustfmt --edition 2024 \"$f\" 2>/dev/null || true;; esac"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
414
.claude/skills/ipx-prod-check/SKILL.md
Normal file
414
.claude/skills/ipx-prod-check/SKILL.md
Normal file
@@ -0,0 +1,414 @@
|
||||
---
|
||||
name: ipx-prod-check
|
||||
description: Look for problems in production ipx (the ipx-web, iPX and ipx-directory containers on Tower, Tower itself and the monitoring that watches them) from their logs in Loki, their traces in Tempo and the metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
|
||||
---
|
||||
|
||||
# Checking production ipx
|
||||
|
||||
Production is three containers in the Arcane project `ipx` on Tower
|
||||
(`/mnt/fast/arcane/projects/ipx/compose.yaml`), one request passing through them in this order:
|
||||
|
||||
| container | what | logs, in Loki | traces, in Tempo | metrics, in Prometheus |
|
||||
|---|---|---|---|---|
|
||||
| `ipx-web` | Caddy: serves the page, passes `/api` and `/media` to ipx (#203). Port 8099, the LAN and the tunnel | `{container="ipx-web"}`, JSON: a line a request (`http.log.access`), its own errors | service `ipx-web`: a span a request, passing `traceparent` on to ipx (#213) | none |
|
||||
| `iPX` | the daemon: API, scans, downloads, DeadAir | `{container="iPX"}`, JSON (`IPX_LOG_FORMAT=json`) | service `ipx`; passes `traceparent` to the directory | `job="ipx"`, its `/metrics` |
|
||||
| `ipx-directory` | the directory of public podcasts, `directory.ipxreader.app` (#204, #214) | `{container="ipx-directory"}`, JSON (`DIRECTORY_LOG_FORMAT=json`) | service `ipx-directory`: a span a request, joining ipx's trace when ipx called; a trace a refresher round (#213) | none |
|
||||
|
||||
Under them, Tower itself: `job="node"` (the node exporter: load, CPU, memory, disks). The Grafana
|
||||
dashboard `iPX` draws all of it in that order (`grafana/dashboard.py`). A trace without the
|
||||
production tag (`resource.deployment.environment.name="production"`, on all three) is a daemon run
|
||||
by hand, not production. Since #213 a request is one trace across the services it touched: a
|
||||
Directory page is ipx-web, then ipx, then the directory, so start from the trace, not the logs. Alloy ships the logs; Loki, Tempo and
|
||||
Prometheus publish no query port to this machine, so use the script beside this file:
|
||||
|
||||
```sh
|
||||
Q=.claude/skills/ipx-prod-check/query.py
|
||||
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
||||
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
||||
$Q traces '<TraceQL>' [since] # slowest first
|
||||
$Q spans '<TraceQL>' [since] # the matching spans, each with its own time, slowest first
|
||||
$Q trace <trace id> # one trace as a tree, with its log lines
|
||||
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
|
||||
$Q flame '<TraceQL>' [since] [out.svg] # where the matching spans' time went, summed over up to
|
||||
# 100 traces: a flame graph, and out.svg.folded for speedscope
|
||||
```
|
||||
|
||||
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||
"Since the last deploy" is since that container started (each deploys on its own):
|
||||
|
||||
```sh
|
||||
for c in iPX ipx-web ipx-directory; do S=$(docker inspect $c -f '{{.State.StartedAt}}'); echo "$c $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m"; done
|
||||
```
|
||||
|
||||
Start every check by seeing that all three run, since Loki cannot see a container that is not:
|
||||
|
||||
```sh
|
||||
docker ps -a --filter name=iPX --filter name=ipx-web --filter name=ipx-directory --format '{{.Names}} {{.Status}}'
|
||||
```
|
||||
|
||||
All three should be `Up`, with no recent restart; they have no Docker healthcheck (#218). Then work through ipx (the checks below), the page, the
|
||||
directory, Tower and the monitoring itself, in that order.
|
||||
|
||||
## ipx
|
||||
|
||||
### What the log carries
|
||||
|
||||
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
|
||||
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
|
||||
Lines inside a traced span, requests and scans, also carry `trace_id` and `span_id`: give the
|
||||
`trace_id` to `$Q trace` to see the whole request or scan. (From 2026-09-29 16:30 UTC; before that,
|
||||
lines had no trace id, the access log's time was `ms`, and each event was logged twice, words under
|
||||
`ipx::scan` and fields under `ipx::io`.)
|
||||
|
||||
| target | fields | what |
|
||||
|---|---|---|
|
||||
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
|
||||
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
|
||||
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
|
||||
| `ipx::daemon`, `ipx::cli`, `ipx::catalogue`, `ipx::scan`, ... | message, sometimes fields | start-up, shutdown, account and config messages, under the module that logs them; plain `ipx` before 2026-10-24, when main.rs was split (#356) |
|
||||
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
|
||||
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
|
||||
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
|
||||
updated), `download_done` (bytes), `download_error` (msg, url),
|
||||
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
|
||||
downloaded: the healthcheck's, every 30s), `deadair_done` (feed, enclosure, `saved_s` at the
|
||||
default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`,
|
||||
`took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on
|
||||
one, which it then passes over until the next restart.
|
||||
|
||||
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
|
||||
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
|
||||
|
||||
Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`): it is much
|
||||
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
|
||||
`| json | __error__=""` drops them.
|
||||
|
||||
### What Prometheus carries
|
||||
|
||||
The daemon's own state between requests, which no log line or trace holds (#194): ipx serves
|
||||
`/metrics` on `[web] metrics_bind` (production: `192.168.1.130:9187`, LAN only), on a thread and
|
||||
port of its own, from memory alone, and Prometheus scrapes it every 15 s as `job="ipx"`. Kept
|
||||
from 2026-10-11 15:15 UTC. All gauges:
|
||||
|
||||
| metric | what |
|
||||
|---|---|
|
||||
| `ipx_db_connections{state="in_use"\|"idle"}`, `ipx_db_connections_max` | the database pool: in use reaching the max means requests and scans wait for a connection |
|
||||
| `ipx_worker_queue` | commands waiting for the sequential worker (scans, downloads, adds) |
|
||||
| `ipx_event_listeners` | open pages and socket clients on the live-update stream |
|
||||
| `ipx_torrent_slots_free` | of 2 |
|
||||
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
|
||||
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
|
||||
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets; mimalloc gives back what is freed, so memory follows use and a rise that never comes down is a leak (#224) |
|
||||
| `ipx_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate |
|
||||
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
|
||||
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; the healthcheck passes while it is stuck |
|
||||
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
|
||||
| `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting |
|
||||
| `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress |
|
||||
| `ipx_download_bytes_total` | counter; `rate()` is the download speed |
|
||||
| `ipx_event_queue`, `ipx_events_missed_total` | live updates unread by some listener; counter of those dropped for one that fell behind |
|
||||
| `ipx_deadair_backlog`, `ipx_deadair_busy_seconds` | files of shows with DeadAir on not yet looked at (episodes everyone heard through stay counted); how long it has been on its file |
|
||||
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
|
||||
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
|
||||
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
|
||||
| `ipx_signals_judged_total{user,key}`, `ipx_signals_hits_total{user,key}` | counters: items Jev judged for Signals, and Signals they fit, by person and whose key paid (`server` or `own`, #278) (#254) |
|
||||
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"rate_limited"\|"error"}`, `ipx_jev_seconds_total`, `ipx_jev_paced_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended; their seconds, the wait for a turn included; and that wait alone. `malformed` is ipx's request at fault (#257); `rate_limited` is a 429 from TypeSafe, whose limits are 80 calls and 100,000 input tokens a second, which ipx paces itself under at 60 and 80,000 (#277) |
|
||||
| `ipx_jev_tokens_total{kind="input"\|"output",user,key}` | counter: tokens TypeSafe counted, as its answers say, by person and whose key paid: what Signals, icon picks and key checks cost (#275, #278). A call shared on the server's key is split by each person's share of its questions |
|
||||
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
|
||||
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
|
||||
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
|
||||
|
||||
### The checks
|
||||
|
||||
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
|
||||
a count says something happened, the lines and traces say why.
|
||||
|
||||
1. **Warnings and errors, grouped.** What went wrong, how often, and since when.
|
||||
```
|
||||
$Q metric 'sum by (target, message) (count_over_time({container="iPX"} | json | __error__="" | level=~"WARN|ERROR" [$range]))'
|
||||
```
|
||||
Feed and download failures name the feed in the message; group them in the next check instead.
|
||||
2. **Failing feeds and downloads.**
|
||||
```
|
||||
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"feed_error\"" | json | __error__="" [$range]))' 7d
|
||||
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"download_error\"" | json | __error__="" [$range]))' 7d
|
||||
```
|
||||
Tell the publisher's problems from ipx's. A 404, 410, DNS failure or 503 from the feed's own
|
||||
server is the publisher (worth saying, since the feed may have moved; one issue for a feed
|
||||
that has been dead for days, not for a 503 once). A parse error on a feed that loads in a
|
||||
browser, a redirect loop ipx should follow, or the same failure on many feeds at once is ipx.
|
||||
3. **Server errors and slow requests.**
|
||||
```
|
||||
$Q metric 'sum by (method, route, status) (count_over_time({container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | status >= 500 [$range]))'
|
||||
$Q metric 'topk(10, quantile_over_time(0.95, {container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | route != "" | route != "/api/events" | unwrap duration_ms [$range]) by (method, route))'
|
||||
```
|
||||
Any 5xx is worth a look. 401s are people signing in, not a problem unless one address is
|
||||
hammering. For a slow route, find its traces (check 5) and see which span holds the time.
|
||||
4. **Is the worker keeping up?** Scans should finish regularly, the queue should drain, and the
|
||||
daemon should not be restarting on its own.
|
||||
```
|
||||
$Q metric 'sum(count_over_time({container="iPX"} |= "\"ev\":\"scan_done\"" [$range]))' 6h
|
||||
$Q logs '{container="iPX"} |= "\"ev\":\"status\"" | json | line_format "{{.timestamp}} pending={{.pending}} downloaded={{.downloaded}}"' 6h
|
||||
$Q logs '{container="iPX"} |= "daemon started"' 7d
|
||||
```
|
||||
A `daemon started` not matched by a deploy (see `git log` and the image's build time) is a
|
||||
crash or an OOM kill: check `docker inspect iPX -f '{{.State.OOMKilled}} {{.RestartCount}}'`
|
||||
and the lines just before it. A pending count that only grows means downloads are not
|
||||
keeping up or not running.
|
||||
|
||||
Since 2026-10-02 the daemon sleeps until the next feed is due (at most 10 minutes) instead of
|
||||
scanning every minute (#114), so expect tens of scans in 6 hours, not 360, nearly all with
|
||||
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
|
||||
the real queue (#113): files a scan will download on its own. Back-catalogue files are
|
||||
`held`, listed but not counted, so it is usually 0 or a handful.
|
||||
5. **Slow and failed traces.** Look at ipx's requests as spans, not traces: a trace started by a
|
||||
tap in the page also holds that page's live updates and any audio it streamed, minutes long,
|
||||
so a list of traces over a few seconds is nothing but those. `spans` lists each matching span
|
||||
with its own time and route, slowest first:
|
||||
```
|
||||
R='resource.deployment.environment.name="production" && resource.service.name="ipx"'
|
||||
$Q spans "{$R && span.http.route != \"\" && span.http.route != \"/api/events\" && span.http.route !~ \"/media/.*\" && duration > 1s} | select(span.http.route)"
|
||||
$Q spans "{$R && name=\"step\" && duration > 500ms} | select(span.of)"
|
||||
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||
$Q trace <id>
|
||||
```
|
||||
Group the first by hour and by route (`awk '{print substr($3,1,2), $NF}' | sort | uniq -c`),
|
||||
then lay the hours beside Tower's (the Tower section): load over 100 with processes blocked
|
||||
and memory under 4 GB is the host stalling, and every request of those minutes is slow with
|
||||
it, ipx's queries included. On 2026-10-18 that was 120 of 130 requests over a second, up to 43 s.
|
||||
What is left at quiet hours is ipx's own: open its trace and see which step holds the time. A
|
||||
request far longer than its steps added up waited outside them, for a connection or a thread.
|
||||
The second line finds steps over half a second wherever they ran; a step slow only once, first
|
||||
thing after hours idle, is Postgres reading from disk what it no longer had in memory (All
|
||||
Subscriptions' `entries_in`, a second where it is 60 ms warm).
|
||||
|
||||
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||
`feed` or `fetch` span holding most of it (`$Q spans '{... && name="feed" && duration > 10s}'`),
|
||||
or a `download` far slower than its size explains.
|
||||
|
||||
Steps are `step` spans named by `of` (#187): an item list's `entries_in` (with the list asked for: `filter`, `sort`, `dir`, `offset`, `limit`, `search`), `sanitize`,
|
||||
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
|
||||
`missing_files`, `prune_abandoned_failures`, `reap_candidates`, `clean_directory` (once a day
|
||||
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
|
||||
span. DeadAir's work on a file is a `deadair` trace (feed, enclosure, cuts) with `probe`,
|
||||
`silencedetect`, `frame_bytes`, `store_plans`: seconds each, more is a file ffmpeg struggles
|
||||
with. A trace with no name in `$Q traces` started in the page (#190, #192): its root is the tap
|
||||
in the browser, which sends no span, so it holds the requests that tap made (API calls, the
|
||||
playlist and audio of a play, position saves, the page's own report) side by side.
|
||||
|
||||
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
|
||||
hint at.
|
||||
```
|
||||
$Q prom 'up{job="ipx"}'
|
||||
$Q prom 'max(max_over_time(ipx_db_connections{job="ipx",state="in_use"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_db_connections_max{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_worker_queue{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_runtime_queue{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(resets(ipx_uptime_seconds{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'ipx_process_resident_bytes{job="ipx"} - ipx_process_resident_bytes{job="ipx"} offset $range' 24h
|
||||
$Q prom 'ipx_process_open_fds{job="ipx"} - ipx_process_open_fds{job="ipx"} offset $range' 24h
|
||||
$Q prom 'ipx_runtime_tasks{job="ipx"} - ipx_runtime_tasks{job="ipx"} offset $range' 24h
|
||||
$Q prom 'max(max_over_time(ipx_worker_job_seconds{job="ipx"}[$range])) by (kind)' 24h
|
||||
$Q prom 'max(max_over_time(ipx_last_scan_age_seconds{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$range]))' 24h
|
||||
$Q prom 'max(max_over_time(ipx_signals_last_pass_age_seconds{job="ipx"}[$range]))' 24h
|
||||
```
|
||||
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
|
||||
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
||||
those in use; in use at 20, idle at 0, means something is waiting for one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
||||
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
|
||||
above 0 for more than a scrape or two means every thread is busy: something is blocking a
|
||||
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
|
||||
is a leak (a file or socket not closed, a task never ending); a step after a deploy is not. A
|
||||
reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4).
|
||||
|
||||
The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over
|
||||
15 minutes ago, is the worker stuck, which the healthcheck does not notice; its trace and the
|
||||
log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is
|
||||
where the worker's time went (reap was most of it until #193). Web requests in flight near
|
||||
the pool's max mean the pool is what they wait on. Live updates missed mean a page showed stale
|
||||
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
|
||||
Any `rate_limited` Jev call means the pacing is not holding ipx under TypeSafe's limits (#277); each
|
||||
`jev` span has `paced_ms`, its wait for a turn. Any `malformed` Jev call is ipx asking wrongly (#257); `key_refused` is someone's key; the Signals
|
||||
judge over 10 minutes since a pass is stuck on Jev: its `signals` trace shows which `jev` call.
|
||||
|
||||
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
|
||||
```
|
||||
$Q metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
|
||||
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
|
||||
$Q metric 'quantile_over_time(0.95, {container="iPX"} |= "\"ev\":\"list_shown\"" | json | __error__="" | unwrap ms [$range]) by (feed, filter)' 24h
|
||||
```
|
||||
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
|
||||
playlist (its trace has the playlist and audio requests), `media error 4` a file the browser
|
||||
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
|
||||
the minified `app.js`, so match the message to `web/src`. A `list_shown` over 1000 ms is a list someone waited for: its trace says whether the API or the network held it. `play_start` over 2000 ms, or a
|
||||
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
|
||||
|
||||
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
|
||||
|
||||
## ipx-web, the page
|
||||
|
||||
Caddy logs one JSON line a request (#210), logger `http.log.access.log0`: Loki's `| json` gives
|
||||
`request_uri`, `request_method`, `request_remote_ip`, `status`, `duration` (seconds, not ms), `size`.
|
||||
The request's headers and a `?token=` are filtered out before it is written. Requests from
|
||||
`127.0.0.1` are local checks; leave them out. `/api` and `/media` pass on to ipx and
|
||||
are in ipx's own access log too, with ipx's time; Caddy's `duration` for them adds the hop.
|
||||
|
||||
```sh
|
||||
W='{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
|
||||
$Q metric "sum by (status) (count_over_time($W [\$range]))"
|
||||
$Q metric "sum by (request_uri) (count_over_time($W | status >= 500 [\$range]))"
|
||||
$Q metric "topk(10, quantile_over_time(0.95, $W | request_uri !~ \"/(api|media)/.*\" | unwrap duration [\$range]) by (request_uri))"
|
||||
$Q logs '{container="ipx-web"} | json | __error__="" | level=~"warn|error" | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"'
|
||||
```
|
||||
|
||||
A 502 is ipx not answering Caddy: a restart (match the deploy) or ipx down; Caddy's error line
|
||||
names it (`dial tcp ...:8099: connect: connection refused`). The page itself (`/`, `/admin`)
|
||||
waits on `/api/page-check` before it is sent, so a slow page is usually a slow page-check: look it
|
||||
up in ipx's access log. 401 and 303 on `/` are people signed out being sent to `/login`, not a
|
||||
problem. Caddy's own notices at each start (no admin endpoint, no HTTPS or HTTP/3, its goodbye on
|
||||
SIGTERM) are by design and filtered out above.
|
||||
|
||||
Each access line carries the request's `traceID` (`| json` gives `traceID`): give it to `$Q trace`
|
||||
to see where the time went, Caddy's span with ipx's inside it. A slow page with ipx's span short is
|
||||
Caddy or the network; a long one is ipx.
|
||||
|
||||
```sh
|
||||
$Q spans '{resource.service.name="ipx-web" && span.url.path != "/api/events" && span.url.path !~ "/media/.*" && duration > 2s} | select(span.url.path)'
|
||||
$Q traces '{resource.service.name="ipx-web" && status = error}'
|
||||
```
|
||||
|
||||
Who reaches it: `request_remote_ip` is `192.168.64.1` for the tunnel and anything else on Tower
|
||||
(Docker's gateway), `192.168.1.130` for Tower itself, a LAN address for the LAN. Caddy passes
|
||||
Cloudflare Access's headers on only from `192.168.64.1` (`TUNNEL_PEER`), and ipx trusts them only
|
||||
from Caddy, with a valid signed token. Testing sign-in from Tower proves nothing about the tunnel
|
||||
(#209): send it from a container on another network.
|
||||
|
||||
## ipx-directory
|
||||
|
||||
It logs a line a request (`target` `ipx_directory::http`: `method`, `path`, `status`,
|
||||
`duration_ms`, `token`, the token's name and never the token), a line a snapshot (`message`
|
||||
`snapshot`: `instance`, `accepted`, `refused_private`, `refused_blocked`, `refused_invalid`), a
|
||||
line a refresher round (`feeds checked`: `checked`), a line a failed feed check (`feed check
|
||||
failed`: `feed`, the directory's id, and `error`), the admin's actions (`feed removed and
|
||||
blocked`, `feed unblocked`, `snapshot dropped`, each with `by`), and `config read again` when its
|
||||
token file changes. It has no metrics. Its traces (#213): a span a request (`http`), a child of
|
||||
ipx's when ipx called with a `traceparent`, and a trace a refresher round (`refresh`, `feeds`)
|
||||
with a `feed` span for each check. Its log lines carry no trace id; find the trace by time or by
|
||||
the feed.
|
||||
|
||||
```sh
|
||||
$Q traces '{resource.service.name="ipx-directory" && duration > 2s}'
|
||||
$Q traces '{resource.service.name="ipx-directory" && name="feed" && duration > 10s}'
|
||||
$Q traces '{resource.service.name="ipx" && name="directory_share"}'
|
||||
```
|
||||
|
||||
A slow Directory page in ipx is usually its `http` span in the directory: the trace says whether.
|
||||
A refresher round far over a minute is one publisher's feed holding it, the slow `feed` span.
|
||||
|
||||
```sh
|
||||
D='{container="ipx-directory"} | json | __error__=""'
|
||||
$Q metric "sum by (status, token) (count_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" [\$range]))"
|
||||
$Q metric "topk(5, max_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" | unwrap duration_ms [\$range]) by (path))"
|
||||
$Q logs "$D | level=~\"WARN|ERROR\""
|
||||
$Q logs "$D | message=\"snapshot\"" 7d
|
||||
$Q metric "sum(count_over_time($D | message=\"snapshot\" [\$range])) by (instance)"
|
||||
$Q metric "sum by (feed, error) (count_over_time($D | message=\"feed check failed\" [\$range]))" 7d
|
||||
$Q metric "sum(sum_over_time($D | message=\"feeds checked\" | unwrap checked [\$range]))" 24h
|
||||
```
|
||||
|
||||
Expect a snapshot from each server when what it shares changes and every half hour, so about 48 a
|
||||
day each; one every half minute is ipx resending what has not changed (#211, fixed). A burst of
|
||||
401s with an empty `token` is someone without a token trying the public address, expected now and
|
||||
then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A snapshot whose
|
||||
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
|
||||
in that server's log, since the directory refuses it but the sender should not have sent it. Many
|
||||
`feed check failed` lines for one feed are the publisher's, not the directory's. Each feed is
|
||||
checked every twelve hours (#243), 25 at a time, so `feeds checked` over a day should be about
|
||||
twice the feeds listed; none for over twelve hours means the refresher is stuck. `could not store a feed
|
||||
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
|
||||
`databases` project's Postgres). A config that does not load (`config changed but does not
|
||||
load`) keeps the old tokens: worth fixing before someone's new token is needed. Weekly, `Apple
|
||||
Podcasts synced` (`shows`, `accepted`) brings Apple's charts in as the `apple-podcasts` snapshot
|
||||
(#252); `Apple Podcasts' sync failed` retries in an hour, worth an issue when it keeps failing, and
|
||||
`Apple's podcast categories are not ipx's` (`added`, `gone`) is an issue to update `CATEGORIES` in
|
||||
`crates/feed`.
|
||||
|
||||
## Tower
|
||||
|
||||
The three share Tower with everything else, so a slow or failing ipx can be the host. The node
|
||||
exporter (`job="node"`) has the history; twelve threads.
|
||||
|
||||
```sh
|
||||
$Q prom 'max_over_time(node_load1{job="node"}[$range])' 24h
|
||||
$Q prom 'min_over_time(node_memory_MemAvailable_bytes{job="node"}[$range]) / 1e9' 24h
|
||||
$Q prom 'sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$range]))' 1h
|
||||
$Q prom 'max by (device) (rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$range]))' 1h
|
||||
```
|
||||
|
||||
For when, step back with `offset`: `max_over_time(node_load1[10m] offset 60m)`. Load far above
|
||||
twelve with little CPU busy is processes waiting, on a disk or on the user shares: on 2026-10-12
|
||||
debug builds linking through `/mnt/user` (shfs, FUSE) took load to 248 with mostly system time and
|
||||
stalled everything on the shares, ipx's downloads included. Available memory near nothing makes
|
||||
it worse. Disk busy near 100% on `nvme1n1` is `/mnt/fast`, appdata and Postgres's data; on `md*`
|
||||
the array behind `/mnt/user`, the downloads. Say what was running at the time if you can tell
|
||||
(`docker stats --no-stream`); a host problem is the user's, not an ipx issue, unless ipx caused it.
|
||||
|
||||
## The monitoring itself
|
||||
|
||||
A quiet dashboard can be a dead exporter. Check what Prometheus scrapes and that each
|
||||
container's lines are reaching Loki:
|
||||
|
||||
```sh
|
||||
curl -s http://192.168.1.130:9090/api/v1/targets | python3 -c 'import json,sys
|
||||
for t in json.load(sys.stdin)["data"]["activeTargets"]: print(t["health"], t["labels"]["job"], t["scrapeUrl"], t.get("lastError","")[:80])'
|
||||
for c in iPX ipx-web ipx-directory; do echo "$c $($Q metric "sum(count_over_time({container=\"$c\"} [1h]))" 1h | tail -1)"; done
|
||||
docker ps -a --filter label=com.docker.compose.project=monitoring --format '{{.Names}} {{.Status}}'
|
||||
```
|
||||
|
||||
`ipx` and `node` should be `up`. Every monitoring container should be running; after Docker
|
||||
restarts, one can be left `Exited (255)` though its policy is `unless-stopped` (Prometheus, on
|
||||
2026-10-12, when Docker lost its exit during an overload): `docker start` it and say so. blocky
|
||||
and nut on 192.168.2.21 are another machine, not ipx's; leave them. A container with no lines in
|
||||
an hour while it runs means Alloy is not shipping it; Alloy can take a few minutes to pick up a
|
||||
container after a deploy.
|
||||
|
||||
## Rules
|
||||
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
|
||||
BUFFERS)` of any query is allowed, read only, as CLAUDE.md's "Production's database" shows; any
|
||||
other query, ask the user first.
|
||||
|
||||
## What to do with what you find
|
||||
|
||||
Follow the repository's rules in CLAUDE.md: **every problem found gets a Gitea issue**, with a
|
||||
closed stdin and a timeout on `tea`. Before filing, list the open issues and do not file one
|
||||
twice; comment on the existing issue with the new evidence instead.
|
||||
|
||||
```sh
|
||||
R="--login git.sdf1.net --repo rays/ipx"
|
||||
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||
t issues list $R --state open
|
||||
t issues create $R -t "<what is wrong, as the user would notice it>" -L bug -d "<what, where, since when, how often, the query or trace id that shows it>"
|
||||
```
|
||||
|
||||
Put in each issue what would let someone pick it up cold: the LogQL or TraceQL that shows it, a
|
||||
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
|
||||
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
|
||||
|
||||
Finish with a short report to the user, a line for each of ipx-web, ipx, the directory, Tower and
|
||||
the monitoring: what is healthy, what is wrong (with the issue numbers), and anything you could
|
||||
not tell from logs, traces and metrics alone. Do not fix things unless asked; the
|
||||
check is for finding them.
|
||||
|
||||
## When the checks come back empty
|
||||
|
||||
Check that there is data before concluding all is well (the monitoring section's per-container
|
||||
count): iPX should have hundreds of lines an hour or more, ipx-web at least its healthchecks'
|
||||
120, ipx-directory a few dozen. Nothing at all means Alloy is not shipping it, or the container
|
||||
is down.
|
||||
230
.claude/skills/ipx-prod-check/query.py
Executable file
230
.claude/skills/ipx-prod-check/query.py
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ask production's Loki, Tempo or Prometheus a question, from anywhere that can run docker on Tower.
|
||||
|
||||
query.py logs '<LogQL log query>' [since] lines, newest first
|
||||
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
|
||||
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
||||
query.py spans '<TraceQL query>' [since] the matching spans, each with its own time
|
||||
query.py trace <trace id> one trace's spans, as a tree
|
||||
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
|
||||
query.py flame '<TraceQL query>' [since] [out.svg] where the matching spans' time
|
||||
went, summed over up to 100 traces, as a
|
||||
flame graph (and out.svg.folded)
|
||||
|
||||
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
|
||||
host, so each call runs a throwaway alpine container on the monitoring project's network.
|
||||
"""
|
||||
import base64, html, json, subprocess, sys, time, urllib.parse, zlib
|
||||
|
||||
NET = "monitoring_default"
|
||||
|
||||
|
||||
def fetch(url):
|
||||
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "wget", "-qO-", url],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if out.returncode:
|
||||
sys.exit(f"query failed: {out.stderr.strip() or out.stdout.strip()}\n{url}")
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def fetch_many(urls):
|
||||
# One container for them all: a docker run apiece is about a second, a hundred traces a minute and a half.
|
||||
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "sh", "-c",
|
||||
'for u; do wget -qO- "$u"; echo; echo @@; done', "--", *urls],
|
||||
capture_output=True, text=True, timeout=600)
|
||||
return [json.loads(part) for part in out.stdout.split("\n@@\n") if part.strip()]
|
||||
|
||||
|
||||
def trace_spans(r):
|
||||
return [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||
|
||||
|
||||
def span_hex(sid):
|
||||
# A trace's span ids are base64 and the search's are hex.
|
||||
return sid if len(sid) == 16 and all(c in "0123456789abcdef" for c in sid) else base64.b64decode(sid).hex()
|
||||
|
||||
|
||||
def label(sp):
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
|
||||
return f"step:{attrs['of']}" if sp["name"] == "step" and attrs.get("of") else sp["name"]
|
||||
|
||||
|
||||
def fold(spans, roots):
|
||||
"""Self time in µs for each stack under the spans in `roots` (hex ids), identical stacks summed.
|
||||
|
||||
shortcut: only as fine as the spans; a route's time outside its `step`s is its own frame. Add
|
||||
steps in ipx where that is large. Children run at once (try_join) can add up past their
|
||||
parent, so a frame's self time is floored at 0.
|
||||
"""
|
||||
kids, stacks = {}, {}
|
||||
for sp in spans:
|
||||
kids.setdefault(span_hex(sp.get("parentSpanId") or "0" * 16), []).append(sp)
|
||||
|
||||
def walk(sp, path):
|
||||
path = f"{path};{label(sp)}" if path else label(sp)
|
||||
dur = lambda s: (int(s["endTimeUnixNano"]) - int(s["startTimeUnixNano"])) // 1000
|
||||
cs = kids.get(span_hex(sp["spanId"]), [])
|
||||
stacks[path] = stacks.get(path, 0) + max(0, dur(sp) - sum(dur(c) for c in cs))
|
||||
for c in cs:
|
||||
walk(c, path)
|
||||
|
||||
for sp in spans:
|
||||
if span_hex(sp["spanId"]) in roots:
|
||||
walk(sp, "")
|
||||
return stacks
|
||||
|
||||
|
||||
def check_fold():
|
||||
def sp(sid, parent, name, start, end, of=None):
|
||||
attrs = [{"key": "of", "value": {"stringValue": of}}] if of else []
|
||||
return {"spanId": sid, "parentSpanId": parent, "name": name, "attributes": attrs,
|
||||
"startTimeUnixNano": str(start * 1000), "endTimeUnixNano": str(end * 1000)}
|
||||
a, b, c, d = (f"{i:016x}" for i in range(1, 5))
|
||||
spans = [sp(a, "", "GET /x", 0, 100), sp(b, a, "step", 0, 60, "q"), sp(c, a, "step", 0, 60, "q"),
|
||||
sp(d, b, "inner", 0, 10)]
|
||||
assert fold(spans, {a}) == {"GET /x": 0, "GET /x;step:q": 110, "GET /x;step:q;inner": 10}, fold(spans, {a})
|
||||
assert fold(spans, {b}) == {"step:q": 50, "step:q;inner": 10}
|
||||
# base64 ids, as a trace sends them, match the search's hex.
|
||||
assert span_hex(base64.b64encode(bytes.fromhex(a)).decode()) == a
|
||||
print("fold: ok")
|
||||
|
||||
|
||||
def flame_svg(stacks, n, title):
|
||||
tree = {} # name -> [total, children]
|
||||
for path, us in stacks.items():
|
||||
node = tree
|
||||
for part in path.split(";"):
|
||||
node = node.setdefault(part, [0, {}])
|
||||
node[0] += us
|
||||
node = node[1]
|
||||
total = sum(v[0] for v in tree.values()) or 1
|
||||
width, row, rects = 1200, 18, []
|
||||
|
||||
def draw(node, x, depth, trail):
|
||||
for name, (us, sub) in sorted(node.items()):
|
||||
w = us / total * width
|
||||
stack = f"{trail};{name}" if trail else name
|
||||
hue = 10 + zlib.crc32(name.encode()) % 45
|
||||
tip = f"{stack}\n{us / 1000:.0f} ms in all, {100 * us / total:.1f}%, {us / 1000 / n:.1f} ms a request"
|
||||
text = html.escape(name[: int(w / 7)]) if w > 30 else ""
|
||||
rects.append(f'<g><title>{html.escape(tip)}</title><rect x="{x:.1f}" y="{30 + depth * row}" '
|
||||
f'width="{max(w - 0.5, 0.1):.1f}" height="{row - 1}" fill="hsl({hue},85%,62%)"/>'
|
||||
f'<text x="{x + 3:.1f}" y="{30 + depth * row + 13}">{text}</text></g>')
|
||||
draw(sub, x, depth + 1, stack)
|
||||
x += w
|
||||
|
||||
draw(tree, 0, 0, "")
|
||||
depth = max(p.count(";") for p in stacks) + 1 if stacks else 1
|
||||
head = html.escape(f"{title}: {n} request(s), {total / 1000 / n:.1f} ms each on average")
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{40 + depth * row}" '
|
||||
f'font-family="monospace" font-size="11"><rect width="100%" height="100%" fill="#fff"/>'
|
||||
f'<text x="4" y="18" font-size="13">{head}</text>{"".join(rects)}</svg>')
|
||||
|
||||
|
||||
def seconds(since):
|
||||
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["flame", "--check"]:
|
||||
return check_fold()
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
kind, q = sys.argv[1], sys.argv[2]
|
||||
since = sys.argv[3] if len(sys.argv) > 3 else "24h"
|
||||
now = time.time()
|
||||
start = now - seconds(since)
|
||||
enc = urllib.parse.quote
|
||||
if kind == "logs":
|
||||
r = fetch(f"http://loki:3100/loki/api/v1/query_range?query={enc(q)}&limit=500"
|
||||
f"&start={int(start * 1e9)}&end={int(now * 1e9)}&direction=backward")
|
||||
lines = [(ts, line) for s in r["data"]["result"] for ts, line in s["values"]]
|
||||
for ts, line in sorted(lines, reverse=True):
|
||||
print(line)
|
||||
print(f"-- {len(lines)} line(s){' (limit reached)' if len(lines) >= 500 else ''}", file=sys.stderr)
|
||||
elif kind == "metric":
|
||||
r = fetch(f"http://loki:3100/loki/api/v1/query?query={enc(q.replace('$range', since))}&time={int(now * 1e9)}")
|
||||
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
|
||||
for s in rows:
|
||||
labels = {k: v for k, v in s["metric"].items() if k not in ("container", "compose_project", "service_name")}
|
||||
print(f"{s['value'][1]:>12} {json.dumps(labels) if labels else ''}")
|
||||
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||
elif kind == "traces":
|
||||
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100")
|
||||
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
|
||||
for t in traces:
|
||||
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
|
||||
# No root name: its root span is a tap in the page, which the browser never sends.
|
||||
name = t.get("rootTraceName") or "(from the page)"
|
||||
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
|
||||
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
||||
elif kind == "spans":
|
||||
# The spans that matched, each with its own time: a trace's length says nothing about a
|
||||
# slow request in it when the trace also holds a page's live updates or an audio stream.
|
||||
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=200&spss=20")
|
||||
rows = []
|
||||
for t in r.get("traces", []):
|
||||
for ss in t.get("spanSets") or [t.get("spanSet") or {}]:
|
||||
for sp in ss.get("spans", []):
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
|
||||
what = attrs.get("http.route") or attrs.get("of") or attrs.get("url.path") or sp.get("name") or ""
|
||||
rows.append((int(sp.get("durationNanos", 0)) / 1e6, int(sp["startTimeUnixNano"]), t["traceID"], what))
|
||||
for ms, ts, tid, what in sorted(rows, reverse=True):
|
||||
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(ts / 1e9))
|
||||
print(f"{ms:>8.0f}ms {when}Z {tid} {what}")
|
||||
print(f"-- {len(rows)} span(s)", file=sys.stderr)
|
||||
elif kind == "prom":
|
||||
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
|
||||
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")
|
||||
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
|
||||
for s in rows:
|
||||
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")}
|
||||
print(f"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
|
||||
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||
elif kind == "flame":
|
||||
out = sys.argv[4] if len(sys.argv) > 4 else "flame.svg"
|
||||
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100&spss=20")
|
||||
found = r.get("traces", [])
|
||||
roots = {sp["spanID"] for t in found for ss in t.get("spanSets") or [t.get("spanSet") or {}]
|
||||
for sp in ss.get("spans", [])}
|
||||
stacks = {}
|
||||
for tr in fetch_many([f"http://tempo:3200/api/traces/{t['traceID']}" for t in found]):
|
||||
for path, us in fold(trace_spans(tr), roots).items():
|
||||
stacks[path] = stacks.get(path, 0) + us
|
||||
n = len(roots) or 1
|
||||
with open(out + ".folded", "w") as f:
|
||||
f.writelines(f"{path} {us}\n" for path, us in sorted(stacks.items()))
|
||||
with open(out, "w") as f:
|
||||
f.write(flame_svg(stacks, n, q))
|
||||
print(f"-- {len(roots)} span(s) in {len(found)} trace(s): {out}, {out}.folded", file=sys.stderr)
|
||||
elif kind == "trace":
|
||||
spans = trace_spans(fetch(f"http://tempo:3200/api/traces/{q}"))
|
||||
kids = {}
|
||||
for sp in spans:
|
||||
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
|
||||
|
||||
def show(sp, depth):
|
||||
ms = (int(sp["endTimeUnixNano"]) - int(sp["startTimeUnixNano"])) / 1e6
|
||||
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])
|
||||
if not a["key"].startswith(("code.", "thread.")) and a["key"] not in ("busy_ns", "idle_ns", "target")}
|
||||
err = " ERROR" if sp.get("status", {}).get("code") in (2, "STATUS_CODE_ERROR") else ""
|
||||
print(f"{' ' * depth}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
|
||||
for e in sp.get("events", []):
|
||||
msg = next((a["value"].get("stringValue") for a in e.get("attributes", []) if a["key"] == "message"), e.get("name"))
|
||||
# A scan logs a skip for every feed not due; they bury what happened.
|
||||
if '"ev":"feed_skip"' in (msg or ""):
|
||||
continue
|
||||
print(f"{' ' * depth} - {msg}")
|
||||
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
|
||||
show(k, depth + 1)
|
||||
|
||||
ids = {sp["spanId"] for sp in spans}
|
||||
for root in [sp for sp in spans if sp.get("parentSpanId", "") not in ids]:
|
||||
show(root, 0)
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
.claude/skills/security-audit
Symbolic link
1
.claude/skills/security-audit
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/security-audit
|
||||
3
.git-blame-ignore-revs
Normal file
3
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,3 @@
|
||||
# Formatting only: `git config blame.ignoreRevsFile .git-blame-ignore-revs` makes blame skip these.
|
||||
# rustfmt adopted
|
||||
692a5102179b958b5d15cecc4f5e6f16b2955b76
|
||||
26
.githooks/pre-commit
Executable file
26
.githooks/pre-commit
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
|
||||
# checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the
|
||||
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory,
|
||||
# and both use ipx-feed. A commit to the page alone runs nothing here.
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACMD)
|
||||
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
|
||||
if ! cargo fmt --all --check >/dev/null 2>&1; then
|
||||
echo "pre-commit: the Rust is not formatted; run cargo fmt --all" >&2
|
||||
exit 1
|
||||
fi
|
||||
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
|
||||
pkgs="ipx-feed ipx-directory ipx"
|
||||
elif echo "$staged" | grep -q '^crates/directory/'; then
|
||||
pkgs="ipx-directory ipx"
|
||||
elif echo "$staged" | grep -q '^crates/ipx/'; then
|
||||
pkgs="ipx"
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
args=$(for p in $pkgs; do printf ' -p %s' "$p"; done)
|
||||
# shellcheck disable=SC2086 # one word a flag
|
||||
if ! cargo clippy $args --all-targets -q -- -D warnings; then
|
||||
echo "pre-commit: clippy has warnings in $pkgs; fix them, or allow one where it is wrong, with a comment saying why" >&2
|
||||
exit 1
|
||||
fi
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,3 +2,8 @@
|
||||
/node_modules
|
||||
/test-results
|
||||
/playwright-report
|
||||
/web/dist
|
||||
|
||||
.claude/settings.local.json
|
||||
__pycache__/
|
||||
site/dist/
|
||||
|
||||
13
.rtk/filters.toml
Normal file
13
.rtk/filters.toml
Normal 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"
|
||||
412
CLAUDE.md
Normal file
412
CLAUDE.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Working on ipx
|
||||
|
||||
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 `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
||||
Arcane project `ipx`: `/mnt/fast/arcane/projects/ipx/compose.yaml`. That file is what runs, and
|
||||
the containers of the split (#207) join it there. `docker-compose.yml` in this repo is a sample for
|
||||
others, ipx alone on SQLite; editing it changes nothing in production. Until 2026-10-12 ipx was a
|
||||
service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
||||
|
||||
| | Host | In the container |
|
||||
|---|---|---|
|
||||
| Image | `192.168.1.130:5000/ipx:latest` | |
|
||||
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
||||
| Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
|
||||
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
|
||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
||||
|
||||
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
||||
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
||||
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||
|
||||
```sh
|
||||
R="--login git.sdf1.net --repo rays/ipx"
|
||||
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||
t issues close $R 40
|
||||
```
|
||||
|
||||
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||
|
||||
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||
for later stays open, and that is how it gets picked up again.
|
||||
|
||||
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/ipx:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx
|
||||
docker logs --tail 20 iPX
|
||||
```
|
||||
|
||||
The directory is the same, with its own image:
|
||||
|
||||
```sh
|
||||
docker buildx build -f Dockerfile.directory --tag 192.168.1.130:5000/ipx-directory:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory
|
||||
```
|
||||
|
||||
And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one:
|
||||
|
||||
```sh
|
||||
docker buildx build -f Dockerfile.web --tag 192.168.1.130:5000/ipx-web:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-web
|
||||
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-web
|
||||
```
|
||||
|
||||
The home page (`site/`, #216) is not on Tower. `sh site/build.sh` puts it in `site/dist` with the
|
||||
typeface, logo and icon from `web/`, and it goes to the Cloudflare Pages project `ipxreader` by
|
||||
direct upload, since there is no wrangler login here: hash each file as wrangler does (BLAKE3 of
|
||||
its base64 contents plus its extension, the first 32 hex digits, with the `blake3-wasm` package),
|
||||
ask the project for an upload token (`GET .../pages/projects/ipxreader/upload-token`), send the
|
||||
files to `/pages/assets/upload` with that token, then create a deployment
|
||||
(`POST .../pages/projects/ipxreader/deployments`) with the manifest of paths and hashes and
|
||||
`_headers`. Its screenshots (`site/shots`) come from a scratch iPX on public feeds, never
|
||||
production's, which would show whose subscriptions are whose.
|
||||
|
||||
Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own requests reach the
|
||||
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
|
||||
container on another network, as cloudflared's are (#209).
|
||||
|
||||
The three containers resolve names through Cloudflare (`dns: [1.1.1.1, 1.0.0.1]` in the compose
|
||||
file), not the router: the LAN's DNS is NextDNS, which answers for a domain it filters with its own
|
||||
block page, and ipx then logged a feed as failing with `invalid peer certificate: UnknownIssuer`
|
||||
(tomrenner.com, 2026-10-17). Docker's own DNS still answers for the services' names first.
|
||||
|
||||
**Name the service.** A bare `up -d` recreates every container in the project, which once the
|
||||
split lands means the frontend and directory too. 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:trixie-slim` and
|
||||
`rust:1-slim-trixie` 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:trixie-slim
|
||||
docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
|
||||
docker pull mirror.gcr.io/library/rust:1-slim-trixie
|
||||
docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
|
||||
```
|
||||
|
||||
Run those again now and then, or the local copies go stale.
|
||||
|
||||
The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower
|
||||
fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
|
||||
each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
|
||||
and `ipx status` against the control socket, which says the daemon answers and can read its
|
||||
database. The socket answers `status` itself instead of queuing it behind the worker's current
|
||||
job, so a worker stuck on one job still answers it. The containers restart on their 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 by its own PID**: start it with `& echo $! > pid` and
|
||||
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
||||
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
||||
running the command and kills the session (exit 144), which has happened more than once.
|
||||
|
||||
## Before you touch the page
|
||||
|
||||
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
||||
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
||||
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
||||
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
|
||||
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
|
||||
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
|
||||
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
|
||||
serves no page. The page loads its script as `/app.js?v=<hash of its contents>`, and `/app.css`
|
||||
and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser
|
||||
keeps them until a deploy changes them and their names. So **every page change needs ipx-web
|
||||
rebuilt and deployed**, not ipx, and building needs node and `npm ci` run once.
|
||||
|
||||
Caddy asks ipx before it serves a page (`forward_auth`): `/api/page-check` sends a browser that is
|
||||
not signed in to `/login` and anyone but an admin away from `/admin` (and refuses them
|
||||
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
|
||||
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
|
||||
for everyone, so what ipx used to write into it per person is the page's own job now:
|
||||
`web/boot.js`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
|
||||
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
|
||||
theme).
|
||||
|
||||
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
||||
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
||||
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
||||
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
||||
|
||||
After any edit to it:
|
||||
|
||||
```sh
|
||||
npx tsc -p .
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js
|
||||
```
|
||||
|
||||
The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
|
||||
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
|
||||
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
|
||||
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
|
||||
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
|
||||
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||
wires at load actually exists. That check 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. The third loads the same built script and
|
||||
calls its helpers directly (#234): a pure function added to the page gets its check there.
|
||||
|
||||
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 fmt --all --check # rustfmt, as rustfmt.toml says; the pre-commit hook refuses a commit without it
|
||||
cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
|
||||
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
|
||||
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
|
||||
cargo test # parsing, filters, retention, schedules, SQL, per-user state
|
||||
npx tsc -p . # type-checks web/src
|
||||
node tests/page-smoke.js
|
||||
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/contrast.js # every theme's palette against WCAG AA
|
||||
npx playwright test # the browser tests, against a real daemon on fixture feeds
|
||||
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||
```
|
||||
|
||||
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
|
||||
directory followed in the same command by its tests' compile took Tower's load past 200 and the
|
||||
session was stopped. So:
|
||||
|
||||
* One cargo command at a time: a build, then the tests, as separate commands, never chained or
|
||||
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
||||
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
|
||||
threads twice over, wait for it to come down before starting another.
|
||||
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
|
||||
|
||||
| changed | run |
|
||||
|---|---|
|
||||
| `web/` alone | the three page checks; `npx playwright test -g '<what changed>'`; no cargo |
|
||||
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
|
||||
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
|
||||
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
|
||||
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
|
||||
|
||||
The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end
|
||||
of a change to the page or the API; never `cargo test` for the workspace in one command.
|
||||
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
|
||||
`/mnt/user` (FUSE) once took the load to 248 on its own.
|
||||
* **Memory is the tighter limit.** Tower has 31 GB, no swap, and the containers leave about 4-7
|
||||
GB. A build past that stalls everything on page faults: load 160 with the CPU idle and the
|
||||
disks quiet, many processes blocked (`node_procs_blocked`, `node_vmstat_pgmajfault`). On
|
||||
2026-10-18 linking a 431 MB test binary did it for 54 minutes. So `[profile.dev]` keeps only
|
||||
line tables and dependencies no debug info; keep it that way, and look at `MemAvailable` beside
|
||||
the load.
|
||||
|
||||
The Rust is formatted by rustfmt (`rustfmt.toml`) and kept free of clippy's warnings. Claude Code
|
||||
formats each `.rs` file it edits (a hook in `.claude/settings.json`), and the pre-commit hook refuses
|
||||
a commit touching Rust that is unformatted or has a clippy warning. A clone turns on the hook once: `git config core.hooksPath .githooks`; and `git config blame.ignoreRevsFile
|
||||
.git-blame-ignore-revs` keeps blame past the commit that first formatted everything. A clippy
|
||||
suggestion is not always the same code: on adopting it, `fold(false, |any, id| remove(id) || any)`
|
||||
was offered as `any(...)`, which stops at the first and would have removed one feed of many. A lint
|
||||
that is wrong for a line gets `#[allow(clippy::...)]` there with a comment saying why.
|
||||
|
||||
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.
|
||||
|
||||
The load tests (`tests/load`, issue #133) are for what one browser cannot show: twenty-five people
|
||||
browsing at once, a hundred players saving positions while a scan writes, fifty listeners
|
||||
seeking through files, a flood of wrong passwords. Things about them:
|
||||
|
||||
* They need **k6**, which `/src/install.sh` installs from k6's own signed apt repository, and they
|
||||
build and run a **release** binary: Argon2 in a debug build takes about a second a sign-in,
|
||||
which would measure the build.
|
||||
* `run.js` serves the feeds itself, generated, and starts **its own daemon** under
|
||||
`/tmp/ipx-load`, wiped each run, on ports 8793 and 8794, so it can run beside the browser suite.
|
||||
It stops that daemon by its PID. People sign in by the `X-Load-User` header, trusted from
|
||||
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
|
||||
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
||||
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
||||
* `ipx status` runs every second throughout, and a run fails if one takes 5s.
|
||||
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
|
||||
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
||||
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
||||
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
|
||||
`ipx.env`; the harness refuses a database whose name does not end in `_test`. Postgres's
|
||||
numbers are what production would see, and a run loads the server production's database is on,
|
||||
so not while someone is using iPX in earnest. Each budget has a value for each database.
|
||||
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
|
||||
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
|
||||
|
||||
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
||||
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
||||
|
||||
## Production's database
|
||||
|
||||
The owner allows `EXPLAIN (ANALYZE, BUFFERS)` of any query against production's Postgres, to see
|
||||
where a query's time goes; nothing else without asking. Run it read only, so nothing can change:
|
||||
|
||||
```sh
|
||||
U=$(grep -E '^IPX_DATABASE_URL=' /mnt/fast/arcane/projects/ipx/ipx.env | cut -d= -f2- | tr -d '"')
|
||||
psql "$U" -X -v ON_ERROR_STOP=1 <<'SQL'
|
||||
BEGIN READ ONLY;
|
||||
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
```
|
||||
|
||||
Never print the URL: it carries the password. Write the query as ipx sends it (`$1` parameters
|
||||
become `PREPARE`/`EXECUTE`, or values), for a real account. A query fast here and slow in ipx's
|
||||
trace is time outside the query: on 2026-10-15 the feed list's count took 5 ms in Postgres and 366
|
||||
ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
|
||||
|
||||
## 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 they were dropped in 0.5.
|
||||
* **The catalogue and the server settings are in the database, not config.toml** (issue #18):
|
||||
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
|
||||
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
|
||||
and who gets in, the database for the rest (`assemble_config`); a change goes through
|
||||
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
|
||||
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
|
||||
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
||||
derived and never in the catalogue.
|
||||
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
|
||||
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
|
||||
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
|
||||
* **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.
|
||||
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
|
||||
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
|
||||
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
|
||||
Rename one and its panels go blank without an error; regenerate the dashboard to match.
|
||||
* `/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.
|
||||
* **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.**
|
||||
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
||||
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
||||
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
||||
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
||||
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for
|
||||
the column first and alter only when it is missing, as `feeds.description` does.
|
||||
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
||||
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
||||
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
||||
1; no `rowid`, `GLOB` or `UPDATE OR IGNORE`. `Args` in `db.rs` builds the parameters.
|
||||
|
||||
## 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 the changelog of the service it changes, in
|
||||
its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||
Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md)
|
||||
(the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page
|
||||
and ipx-web) and [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory). A
|
||||
change to more than one gets a line in each, each saying what that service does differently. The
|
||||
monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not
|
||||
a service anyone uses and get no line. 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, it goes in the commit message's
|
||||
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
||||
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||
|
||||
Each service has its own version and is released on its own, when its changelog has something to
|
||||
release:
|
||||
|
||||
| service | version in | changelog | tag |
|
||||
|---|---|---|---|
|
||||
| ipx | `crates/ipx/Cargo.toml` | `crates/ipx/CHANGELOG.md` | `vX.Y.Z`, as every release before the split |
|
||||
| ipx-directory | `crates/directory/Cargo.toml` | `crates/directory/CHANGELOG.md` | `directory-vX.Y.Z` |
|
||||
| ipx-web, the page | `package.json` | `web/CHANGELOG.md` | `web-vX.Y.Z` |
|
||||
|
||||
`crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it
|
||||
is a line in the changelog of each service it changes. The page shows ipx's version and its own in
|
||||
the logo's tooltip; the directory logs its own at start; each binary answers `--version`.
|
||||
|
||||
Cutting a release of one: first read its `[Unreleased]` against its last release, not line by
|
||||
line, and rewrite it as what changed between the two. Lines go in as the work happens, so they
|
||||
record the way there: a feature added and then reworked in the same release is one line saying
|
||||
what it is now, under Added; something added and taken out again before the release, or a fix for
|
||||
a bug no release ever had, is not there at all; and "instead of" and "no longer" compare with the
|
||||
last release, not with last week. Issue numbers stay, all of them, on the line that survives. Then
|
||||
rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it,
|
||||
set the service's version to `X.Y.Z` (dropping `-dev`), tag the commit with the service's tag, and
|
||||
update the compare links at the bottom of its changelog. Push the commit and the tag, build and
|
||||
deploy the service's image, then `sh scripts/release.sh ipx|directory|web` makes the Gitea
|
||||
release: that version's changelog section as its notes, and a tarball of what the image runs (the
|
||||
binary, or the page's files with the Caddyfile) attached, checked to be that version. Then, in the
|
||||
next commit, set its version
|
||||
to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
|
||||
instead of claiming to be the last release. The release that follows can still be a minor or major
|
||||
one; `-dev` only says the work comes after `X.Y.Z`. A service never released is at `0.1.0-dev`,
|
||||
working towards its first release, `0.1.0`.
|
||||
|
||||
**Traces first, logs second.** Anything that does work (a service, a handler, a job, a call to
|
||||
another service) gets spans, and passes `traceparent` on to whatever it calls, so one trace in
|
||||
Tempo follows a request or a job across ipx-web, ipx and the directory. A trace shows where the
|
||||
time went and what called what; a log line only that something happened. Log what a trace cannot
|
||||
carry: events worth counting on the dashboard, warnings and errors. A new service or route without
|
||||
traces is not finished.
|
||||
|
||||
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
|
||||
|
||||
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||
issue left open.
|
||||
|
||||
## Styleguide
|
||||
|
||||
* Best effort to adhere to the rust style guide found at /src/styleguide/rustguide.md
|
||||
|
||||
<!-- 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 -->
|
||||
1298
Cargo.lock
generated
1298
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
53
Cargo.toml
53
Cargo.toml
@@ -1,33 +1,60 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.1.0"
|
||||
[workspace]
|
||||
members = ["crates/ipx", "crates/feed", "crates/directory"]
|
||||
resolver = "3"
|
||||
|
||||
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
|
||||
# (the page), released and tagged on its own; see CLAUDE.md.
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# None of ours needs unsafe, and the style guide asks for none where safe code will do.
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.dependencies]
|
||||
ipx-feed = { path = "crates/feed" }
|
||||
ipx-directory = { path = "crates/directory" }
|
||||
ammonia = "4.1.4"
|
||||
anyhow = "1.0.104"
|
||||
argon2 = "0.6.0"
|
||||
atom_syndication = "0.12.10"
|
||||
axum = "0.8.9"
|
||||
flate2 = "1.1.10"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
dirs = "7.0.0"
|
||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||
infer = "0.22.0"
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||
opentelemetry = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
|
||||
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opml = "1.1.6"
|
||||
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
|
||||
mimalloc = "0.1.52"
|
||||
http = "1.5.0"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy"] }
|
||||
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"] }
|
||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
|
||||
rss = "2.1.1"
|
||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
||||
tokio-stream = { version = "0.1.19", features = ["sync"] }
|
||||
sha2 = "0.10.9"
|
||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
|
||||
toml = "1.1.5"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs", "compression-br", "compression-gzip", "compression-zstd", "decompression-gzip"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
url = "2.5.8"
|
||||
|
||||
# Backtraces keep file and line; the full debug info made a 431 MB test binary whose link ran
|
||||
# Tower, which has no swap, out of memory and stalled it for most of an hour (2026-10-18).
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Nobody steps through dependencies.
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,25 +1,35 @@
|
||||
# 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
|
||||
# The pages are ipx-web's (Dockerfile.web), so no node here (#203).
|
||||
FROM rust:1-slim-trixie 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.
|
||||
# Dependencies first, so editing the source does not rebuild librqbit every time: each crate of
|
||||
# the workspace as an empty stub, its manifest real.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
||||
&& cargo build --release --locked \
|
||||
&& rm -rf src
|
||||
COPY crates/ipx/Cargo.toml crates/ipx/
|
||||
COPY crates/feed/Cargo.toml crates/feed/
|
||||
COPY crates/directory/Cargo.toml crates/directory/
|
||||
RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \
|
||||
&& echo 'fn main(){}' > crates/ipx/src/main.rs && touch crates/feed/src/lib.rs \
|
||||
&& echo 'fn main(){}' > crates/directory/src/main.rs && touch crates/directory/src/lib.rs \
|
||||
&& cargo build --release --locked -p ipx \
|
||||
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
|
||||
|
||||
COPY src ./src
|
||||
COPY web ./web
|
||||
COPY crates ./crates
|
||||
# ipx builds the directory crate in, whose public pages serve these (#215).
|
||||
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||
RUN touch src/main.rs && cargo build --release --locked
|
||||
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
|
||||
# about 480 MB, once: the layer stays cached until the base image changes.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates gosu \
|
||||
ca-certificates gosu ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
25
Dockerfile.directory
Normal file
25
Dockerfile.directory
Normal file
@@ -0,0 +1,25 @@
|
||||
# ipx-directory (#204). Built from the repo's root: docker buildx build -f Dockerfile.directory .
|
||||
# No node here: only ipx's own build runs web/build.mjs.
|
||||
FROM rust:1-slim-trixie AS build
|
||||
# SQLite is compiled from source for sqlx, which needs a C toolchain.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
# Every member of the workspace, since cargo reads them all to build one.
|
||||
# ponytail: no dependency layer, so each build compiles sea-orm again; add the stub trick from
|
||||
# the main Dockerfile if builds get slow enough to matter.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
# The logo and icon the public pages serve (#215), compiled into the binary.
|
||||
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
|
||||
RUN cargo build --release --locked -p ipx-directory
|
||||
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/target/release/ipx-directory /usr/local/bin/ipx-directory
|
||||
ENV DIRECTORY_CONFIG=/config/directory.toml
|
||||
# Unraid's nobody:users. It writes nothing to disk: the database is elsewhere.
|
||||
USER 99:100
|
||||
EXPOSE 8100
|
||||
CMD ["ipx-directory"]
|
||||
16
Dockerfile.web
Normal file
16
Dockerfile.web
Normal file
@@ -0,0 +1,16 @@
|
||||
# ipx-web (#203): the frontend, built from web/ and served by Caddy, which passes the API to ipx.
|
||||
# Built from the repo's root: docker buildx build -f Dockerfile.web .
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY web ./web
|
||||
RUN node web/build.mjs /dist
|
||||
|
||||
FROM caddy:2
|
||||
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
|
||||
# curl flaw such as CVE-2026-92392 has nothing to reach in a container facing the internet (#237).
|
||||
RUN apk del --no-cache curl
|
||||
COPY web/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /dist /srv
|
||||
EXPOSE 8080
|
||||
1187
PROGRESS.md
1187
PROGRESS.md
File diff suppressed because it is too large
Load Diff
265
README.md
265
README.md
@@ -1,238 +1,87 @@
|
||||
# ipodderx-rs
|
||||
# ipx
|
||||
|
||||
A headless podcatcher: scans RSS/Atom feeds, downloads enclosures (HTTP and BitTorrent),
|
||||
files them into per-feed folders, and reaps old episodes to stay under a disk quota.
|
||||
Runs as a one-shot CLI or as a daemon with a Unix-socket JSON event stream for a UI to attach to.
|
||||
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
||||
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
||||
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
|
||||
Python 2 engine behind **iPodderX** (2004-2008, Ray Slakinski & August Trometer), which was
|
||||
open-sourced under the MIT License in 2010.
|
||||
## What it does
|
||||
|
||||
What carries over: the feed scan and TTL handling, GUID/URL dedupe, per-feed and per-date download
|
||||
folders, keyword filters, the explicit-content filter, torrent enclosures, and "SmartSpace" -- the
|
||||
oldest-first disk quota reaper.
|
||||
- **The web UI.** It has a toolbar, and a feed list that opens with the Directory, Currently Listening and All
|
||||
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
|
||||
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, pinned
|
||||
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 limit
|
||||
on how many of a feed's newest episodes are downloaded.
|
||||
- **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 pinned.
|
||||
- **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
|
||||
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).
|
||||
## Run it
|
||||
|
||||
## Quickstart
|
||||
With Docker:
|
||||
|
||||
```sh
|
||||
cargo install --path .
|
||||
|
||||
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>
|
||||
docker build -t ipx .
|
||||
docker build -f Dockerfile.web -t ipx-web .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Config lives at `~/.config/ipx/config.toml` (mode 0600, since it may hold feed passwords);
|
||||
state at `~/.local/share/ipx/state.db`. Override with `IPX_CONFIG` and `IPX_DATA_DIR`.
|
||||
Set `IPX_LOG=ipx=debug` for verbose logging on stderr.
|
||||
`docker-compose.yml` is a sample: ipx, with its database in SQLite and its three volumes
|
||||
(`/config`, `/data` and `/downloads`) in folders beside the file, and ipx-web, the page, served by
|
||||
Caddy in front of it. Change them to suit. The UI is on port 8099. BitTorrent uses 6881
|
||||
over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
|
||||
|
||||
## Commands
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `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 list` / `ipx status` | subscriptions and their state |
|
||||
| `ipx fetch [FEED] [--force]` | scan; `--force` ignores the TTL |
|
||||
| `ipx reap [--dry-run]` | run retention now |
|
||||
| `ipx import/export <file.opml>` | move subscriptions in or out |
|
||||
| `ipx daemon` | scheduler plus the control socket |
|
||||
|
||||
Any command with a wire form probes the socket first: if a daemon is running it does the work,
|
||||
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
|
||||
max_new_per_check = 3 # per feed, per scan. 0 = unlimited (pulls whole back catalogues)
|
||||
media_types = ["audio", "video"] # what downloads automatically. Anything else is still
|
||||
# listed and can be fetched by hand -- blog feeds put article
|
||||
# images in enclosures. Empty takes everything.
|
||||
|
||||
[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.
|
||||
From source:
|
||||
|
||||
```sh
|
||||
$ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
|
||||
{"ev":"feed_start","feed":"atp"}
|
||||
{"ev":"progress","feed":"atp","url":"...","file":"ep1.mp3","done":8192,"total":3000000}
|
||||
{"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}
|
||||
cargo build --release
|
||||
./target/release/ipx daemon
|
||||
```
|
||||
|
||||
Commands: `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `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 there.
|
||||
The first start creates **admin** with a random password, written to `admin-password` beside
|
||||
config.toml (readable by ipx's user alone; the log says where, never what it is). Set
|
||||
`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it:
|
||||
|
||||
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.
|
||||
```sh
|
||||
echo -n 'a good password' | ipx user passwd admin
|
||||
```
|
||||
|
||||
## OPML
|
||||
The UI is plain HTTP, so put TLS in front of it if it is reachable from outside your network.
|
||||
|
||||
Two different things, both supported:
|
||||
## Documentation
|
||||
|
||||
**Importing and exporting** a file copies subscriptions in or out once — `ipx import subs.opml`,
|
||||
`ipx export subs.opml`, or the OPML button in the UI.
|
||||
|
||||
**Subscribing to an OPML URL** is a live subscription, as iPodderX had. Add the OPML's URL like any
|
||||
other feed; every scan re-reads it and keeps your feed list in step. An OPML is recognised by its
|
||||
content, so a URL without a `.opml` extension still works.
|
||||
|
||||
The feeds inside it are **not written to `config.toml`** — the OPML is the source of truth, so they
|
||||
are re-derived each scan and held in the database. Your config keeps only what you chose. They show
|
||||
as a collapsible folder in the sidebar, download into one nested folder, and newly listed ones are
|
||||
scanned in the same run rather than waiting for the next interval. They inherit the subscription's
|
||||
settings; change anything on one and it gets its own config entry from then on.
|
||||
|
||||
When a feed drops out of the OPML upstream:
|
||||
|
||||
| it has downloads | what happens |
|
||||
| | |
|
||||
|---|---|
|
||||
| no | unsubscribed and removed from the config |
|
||||
| yes | kept, flagged in the UI as no longer listed |
|
||||
|
||||
A downloaded file is never left behind with nothing explaining where it came from.
|
||||
|
||||
## Web UI
|
||||
|
||||
```toml
|
||||
[web]
|
||||
enabled = true
|
||||
bind = "0.0.0.0:8080" # 127.0.0.1:8080 by default
|
||||
token = "" # generated and written back on first run
|
||||
```
|
||||
|
||||
`ipx daemon` then serves it in the same process (`ipx daemon --web ADDR` overrides the bind for one
|
||||
run). On first start it mints a token, saves it to config.toml, and prints the URL to open:
|
||||
|
||||
```
|
||||
web ui token generated. Open:
|
||||
http://0.0.0.0:8080/?token=1f4c…
|
||||
```
|
||||
|
||||
`?token=` sets a year-long cookie, so you only paste it once per browser. Everything is behind that
|
||||
token, including `/media/...` — a cookie rather than a header precisely because an `<audio>` element
|
||||
cannot send headers.
|
||||
|
||||
Browse feeds, read show notes, play episodes in the browser (Range requests are served, so seeking
|
||||
works), download or delete individual files, mark episodes read or flag them to keep, and edit a
|
||||
feed's folder/keywords/explicit/auto-download/limit settings. Config edits are written to
|
||||
config.toml and hot-reloaded — no daemon restart.
|
||||
|
||||
Show notes are feed-supplied HTML from an untrusted source; they are sanitized with `ammonia`
|
||||
server-side before they reach the page.
|
||||
|
||||
**It is plain HTTP.** On a LAN bind, the token and everything else crosses the network in the
|
||||
clear — and a feed URL can itself contain a credential (Patreon's, for one, carries an auth token).
|
||||
Put it behind a reverse proxy with TLS if that matters to you.
|
||||
|
||||
## Layout
|
||||
|
||||
Feeds down the side; the selected feed's episodes across the top; the selected episode's text and
|
||||
its enclosures in the pane below, which is where you play, download or delete them. The divider
|
||||
between the two panes drags and the position is remembered. An OPML subscription's page instead
|
||||
lists the feeds inside it.
|
||||
|
||||
An item may carry several enclosures. All of them appear in the pane below; the row summarises the
|
||||
one you would act on and notes how many others there are. Anything that is not audio or video gets a
|
||||
View link — opening the publisher's copy, or the local one once downloaded — rather than a player.
|
||||
|
||||
## Log view
|
||||
|
||||
The **Log** button in the sidebar shows the running daemon's output live, in four tabs:
|
||||
**Daemon I/O** is the control protocol itself — every command in and every event out, as JSON;
|
||||
**Scans** is feed and download activity; **HTTP** is web requests; **All** is everything: feed scans, downloads,
|
||||
torrent activity and every HTTP request, with level and text filters and a copy button. It reads a
|
||||
2000-line ring buffer held inside the process (`/api/logs`), not a file — so it works the same under
|
||||
Docker, where logs go to stdout and there is no file to tail. The buffer keeps `debug` even when the terminal does not, so protocol traffic and routine
|
||||
skips are there without making stderr unreadable — `IPX_UI_LOG` changes what it captures and
|
||||
`IPX_LOG` what reaches stderr.
|
||||
| [docs/configuration.md](docs/configuration.md) | Every config key, path and environment variable |
|
||||
| [docs/cli.md](docs/cli.md) | Every command, including `ipx user` |
|
||||
| [docs/users.md](docs/users.md) | Accounts, and what several people share |
|
||||
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
||||
| [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md), [web/CHANGELOG.md](web/CHANGELOG.md), [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) | What changed, by release, for ipx, the page and the directory |
|
||||
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
cargo test # the server: parsing, filters, retention, schedules, SQL
|
||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||
node tests/page-smoke.js # the page script loads without throwing
|
||||
npx playwright test # a real browser against a real daemon
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
The Rust tests cover the server and the smoke test catches a script that fails to load, but neither
|
||||
can see a wrong selector, a handler that runs and does nothing, or a page that renders empty — which
|
||||
is what has actually slipped through. The Playwright suite drives a headless browser against a
|
||||
daemon started on fixture feeds, and each test maps to a bug that reached a user: the page rendering
|
||||
empty, a dead theme toggle, settings not persisting, episode metadata, filter tabs, the feed URL
|
||||
field and its copy button, the log tabs, and OPML folders.
|
||||
|
||||
`npm install` gets the test runner; the browser itself comes from
|
||||
`npx playwright install --with-deps chromium` (in `install.sh`).
|
||||
|
||||
## Docker
|
||||
|
||||
```sh
|
||||
docker compose up -d # builds the image and starts it
|
||||
docker compose logs -f ipx # the token is printed on first start
|
||||
```
|
||||
|
||||
`docker-compose.yml` mounts `./config`, `./data` and a downloads directory, publishes 8099 for the
|
||||
UI and 6881 (TCP **and** UDP — DHT needs the UDP side), and sets `PUID`/`PGID` to `99:100` so files
|
||||
land owned the way Unraid shares expect. On first start the entrypoint writes a config bound to
|
||||
`0.0.0.0`, since a container's loopback is not reachable from outside it, and prints the URL with
|
||||
its generated token.
|
||||
|
||||
The healthcheck runs `ipx status`, which goes through the control socket to the command worker — so
|
||||
it catches a daemon that is alive but wedged, not merely one that has died.
|
||||
|
||||
## 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).
|
||||
`npm install` gets the test runner, and `npx playwright install --with-deps chromium` gets the
|
||||
browser.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
## Signing in
|
||||
|
||||
A new database starts with **admin / ipodderx** at `/login`; change it with
|
||||
`echo -n <password> | ipx user passwd admin`. Accounts are managed with `ipx user add|list|passwd|rm`.
|
||||
|
||||
To let Cloudflare Zero Trust or Authentik do the authenticating instead, see
|
||||
[docs/sso.md](docs/sso.md).
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
101
crates/directory/CHANGELOG.md
Normal file
101
crates/directory/CHANGELOG.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Changelog: ipx-directory
|
||||
|
||||
The directory of public podcasts that ipx servers share (docs/directory.md). ipx's changelog is
|
||||
[crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md), the page's [web/CHANGELOG.md](../../web/CHANGELOG.md).
|
||||
|
||||
Its version is in `crates/directory/Cargo.toml`, and a release is tagged `directory-vX.Y.Z`.
|
||||
|
||||
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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.1] - 2026-10-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- A feed larger than 50 MB is checked again, up to 200 MB, where it was marked as failing (#357).
|
||||
|
||||
## [1.4.0] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- A WordPress.com site that answers its feed with a browser check (HTTP 403) is read from WordPress.com's API instead of failing (#317).
|
||||
|
||||
### Security
|
||||
|
||||
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
||||
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
|
||||
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
|
||||
- A server's count of a feed's subscribers is taken up to a million. Two servers sending the largest number there is made the sum overflow, and every listing and page failed until one was dropped (#342).
|
||||
|
||||
## [1.3.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- A feed's items written in Markdown show formatted on its page, from a Markdown description or RSS's `<source:markdown>` (#284).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A listed feed whose address serves something larger than 50 MB is refused as too large, instead of read whole into memory at every check (#295).
|
||||
|
||||
### Security
|
||||
|
||||
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one (#281).
|
||||
|
||||
## [1.2.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Answers and pages go out compressed, brotli, zstd or gzip as the caller takes them: the feed list ipx asks for was 603 KB of JSON. A snapshot may come gzipped, `/metrics` answers gzipped, and with `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` traces go to the collector compressed (#253).
|
||||
- With `apple_country` set, Apple Podcasts' top charts for that country (the top 100 and the top 50 of each category) come into the directory every week, beside what servers share, without counting as subscribers; and a warning says when Apple's categories no longer match ipx's (#252).
|
||||
|
||||
### Changed
|
||||
|
||||
- Each feed is checked every twelve hours instead of six (#243).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the server keeps all ten of its database connections open, so a request does not wait for a new one (#241).
|
||||
|
||||
## [1.1.1] - 2026-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
|
||||
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
|
||||
|
||||
### Security
|
||||
|
||||
- A failed feed check is logged without its address's query or password (#238).
|
||||
|
||||
## [1.1.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- With `metrics_bind` set, the directory serves Prometheus metrics on a port of its own: memory, open files, and the feeds it has fetched and their bytes. A feed check's trace says how large the feed was (#224).
|
||||
|
||||
### Changed
|
||||
|
||||
- The directory uses mimalloc instead of glibc's allocator, so it gives back memory it has freed instead of holding over a gigabyte while idle (#224).
|
||||
- The example in docs/directory.md has no healthcheck; `ipx-directory health` is still there to run by hand (#218).
|
||||
|
||||
### Security
|
||||
|
||||
- A feed check's trace keeps the feed's address without its query or password (#225).
|
||||
|
||||
## [1.0.0] - 2026-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Anyone can browse the directory in a web browser, read only: the most subscribed, categories, every feed A to Z, a search by name, and each feed's page with its latest items and its address to copy into any podcast app (#215).
|
||||
- `GET /v1/instances` lists the servers holding a snapshot, with their feeds and subscribers, for an admin token (#206).
|
||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
||||
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...main
|
||||
[1.4.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...directory-v1.4.1
|
||||
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.3.0...directory-v1.4.0
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...directory-v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
|
||||
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
||||
41
crates/directory/Cargo.toml
Normal file
41
crates/directory/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "ipx-directory"
|
||||
version = "1.4.1"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "ipx-directory"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
futures-util.workspace = true
|
||||
ipx-feed.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry-otlp.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
mimalloc.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
reqwest.workspace = true
|
||||
sea-orm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
flate2.workspace = true
|
||||
tower.workspace = true
|
||||
428
crates/directory/src/api.rs
Normal file
428
crates/directory/src/api.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
//! The HTTP side: the router, the token check, and `/v1`, where ipx servers send snapshots and read the
|
||||
//! catalogue.
|
||||
|
||||
use crate::config::Access;
|
||||
use crate::directory::Shared;
|
||||
use crate::wire::{Blocked, FeedPage, Instance, Listing, MAX_SNAPSHOT, Me, SnapshotEntry, SnapshotResult};
|
||||
use crate::{otel, site};
|
||||
use axum::extract::{Path, Query, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use std::time::Instant;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::decompression::RequestDecompressionLayer;
|
||||
use tracing::Instrument;
|
||||
|
||||
/// Every route: the API under `/v1`, behind a token, and the public pages.
|
||||
pub fn router(dir: Shared) -> Router {
|
||||
let v1 = Router::new()
|
||||
.route("/v1/me", get(me))
|
||||
.route("/v1/feeds", get(list))
|
||||
.route("/v1/popular", get(popular))
|
||||
.route("/v1/feeds/{id}", get(feed).delete(remove))
|
||||
.route("/v1/feeds/{id}/refresh", post(refresh))
|
||||
.route("/v1/snapshot", post(snapshot))
|
||||
.route("/v1/blocked", get(blocked))
|
||||
.route("/v1/blocked/{id}", delete(unblock))
|
||||
.route("/v1/instances", get(instances))
|
||||
.route("/v1/instances/{name}", delete(drop_instance))
|
||||
.layer(axum::middleware::from_fn_with_state(dir.clone(), authenticate));
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.merge(v1)
|
||||
// Anyone may browse, no token, read only (#215).
|
||||
.merge(site::router())
|
||||
// Brotli, zstd or gzip as the caller takes them: the feed list went to ipx as 603 KB of
|
||||
// JSON, most of a Directory page's time (#253). Inside the access log, which records what
|
||||
// was sent.
|
||||
.layer(CompressionLayer::new().br(true).zstd(true).gzip(true))
|
||||
// And takes a request's body gzipped: ipx sends its snapshot so, about 100 KB as JSON.
|
||||
.layer(RequestDecompressionLayer::new().gzip(true))
|
||||
.layer(axum::middleware::from_fn(access_log))
|
||||
.with_state(dir)
|
||||
}
|
||||
|
||||
pub(crate) struct ApiError(StatusCode, String);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ApiError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
tracing::error!(error = %format!("{e:#}"), "request failed");
|
||||
ApiError(StatusCode::INTERNAL_SERVER_ERROR, "something went wrong; the directory's log says what".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Who made the request, for the access log, set by `authenticate`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Who(String);
|
||||
|
||||
/// One line a request, with the token's name and never the token: what the Grafana panels read.
|
||||
pub(crate) async fn access_log(req: Request, next: Next) -> Response {
|
||||
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
|
||||
let start = Instant::now();
|
||||
// A span for each request but the healthcheck's, a child of the caller's when it sent a
|
||||
// traceparent, as ipx does on its calls here (#213).
|
||||
let span = if path == "/healthz" {
|
||||
tracing::Span::none()
|
||||
} else {
|
||||
tracing::info_span!(
|
||||
"http",
|
||||
otel.name = %format!("{method} {path}"),
|
||||
http.request.method = %method,
|
||||
url.path = %path,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
)
|
||||
};
|
||||
if let Some(parent) = otel::remote_parent(req.headers(), None) {
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let _ = span.set_parent(parent);
|
||||
}
|
||||
let resp = Instrument::instrument(next.run(req), span.clone()).await;
|
||||
span.record("http.response.status_code", resp.status().as_u16());
|
||||
let _in = span.enter();
|
||||
let who = resp.extensions().get::<Who>().map_or("", |w| w.0.as_str()).to_owned();
|
||||
let status = resp.status().as_u16();
|
||||
if path != "/healthz" {
|
||||
tracing::info!(
|
||||
target: "ipx_directory::http",
|
||||
method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who,
|
||||
"{method} {path} -> {status}"
|
||||
);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
pub(crate) async fn authenticate(State(dir): State<Shared>, mut req: Request, next: Next) -> Response {
|
||||
let presented = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty());
|
||||
let Some(me) = presented.and_then(|t| dir.caller(t)) else {
|
||||
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into())
|
||||
.into_response();
|
||||
};
|
||||
let who = Who(me.name.clone());
|
||||
req.extensions_mut().insert(me);
|
||||
let mut resp = next.run(req).await;
|
||||
resp.extensions_mut().insert(who);
|
||||
resp
|
||||
}
|
||||
|
||||
pub(crate) fn need(me: &Me, access: Access) -> Result<(), ApiError> {
|
||||
if me.access >= access {
|
||||
Ok(())
|
||||
} else {
|
||||
let what = match access {
|
||||
Access::Ro => "browse",
|
||||
Access::Rw => "send snapshots",
|
||||
Access::Rwa => "administer the directory",
|
||||
};
|
||||
Err(ApiError(StatusCode::FORBIDDEN, format!("this token may not {what}")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn healthz(State(dir): State<Shared>) -> Result<&'static str, ApiError> {
|
||||
dir.store.ping().await?;
|
||||
Ok("ok")
|
||||
}
|
||||
|
||||
pub(crate) async fn me(Extension(me): Extension<Me>) -> Json<Me> {
|
||||
Json(me)
|
||||
}
|
||||
|
||||
pub(crate) async fn list(State(dir): State<Shared>) -> Result<Json<Vec<Listing>>, ApiError> {
|
||||
Ok(Json(dir.store.list().await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Limit {
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) async fn popular(State(dir): State<Shared>, Query(q): Query<Limit>) -> Result<Json<Vec<Listing>>, ApiError> {
|
||||
Ok(Json(dir.store.popular(q.limit.unwrap_or(10).clamp(1, 100)).await?))
|
||||
}
|
||||
|
||||
pub(crate) async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
|
||||
dir.store
|
||||
.feed(&id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
|
||||
}
|
||||
|
||||
pub(crate) async fn snapshot(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Json(entries): Json<Vec<SnapshotEntry>>,
|
||||
) -> Result<Json<SnapshotResult>, ApiError> {
|
||||
need(&me, Access::Rw)?;
|
||||
if entries.len() > MAX_SNAPSHOT {
|
||||
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("a snapshot lists at most {MAX_SNAPSHOT} feeds")));
|
||||
}
|
||||
let r = dir.store.snapshot(&me.name, &entries).await?;
|
||||
dir.wake.notify_one();
|
||||
tracing::info!(
|
||||
instance = %me.name, accepted = r.accepted, refused_private = r.refused_private,
|
||||
refused_blocked = r.refused_blocked, refused_invalid = r.refused_invalid, "snapshot"
|
||||
);
|
||||
Ok(Json(r))
|
||||
}
|
||||
|
||||
pub(crate) async fn remove(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
dir.store.block(&id).await?;
|
||||
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub(crate) async fn blocked(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
) -> Result<Json<Vec<Blocked>>, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
Ok(Json(dir.store.blocked().await?))
|
||||
}
|
||||
|
||||
pub(crate) async fn unblock(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
if !dir.store.unblock(&id).await? {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
|
||||
}
|
||||
tracing::info!(feed = %id, by = %me.name, "feed unblocked");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub(crate) async fn instances(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
) -> Result<Json<Vec<Instance>>, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
Ok(Json(dir.store.instances().await?))
|
||||
}
|
||||
|
||||
pub(crate) async fn drop_instance(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
let n = dir.store.drop_instance(&name).await?;
|
||||
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh(
|
||||
State(dir): State<Shared>,
|
||||
Extension(me): Extension<Me>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<FeedPage>, ApiError> {
|
||||
need(&me, Access::Rwa)?;
|
||||
let due = dir
|
||||
.store
|
||||
.due_one(&id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
|
||||
dir.refresh(&due).await?;
|
||||
feed(State(dir), Path(id)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::config;
|
||||
use crate::config::Token;
|
||||
use crate::directory::Directory;
|
||||
use crate::store;
|
||||
use crate::store::Store;
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn app() -> (Router, Shared) {
|
||||
let tokens = ["ro", "rw", "rwa"]
|
||||
.iter()
|
||||
.map(|n| Token {
|
||||
name: format!("{n}-token"),
|
||||
access: serde_json::from_value(serde_json::json!(n)).unwrap(),
|
||||
sha256: config::hash(&format!("secret-{n}")),
|
||||
})
|
||||
.collect();
|
||||
let dir = Directory::new(Store::scratch().await, tokens, Default::default()).unwrap();
|
||||
(router(dir.clone()), dir)
|
||||
}
|
||||
|
||||
async fn call(
|
||||
app: &Router,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> (u16, serde_json::Value) {
|
||||
let mut req = axum::http::Request::builder().method(method).uri(path);
|
||||
if let Some(t) = token {
|
||||
req = req.header("authorization", format!("Bearer secret-{t}"));
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_snapshot_may_come_gzipped() {
|
||||
use std::io::Write as _;
|
||||
let (app, _) = app().await;
|
||||
let json = serde_json::json!([{"url": "https://example.com/feed.xml", "subscribers": 2}]).to_string();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(json.as_bytes()).unwrap();
|
||||
// As ipx sends it (#253).
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/snapshot")
|
||||
.header("authorization", "Bearer secret-rw")
|
||||
.header("content-type", "application/json")
|
||||
.header("content-encoding", "gzip")
|
||||
.body(Body::from(gz.finish().unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got: serde_json::Value =
|
||||
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
|
||||
assert_eq!(got["accepted"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn answers_come_compressed_as_the_caller_asks() {
|
||||
let (app, _) = app().await;
|
||||
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
|
||||
// A page, not an empty feed list: under 32 bytes is sent as it is.
|
||||
let req =
|
||||
axum::http::Request::builder().uri("/").header("accept-encoding", asked).body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
||||
assert_eq!(got.as_deref(), sent, "asked for {asked} (#253)");
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_page(app: &Router, path: &str) -> (u16, String, Option<String>) {
|
||||
let req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let csp = resp.headers().get("content-security-policy").map(|v| v.to_str().unwrap().to_owned());
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 22).await.unwrap();
|
||||
(status, String::from_utf8(bytes.to_vec()).unwrap(), csp)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anyone_browses_the_pages_and_the_api_still_needs_a_token() {
|
||||
let (app, dir) = app().await;
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 3 }]);
|
||||
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
let id = dir.store.list().await.unwrap()[0].id.clone();
|
||||
|
||||
let (s, home, csp) = get_page(&app, "/").await;
|
||||
assert_eq!(s, 200);
|
||||
assert!(csp.unwrap().contains("script-src 'self'"));
|
||||
assert!(home.contains("Most subscribed") && home.contains(&format!("/feed/{id}")), "{home}");
|
||||
assert!(get_page(&app, "/all").await.1.contains("3 people subscribe"));
|
||||
let (s, page, _) = get_page(&app, &format!("/feed/{id}")).await;
|
||||
assert_eq!(s, 200);
|
||||
// Its address, to subscribe to anywhere.
|
||||
assert!(page.contains("value=\"https://x.test/a.xml\""), "{page}");
|
||||
assert!(!page.contains("<script>"), "the publisher's HTML is text here");
|
||||
assert_eq!(get_page(&app, "/feed/nope").await.0, 404);
|
||||
assert_eq!(get_page(&app, "/category/Nothing").await.0, 404);
|
||||
assert_eq!(get_page(&app, "/search?q=").await.0, 303);
|
||||
assert_eq!(get_page(&app, "/site.js").await.0, 200);
|
||||
// Reading the pages takes no token; the API still does.
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), None, None).await.0, 401);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/feed/{id}"), None, None).await.0, 405);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_access_level_does_what_it_may_and_no_more() {
|
||||
let (app, dir) = app().await;
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 2, "category": "Comedy" }]);
|
||||
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("wrong"), None).await.0, 401);
|
||||
assert_eq!(call(&app, "GET", "/healthz", None, None).await.0, 200);
|
||||
|
||||
let (s, me) = call(&app, "GET", "/v1/me", Some("ro"), None).await;
|
||||
assert_eq!((s, me["name"].as_str(), me["access"].as_str()), (200, Some("ro-token"), Some("ro")));
|
||||
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("ro"), Some(snap.clone())).await.0, 403);
|
||||
|
||||
let (s, r) = call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await;
|
||||
assert_eq!((s, r["accepted"].as_u64()), (200, Some(1)));
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
let (_, list) = call(&app, "GET", "/v1/feeds", Some("ro"), None).await;
|
||||
let id = list[0]["id"].as_str().unwrap().to_owned();
|
||||
assert_eq!(list[0]["subscribers"], 2);
|
||||
assert_ne!(list[0]["category"], "Comedy", "the feed's own category wins over the sender's");
|
||||
assert_eq!(call(&app, "GET", &format!("/v1/feeds/{id}"), Some("ro"), None).await.0, 200);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
|
||||
assert_eq!(call(&app, "GET", "/v1/popular?limit=5", Some("ro"), None).await.1[0]["id"], id.as_str());
|
||||
|
||||
for (m, p) in [
|
||||
("DELETE", format!("/v1/feeds/{id}")),
|
||||
("GET", "/v1/blocked".into()),
|
||||
("DELETE", "/v1/instances/rw-token".into()),
|
||||
] {
|
||||
assert_eq!(call(&app, m, &p, Some("rw"), None).await.0, 403, "{m} {p}");
|
||||
}
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), Some("rwa"), None).await.0, 204);
|
||||
assert_eq!(call(&app, "GET", "/v1/blocked", Some("rwa"), None).await.1[0]["id"], id.as_str());
|
||||
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await.1["refused_blocked"], 1);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 204);
|
||||
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 404);
|
||||
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
|
||||
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rwa"), None).await.0, 204);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("ro"), None).await.1, serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_revoked_token_loses_its_access_and_its_counts() {
|
||||
let dir = Directory::new(Store::scratch().await, vec![], Default::default()).unwrap();
|
||||
let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) };
|
||||
dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
|
||||
let app = router(dir.clone());
|
||||
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 1 }]);
|
||||
call(&app, "POST", "/v1/snapshot", Some("a"), Some(snap.clone())).await;
|
||||
call(&app, "POST", "/v1/snapshot", Some("b"), Some(snap)).await;
|
||||
store::tests::fetch_all(&dir.store).await;
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 2);
|
||||
|
||||
dir.set_tokens(vec![t("a")]).await.unwrap();
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("b"), None).await.0, 401);
|
||||
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 1);
|
||||
}
|
||||
}
|
||||
281
crates/directory/src/apple.rs
Normal file
281
crates/directory/src/apple.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! Apple Podcasts, weekly (#252): its top charts for a country into the directory, beside what
|
||||
//! ipx servers share, and its categories checked against the copy ipx keeps (`ipx_feed`).
|
||||
//!
|
||||
//! The charts are a snapshot of their own, under `INSTANCE`, with no subscribers: Apple's shows
|
||||
//! are fetched, categorised and listed like any other feed, and the most subscribed stay what this
|
||||
//! directory's people subscribe to. Each show's RSS address comes from Apple's lookup API.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::{Shared, SnapshotEntry};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
/// What the charts are filed under, as a server's snapshot is under its token's name.
|
||||
pub const INSTANCE: &str = "apple-podcasts";
|
||||
/// How often, and how long to wait after a sync fails before trying again.
|
||||
const EVERY: i64 = 7 * 86_400;
|
||||
const RETRY: Duration = Duration::from_secs(3600);
|
||||
/// The top this many overall, and of each category: about a thousand shows once they overlap.
|
||||
const TOP: u32 = 100;
|
||||
const TOP_OF_EACH: u32 = 50;
|
||||
/// Apple asks for no more than about twenty calls a minute.
|
||||
const PAUSE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Syncs when a week has passed since the last, forever.
|
||||
pub async fn run(dir: Shared, country: String) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return tracing::error!(error = %e, "Apple Podcasts' sync could not start"),
|
||||
};
|
||||
loop {
|
||||
let last = dir.store.synced_at(INSTANCE).await.unwrap_or(None).unwrap_or(0);
|
||||
let wait = last + EVERY - crate::store::now();
|
||||
if wait > 0 {
|
||||
// Woken hourly at most, so a database restored from before the last sync is noticed.
|
||||
tokio::time::sleep(Duration::from_secs((wait as u64).min(3600))).await;
|
||||
continue;
|
||||
}
|
||||
let span = tracing::info_span!("apple_sync", country = %country);
|
||||
match sync(&dir, &client, &country).instrument(span).await {
|
||||
Ok(()) => {
|
||||
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "Apple Podcasts' sync failed; trying again in an hour");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
|
||||
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
|
||||
let theirs = categories(&genres);
|
||||
let (added, gone) = differ(&theirs, ipx_feed::categories());
|
||||
if !added.is_empty() || !gone.is_empty() {
|
||||
tracing::warn!(
|
||||
added = %added.join(", "), gone = %gone.join(", "),
|
||||
"Apple's podcast categories are not ipx's: the copy in crates/feed (CATEGORIES) wants updating"
|
||||
);
|
||||
}
|
||||
|
||||
// The top overall, then the top of each category, each show once.
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
|
||||
for (genre, n) in charts {
|
||||
let url = match &genre {
|
||||
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
|
||||
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
|
||||
};
|
||||
match get::<Chart>(client, &url).await {
|
||||
Ok(chart) => ids.extend(chart_ids(&chart)),
|
||||
// One category's chart missing is no reason to keep last week's whole list.
|
||||
Err(e) => tracing::info!(genre = ?genre, error = %format!("{e:#}"), "an Apple chart did not load"),
|
||||
}
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
ids.retain(|id| seen.insert(id.clone()));
|
||||
anyhow::ensure!(!ids.is_empty(), "no chart had a show in it");
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for batch in ids.chunks(100) {
|
||||
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
|
||||
let found: Lookup = get(client, &url).await?;
|
||||
entries.extend(lookup_entries(&found));
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
||||
tracing::info!(
|
||||
shows = ids.len(),
|
||||
accepted = result.accepted,
|
||||
refused_private = result.refused_private,
|
||||
refused_blocked = result.refused_blocked,
|
||||
refused_invalid = result.refused_invalid,
|
||||
"Apple Podcasts synced"
|
||||
);
|
||||
// Fetched now, not when the refresher next looks.
|
||||
dir.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get<T: serde::de::DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
|
||||
let span = tracing::info_span!("apple_get", url = %url);
|
||||
async {
|
||||
let resp = client.get(url).send().await.context("asking Apple")?.error_for_status()?;
|
||||
resp.json::<T>().await.context("reading Apple's answer")
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Apple's genres under Podcasts (26): each category's id and name, and its subcategories.
|
||||
#[derive(Deserialize)]
|
||||
struct Genres(BTreeMap<String, Genre>);
|
||||
#[derive(Deserialize)]
|
||||
struct Genre {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
subgenres: BTreeMap<String, Genre>,
|
||||
}
|
||||
|
||||
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
||||
g.0.get("26")
|
||||
.map(|podcasts| {
|
||||
podcasts
|
||||
.subgenres
|
||||
.values()
|
||||
.map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn genre_ids(g: &Genres) -> Vec<String> {
|
||||
g.0.get("26").map(|p| p.subgenres.keys().cloned().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What Apple has that ipx's list lacks, and what ipx's has that Apple no longer does, as
|
||||
/// "Category" or "Category / Subcategory".
|
||||
fn differ(theirs: &BTreeMap<String, Vec<String>>, ours: &[(&str, &[&str])]) -> (Vec<String>, Vec<String>) {
|
||||
let flat = |c: &str, subs: &mut dyn Iterator<Item = String>| {
|
||||
std::iter::once(c.to_string()).chain(subs.map(|s| format!("{c} / {s}"))).collect::<Vec<_>>()
|
||||
};
|
||||
let theirs: BTreeSet<String> = theirs.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().cloned())).collect();
|
||||
let ours: BTreeSet<String> =
|
||||
ours.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().map(|s| s.to_string()))).collect();
|
||||
(theirs.difference(&ours).cloned().collect(), ours.difference(&theirs).cloned().collect())
|
||||
}
|
||||
|
||||
/// A chart, as Apple's iTunes RSS gives it: the shows' Apple ids, in order.
|
||||
#[derive(Deserialize)]
|
||||
struct Chart {
|
||||
feed: ChartFeed,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartFeed {
|
||||
// A chart of one show is that show, not a list of one.
|
||||
#[serde(default, deserialize_with = "one_or_many")]
|
||||
entry: Vec<ChartEntry>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartEntry {
|
||||
id: ChartId,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartId {
|
||||
attributes: ChartIdAttributes,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartIdAttributes {
|
||||
#[serde(rename = "im:id")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ChartEntry>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany {
|
||||
One(ChartEntry),
|
||||
Many(Vec<ChartEntry>),
|
||||
}
|
||||
Ok(match OneOrMany::deserialize(d)? {
|
||||
OneOrMany::One(e) => vec![e],
|
||||
OneOrMany::Many(v) => v,
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_ids(c: &Chart) -> Vec<String> {
|
||||
c.feed.entry.iter().map(|e| e.id.attributes.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Apple's lookup answer: each show's RSS address and the category Apple files it under.
|
||||
#[derive(Deserialize)]
|
||||
struct Lookup {
|
||||
#[serde(default)]
|
||||
results: Vec<LookupResult>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct LookupResult {
|
||||
#[serde(rename = "feedUrl")]
|
||||
feed_url: Option<String>,
|
||||
#[serde(rename = "primaryGenreName")]
|
||||
genre: Option<String>,
|
||||
}
|
||||
|
||||
fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
|
||||
l.results
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let url = r.feed_url.as_deref()?.trim();
|
||||
(!url.is_empty()).then(|| SnapshotEntry {
|
||||
url: url.to_owned(),
|
||||
subscribers: 0,
|
||||
// "Podcasts" is the root Apple files a show under when it gives no category.
|
||||
category: r.genre.clone().filter(|g| g != "Podcasts"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apples_categories_are_read_and_compared_with_ipxs() {
|
||||
let genres: Genres = serde_json::from_str(
|
||||
r#"{"26": {"name": "Podcasts", "subgenres": {
|
||||
"1301": {"name": "Arts", "subgenres": {"1482": {"name": "Books"}, "1999": {"name": "Murals"}}},
|
||||
"1310": {"name": "Music"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(genre_ids(&genres), ["1301", "1310"]);
|
||||
let ours: &[(&str, &[&str])] = &[("Arts", &["Books", "Design"]), ("Music", &[]), ("Hobbies", &[])];
|
||||
let (added, gone) = differ(&categories(&genres), ours);
|
||||
assert_eq!(added, ["Arts / Murals"]);
|
||||
assert_eq!(gone, ["Arts / Design", "Hobbies"]);
|
||||
// ipx's own copy is Apple's whole list, each category once.
|
||||
let mine = ipx_feed::categories();
|
||||
assert_eq!(mine.len(), 19);
|
||||
assert!(mine.iter().any(|(c, subs)| *c == "News" && subs.contains(&"Daily News")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_gives_its_shows_in_order_and_a_lookup_their_addresses() {
|
||||
let chart: Chart = serde_json::from_str(
|
||||
r#"{"feed": {"entry": [
|
||||
{"id": {"label": "https://x", "attributes": {"im:id": "1200361736"}}},
|
||||
{"id": {"label": "https://y", "attributes": {"im:id": "1028908750"}}}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
||||
let one: Chart =
|
||||
serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
||||
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
|
||||
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
||||
assert!(chart_ids(&empty).is_empty());
|
||||
|
||||
let found: Lookup = serde_json::from_str(
|
||||
r#"{"resultCount": 3, "results": [
|
||||
{"trackName": "The Daily", "feedUrl": "https://feeds.simplecast.com/Sl5CSM3S", "primaryGenreName": "Daily News"},
|
||||
{"trackName": "No feed"},
|
||||
{"trackName": "Rooted", "feedUrl": "https://example.com/rss", "primaryGenreName": "Podcasts"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let entries = lookup_entries(&found);
|
||||
assert_eq!(entries.len(), 2, "a show Apple has no feed for is left out");
|
||||
assert_eq!((entries[0].subscribers, entries[0].category.as_deref()), (0, Some("Daily News")));
|
||||
assert_eq!(entries[1].category, None, "Podcasts is no category");
|
||||
}
|
||||
}
|
||||
207
crates/directory/src/config.rs
Normal file
207
crates/directory/src/config.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! The directory's config file: where it listens, where its database is, and the tokens it
|
||||
//! accepts. Tokens are only ever changed here, never over the API, so whoever can edit this
|
||||
//! file decides who gets in (#204).
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
/// `directory.toml`. See docs/directory.md.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// Where the API and the public pages listen.
|
||||
#[serde(default = "default_bind")]
|
||||
pub bind: String,
|
||||
/// The environment variable holding the database URL, so the file holds no secret.
|
||||
#[serde(default = "default_database_url_env")]
|
||||
pub database_url_env: String,
|
||||
/// Where to serve /metrics for Prometheus (#224), apart from `bind`, which the tunnel
|
||||
/// publishes: `0.0.0.0:9188`. Empty serves none.
|
||||
#[serde(default)]
|
||||
pub metrics_bind: String,
|
||||
/// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the
|
||||
/// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or
|
||||
/// left out, does neither.
|
||||
#[serde(default)]
|
||||
pub apple_country: String,
|
||||
/// Internal addresses and networks the refresher may fetch feeds from all the same, as ipx's
|
||||
/// `fetch_internal` (#329). Empty, the default, fetches nothing off the public internet.
|
||||
#[serde(default)]
|
||||
pub fetch_internal: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tokens: Vec<TokenCfg>,
|
||||
}
|
||||
|
||||
fn default_bind() -> String {
|
||||
"0.0.0.0:8100".into()
|
||||
}
|
||||
|
||||
fn default_database_url_env() -> String {
|
||||
"DIRECTORY_DATABASE_URL".into()
|
||||
}
|
||||
|
||||
/// A token as `directory.toml` lists it: a name, what it may do, and only the token's hash.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TokenCfg {
|
||||
/// Who holds it: what its snapshot is filed under and what the log says, never the token.
|
||||
pub name: String,
|
||||
/// No default: a token whose access is missing or misspelled is refused when the file is
|
||||
/// read, rather than given some access nobody chose.
|
||||
pub access: Access,
|
||||
/// The token's SHA-256, in hex: what `ipx-directory token new` prints. The file holds no
|
||||
/// token, so adding or revoking one is an edit here and nothing else.
|
||||
pub token_sha256: String,
|
||||
}
|
||||
|
||||
/// What a token may do, each level including the one before.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Access {
|
||||
/// Browse.
|
||||
Ro,
|
||||
/// Browse and send snapshots.
|
||||
Rw,
|
||||
/// All that, and remove, block and refresh feeds and drop an instance's snapshot.
|
||||
Rwa,
|
||||
}
|
||||
|
||||
/// A token as the server checks it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token {
|
||||
pub name: String,
|
||||
pub access: Access,
|
||||
/// SHA-256 of the token, lower-case hex.
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Reads and checks `path`; see `parse`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&text).with_context(|| format!("in {}", path.display()))
|
||||
}
|
||||
|
||||
/// Reads a config, refusing tokens with empty or repeated names, which would file two
|
||||
/// instances' snapshots together.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
let cfg: Config = toml::from_str(text)?;
|
||||
let mut seen = HashSet::new();
|
||||
for t in &cfg.tokens {
|
||||
if t.name.trim().is_empty() {
|
||||
bail!("a token has an empty name");
|
||||
}
|
||||
// Snapshots are filed by name: two instances under one name would overwrite each other.
|
||||
if !seen.insert(t.name.as_str()) {
|
||||
bail!("two tokens are named {:?}", t.name);
|
||||
}
|
||||
// Apple's charts are filed under it; a server of that name would replace them.
|
||||
if t.name == crate::apple::INSTANCE {
|
||||
bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name);
|
||||
}
|
||||
}
|
||||
let c = cfg.apple_country.trim();
|
||||
if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) {
|
||||
bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}");
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// The tokens as the server checks them. A hash that is not 64 hex digits, or the same
|
||||
/// hash under two names, is an error: which name a request is would be ambiguous.
|
||||
pub fn tokens(&self) -> Result<Vec<Token>> {
|
||||
let mut seen = HashSet::new();
|
||||
self.tokens
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let sha256 = t.token_sha256.trim().to_ascii_lowercase();
|
||||
if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
bail!("token {:?}: token_sha256 is not a SHA-256 in hex", t.name);
|
||||
}
|
||||
if !seen.insert(sha256.clone()) {
|
||||
bail!("token {:?} is the same token as another", t.name);
|
||||
}
|
||||
Ok(Token { name: t.name.clone(), access: t.access, sha256 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A token's SHA-256 in hex, as the config file holds it.
|
||||
#[must_use]
|
||||
pub fn hash(token: &str) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(token.trim().as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Equal without saying, by how long it took, how much of the guess was right.
|
||||
#[must_use]
|
||||
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// A new token: `ipxd_` and 256 random bits, the prefix so one found in a log says what it is.
|
||||
#[must_use]
|
||||
pub fn new_token() -> String {
|
||||
use std::io::Read;
|
||||
let mut bytes = [0u8; 32];
|
||||
// Falling back to the clock would make a guessable token. Better to fail.
|
||||
File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)).expect("no source of randomness for a token");
|
||||
format!("ipxd_{}", bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = r#"
|
||||
[[tokens]]
|
||||
name = "tower"
|
||||
access = "rwa"
|
||||
token_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
[[tokens]]
|
||||
name = "friend"
|
||||
access = "ro"
|
||||
token_sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn tokens_carry_their_access_and_take_their_value_from_the_environment() {
|
||||
let cfg = Config::parse(GOOD).unwrap();
|
||||
assert_eq!(cfg.bind, "0.0.0.0:8100");
|
||||
let t = cfg.tokens().unwrap();
|
||||
assert_eq!(
|
||||
(t[0].name.as_str(), t[0].access, t[0].sha256.as_str()),
|
||||
("tower", Access::Rwa, "a".repeat(64).as_str())
|
||||
);
|
||||
assert_eq!(t[1].access, Access::Ro);
|
||||
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
|
||||
let h = "a".repeat(64);
|
||||
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err());
|
||||
assert!(
|
||||
Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err()
|
||||
);
|
||||
assert!(Config::parse(&GOOD.replace("\"friend\"", "\"tower\"")).is_err());
|
||||
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), "xyz")).unwrap().tokens().is_err());
|
||||
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_compare_whole() {
|
||||
assert!(same_secret("abc", "abc"));
|
||||
assert!(!same_secret("abc", "abd"));
|
||||
assert!(!same_secret("abc", "abcd"));
|
||||
let t = new_token();
|
||||
assert!(t.starts_with("ipxd_") && t.len() == 69 && t != new_token());
|
||||
assert_eq!(hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
}
|
||||
}
|
||||
218
crates/directory/src/directory.rs
Normal file
218
crates/directory/src/directory.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! The running directory: its store, the tokens it accepts, the refresher that fetches its feeds, and
|
||||
//! the reloader that picks up a changed config.
|
||||
|
||||
use crate::config::Token;
|
||||
use crate::wire::{Listing, Me};
|
||||
use crate::{config, store};
|
||||
use reqwest::redirect::Policy;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::{Refreshed, Store};
|
||||
use tokio::sync::Notify;
|
||||
use tracing::Instrument;
|
||||
|
||||
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
|
||||
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
||||
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
||||
|
||||
/// The running directory: its database, the tokens it accepts and the client its refresher
|
||||
/// fetches feeds with.
|
||||
pub struct Directory {
|
||||
pub store: Store,
|
||||
pub(crate) tokens: RwLock<Vec<Token>>,
|
||||
pub(crate) client: reqwest::Client,
|
||||
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
|
||||
pub(crate) wake: Notify,
|
||||
/// The public pages' copy of the list, and when it was read.
|
||||
pub(crate) pages_list: tokio::sync::Mutex<Option<(Instant, Arc<Vec<Listing>>)>>,
|
||||
}
|
||||
|
||||
/// How long the public pages show the same copy of the list.
|
||||
pub(crate) const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The directory as every handler and task holds it.
|
||||
pub type Shared = Arc<Directory>;
|
||||
|
||||
impl Directory {
|
||||
/// A directory on `store`, accepting `tokens`, whose refresher may reach the internal
|
||||
/// addresses `fetch_internal` lists and no others.
|
||||
pub fn new(
|
||||
store: Store,
|
||||
tokens: Vec<Token>,
|
||||
fetch_internal: ipx_feed::guard::Allowed,
|
||||
) -> Result<Shared, reqwest::Error> {
|
||||
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(ipx_feed::FEED_SILENCE)
|
||||
.redirect(Policy::none())
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
// The feeds are named by whoever holds a write token, and redirect where their
|
||||
// publishers like: nothing on this network is fetched unless listed (#329).
|
||||
.connector_layer(ipx_feed::guard::Guard::new(fetch_internal))
|
||||
.build()?;
|
||||
Ok(Arc::new(Self {
|
||||
store,
|
||||
tokens: RwLock::new(tokens),
|
||||
client,
|
||||
wake: Notify::new(),
|
||||
pages_list: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The whole list for the public pages, read from the database at most once a minute. Anyone
|
||||
/// may ask for a page, and each asked the database for the whole catalogue on the pool the
|
||||
/// refresher and every server's /v1 calls share; a query string of its own got past any
|
||||
/// cache in front (#341). The lock is held through the read, so a burst waits on one query.
|
||||
pub async fn pages_list(&self) -> anyhow::Result<Arc<Vec<Listing>>> {
|
||||
let mut held = self.pages_list.lock().await;
|
||||
if let Some((at, list)) = held.as_ref()
|
||||
&& at.elapsed() < PAGES_LIST_FOR
|
||||
{
|
||||
return Ok(list.clone());
|
||||
}
|
||||
let list = Arc::new(self.store.list().await?);
|
||||
*held = Some((Instant::now(), list.clone()));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
|
||||
pub async fn set_tokens(&self, tokens: Vec<Token>) -> anyhow::Result<u64> {
|
||||
let names: Vec<String> = tokens.iter().map(|t| t.name.clone()).collect();
|
||||
*self.tokens.write().expect("a panic elsewhere poisoned this lock") = tokens;
|
||||
self.store.keep_instances(&names).await
|
||||
}
|
||||
|
||||
pub(crate) fn caller(&self, presented: &str) -> Option<Me> {
|
||||
let h = config::hash(presented);
|
||||
// Every token compared, whether or not one matched, so timing says nothing either way.
|
||||
self.tokens.read().expect("a panic elsewhere poisoned this lock").iter().fold(None, |found, t| {
|
||||
if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found }
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches one feed and stores what it found.
|
||||
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
|
||||
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok((ipx_feed::Fetched::NotModified, _)) => Refreshed::NotModified,
|
||||
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
|
||||
if ipx_feed::is_opml(&bytes) {
|
||||
// ipx sends the feeds inside an OPML, never the OPML.
|
||||
Refreshed::Failed("an OPML list, not a feed".into())
|
||||
} else {
|
||||
match ipx_feed::parse(&bytes) {
|
||||
Ok(parsed) => Refreshed::Feed { parsed, etag, last_modified },
|
||||
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||
};
|
||||
if let Refreshed::Failed(e) = &got {
|
||||
tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
|
||||
}
|
||||
// ponytail: a feed that moved for good is still asked for at its old address, which
|
||||
// redirects; follow the move when a feed's id can change without losing its counts.
|
||||
self.store.save(&due.id, got).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches whatever is due, a few at a time, forever.
|
||||
pub async fn refresher(dir: Shared) {
|
||||
loop {
|
||||
match dir.store.due(REFRESH_EVERY, 25).await {
|
||||
Ok(due) => {
|
||||
if !due.is_empty() {
|
||||
// A round is a trace, each feed's check a span in it (#213).
|
||||
let round = tracing::info_span!("refresh", feeds = due.len());
|
||||
Instrument::instrument(
|
||||
async {
|
||||
// A few at a time: in turn, two publishers timing out held a round
|
||||
// for a minute and more (#227).
|
||||
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
|
||||
let check = tracing::info_span!("feed", feed = %d.id);
|
||||
async {
|
||||
if let Err(e) = Instrument::instrument(dir.refresh(d), check).await {
|
||||
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
|
||||
tracing::info!(checked = due.len(), "feeds checked");
|
||||
},
|
||||
round,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not read which feeds are due"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||
_ = dir.wake.notified() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the config file again whenever it changes, checked each minute. A file that fails to
|
||||
/// load leaves the tokens as they were, so a typo locks nobody out.
|
||||
pub async fn reloader(dir: Shared, path: PathBuf) {
|
||||
let mtime = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
|
||||
let mut last = mtime(&path);
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
let now = mtime(&path);
|
||||
if now == last {
|
||||
continue;
|
||||
}
|
||||
last = now;
|
||||
match config::Config::load(&path).and_then(|c| c.tokens()) {
|
||||
Ok(tokens) => {
|
||||
let n = tokens.len();
|
||||
match dir.set_tokens(tokens).await {
|
||||
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn scratch() -> Shared {
|
||||
Directory::new(Store::scratch().await, vec![], Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_refresher_fetches_nothing_on_this_network() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
|
||||
let dir = scratch().await;
|
||||
let err = dir.client.get(format!("http://{addr}/feed.xml")).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_public_pages_read_the_list_once_a_minute() {
|
||||
let dir = scratch().await;
|
||||
let first = dir.pages_list().await.unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()), "a second page asks the database nothing");
|
||||
// Once the copy is a minute old, the next page reads the list again.
|
||||
dir.pages_list.lock().await.as_mut().unwrap().0 -= PAGES_LIST_FOR;
|
||||
assert!(!Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()));
|
||||
}
|
||||
}
|
||||
22
crates/directory/src/lib.rs
Normal file
22
crates/directory/src/lib.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! ipx's directory service (#204): a catalogue of public podcasts and blogs that ipx servers
|
||||
//! share. It holds metadata only, never a file: each server still fetches its own feeds. ipx
|
||||
//! servers send which public feeds their people subscribe to, the directory fetches each
|
||||
//! feed's title, artwork, description and latest items on a slow schedule, and any server
|
||||
//! holding a token can browse it.
|
||||
//!
|
||||
//! The types here are the wire format, shared with ipx, which asks the directory on its
|
||||
//! people's behalf.
|
||||
|
||||
mod api;
|
||||
pub mod apple;
|
||||
pub mod config;
|
||||
mod directory;
|
||||
pub mod metrics;
|
||||
pub mod otel;
|
||||
mod site;
|
||||
pub mod store;
|
||||
mod wire;
|
||||
|
||||
pub use api::router;
|
||||
pub use directory::{Directory, REFRESH_EVERY, Shared, refresher, reloader};
|
||||
pub use wire::{Blocked, FeedPage, Instance, Item, Listing, MAX_SNAPSHOT, Me, SnapshotEntry, SnapshotResult};
|
||||
143
crates/directory/src/main.rs
Normal file
143
crates/directory/src/main.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! `ipx-directory`: serve the directory, make a token, or check that a running one answers.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use ipx_directory::store::Store;
|
||||
use ipx_directory::{Directory, config};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "ipx's directory of public podcasts")]
|
||||
struct Cli {
|
||||
/// The config file: where to listen, the database, and the tokens it accepts. Otherwise
|
||||
/// DIRECTORY_CONFIG, or else /config/directory.toml.
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Serve the directory (the default).
|
||||
Serve,
|
||||
/// Make a token, and print it with the lines that let it in.
|
||||
Token {
|
||||
#[command(subcommand)]
|
||||
cmd: TokenCmd,
|
||||
},
|
||||
/// Check that the directory on this machine answers and can read its database.
|
||||
Health,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TokenCmd {
|
||||
New {
|
||||
/// Who it is for, as the log and their snapshot will name them.
|
||||
name: String,
|
||||
#[arg(long, value_parser = ["ro", "rw", "rwa"])]
|
||||
access: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config_path = cli
|
||||
.config
|
||||
.or_else(|| std::env::var_os("DIRECTORY_CONFIG").map(Into::into))
|
||||
.unwrap_or_else(|| "/config/directory.toml".into());
|
||||
match cli.cmd.unwrap_or(Cmd::Serve) {
|
||||
Cmd::Serve => serve(&config_path).await,
|
||||
Cmd::Token { cmd: TokenCmd::New { name, access } } => {
|
||||
let token = config::new_token();
|
||||
println!("A token for {name}, to give them; it is not kept anywhere:\n\n {token}\n");
|
||||
println!("To let it in, add this to {}:\n", config_path.display());
|
||||
println!("[[tokens]]\nname = {name:?}\naccess = {access:?}\ntoken_sha256 = {:?}", config::hash(&token));
|
||||
println!("\nThe directory reads the file again within a minute. Delete the entry to revoke it.");
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Health => {
|
||||
let cfg = config::Config::load(&config_path)?;
|
||||
let port = cfg.bind.rsplit(':').next().unwrap_or("8100");
|
||||
let resp = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:{port}/healthz"))
|
||||
.timeout(Duration::from_secs(4))
|
||||
.send()
|
||||
.await
|
||||
.context("the directory does not answer")?;
|
||||
anyhow::ensure!(resp.status().is_success(), "the directory answers {}", resp.status());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(path: &Path) -> Result<()> {
|
||||
// One JSON object a line for Loki when DIRECTORY_LOG_FORMAT=json, as ipx does; text otherwise.
|
||||
let json = std::env::var("DIRECTORY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
let filter = || EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
|
||||
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's (#213).
|
||||
let otel = ipx_directory::otel::provider("ipx-directory")?;
|
||||
tracing_subscriber::registry()
|
||||
.with((!json).then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter())))
|
||||
.with(json.then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(filter())
|
||||
}))
|
||||
.with(otel.as_ref().map(|p| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx-directory"))
|
||||
.with_filter(EnvFilter::new("ipx_directory=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
|
||||
let cfg = config::Config::load(path)?;
|
||||
let tokens = cfg.tokens()?;
|
||||
let url = std::env::var(&cfg.database_url_env)
|
||||
.ok()
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
|
||||
let store = Store::open(&url, /* keep_open */ true).await.context("opening the database")?;
|
||||
let dir = Directory::new(store, vec![], ipx_feed::guard::Allowed::parse(&cfg.fetch_internal)?)?;
|
||||
let dropped = dir.set_tokens(tokens).await?;
|
||||
|
||||
let listener = TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
|
||||
if !cfg.metrics_bind.is_empty() {
|
||||
let metrics = TcpListener::bind(&cfg.metrics_bind)
|
||||
.await
|
||||
.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
|
||||
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
|
||||
tokio::spawn(ipx_directory::metrics::serve(metrics));
|
||||
}
|
||||
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
||||
let country = cfg.apple_country.trim().to_ascii_lowercase();
|
||||
if !country.is_empty() {
|
||||
tracing::info!(country = %country, "Apple Podcasts' charts are synced weekly");
|
||||
tokio::spawn(ipx_directory::apple::run(dir.clone(), country));
|
||||
}
|
||||
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||
axum::serve(listener, ipx_directory::router(dir))
|
||||
.with_graceful_shutdown(async {
|
||||
let mut term = tokio::signal::unix::signal(SignalKind::terminate()).expect("SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = term.recv() => {}
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
||||
if let Some(p) = otel {
|
||||
let _ = p.shutdown();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
103
crates/directory/src/metrics.rs
Normal file
103
crates/directory/src/metrics.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! Memory, for ipx and the directory both (#224), and the directory's own /metrics.
|
||||
//!
|
||||
//! mimalloc is the allocator of every binary that builds this crate in, ipx included: with
|
||||
//! glibc's, ipx kept the 500-850 MB a night of scans had needed until it restarted, and the
|
||||
//! directory sat at 1.25 GiB doing nothing, each thread's arena holding what it had freed.
|
||||
//! mimalloc gives freed pages back, so resident memory follows what is in use: a spike that
|
||||
//! does not come down again is a leak. Not jemalloc, whose upstream was archived in 2025.
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
use std::time::Instant;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
|
||||
#[must_use]
|
||||
pub fn process() -> (Option<f64>, Option<f64>) {
|
||||
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
|
||||
let kb =
|
||||
s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
Some(kb * 1024.0)
|
||||
});
|
||||
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
|
||||
(rss, fds)
|
||||
}
|
||||
|
||||
/// The directory's /metrics, in Prometheus's text format: memory, open files, and the feeds it
|
||||
/// has fetched. Nothing from the database, so a scrape costs nothing and answers while it is down.
|
||||
fn render(started: Instant) -> String {
|
||||
let mut out = String::new();
|
||||
let mut gauge = |name: &str, kind: &str, help: &str, labels: &str, v: f64| {
|
||||
out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} {kind}\n{name}{labels} {v}\n"));
|
||||
};
|
||||
let (rss, fds) = process();
|
||||
if let Some(v) = rss {
|
||||
gauge("directory_process_resident_bytes", "gauge", "Memory the process holds.", "", v);
|
||||
}
|
||||
if let Some(v) = fds {
|
||||
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
|
||||
}
|
||||
gauge(
|
||||
"directory_feed_fetches_total",
|
||||
"counter",
|
||||
"Feeds fetched, answered 304 included.",
|
||||
"",
|
||||
ipx_feed::FETCHES.load(Relaxed) as f64,
|
||||
);
|
||||
gauge(
|
||||
"directory_feed_bytes_total",
|
||||
"counter",
|
||||
"Bytes of feed bodies fetched.",
|
||||
"",
|
||||
ipx_feed::FETCHED_BYTES.load(Relaxed) as f64,
|
||||
);
|
||||
gauge(
|
||||
"directory_uptime_seconds",
|
||||
"gauge",
|
||||
"Seconds since the directory started.",
|
||||
"",
|
||||
started.elapsed().as_secs() as f64,
|
||||
);
|
||||
gauge(
|
||||
"directory_build_info",
|
||||
"gauge",
|
||||
"The version running, as a label.",
|
||||
&format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")),
|
||||
1.0,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Serves /metrics on a listener of its own: the public port is behind the tunnel with no
|
||||
/// Access in front, and these are for the LAN.
|
||||
pub async fn serve(listener: TcpListener) {
|
||||
let started = Instant::now();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || async move {
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip (#253).
|
||||
.layer(CompressionLayer::new().gzip(true));
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!(error = %e, "metrics stopped");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn metrics_say_memory_and_feeds() {
|
||||
let text = super::render(std::time::Instant::now());
|
||||
assert!(
|
||||
text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
95
crates/directory/src/otel.rs
Normal file
95
crates/directory/src/otel.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! Traces, for ipx and the directory both (#213): the exporter, and W3C `traceparent` in from a
|
||||
//! caller and out to a callee, so one trace follows a request from the browser through ipx-web
|
||||
//! and ipx into the directory.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
/// Traces over OTLP, to Tempo for one, when OTEL_EXPORTER_OTLP_ENDPOINT names a collector
|
||||
/// (`http://host:4318`: the exporter speaks OTLP over HTTP and adds `/v1/traces`). The exporter
|
||||
/// reads that and the other `OTEL_` variables itself.
|
||||
pub fn provider(service: &'static str) -> anyhow::Result<Option<opentelemetry_sdk::trace::SdkTracerProvider>> {
|
||||
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let exporter = opentelemetry_otlp::SpanExporter::builder().with_http().build()?;
|
||||
let resource = opentelemetry_sdk::Resource::builder().with_service_name(service).build();
|
||||
Ok(Some(
|
||||
opentelemetry_sdk::trace::SdkTracerProvider::builder()
|
||||
.with_batch_exporter(exporter)
|
||||
.with_resource(resource)
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
|
||||
/// one action sets off are one trace. None for a missing or malformed one. In the query where no
|
||||
/// header can be set: a position saved by sendBeacon, and the playlist Safari's player fetches
|
||||
/// itself (#192).
|
||||
#[must_use]
|
||||
pub fn remote_parent(headers: &HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
|
||||
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
|
||||
let v = match headers.get("traceparent") {
|
||||
Some(h) => h.to_str().ok()?,
|
||||
None => query?.split('&').find_map(|kv| kv.strip_prefix("traceparent="))?,
|
||||
};
|
||||
let mut parts = v.split('-');
|
||||
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
if version != "00" || trace.len() != 32 || span.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let sc = SpanContext::new(
|
||||
TraceId::from_hex(trace).ok()?,
|
||||
SpanId::from_hex(span).ok()?,
|
||||
TraceFlags::SAMPLED,
|
||||
true,
|
||||
TraceState::default(),
|
||||
);
|
||||
sc.is_valid().then(|| opentelemetry::Context::new().with_remote_span_context(sc))
|
||||
}
|
||||
|
||||
/// The current span as a `traceparent`, for a request to another service, so its spans join
|
||||
/// this trace. None outside a traced span.
|
||||
#[must_use]
|
||||
pub fn traceparent() -> Option<String> {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let cx = tracing::Span::current().context();
|
||||
let sc = cx.span().span_context().clone();
|
||||
sc.is_valid().then(|| format!("00-{}-{}-{:02x}", sc.trace_id(), sc.span_id(), sc.trace_flags().to_u8()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_requests_trace_is_the_pages_from_its_traceparent() {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
let with = |v: &str| {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("traceparent", v.parse().unwrap());
|
||||
remote_parent(&h, None)
|
||||
};
|
||||
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
|
||||
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assert!(cx.span().span_context().is_remote());
|
||||
// Anything else from the browser starts a trace of its own.
|
||||
for bad in [
|
||||
"",
|
||||
"00-xyz-00f067aa0ba902b7-01",
|
||||
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01",
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
] {
|
||||
assert!(with(bad).is_none(), "{bad}");
|
||||
}
|
||||
assert!(remote_parent(&axum::http::HeaderMap::new(), None).is_none());
|
||||
// From the query, where a beacon or Safari's player cannot send a header (#192).
|
||||
let q = remote_parent(
|
||||
&axum::http::HeaderMap::new(),
|
||||
Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
|
||||
);
|
||||
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
|
||||
}
|
||||
}
|
||||
67
crates/directory/src/site.css
Normal file
67
crates/directory/src/site.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/* The directory's pages (#215), in iPX's palette: the logo's navy, sky blue and needle orange,
|
||||
dark or light as the system is. */
|
||||
:root{--bg:#eef5fb;--panel:#fff;--panel2:#e4eff9;--line:#c6d9ea;--fg:#10233a;--dim:#46596e;--faint:#56697e;--accent:#2f6aa0;--accent2:#c43e00;--ink:#fff}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0a1726;--panel:#0f1f31;--panel2:#15283d;--line:#264060;--fg:#f3f7fb;--dim:#a3b6ca;--faint:#8a9fb5;--accent:#8fc2ea;--accent2:#ff6a1a;--ink:#0a1726}}
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1}
|
||||
.brand{display:flex;align-items:center;gap:10px;color:var(--fg);font-weight:700;font-size:17px;white-space:nowrap}
|
||||
.brand img{border-radius:8px;display:block}
|
||||
header form{margin-left:auto;flex:0 1 320px}
|
||||
input{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:8px 12px;width:100%}
|
||||
main{max-width:1100px;margin:0 auto;padding:20px 20px 40px}
|
||||
footer{max-width:1100px;margin:0 auto;padding:16px 20px 32px;color:var(--faint);font-size:13px;border-top:1px solid var(--line)}
|
||||
h2{font-size:22px;margin:20px 0 12px}
|
||||
h3{font-size:17px;margin:28px 0 8px}
|
||||
.hint,.count{color:var(--dim)}
|
||||
.back{margin:0;color:var(--faint);font-size:14px}
|
||||
.more{font-weight:600}
|
||||
.art{position:relative;display:block;aspect-ratio:1;border-radius:10px;overflow:hidden;background:var(--panel2);flex:none}
|
||||
.art i{position:absolute;inset:0;display:grid;place-items:center;font-style:normal;font-weight:700;color:var(--accent);font-size:28px}
|
||||
.art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||
.art.sm{width:48px;border-radius:8px}.art.sm i{font-size:16px}
|
||||
.art.lg{width:180px;border-radius:14px}.art.lg i{font-size:48px}
|
||||
.chart{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));gap:4px 24px}
|
||||
/* A grid item is as wide as its content unless told otherwise, and a long title then runs off the page. */
|
||||
.chart li,.tile{min-width:0}
|
||||
.chart a{display:flex;align-items:center;gap:12px;padding:8px;border-radius:10px;color:var(--fg)}
|
||||
.chart a:hover{background:var(--panel);text-decoration:none}
|
||||
.no{width:22px;text-align:right;color:var(--faint);font-variant-numeric:tabular-nums;flex:none}
|
||||
.txt{display:flex;flex-direction:column;min-width:0}
|
||||
.txt b,.tile b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
small{color:var(--dim);font-size:13px}
|
||||
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
|
||||
.cat{display:flex;flex-direction:column;gap:2px;padding:14px 16px;border-radius:12px;background:var(--panel);border:1px solid var(--line);color:var(--fg)}
|
||||
.cat:hover{border-color:var(--accent);text-decoration:none}
|
||||
.tabs,.pills{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 12px}
|
||||
.tabs a,.pills a{padding:6px 14px;border-radius:999px;background:var(--panel);border:1px solid var(--line);color:var(--fg);font-size:14px}
|
||||
.tabs a[aria-current],.pills a[aria-current]{background:var(--accent);border-color:var(--accent);color:var(--ink)}
|
||||
.pills a[aria-current] small{color:inherit}
|
||||
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px 14px}
|
||||
.tile{display:flex;flex-direction:column;gap:4px;min-width:0;color:var(--fg)}
|
||||
.tile:hover{text-decoration:none}.tile:hover b{text-decoration:underline}
|
||||
.show{display:flex;gap:24px;align-items:flex-end;margin-top:8px}
|
||||
.meta{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1}
|
||||
.meta h2{margin:0;font-size:28px;line-height:1.15;overflow-wrap:anywhere}
|
||||
.sub{margin:0;color:var(--dim)}
|
||||
.addr{display:flex;flex-direction:column;gap:4px;margin-top:8px;max-width:560px}
|
||||
.addr label{font-size:13px;color:var(--faint)}
|
||||
.addr .row{display:flex;gap:8px}
|
||||
.addr input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
|
||||
button{font:inherit;font-weight:600;padding:8px 16px;border-radius:10px;border:0;background:var(--accent);color:var(--ink);cursor:pointer}
|
||||
.about{color:var(--dim);max-width:72ch;margin:20px 0 0}
|
||||
.failing{color:var(--accent2)}
|
||||
article{padding:12px 0;border-bottom:1px solid var(--line);max-width:80ch}
|
||||
article p{margin:4px 0;color:var(--dim)}
|
||||
@media (max-width:600px){
|
||||
header{padding:10px 16px;gap:10px}
|
||||
.brand span,.brand{font-size:15px}
|
||||
main{padding:16px}
|
||||
.show{flex-direction:column;align-items:flex-start;gap:14px}
|
||||
.art.lg{width:140px}
|
||||
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
|
||||
}
|
||||
10
crates/directory/src/site.js
Normal file
10
crates/directory/src/site.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// The one thing the directory's pages script (#215): Copy puts a feed's address on the clipboard.
|
||||
// Without it, the address is in a field of its own to select by hand.
|
||||
document.addEventListener('click', ev => {
|
||||
const b = ev.target.closest('[data-copy]');
|
||||
if (!b || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(b.dataset.copy).then(() => {
|
||||
b.textContent = 'Copied';
|
||||
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
429
crates/directory/src/site.rs
Normal file
429
crates/directory/src/site.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
//! The directory in a web browser (#215): anyone may browse it, no token, read only. The most
|
||||
//! subscribed and the categories, every feed A to Z, a search by name, and a page per feed with
|
||||
//! its address to subscribe to in any podcast app. Rendered here from the store, as plain HTML:
|
||||
//! no build step, and nothing the API does not already hold.
|
||||
//!
|
||||
//! A feed's description and items are the publisher's HTML, unsanitized in the store; they are
|
||||
//! shown as text, tags stripped, and an item's link only when it is http(s).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
|
||||
use crate::{Listing, Shared};
|
||||
use url::form_urlencoded::Serializer;
|
||||
|
||||
pub fn router() -> Router<Shared> {
|
||||
Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/all", get(all))
|
||||
.route("/search", get(search))
|
||||
.route("/category/{name}", get(category))
|
||||
.route("/feed/{id}", get(feed))
|
||||
.route("/site.css", get(|| asset("text/css; charset=utf-8", include_bytes!("site.css"))))
|
||||
.route("/site.js", get(|| asset("text/javascript; charset=utf-8", include_bytes!("site.js"))))
|
||||
.route("/logo.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo.svg"))))
|
||||
.route("/logo-dark.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo-dark.svg"))))
|
||||
.route("/favicon.png", get(|| asset("image/png", include_bytes!("../../../web/favicon.png"))))
|
||||
}
|
||||
|
||||
/// Its own script and stylesheet only; covers come from their publishers.
|
||||
const CSP: &str = "default-src 'none'; img-src https: http: data:; style-src 'self'; script-src 'self'; \
|
||||
base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
async fn asset(kind: &'static str, body: &'static [u8]) -> Response {
|
||||
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Filter {
|
||||
/// `podcasts` or `blogs`; anything else is both.
|
||||
kind: Option<String>,
|
||||
sub: Option<String>,
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn keep(&self, l: &Listing) -> bool {
|
||||
match self.kind.as_deref() {
|
||||
Some("podcasts") => l.podcast,
|
||||
Some("blogs") => !l.podcast,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn home(State(dir): State<Shared>) -> Response {
|
||||
let all = match dir.pages_list().await {
|
||||
Ok(a) => a,
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
let mut top = (*all).clone();
|
||||
top.sort_by(|a, b| {
|
||||
b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))
|
||||
});
|
||||
top.truncate(10);
|
||||
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
|
||||
for l in all.iter() {
|
||||
cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
|
||||
}
|
||||
let mut b = String::new();
|
||||
if all.is_empty() {
|
||||
b.push_str("<p class=hint>Nothing is listed yet. A feed appears once a server has shared it and the directory has read it.</p>");
|
||||
} else {
|
||||
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
|
||||
for (i, l) in top.iter().enumerate() {
|
||||
let _ = write!(
|
||||
b,
|
||||
"<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
|
||||
esc(&l.id),
|
||||
i + 1,
|
||||
art(l, "art sm"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers)
|
||||
);
|
||||
}
|
||||
b.push_str("</ol><h2>Categories</h2><div class=cats>");
|
||||
for (c, ls) in &cats {
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>",
|
||||
seg(c),
|
||||
esc(c),
|
||||
ls.len()
|
||||
);
|
||||
}
|
||||
let _ = write!(b, "</div><p><a class=more href=\"/all\">All {} feeds, A to Z</a></p>", all.len());
|
||||
}
|
||||
page("iPX Directory", None, &b)
|
||||
}
|
||||
|
||||
async fn all(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
|
||||
listed(&dir, "All feeds", "/all", &f, |_| true).await
|
||||
}
|
||||
|
||||
async fn search(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
|
||||
let q = f.q.clone().unwrap_or_default().trim().to_lowercase();
|
||||
if q.is_empty() {
|
||||
return redirect("/all");
|
||||
}
|
||||
let title = format!("Results for \u{201c}{}\u{201d}", f.q.as_deref().unwrap_or("").trim());
|
||||
listed(&dir, &title, "/search", &f, move |l| name(l).to_lowercase().contains(&q)).await
|
||||
}
|
||||
|
||||
async fn category(State(dir): State<Shared>, Path(c): Path<String>, Query(f): Query<Filter>) -> Response {
|
||||
let here = format!("/category/{}", seg(&c));
|
||||
let cat = c.clone();
|
||||
listed(&dir, &c, &here, &f, move |l| l.category.as_deref().unwrap_or("Other") == cat).await
|
||||
}
|
||||
|
||||
/// A page of tiles: those `pick` takes, of the kind and subcategory asked for, A to Z.
|
||||
async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn(&Listing) -> bool) -> Response {
|
||||
let mut ls: Vec<Listing> = match dir.pages_list().await {
|
||||
Ok(a) => a.iter().filter(|l| pick(l)).cloned().collect(),
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
if ls.is_empty() && here.starts_with("/category/") {
|
||||
return not_found("That category has nothing in it.");
|
||||
}
|
||||
let subs: BTreeMap<String, usize> =
|
||||
ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
|
||||
*m.entry(s).or_default() += 1;
|
||||
m
|
||||
});
|
||||
ls.retain(|l| f.keep(l) && f.sub.as_ref().is_none_or(|s| l.subcategory.as_ref() == Some(s)));
|
||||
ls.sort_by_key(|l| name(l).to_lowercase());
|
||||
|
||||
// The query a link keeps, with one of its keys changed.
|
||||
let link = |kind: Option<&str>, sub: Option<&str>| {
|
||||
let mut q = Serializer::new(String::new());
|
||||
if let Some(v) = f.q.as_deref() {
|
||||
q.append_pair("q", v);
|
||||
}
|
||||
if let Some(v) = kind {
|
||||
q.append_pair("kind", v);
|
||||
}
|
||||
if let Some(v) = sub {
|
||||
q.append_pair("sub", v);
|
||||
}
|
||||
let q = q.finish();
|
||||
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
|
||||
};
|
||||
let mut b = String::new();
|
||||
let _ = write!(
|
||||
b,
|
||||
"<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>",
|
||||
esc(title)
|
||||
);
|
||||
for (k, label) in [(None, "All"), (Some("podcasts"), "Podcasts"), (Some("blogs"), "Blogs")] {
|
||||
let on = f.kind.as_deref() == k;
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>{label}</a>",
|
||||
esc(&link(k, f.sub.as_deref())),
|
||||
if on { " aria-current=page" } else { "" }
|
||||
);
|
||||
}
|
||||
b.push_str("</nav>");
|
||||
if !subs.is_empty() {
|
||||
b.push_str("<nav class=pills aria-label=Subcategory>");
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>All</a>",
|
||||
esc(&link(f.kind.as_deref(), None)),
|
||||
if f.sub.is_none() { " aria-current=page" } else { "" }
|
||||
);
|
||||
for (s, n) in &subs {
|
||||
let on = f.sub.as_deref() == Some(s.as_str());
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a href=\"{}\"{}>{} <small>{n}</small></a>",
|
||||
esc(&link(f.kind.as_deref(), Some(s))),
|
||||
if on { " aria-current=page" } else { "" },
|
||||
esc(s)
|
||||
);
|
||||
}
|
||||
b.push_str("</nav>");
|
||||
}
|
||||
if ls.is_empty() {
|
||||
b.push_str("<p class=hint>Nothing here.</p>");
|
||||
} else {
|
||||
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
|
||||
for l in &ls {
|
||||
let _ = write!(
|
||||
b,
|
||||
"<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>",
|
||||
esc(&l.id),
|
||||
art(l, "art"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers)
|
||||
);
|
||||
}
|
||||
b.push_str("</div>");
|
||||
}
|
||||
page(title, None, &b)
|
||||
}
|
||||
|
||||
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
|
||||
let p = match dir.store.feed(&id).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return not_found("That feed is not in the directory."),
|
||||
Err(e) => return failed(e),
|
||||
};
|
||||
let l = &p.listing;
|
||||
let mut b = String::new();
|
||||
b.push_str("<p class=back><a href=\"/\">Directory</a>");
|
||||
if let Some(c) = &l.category {
|
||||
let _ = write!(b, " <span aria-hidden=true>/</span> <a href=\"/category/{}\">{}</a>", seg(c), esc(c));
|
||||
}
|
||||
let _ = write!(
|
||||
b,
|
||||
"</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>",
|
||||
art(l, "art lg"),
|
||||
esc(name(l)),
|
||||
subscribers(l.subscribers),
|
||||
l.subcategory.as_deref().map(|s| format!(" · {}", esc(s))).unwrap_or_default()
|
||||
);
|
||||
let _ = write!(
|
||||
b,
|
||||
"<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
|
||||
<button type=button data-copy=\"{}\">Copy</button></span><small>Paste it into any podcast app or feed reader to subscribe.</small></div></div></div>",
|
||||
esc(&l.url),
|
||||
esc(&l.url)
|
||||
);
|
||||
if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) {
|
||||
let _ = write!(b, "<p class=about>{}</p>", esc(&d));
|
||||
}
|
||||
if let Some(why) = &p.failing {
|
||||
let _ = write!(b, "<p class=failing>{}</p>", esc(why));
|
||||
}
|
||||
let _ = write!(b, "<h3>{}</h3>", if l.podcast { "Latest episodes" } else { "Latest posts" });
|
||||
if p.items.is_empty() {
|
||||
b.push_str("<p class=hint>Nothing read from it yet. The directory checks each feed every six hours.</p>");
|
||||
}
|
||||
for e in &p.items {
|
||||
let title = e.title.as_deref().map(text).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled".into());
|
||||
let head = match e.link.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://")) {
|
||||
Some(u) => format!("<a href=\"{}\" rel=\"noopener nofollow\">{}</a>", esc(u), esc(&title)),
|
||||
None => esc(&title),
|
||||
};
|
||||
let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect();
|
||||
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let _ = write!(
|
||||
b,
|
||||
"<article><b>{head}</b>{}{}</article>",
|
||||
if blurb.is_empty() { String::new() } else { format!("<p>{}</p>", esc(&blurb)) },
|
||||
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") }
|
||||
);
|
||||
}
|
||||
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
|
||||
}
|
||||
|
||||
fn page(title: &str, description: Option<&str>, body: &str) -> Response {
|
||||
let full = if title == "iPX Directory" { title.to_owned() } else { format!("{title} | iPX Directory") };
|
||||
let desc = description
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(|d| d.chars().take(200).collect::<String>())
|
||||
.unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into());
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
|
||||
<title>{}</title><meta name=description content=\"{}\"><meta name=color-scheme content=\"light dark\">\
|
||||
<link rel=icon href=/favicon.png><link rel=stylesheet href=/site.css><script src=/site.js defer></script></head>\
|
||||
<body><header><a class=brand href=\"/\"><picture><source srcset=/logo-dark.svg media=\"(prefers-color-scheme: dark)\">\
|
||||
<img src=/logo.svg alt=\"\" width=32 height=32></picture>iPX Directory</a>\
|
||||
<form action=/search role=search><input type=search name=q placeholder=\"Find a feed\" aria-label=\"Find a feed by name\"></form></header>\
|
||||
<main>{body}</main><footer>Public podcasts and blogs that iPX servers share, and how many people on them subscribe. \
|
||||
Read only: to change what is here, an iPX server shares its public feeds.</footer></body></html>",
|
||||
esc(&full),
|
||||
esc(&desc),
|
||||
);
|
||||
let mut r = html.into_response();
|
||||
let h = r.headers_mut();
|
||||
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
|
||||
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP));
|
||||
h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
|
||||
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
r
|
||||
}
|
||||
|
||||
fn not_found(why: &str) -> Response {
|
||||
let mut r = page(
|
||||
"Not found",
|
||||
None,
|
||||
&format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)),
|
||||
);
|
||||
*r.status_mut() = StatusCode::NOT_FOUND;
|
||||
r
|
||||
}
|
||||
|
||||
fn failed(e: anyhow::Error) -> Response {
|
||||
tracing::error!(error = %format!("{e:#}"), "a directory page failed");
|
||||
let mut r = page(
|
||||
"Unavailable",
|
||||
None,
|
||||
"<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>",
|
||||
);
|
||||
*r.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
|
||||
r
|
||||
}
|
||||
|
||||
fn redirect(to: &'static str) -> Response {
|
||||
(StatusCode::SEE_OTHER, [(header::LOCATION, to)]).into_response()
|
||||
}
|
||||
|
||||
/// A cover from its publisher, over the feed's initials, which show where it has none or it fails.
|
||||
fn art(l: &Listing, class: &str) -> String {
|
||||
// The first letter of each of the first two words that have one: `<antirez>` is A, not nothing.
|
||||
let initials: String =
|
||||
name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
|
||||
let img = l
|
||||
.image
|
||||
.as_deref()
|
||||
.filter(|u| u.starts_with("https://") || u.starts_with("http://"))
|
||||
.map(|u| format!("<img src=\"{}\" alt=\"\" loading=lazy referrerpolicy=no-referrer>", esc(u)))
|
||||
.unwrap_or_default();
|
||||
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
|
||||
}
|
||||
|
||||
fn name(l: &Listing) -> &str {
|
||||
l.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&l.id)
|
||||
}
|
||||
|
||||
fn subscribers(n: i64) -> String {
|
||||
match n {
|
||||
0 => "Listed, no subscribers yet".into(),
|
||||
1 => "1 person subscribes".into(),
|
||||
n => format!("{n} people subscribe"),
|
||||
}
|
||||
}
|
||||
|
||||
fn plural(n: usize, word: &str) -> String {
|
||||
format!("{n} {word}{}", if n == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
/// `12 Oct 2026`, from a Unix time, with no date library: the civil-from-days algorithm.
|
||||
fn date(t: i64) -> String {
|
||||
let z = t.div_euclid(86_400) + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
format!("{d} {} {y}", MONTHS[(m - 1) as usize])
|
||||
}
|
||||
|
||||
fn clock(secs: i64) -> String {
|
||||
let (h, m) = (secs / 3600, secs % 3600 / 60);
|
||||
if h > 0 { format!("{h} h {m} min") } else { format!("{} min", m.max(1)) }
|
||||
}
|
||||
|
||||
/// The publisher's HTML as plain text: tags gone, entities read, whitespace collapsed. Escaped
|
||||
/// again where it is shown.
|
||||
fn text(html: &str) -> String {
|
||||
// A space where each tag was, or two paragraphs run together: "drives.For over".
|
||||
let stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
|
||||
let decoded = stripped
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&");
|
||||
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A category as a path segment.
|
||||
fn seg(s: &str) -> String {
|
||||
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_publishers_html_becomes_text_and_is_escaped_where_shown() {
|
||||
assert_eq!(text("<p>Tom & Jerry <script>alert(1)</script><b>live</b></p>"), "Tom & Jerry live");
|
||||
assert_eq!(text("<p>One.</p><p>Two.</p>"), "One. Two.");
|
||||
assert_eq!(esc(&text("a <b> c")), "a <b> c");
|
||||
assert_eq!(esc("\"><img onerror=x>"), ""><img onerror=x>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_and_lengths_read_as_people_write_them() {
|
||||
assert_eq!(date(0), "1 Jan 1970");
|
||||
assert_eq!(date(1_791_820_800), "12 Oct 2026");
|
||||
assert_eq!(date(951_782_400), "29 Feb 2000");
|
||||
assert_eq!(clock(59), "1 min");
|
||||
assert_eq!(clock(3_725), "1 h 2 min");
|
||||
}
|
||||
}
|
||||
695
crates/directory/src/store.rs
Normal file
695
crates/directory/src/store.rs
Normal file
@@ -0,0 +1,695 @@
|
||||
//! The directory's database: the feeds instances have sent, what each instance sent, and what
|
||||
//! an admin has blocked. SQL written by hand, as ipx's is, so it runs on SQLite (tests) and
|
||||
//! Postgres (production) both: `$1` parameters, `ON CONFLICT`, booleans as themselves.
|
||||
|
||||
use anyhow::Result;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait, Value};
|
||||
|
||||
use crate::{Blocked, FeedPage, Instance, Item, Listing, SnapshotEntry, SnapshotResult};
|
||||
use sea_orm::{ConnectOptions, Database, QueryResult};
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::time::SystemTime;
|
||||
use url::Url;
|
||||
|
||||
/// A feed's items as its page shows them, and as many as ipx's own Directory page showed.
|
||||
pub const ITEMS: usize = 20;
|
||||
|
||||
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
|
||||
const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
||||
|
||||
/// The most subscribers one instance may say a feed has.
|
||||
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
|
||||
|
||||
/// The directory's database: feeds, snapshots, blocks and when each sync ran.
|
||||
pub struct Store {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
/// What the refresher needs to ask for a feed again.
|
||||
pub struct Due {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub etag: Option<String>,
|
||||
pub last_modified: Option<String>,
|
||||
}
|
||||
|
||||
/// What a fetch found, to be stored.
|
||||
pub enum Refreshed {
|
||||
/// Unchanged since the last fetch.
|
||||
NotModified,
|
||||
/// Read anew, with the validators to send next time.
|
||||
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
||||
/// The fetch failed, and why, in words.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Unix seconds now.
|
||||
#[must_use]
|
||||
pub fn now() -> i64 {
|
||||
SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// The address as every instance would write it: scheme and host lower-cased, a default port
|
||||
/// and a fragment dropped. None for anything that is not http(s).
|
||||
#[must_use]
|
||||
pub fn normalise(url: &str) -> Option<String> {
|
||||
let mut u = Url::parse(url.trim()).ok()?;
|
||||
if !matches!(u.scheme(), "http" | "https") || u.host_str().is_none() {
|
||||
return None;
|
||||
}
|
||||
u.set_fragment(None);
|
||||
Some(u.to_string())
|
||||
}
|
||||
|
||||
/// A feed's id: the first 64 bits of its normalised address's SHA-256, so every instance, and
|
||||
/// anyone else, arrives at the same id for the same feed without asking.
|
||||
#[must_use]
|
||||
pub fn feed_id(normalised: &str) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(normalised.as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
const SCHEMA: &[&str] = &[
|
||||
"CREATE TABLE IF NOT EXISTS feeds (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
image TEXT,
|
||||
category TEXT,
|
||||
subcategory TEXT,
|
||||
description TEXT,
|
||||
podcast BOOLEAN NOT NULL DEFAULT false,
|
||||
items TEXT,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
checked_at BIGINT,
|
||||
last_error TEXT,
|
||||
error_since BIGINT
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS snapshots (
|
||||
instance TEXT NOT NULL,
|
||||
feed_id TEXT NOT NULL,
|
||||
subscribers BIGINT NOT NULL,
|
||||
category TEXT,
|
||||
subcategory TEXT,
|
||||
PRIMARY KEY (instance, feed_id)
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
||||
// When each periodic job last finished: Apple's weekly sync (#252).
|
||||
"CREATE TABLE IF NOT EXISTS syncs (
|
||||
name TEXT PRIMARY KEY,
|
||||
at BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS blocked (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
at BIGINT NOT NULL
|
||||
)",
|
||||
];
|
||||
|
||||
impl Store {
|
||||
/// A database of its own for a test: a fresh schema on IPX_TEST_DATABASE_URL's Postgres when
|
||||
/// that is set, as ipx's tests do, or else SQLite in memory.
|
||||
#[cfg(test)]
|
||||
pub async fn scratch() -> Self {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) else {
|
||||
return Self::open("sqlite::memory:", false).await.unwrap();
|
||||
};
|
||||
let (pid, n) = (std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
// A bare connection: Store::open would make the tables in the public schema, and the
|
||||
// tests running at once raced each other doing it.
|
||||
let admin = Self { db: sea_orm::Database::connect(url.as_str()).await.unwrap() };
|
||||
// Schemas an earlier run left behind: anything of ours not made by this process.
|
||||
let old = admin
|
||||
.rows(&format!(r"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'ipxd\_%' AND nspname NOT LIKE 'ipxd\_{pid}\_%'"), vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
for r in old {
|
||||
let name: String = r.try_get_by_index(0).unwrap();
|
||||
admin.db.execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")).await.unwrap();
|
||||
}
|
||||
let schema = format!("ipxd_{pid}_{n}");
|
||||
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
Self::open(
|
||||
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `keep_open` for the server, which keeps its whole pool connected.
|
||||
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
|
||||
let mut url = url.to_owned();
|
||||
if url.starts_with("postgres") && !url.contains("options=") {
|
||||
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
|
||||
// asking for none keeps them out of the log, as ipx's connections do.
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
url = format!("{url}{sep}options=-c%20client_min_messages%3Dwarning");
|
||||
}
|
||||
let mut opts = ConnectOptions::new(url.clone());
|
||||
opts.sqlx_logging(false);
|
||||
// An in-memory SQLite database is one per connection: a pool of them would be several.
|
||||
if url.contains(":memory:") {
|
||||
opts.max_connections(1);
|
||||
}
|
||||
// The server keeps every connection open, as ipx's daemon does: a Postgres connection takes
|
||||
// 80-330 ms to open (#241). Not a test, which would hold ten for one question.
|
||||
if keep_open && url.starts_with("postgres") {
|
||||
opts.max_connections(10).min_connections(10);
|
||||
}
|
||||
let db = Database::connect(opts).await?;
|
||||
for sql in SCHEMA {
|
||||
db.execute_unprepared(sql).await?;
|
||||
}
|
||||
// Added after the first deploy: a database made before has the table without them.
|
||||
if db.execute_unprepared("SELECT category FROM snapshots WHERE false").await.is_err() {
|
||||
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN category TEXT").await?;
|
||||
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN subcategory TEXT").await?;
|
||||
}
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
fn stmt(&self, sql: &str, values: Vec<Value>) -> Statement {
|
||||
Statement::from_sql_and_values(self.db.get_database_backend(), sql, values)
|
||||
}
|
||||
|
||||
async fn exec(&self, sql: &str, values: Vec<Value>) -> Result<u64> {
|
||||
Ok(self.db.execute_raw(self.stmt(sql, values)).await?.rows_affected())
|
||||
}
|
||||
|
||||
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<QueryResult>> {
|
||||
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
||||
}
|
||||
|
||||
/// When the job `name` last finished, Unix seconds.
|
||||
pub async fn synced_at(&self, name: &str) -> Result<Option<i64>> {
|
||||
Ok(match self.rows("SELECT at FROM syncs WHERE name = $1", vec![name.into()]).await?.first() {
|
||||
Some(r) => Some(r.try_get("", "at")?),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Records when the sync `name` (Apple's charts) last ran.
|
||||
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
||||
vec![name.into(), at.into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the database answers, for the healthcheck.
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
self.rows("SELECT 1", vec![]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces everything `instance` sent before with `entries`. A private-looking address or a
|
||||
/// blocked feed is left out and counted; the rest are added to the feeds, new ones to be
|
||||
/// fetched by the refresher. Feeds no instance sends any more go.
|
||||
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
|
||||
let blocked: HashSet<String> = self
|
||||
.rows("SELECT id FROM blocked", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "id"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut result = SnapshotResult::default();
|
||||
let mut keep = BTreeMap::new();
|
||||
for e in entries {
|
||||
let Some(url) = normalise(&e.url) else {
|
||||
result.refused_invalid += 1;
|
||||
continue;
|
||||
};
|
||||
// Checked here as well as by the sender: one instance's miss must not publish
|
||||
// someone's paid feed to every other.
|
||||
if ipx_feed::url_looks_private(&url) {
|
||||
result.refused_private += 1;
|
||||
continue;
|
||||
}
|
||||
let id = feed_id(&url);
|
||||
if blocked.contains(&id) {
|
||||
result.refused_blocked += 1;
|
||||
continue;
|
||||
}
|
||||
// The same feed twice in one snapshot counts once, at its larger number.
|
||||
// Bounded above as well: two instances' i64::MAX summed past BIGINT, and every listing
|
||||
// and page failed until the instance was dropped (#342). No server has a million.
|
||||
let n = e.subscribers.clamp(0, MAX_SUBSCRIBERS);
|
||||
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
|
||||
let slot = keep.entry(id).or_insert((url, n, path.clone()));
|
||||
slot.1 = slot.1.max(n);
|
||||
if slot.2.is_none() {
|
||||
slot.2 = path;
|
||||
}
|
||||
}
|
||||
result.accepted = keep.len();
|
||||
|
||||
let tx = self.db.begin().await?;
|
||||
let backend = tx.get_database_backend();
|
||||
let run = |sql: &str, values: Vec<Value>| Statement::from_sql_and_values(backend, sql, values);
|
||||
tx.execute_raw(run("DELETE FROM snapshots WHERE instance = $1", vec![instance.into()])).await?;
|
||||
for (id, (url, n, path)) in &keep {
|
||||
tx.execute_raw(run(
|
||||
"INSERT INTO feeds (id, url) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
|
||||
vec![id.clone().into(), url.clone().into()],
|
||||
))
|
||||
.await?;
|
||||
tx.execute_raw(run(
|
||||
"INSERT INTO snapshots (instance, feed_id, subscribers, category, subcategory) VALUES ($1, $2, $3, $4, $5)",
|
||||
vec![
|
||||
instance.into(),
|
||||
id.clone().into(),
|
||||
(*n).into(),
|
||||
path.as_ref().map(|p| p.0.clone()).into(),
|
||||
path.as_ref().and_then(|p| p.1.clone()).into(),
|
||||
],
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
tx.execute_raw(run("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![])).await?;
|
||||
tx.commit().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Drops the snapshots of every instance not in `names`: a token taken out of the config
|
||||
/// file takes its counts with it.
|
||||
pub async fn keep_instances(&self, names: &[String]) -> Result<u64> {
|
||||
let mut dropped = 0;
|
||||
let present: Vec<String> = self
|
||||
.rows("SELECT DISTINCT instance FROM snapshots", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "instance"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
// Apple's charts are no token's, and stay until the next sync replaces them.
|
||||
let gone = present.iter().filter(|n| !names.contains(n) && n.as_str() != crate::apple::INSTANCE);
|
||||
for name in gone {
|
||||
dropped += self.drop_instance(name).await?;
|
||||
}
|
||||
Ok(dropped)
|
||||
}
|
||||
|
||||
/// Drops what one instance sent; how many feeds it had.
|
||||
pub async fn drop_instance(&self, name: &str) -> Result<u64> {
|
||||
let n = self.exec("DELETE FROM snapshots WHERE instance = $1", vec![name.into()]).await?;
|
||||
self.exec("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![]).await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// The feed's own category, or else one a sending server's admin gave it.
|
||||
// Only feeds read as a feed at least once (`items` set): never an OPML a server sent before it
|
||||
// knew it was one, nor an address that has never worked.
|
||||
const LISTING: &str = "SELECT f.id, f.url, f.title, f.image,
|
||||
CASE WHEN f.category IS NULL THEN max(s.category) ELSE f.category END AS category,
|
||||
CASE WHEN f.category IS NULL THEN max(s.subcategory) ELSE f.subcategory END AS subcategory,
|
||||
f.podcast, CAST(sum(s.subscribers) AS BIGINT) AS subscribers
|
||||
FROM feeds f JOIN snapshots s ON s.feed_id = f.id WHERE f.items IS NOT NULL";
|
||||
|
||||
fn listing(r: &QueryResult) -> Result<Listing> {
|
||||
Ok(Listing {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
title: r.try_get("", "title")?,
|
||||
image: r.try_get("", "image")?,
|
||||
category: r.try_get("", "category")?,
|
||||
subcategory: r.try_get("", "subcategory")?,
|
||||
podcast: r.try_get("", "podcast")?,
|
||||
subscribers: r.try_get("", "subscribers")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every feed, A to Z by title (one not fetched yet has none and sorts by its address).
|
||||
///
|
||||
/// ponytail: the whole list in one answer, as ipx's own Directory was; page it when it
|
||||
/// grows past a few thousand feeds.
|
||||
pub async fn list(&self) -> Result<Vec<Listing>> {
|
||||
let sql = format!("{} GROUP BY f.id ORDER BY lower(coalesce(f.title, f.url)), f.id", Self::LISTING);
|
||||
self.rows(&sql, vec![]).await?.iter().map(Self::listing).collect()
|
||||
}
|
||||
|
||||
/// The most subscribed, most first.
|
||||
pub async fn popular(&self, limit: u64) -> Result<Vec<Listing>> {
|
||||
let sql = format!(
|
||||
"{} GROUP BY f.id ORDER BY sum(s.subscribers) DESC, lower(coalesce(f.title, f.url)), f.id LIMIT $1",
|
||||
Self::LISTING
|
||||
);
|
||||
self.rows(&sql, vec![(limit as i64).into()]).await?.iter().map(Self::listing).collect()
|
||||
}
|
||||
|
||||
/// One feed with its description and latest items.
|
||||
pub async fn feed(&self, id: &str) -> Result<Option<FeedPage>> {
|
||||
let sql = format!("{} AND f.id = $1 GROUP BY f.id", Self::LISTING);
|
||||
let Some(r) = self.rows(&sql, vec![id.into()]).await?.into_iter().next() else { return Ok(None) };
|
||||
let listing = Self::listing(&r)?;
|
||||
let r = self
|
||||
.rows("SELECT description, items, last_error, error_since FROM feeds WHERE id = $1", vec![id.into()])
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("feed {id} went while it was read"))?;
|
||||
let items: Option<String> = r.try_get("", "items")?;
|
||||
let last_error: Option<String> = r.try_get("", "last_error")?;
|
||||
let error_since: Option<i64> = r.try_get("", "error_since")?;
|
||||
Ok(Some(FeedPage {
|
||||
listing,
|
||||
description: r.try_get("", "description")?,
|
||||
failing: last_error.map(|e| failure_words(&e, now() - error_since.unwrap_or_else(now))),
|
||||
items: items.and_then(|i| serde_json::from_str(&i).ok()).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Removes a feed and blocks it, so no snapshot brings it back. False when it was neither
|
||||
/// listed nor blocked already.
|
||||
pub async fn block(&self, id: &str) -> Result<bool> {
|
||||
let r = self.rows("SELECT url, title FROM feeds WHERE id = $1", vec![id.into()]).await?.into_iter().next();
|
||||
let (url, title): (Option<String>, Option<String>) = match &r {
|
||||
Some(r) => (r.try_get("", "url")?, r.try_get("", "title")?),
|
||||
None => (None, None),
|
||||
};
|
||||
let added = self
|
||||
.exec(
|
||||
"INSERT INTO blocked (id, url, title, at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
|
||||
vec![id.into(), url.into(), title.into(), now().into()],
|
||||
)
|
||||
.await?;
|
||||
self.exec("DELETE FROM snapshots WHERE feed_id = $1", vec![id.into()]).await?;
|
||||
let removed = self.exec("DELETE FROM feeds WHERE id = $1", vec![id.into()]).await?;
|
||||
Ok(added + removed > 0)
|
||||
}
|
||||
|
||||
/// Unblocks a feed; it comes back with the next snapshot that has it.
|
||||
pub async fn unblock(&self, id: &str) -> Result<bool> {
|
||||
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
|
||||
}
|
||||
|
||||
/// Each instance that has sent a snapshot, with how many feeds and people it counted.
|
||||
pub async fn instances(&self) -> Result<Vec<Instance>> {
|
||||
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
|
||||
self.rows(
|
||||
"SELECT instance, count(*) AS feeds, CAST(sum(subscribers) AS BIGINT) AS subscribers
|
||||
FROM snapshots GROUP BY instance ORDER BY instance",
|
||||
vec![],
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(Instance {
|
||||
name: r.try_get("", "instance")?,
|
||||
feeds: r.try_get("", "feeds")?,
|
||||
subscribers: r.try_get("", "subscribers")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The feeds an admin has blocked.
|
||||
pub async fn blocked(&self) -> Result<Vec<Blocked>> {
|
||||
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(Blocked {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
title: r.try_get("", "title")?,
|
||||
at: r.try_get("", "at")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Feeds never fetched, then those fetched longest ago, older than `every` seconds.
|
||||
pub async fn due(&self, every: i64, limit: u64) -> Result<Vec<Due>> {
|
||||
self.rows(
|
||||
"SELECT id, url, etag, last_modified FROM feeds
|
||||
WHERE checked_at IS NULL OR checked_at < $1
|
||||
ORDER BY coalesce(checked_at, 0), id LIMIT $2",
|
||||
vec![(now() - every).into(), (limit as i64).into()],
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(Due {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
etag: r.try_get("", "etag")?,
|
||||
last_modified: r.try_get("", "last_modified")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One feed, due or not, for an admin's Refresh.
|
||||
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||
.await?
|
||||
.first()
|
||||
.map(|r| -> Result<Due> {
|
||||
Ok(Due {
|
||||
id: r.try_get("", "id")?,
|
||||
url: r.try_get("", "url")?,
|
||||
etag: r.try_get("", "etag")?,
|
||||
last_modified: r.try_get("", "last_modified")?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Stores what a fetch of feed `id` found, and when.
|
||||
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
||||
let t = now();
|
||||
match got {
|
||||
Refreshed::NotModified => {
|
||||
self.exec(
|
||||
"UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2",
|
||||
vec![t.into(), id.into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Refreshed::Failed(err) => {
|
||||
self.exec(
|
||||
"UPDATE feeds SET checked_at = $1, last_error = $2, error_since = coalesce(error_since, $1) WHERE id = $3",
|
||||
vec![t.into(), err.into(), id.into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Refreshed::Feed { parsed, etag, last_modified } => {
|
||||
let path = parsed.category.as_deref().map(ipx_feed::category_path);
|
||||
let podcast = parsed
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|e| &e.enclosures)
|
||||
.any(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/")));
|
||||
let mut entries = parsed.entries;
|
||||
entries.sort_by_key(|e| Reverse(e.published));
|
||||
// What an item is, never its files: their addresses are where a key would be.
|
||||
let items: Vec<Item> = entries
|
||||
.into_iter()
|
||||
.take(ITEMS)
|
||||
.map(|e| Item {
|
||||
guid: e.guid,
|
||||
title: e.title,
|
||||
link: e.link,
|
||||
published: e.published,
|
||||
duration: e.duration,
|
||||
description: e.description,
|
||||
})
|
||||
.collect();
|
||||
self.exec(
|
||||
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
|
||||
podcast = $6, items = $7, etag = $8, last_modified = $9, checked_at = $10,
|
||||
last_error = NULL, error_since = NULL
|
||||
WHERE id = $11",
|
||||
vec![
|
||||
parsed.title.into(),
|
||||
parsed.image.into(),
|
||||
path.as_ref().map(|p| p.0.clone()).into(),
|
||||
path.and_then(|p| p.1).into(),
|
||||
parsed.description.into(),
|
||||
podcast.into(),
|
||||
serde_json::to_string(&items)?.into(),
|
||||
etag.into(),
|
||||
last_modified.into(),
|
||||
t.into(),
|
||||
id.into(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A feed's last error as its page says it: explained once it has been failing a day, or else
|
||||
/// only that the check failed. Never the error itself, which is for the log.
|
||||
fn failure_words(err: &str, failing_for: i64) -> String {
|
||||
(failing_for >= FLAG_AFTER_SECS)
|
||||
.then_some(err)
|
||||
.and_then(ipx_feed::explain_failure)
|
||||
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Reads the fixture feed as every feed waiting to be fetched, as the refresher would.
|
||||
pub(crate) async fn fetch_all(s: &Store) {
|
||||
for d in s.due(0, 100).await.unwrap() {
|
||||
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn e(url: &str, n: i64) -> SnapshotEntry {
|
||||
SnapshotEntry { url: url.into(), subscribers: n, category: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_is_explained_after_a_day_and_never_names_its_address() {
|
||||
let nothing = "the feed's address answered with nothing at all";
|
||||
assert_eq!(failure_words(nothing, FLAG_AFTER_SECS), "This address answers with nothing; the site may be gone.");
|
||||
// Not before it has been failing a day, as on the feed's own page in ipx (#143).
|
||||
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
|
||||
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
|
||||
assert_eq!(failure_words(raw, FLAG_AFTER_SECS), "Its last check failed.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_instance_writes_one_feed_the_same_way() {
|
||||
let a = normalise("HTTPS://Example.COM:443/feed.xml#top").unwrap();
|
||||
assert_eq!(a, "https://example.com/feed.xml");
|
||||
assert_eq!(feed_id(&a), feed_id(&normalise("https://example.com/feed.xml").unwrap()));
|
||||
assert_eq!(feed_id(&a).len(), 16);
|
||||
assert!(normalise("ftp://example.com/x").is_none());
|
||||
assert!(normalise("not a url").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_count_an_instance_sends_breaks_the_listing() {
|
||||
let s = Store::scratch().await;
|
||||
s.snapshot("a", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||
s.snapshot("b", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||
fetch_all(&s).await;
|
||||
assert_eq!(s.list().await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS, "summed, not overflowed (#342)");
|
||||
assert_eq!(s.popular(10).await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS);
|
||||
assert!(s.instances().await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
|
||||
let s = Store::scratch().await;
|
||||
let r = s
|
||||
.snapshot("a", &[e("https://x.test/a.xml", 3), e("https://x.test/b.xml", 1), e("https://x.test/a.xml", 5)])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.accepted, 2);
|
||||
s.snapshot("b", &[e("https://x.test/a.xml", 2)]).await.unwrap();
|
||||
// Nothing is listed until it has been read as a feed.
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
fetch_all(&s).await;
|
||||
let pop = s.popular(10).await.unwrap();
|
||||
assert_eq!((pop[0].url.as_str(), pop[0].subscribers), ("https://x.test/a.xml", 7));
|
||||
assert_eq!(pop[1].subscribers, 1);
|
||||
|
||||
// a no longer has b.xml: nobody does, so it goes.
|
||||
s.snapshot("a", &[e("https://x.test/a.xml", 1)]).await.unwrap();
|
||||
let all = s.list().await.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].subscribers, 3);
|
||||
|
||||
let i = s.instances().await.unwrap();
|
||||
assert_eq!(
|
||||
i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(),
|
||||
[("a", 1, 1), ("b", 1, 2)]
|
||||
);
|
||||
|
||||
// A token taken out of the config file takes its counts.
|
||||
s.keep_instances(&["b".into()]).await.unwrap();
|
||||
assert_eq!(s.list().await.unwrap()[0].subscribers, 2);
|
||||
s.drop_instance("b").await.unwrap();
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_looking_addresses_and_blocked_feeds_are_refused() {
|
||||
let s = Store::scratch().await;
|
||||
let r = s
|
||||
.snapshot(
|
||||
"a",
|
||||
&[
|
||||
e("https://feeds.supercast.com/feeds/abc123", 1),
|
||||
e("https://x.test/feed?token=secret", 1),
|
||||
e("https://user:pass@x.test/feed", 1),
|
||||
e("https://www.patreon.com/rss/show?auth=k", 1),
|
||||
e("mailto:x@y", 1),
|
||||
e("https://x.test/public.xml", 1),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((r.accepted, r.refused_private, r.refused_invalid), (1, 4, 1));
|
||||
fetch_all(&s).await;
|
||||
|
||||
let id = s.list().await.unwrap()[0].id.clone();
|
||||
assert!(s.block(&id).await.unwrap());
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
let r = s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap();
|
||||
assert_eq!((r.accepted, r.refused_blocked), (0, 1));
|
||||
assert_eq!(s.blocked().await.unwrap()[0].url.as_deref(), Some("https://x.test/public.xml"));
|
||||
assert!(s.unblock(&id).await.unwrap());
|
||||
assert_eq!(s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap().accepted, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
|
||||
let s = Store::scratch().await;
|
||||
let entry = |c: Option<&str>| SnapshotEntry {
|
||||
url: "https://x.test/blog.xml".into(),
|
||||
subscribers: 1,
|
||||
category: c.map(Into::into),
|
||||
};
|
||||
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
|
||||
let d = s.due(0, 10).await.unwrap().remove(0);
|
||||
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
parsed.category = None;
|
||||
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
|
||||
let l = s.list().await.unwrap().remove(0);
|
||||
assert_eq!((l.category.as_deref(), l.subcategory.as_deref()), (Some("Arts"), Some("Visual Arts")));
|
||||
s.snapshot("a", &[entry(None)]).await.unwrap();
|
||||
assert_eq!(s.list().await.unwrap()[0].category, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetched_feed_shows_its_metadata_and_newest_items_but_no_files() {
|
||||
let s = Store::scratch().await;
|
||||
s.snapshot("a", &[e("https://x.test/feed.xml", 1)]).await.unwrap();
|
||||
let due = s.due(3600, 10).await.unwrap();
|
||||
assert_eq!(due.len(), 1);
|
||||
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
|
||||
let title = parsed.title.clone();
|
||||
s.save(&due[0].id, Refreshed::Feed { parsed, etag: Some("e".into()), last_modified: None }).await.unwrap();
|
||||
assert!(s.due(3600, 10).await.unwrap().is_empty());
|
||||
let page = s.feed(&due[0].id).await.unwrap().unwrap();
|
||||
assert_eq!(page.listing.title, title);
|
||||
assert!(page.listing.podcast);
|
||||
assert!(!page.items.is_empty() && page.items.len() <= ITEMS);
|
||||
assert!(page.items.windows(2).all(|w| w[0].published >= w[1].published));
|
||||
assert!(page.failing.is_none());
|
||||
let json = serde_json::to_string(&page).unwrap();
|
||||
assert!(!json.contains(".mp3"), "an item's file must not be in its page: {json}");
|
||||
|
||||
s.save(&due[0].id, Refreshed::Failed("HTTP 404 Not Found".into())).await.unwrap();
|
||||
let page = s.feed(&due[0].id).await.unwrap().unwrap();
|
||||
assert_eq!(page.failing.as_deref(), Some("Its last check failed."));
|
||||
assert_eq!(page.listing.title, title, "a failure keeps what was found before");
|
||||
}
|
||||
}
|
||||
99
crates/directory/src/wire.rs
Normal file
99
crates/directory/src/wire.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! What ipx servers and the directory send each other: listings, feed pages, snapshots and their results.
|
||||
|
||||
use crate::config::Access;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A feed as the directory lists it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Listing {
|
||||
/// The first 64 bits of the SHA-256 of its normalised address (`store::feed_id`).
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub image: Option<String>,
|
||||
/// Apple's category and the subcategory within it, from the feed's own iTunes category.
|
||||
pub category: Option<String>,
|
||||
pub subcategory: Option<String>,
|
||||
/// Any audio or video in its latest items.
|
||||
pub podcast: bool,
|
||||
/// Summed over every instance's latest snapshot.
|
||||
pub subscribers: i64,
|
||||
}
|
||||
|
||||
/// One feed's page: what it says it is, and its latest items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeedPage {
|
||||
#[serde(flatten)]
|
||||
pub listing: Listing,
|
||||
/// The feed's own text, HTML as often as not, as it was sent: whoever shows it sanitizes it.
|
||||
pub description: Option<String>,
|
||||
/// Why its last check failed, in words, when it did.
|
||||
pub failing: Option<String>,
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// One of a feed's latest items: what it is, never its files.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
pub guid: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
/// Unix seconds.
|
||||
pub published: Option<i64>,
|
||||
/// Seconds.
|
||||
pub duration: Option<i64>,
|
||||
/// As the feed sent it, unsanitized.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// One feed in a snapshot: its address, and how many people on the sending server subscribe.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SnapshotEntry {
|
||||
pub url: String,
|
||||
pub subscribers: i64,
|
||||
/// The category the sending server's admin gave a feed that names none itself: used only
|
||||
/// while the feed names none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// What became of a snapshot's entries.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SnapshotResult {
|
||||
pub accepted: usize,
|
||||
/// Addresses that look like they carry a subscriber's own access.
|
||||
pub refused_private: usize,
|
||||
/// Feeds an admin removed from the directory.
|
||||
pub refused_blocked: usize,
|
||||
/// Not an http(s) address.
|
||||
pub refused_invalid: usize,
|
||||
}
|
||||
|
||||
/// Who a token belongs to and what it may do: `/v1/me`'s answer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Me {
|
||||
pub name: String,
|
||||
pub access: Access,
|
||||
}
|
||||
|
||||
/// A token whose snapshot the directory holds: how many feeds it gave, and how many people.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub name: String,
|
||||
pub feeds: i64,
|
||||
pub subscribers: i64,
|
||||
}
|
||||
|
||||
/// A feed an admin removed from the directory, which snapshots may not bring back.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Blocked {
|
||||
pub id: String,
|
||||
pub url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
/// When it was blocked, Unix seconds.
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
/// The most a snapshot may list: far more than one server's subscriptions, few enough that one
|
||||
/// request cannot hold the database for long.
|
||||
pub const MAX_SNAPSHOT: usize = 20_000;
|
||||
27
crates/feed/Cargo.toml
Normal file
27
crates/feed/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "ipx-feed"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
atom_syndication.workspace = true
|
||||
chrono.workspace = true
|
||||
http.workspace = true
|
||||
hyper-util.workspace = true
|
||||
opml.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
rss.workspace = true
|
||||
serde_json.workspace = true
|
||||
tower.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
117
crates/feed/src/artwork.rs
Normal file
117
crates/feed/src/artwork.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Artwork: on https when its host serves it there, and a site icon where a feed has none.
|
||||
|
||||
use crate::{discover, fetch};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Artwork for a feed that has none: the icon its site's page names, or else the site's
|
||||
/// `/favicon.ico`. None if neither is there.
|
||||
#[tracing::instrument(skip_all, fields(site = site))]
|
||||
pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
||||
let timeout = Duration::from_secs(20);
|
||||
let resp = client.get(site).timeout(timeout).send().await.ok()?;
|
||||
// Relative to where the page ended up, not where it was asked for: a site that redirects
|
||||
// to /en/ would otherwise have its icon looked for in the wrong place.
|
||||
let base = resp.url().clone();
|
||||
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
|
||||
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
|
||||
if resp.status().is_success()
|
||||
&& let Ok(page) = fetch::read_capped(resp).await
|
||||
&& let Some(href) = page_icon(&page)
|
||||
&& let Ok(url) = base.join(&href)
|
||||
&& is_image(client, url.as_str()).await
|
||||
{
|
||||
return Some(url.into());
|
||||
}
|
||||
let ico = base.join("/favicon.ico").ok()?;
|
||||
is_image(client, ico.as_str()).await.then(|| ico.into())
|
||||
}
|
||||
|
||||
/// How many hosts one scan of a feed asks whether its artwork is on https.
|
||||
pub(crate) const MAX_ART_HOSTS: usize = 8;
|
||||
|
||||
/// Artwork's address on https when its host serves it there, else as it was. The page is https
|
||||
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
|
||||
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
|
||||
/// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN
|
||||
/// presents another name's certificate (#110).
|
||||
pub async fn prefer_https(client: &reqwest::Client, url: &str, known: &mut HashMap<String, bool>) -> String {
|
||||
let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() };
|
||||
let host = rest.split('/').next().unwrap_or("").to_owned();
|
||||
let secure = format!("https://{rest}");
|
||||
let ok = match known.get(&host) {
|
||||
Some(ok) => *ok,
|
||||
// Each new host is a probe of up to 20 s, one after another on the scan, and the feed's
|
||||
// author decides how many hosts there are: ports and user names make as many as they like
|
||||
// of one server (#339). Real feeds put their artwork on a few.
|
||||
None if known.len() >= MAX_ART_HOSTS => false,
|
||||
None => {
|
||||
let ok = is_image(client, &secure).await;
|
||||
known.insert(host, ok);
|
||||
ok
|
||||
}
|
||||
};
|
||||
if ok { secure } else { url.to_owned() }
|
||||
}
|
||||
|
||||
/// Whether `url` answers with an image. A site with no favicon often answers 200 with its home
|
||||
/// page, which is not an icon.
|
||||
pub async fn is_image(client: &reqwest::Client, url: &str) -> bool {
|
||||
let timeout = Duration::from_secs(20);
|
||||
let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false };
|
||||
resp.status().is_success()
|
||||
&& resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|t| t.starts_with("image/"))
|
||||
}
|
||||
|
||||
/// The icon a web page names in its `<link>` tags, the larger apple-touch-icon first: a plain
|
||||
/// `icon` is often 16 pixels, which blurs at the size the list draws artwork.
|
||||
pub(crate) fn page_icon(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
// ASCII only, so byte offsets in the lowered copy stay valid in the original.
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let (mut touch, mut icon) = (None, None);
|
||||
let mut pos = 0;
|
||||
while let Some(at) = lower[pos..].find("<link") {
|
||||
let start = pos + at;
|
||||
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
|
||||
pos = end + 1;
|
||||
let tag = &text[start..end];
|
||||
let Some(rel) = discover::tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
|
||||
let rels: Vec<&str> = rel.split_whitespace().collect();
|
||||
if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) {
|
||||
touch = discover::tag_attr(tag, "href");
|
||||
} else if icon.is_none() && rels.contains(&"icon") {
|
||||
icon = discover::tag_attr(tag, "href");
|
||||
}
|
||||
}
|
||||
touch.or(icon).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn artwork_on_more_hosts_than_the_limit_is_not_asked_about() {
|
||||
let mut known: std::collections::HashMap<String, bool> =
|
||||
(0..MAX_ART_HOSTS).map(|n| (format!("h{n}:80"), false)).collect();
|
||||
// No server answers here: were it asked, this would wait on a connection, not return.
|
||||
let url = "http://192.0.2.1:9/a.png";
|
||||
assert_eq!(prefer_https(&reqwest::Client::new(), url, &mut known).await, url);
|
||||
assert_eq!(known.len(), MAX_ART_HOSTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_icon_prefers_the_touch_icon() {
|
||||
let page = br#"<head><link rel="stylesheet" href="/a.css">
|
||||
<link rel="shortcut icon" href="/fav.ico">
|
||||
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
|
||||
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
|
||||
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
|
||||
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
|
||||
}
|
||||
}
|
||||
125
crates/feed/src/category.rs
Normal file
125
crates/feed/src/category.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! Apple Podcasts' categories, and where a feed's category sits among them (#118).
|
||||
|
||||
/// Apple's podcast categories, each with its subcategories
|
||||
/// (https://podcasters.apple.com/support/1691-apple-podcasts-categories): the list a podcast's own
|
||||
/// `<itunes:category>` comes from, and a fixed one, so the Directory's categories stay few.
|
||||
pub(crate) const CATEGORIES: &[(&str, &[&str])] = &[
|
||||
("Arts", &["Books", "Design", "Fashion & Beauty", "Food", "Performing Arts", "Visual Arts"]),
|
||||
("Business", &["Careers", "Entrepreneurship", "Investing", "Management", "Marketing", "Non-Profit"]),
|
||||
("Comedy", &["Comedy Interviews", "Improv", "Stand-Up"]),
|
||||
("Education", &["Courses", "How To", "Language Learning", "Self-Improvement"]),
|
||||
("Fiction", &["Comedy Fiction", "Drama", "Science Fiction"]),
|
||||
("Government", &[]),
|
||||
("History", &[]),
|
||||
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
|
||||
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
|
||||
(
|
||||
"Leisure",
|
||||
&["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"],
|
||||
),
|
||||
("Music", &["Music Commentary", "Music History", "Music Interviews"]),
|
||||
(
|
||||
"News",
|
||||
&[
|
||||
"Business News",
|
||||
"Daily News",
|
||||
"Entertainment News",
|
||||
"News Commentary",
|
||||
"Politics",
|
||||
"Sports News",
|
||||
"Tech News",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Religion & Spirituality",
|
||||
&["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"],
|
||||
),
|
||||
(
|
||||
"Science",
|
||||
&[
|
||||
"Astronomy",
|
||||
"Chemistry",
|
||||
"Earth Sciences",
|
||||
"Life Sciences",
|
||||
"Mathematics",
|
||||
"Natural Sciences",
|
||||
"Nature",
|
||||
"Physics",
|
||||
"Social Sciences",
|
||||
],
|
||||
),
|
||||
("Society & Culture", &["Documentary", "Personal Journals", "Philosophy", "Places & Travel", "Relationships"]),
|
||||
(
|
||||
"Sports",
|
||||
&[
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Cricket",
|
||||
"Fantasy Sports",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Hockey",
|
||||
"Rugby",
|
||||
"Running",
|
||||
"Soccer",
|
||||
"Swimming",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wilderness",
|
||||
"Wrestling",
|
||||
],
|
||||
),
|
||||
("Technology", &[]),
|
||||
("True Crime", &[]),
|
||||
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
||||
];
|
||||
|
||||
/// Names that are not in Apple's list but mean one that is (#125). Jev picked from this list when
|
||||
/// it spelt Football and Soccer its own way, and stored those answers; Software How-To is Apple's
|
||||
/// Technology subcategory from before its 2019 list, which older feeds still name.
|
||||
pub(crate) const ALIASES: &[(&str, &str)] =
|
||||
&[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")];
|
||||
|
||||
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
||||
#[must_use]
|
||||
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
||||
CATEGORIES
|
||||
}
|
||||
|
||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||
#[must_use]
|
||||
pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
|
||||
let name = name.trim().trim_end_matches([',', ';', '.']).trim_end();
|
||||
let name = ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name)).map_or(name, |(_, now)| now);
|
||||
for (category, subs) in CATEGORIES {
|
||||
if category.eq_ignore_ascii_case(name) {
|
||||
return (category.to_string(), None);
|
||||
}
|
||||
if let Some(sub) = subs.iter().find(|s| s.eq_ignore_ascii_case(name)) {
|
||||
return (category.to_string(), Some(sub.to_string()));
|
||||
}
|
||||
}
|
||||
(name.to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn category_path_finds_a_subcategory_s_category() {
|
||||
use super::category_path;
|
||||
assert_eq!(category_path("Tech News"), ("News".into(), Some("Tech News".into())));
|
||||
assert_eq!(category_path("video games"), ("Leisure".into(), Some("Video Games".into())), "Apple's spelling");
|
||||
assert_eq!(category_path("Technology"), ("Technology".into(), None));
|
||||
assert_eq!(category_path("Homebrew"), ("Homebrew".into(), None), "not Apple's: its own category");
|
||||
// Apple's spelling, an old answer of Jev's, a stray comma and Apple's old list (#125).
|
||||
assert_eq!(category_path("Soccer"), ("Sports".into(), Some("Soccer".into())));
|
||||
assert_eq!(category_path("Football (Soccer)"), ("Sports".into(), Some("Soccer".into())));
|
||||
assert_eq!(category_path("Soccer,"), ("Sports".into(), Some("Soccer".into())));
|
||||
assert_eq!(category_path("Football"), ("Sports".into(), Some("Football".into())));
|
||||
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
|
||||
}
|
||||
}
|
||||
107
crates/feed/src/discover.rs
Normal file
107
crates/feed/src/discover.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Finding a feed from what someone typed: a feed address, a site, or a page linking one.
|
||||
|
||||
use crate::{fetch, outlines, parse};
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 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. An address with no scheme is https: 'cnn.com'
|
||||
/// was stored as typed and every check failed with "relative URL without a base" (#101).
|
||||
#[must_use]
|
||||
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 if let Some(rest) = s.strip_prefix("//") {
|
||||
format!("https://{rest}")
|
||||
} else if !s.contains("://") {
|
||||
format!("https://{s}")
|
||||
} else {
|
||||
s.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
// ASCII only: to_lowercase changes some characters' length (U+0130 grows a byte), and the
|
||||
// offsets found in the lowered copy then sliced the original off a char boundary.
|
||||
let lower = text.to_ascii_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
|
||||
}
|
||||
|
||||
/// What someone asked to add, as a feed: the address itself when it is a feed or an OPML list,
|
||||
/// otherwise the feed its web page links as its own, otherwise an error and nothing is added.
|
||||
/// A page that linked no feed used to be added as it was and failed on every check, called a
|
||||
/// feed that moved (#102); cnn.com is one.
|
||||
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
||||
let read = |u: String| async move {
|
||||
let resp = client.get(&u).timeout(Duration::from_secs(30)).send().await.context("connecting")?;
|
||||
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
|
||||
let base = resp.url().clone();
|
||||
anyhow::Ok((base, fetch::read_capped(resp).await.context("reading")?))
|
||||
};
|
||||
let is_feed = |b: &[u8]| outlines::is_opml(b) || parse::parse(b).is_ok();
|
||||
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
|
||||
if is_feed(&body) {
|
||||
return Ok(url.to_owned());
|
||||
}
|
||||
if !parse::looks_like_html(&body) {
|
||||
let why = parse::parse(&body).err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||
anyhow::bail!("{url} is not a feed: {why}");
|
||||
}
|
||||
let Some(href) = alternate_feed_link(&body) else {
|
||||
anyhow::bail!("{url} is a web page that links no feed, so there is nothing to subscribe to");
|
||||
};
|
||||
let linked =
|
||||
base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
|
||||
let (_, feed) = read(linked.to_string()).await.with_context(|| format!("{url} links {linked} as its feed, but"))?;
|
||||
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
|
||||
Ok(linked.into())
|
||||
}
|
||||
|
||||
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
||||
pub(crate) 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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feed_link_after_non_ascii_text_is_found() {
|
||||
// U+0130 lowercases to three bytes from two, which once shifted every offset after it.
|
||||
let page = "<html><title>\u{130}stanbul \u{130}\u{130}</title>\
|
||||
<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/feed.xml\">";
|
||||
assert_eq!(alternate_feed_link(page.as_bytes()).as_deref(), Some("/feed.xml"));
|
||||
}
|
||||
}
|
||||
145
crates/feed/src/failure.rs
Normal file
145
crates/feed/src/failure.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! A failed read, in words for whoever subscribes, and as a kind for the log.
|
||||
|
||||
/// 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 {
|
||||
/// What to tell them, in a sentence.
|
||||
pub reason: &'static str,
|
||||
/// Where the feed went, when the error says.
|
||||
pub new_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as
|
||||
/// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the
|
||||
/// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind.
|
||||
#[must_use]
|
||||
pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
||||
let low = msg.to_ascii_lowercase();
|
||||
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().ok());
|
||||
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
|
||||
return (c.to_string(), Some(c));
|
||||
}
|
||||
let kind = if low.contains("dns error")
|
||||
|| low.contains("failed to lookup address")
|
||||
|| low.contains("no address associated")
|
||||
{
|
||||
"dns"
|
||||
} else if low.contains("too many redirects") {
|
||||
"redirect_loop"
|
||||
} else if low.contains("timed out") || low.contains("timeout") {
|
||||
"timeout"
|
||||
} else if low.contains("certificate") || low.contains("tls") {
|
||||
"tls"
|
||||
} else if low.contains("got a web page") {
|
||||
"not_a_feed"
|
||||
} else if low.contains("the site sent ") {
|
||||
"site_message"
|
||||
} else if low.contains("connect") {
|
||||
"connect"
|
||||
} else if low.contains("pars") {
|
||||
"parse"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
(kind.into(), None)
|
||||
}
|
||||
|
||||
/// 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("answered with nothing") {
|
||||
return Some(Failure { reason: "This address answers with nothing; the site may be gone.", new_url: None });
|
||||
}
|
||||
if low.contains("http 402") {
|
||||
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
|
||||
}
|
||||
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
|
||||
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|
||||
if !is_temporary_lookup_failure(msg)
|
||||
&& (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 may be gone.", new_url: None });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A host that could not be looked up for the moment -- the resolver's EAI_AGAIN, glibc's
|
||||
/// "Temporary failure in name resolution", musl's "Try again" -- as against a name that does
|
||||
/// not exist ("Name or service not known"). It says the network was not answering, not that the
|
||||
/// feed is gone (#143).
|
||||
#[must_use]
|
||||
pub fn is_temporary_lookup_failure(msg: &str) -> bool {
|
||||
let low = msg.to_ascii_lowercase();
|
||||
low.contains("dns error") && (low.contains("temporary failure in name resolution") || low.contains("try again"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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.");
|
||||
assert_eq!(
|
||||
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
||||
"This address answers with nothing; the site may be gone."
|
||||
);
|
||||
let dns =
|
||||
explain_failure("connecting: dns error: failed to lookup address information: Name or service not known")
|
||||
.unwrap();
|
||||
assert_eq!(dns.reason, "This address no longer resolves; the site may be gone.");
|
||||
// A lookup that failed for the moment is not a site gone (#143).
|
||||
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||
assert!(is_temporary_lookup_failure(blip));
|
||||
assert!(explain_failure(blip).is_none());
|
||||
assert!(!is_temporary_lookup_failure(
|
||||
"connecting: dns error: failed to lookup address information: Name or service not known"
|
||||
));
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
190
crates/feed/src/fetch.rs
Normal file
190
crates/feed/src/fetch.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Reading a feed over HTTP: conditional requests, redirects followed one by one, bounded bodies.
|
||||
|
||||
use crate::{privacy, wordpress};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::HeaderName;
|
||||
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What `fetch` got.
|
||||
pub enum Fetched {
|
||||
/// Server said 304, or returned a body we already have.
|
||||
NotModified,
|
||||
/// A body to read, with the validators to send next time.
|
||||
Body { bytes: Vec<u8>, etag: Option<String>, last_modified: Option<String> },
|
||||
}
|
||||
|
||||
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
||||
/// with no limit one hung server held every scan for as long as it did. Dreamwidth answered 504
|
||||
/// after 60-67 s for a day, and each scan took 70-80 s instead of 15 (#108). A feed is small;
|
||||
/// downloads, which are not, have no such limit.
|
||||
pub const FEED_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The longest a feed's server may send nothing, for the clients that fetch feeds to set: one
|
||||
/// that took the connection and never answered ran the whole 30 s, every scan it was in (#230).
|
||||
/// No feed that answered took over 8 s in a day of traces; one that keeps sending, however
|
||||
/// slowly, still has until FEED_TIMEOUT.
|
||||
pub const FEED_SILENCE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
|
||||
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
|
||||
/// allocations.
|
||||
pub static FETCHES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// See `FETCHES`.
|
||||
pub static FETCHED_BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The most of a feed or page read. Without a limit an episode's MP3 given as a feed was read
|
||||
/// whole into memory, at every check (#295). Real feeds can be large: a daily show's whole back
|
||||
/// catalogue was 62 MB, and 50 MB here stopped it updating (#357).
|
||||
pub(crate) const MAX_BODY: usize = 200 << 20;
|
||||
|
||||
/// A response's body, up to `MAX_BODY`: past it, an error, and the rest is never read.
|
||||
pub(crate) async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
|
||||
if resp.content_length().is_some_and(|n| n > MAX_BODY as u64) {
|
||||
return Err(anyhow!("larger than {} MB, too large to be a feed", MAX_BODY >> 20));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
while let Some(c) = resp.chunk().await? {
|
||||
out.extend_from_slice(&c);
|
||||
anyhow::ensure!(out.len() <= MAX_BODY, "larger than {} MB, too large to be a feed", MAX_BODY >> 20);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reads a feed, asking only for what changed since `etag` and `last_modified`. Redirects are
|
||||
/// followed here, up to ten, so `auth` goes to the feed's own host alone. Also returns the new
|
||||
/// address when every hop said the feed moved for good, for the caller to keep instead.
|
||||
#[tracing::instrument(skip_all, fields(url = %privacy::redact(feed_url), http.response.body.size))]
|
||||
pub async fn fetch(
|
||||
client: &reqwest::Client,
|
||||
feed_url: &str,
|
||||
auth: Option<(&str, Option<String>)>,
|
||||
etag: Option<&str>,
|
||||
last_modified: Option<&str>,
|
||||
) -> Result<(Fetched, Option<String>)> {
|
||||
let start = reqwest::Url::parse(feed_url).context("the feed's address")?;
|
||||
let mut url = start.clone();
|
||||
let mut permanent = true;
|
||||
for _ in 0..10 {
|
||||
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
|
||||
if let Some(tag) = etag {
|
||||
req = req.header(IF_NONE_MATCH, tag);
|
||||
}
|
||||
if let Some(lm) = last_modified {
|
||||
req = req.header(IF_MODIFIED_SINCE, lm);
|
||||
}
|
||||
// The feed's own host only: a redirect elsewhere must not be handed the password.
|
||||
if let Some((user, password)) = &auth
|
||||
&& url.host_str() == start.host_str()
|
||||
{
|
||||
req = req.basic_auth(user, password.as_deref());
|
||||
}
|
||||
|
||||
let resp = req.send().await.context("connecting")?;
|
||||
let status = resp.status();
|
||||
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
|
||||
let to = resp
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| anyhow!("HTTP {status} without a Location to go to"))?;
|
||||
url = url.join(to).with_context(|| format!("redirected to {to:?}, which is not an address"))?;
|
||||
permanent &= matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT);
|
||||
continue;
|
||||
}
|
||||
let moved = (permanent && url != start).then(|| url.to_string());
|
||||
FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if status == StatusCode::NOT_MODIFIED {
|
||||
return Ok((Fetched::NotModified, moved));
|
||||
}
|
||||
if !status.is_success() {
|
||||
// WordPress.com answers some readers' feed requests with a browser check that only
|
||||
// JavaScript passes, for hours at a time and whatever the User-Agent; its API has the
|
||||
// same posts and no check (#317).
|
||||
if status == StatusCode::FORBIDDEN
|
||||
&& let Some(host) = url.host_str().map(str::to_owned)
|
||||
&& read_capped(resp).await.is_ok_and(|b| wordpress::is_wpcom_challenge(&b))
|
||||
{
|
||||
return Ok((wordpress::wpcom_posts(client, &host).await?, moved));
|
||||
}
|
||||
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
||||
return Err(anyhow!("HTTP {status}"));
|
||||
}
|
||||
let header = |h: HeaderName| resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
let etag = header(ETAG);
|
||||
let last_modified = header(LAST_MODIFIED);
|
||||
let bytes = read_capped(resp).await.context("reading body")?;
|
||||
tracing::Span::current().record("http.response.body.size", bytes.len());
|
||||
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||
}
|
||||
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
||||
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::failure;
|
||||
|
||||
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
|
||||
/// to /tmp, /new is the feed.
|
||||
async fn redirecting_server() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
||||
let body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
|
||||
let resp = match path.as_str() {
|
||||
"/old" => {
|
||||
"HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
"/tmp" => "HTTP/1.1 302 Found\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||
"/chain" => {
|
||||
"HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
// An episode's file given as a feed: its size said, its body never sent.
|
||||
"/huge" => "HTTP/1.1 200 OK\r\nContent-Length: 600000000\r\n\r\n".to_owned(),
|
||||
"/loop" => {
|
||||
"HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned()
|
||||
}
|
||||
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
|
||||
};
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
|
||||
let base = redirecting_server().await;
|
||||
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
|
||||
let get = |path: &str| {
|
||||
let url = format!("{base}{path}");
|
||||
let client = client.clone();
|
||||
async move { super::fetch(&client, &url, None, None, None).await }
|
||||
};
|
||||
let (got, moved) = get("/old").await.unwrap();
|
||||
assert!(matches!(got, super::Fetched::Body { .. }));
|
||||
assert_eq!(moved, Some(format!("{base}/new")));
|
||||
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
|
||||
let huge = get("/huge").await.err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||
assert!(huge.contains("too large to be a feed"), "{huge}");
|
||||
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
|
||||
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
||||
let looped = get("/loop").await.err().unwrap().to_string();
|
||||
assert_eq!(failure::failure_kind(&looped).0, "redirect_loop");
|
||||
}
|
||||
}
|
||||
277
crates/feed/src/guard.rs
Normal file
277
crates/feed/src/guard.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! Keeps fetches off internal addresses (#320). A feed, an episode, its artwork and a .torrent are
|
||||
//! all at addresses a publisher or any signed-in person names, and redirects go anywhere, so
|
||||
//! without this the server fetched from its own loopback, its Docker network and the LAN, and
|
||||
//! handed back what answered.
|
||||
//!
|
||||
//! The check is on the connection, after the name is resolved and before a byte of the request
|
||||
//! is sent, so it covers every request, every redirect, an address written as an IP, and a name
|
||||
//! that resolves to an internal address only on the second lookup.
|
||||
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use http::Extensions;
|
||||
use hyper_util::client::legacy::connect::{Connection, HttpInfo};
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
/// Internal addresses and networks a fetch may reach all the same: config.toml's `fetch_internal`,
|
||||
/// as `192.168.1.20` or `192.168.1.0/24`. Names are not taken: the check sees only the address.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
|
||||
|
||||
impl Allowed {
|
||||
/// Reads `fetch_internal`'s list, refusing an entry that is neither an address nor a network.
|
||||
pub fn parse(list: &[String]) -> Result<Self> {
|
||||
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
|
||||
}
|
||||
|
||||
/// Whether a connection to `ip` may go ahead: any public address, and the internal ones listed.
|
||||
#[must_use]
|
||||
pub fn permits(&self, ip: IpAddr) -> bool {
|
||||
let ip = canonical(ip);
|
||||
!is_internal(ip) || self.0.iter().any(|&(net, bits)| contains(net, bits, ip))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_net(s: &str) -> Result<(IpAddr, u8)> {
|
||||
let bad = || format!("fetch_internal: {s:?} is not an address or a network such as 192.168.1.0/24");
|
||||
let (addr, bits) = s.trim().split_once('/').map_or((s.trim(), None), |(a, b)| (a, Some(b)));
|
||||
let ip = canonical(addr.parse::<IpAddr>().with_context(bad)?);
|
||||
let max = if ip.is_ipv4() { 32 } else { 128 };
|
||||
let bits = match bits {
|
||||
Some(b) => b.parse::<u8>().ok().filter(|n| *n <= max).with_context(bad)?,
|
||||
None => max,
|
||||
};
|
||||
Ok((ip, bits))
|
||||
}
|
||||
|
||||
/// An IPv4 address written as IPv6 (`::ffff:127.0.0.1`) is the IPv4 one.
|
||||
fn canonical(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
|
||||
v4 => v4,
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(net: IpAddr, bits: u8, ip: IpAddr) -> bool {
|
||||
match (net, ip) {
|
||||
(IpAddr::V4(n), IpAddr::V4(i)) => {
|
||||
let mask = u32::MAX.checked_shl(32 - u32::from(bits)).unwrap_or(0);
|
||||
u32::from(n) & mask == u32::from(i) & mask
|
||||
}
|
||||
(IpAddr::V6(n), IpAddr::V6(i)) => {
|
||||
let mask = u128::MAX.checked_shl(128 - u32::from(bits)).unwrap_or(0);
|
||||
u128::from(n) & mask == u128::from(i) & mask
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Not on the public internet: loopback, private, link-local, shared (CGNAT), multicast and
|
||||
/// reserved ranges, and their IPv6 counterparts. `IpAddr::is_global` would say this, but is
|
||||
/// unstable.
|
||||
// shortcut: 6to4 (2002::/16) and Teredo addresses that wrap an internal IPv4 one pass; check the
|
||||
// embedded address if an IPv6 route to them ever exists here.
|
||||
#[must_use]
|
||||
pub fn is_internal(ip: IpAddr) -> bool {
|
||||
match canonical(ip) {
|
||||
IpAddr::V4(a) => {
|
||||
let [o0, o1, o2, _] = a.octets();
|
||||
a.is_private()
|
||||
|| a.is_loopback()
|
||||
|| a.is_link_local()
|
||||
|| a.is_unspecified()
|
||||
|| a.is_broadcast()
|
||||
|| a.is_multicast()
|
||||
|| o0 == 0
|
||||
|| o0 >= 240
|
||||
|| (o0 == 100 && o1 & 0xc0 == 64)
|
||||
|| (o0 == 192 && o1 == 0 && o2 == 0)
|
||||
}
|
||||
IpAddr::V6(a) => {
|
||||
let s = a.segments();
|
||||
a.is_loopback()
|
||||
|| a.is_unspecified()
|
||||
|| a.is_multicast()
|
||||
|| s[0] & 0xfe00 == 0xfc00
|
||||
|| s[0] & 0xffc0 == 0xfe80
|
||||
// NAT64 carries an IPv4 address in its last 32 bits.
|
||||
|| (s[0] == 0x64 && s[1] == 0xff9b && is_internal(IpAddr::V4(Ipv4Addr::from(u128::from(a) as u32))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector layer for `reqwest::ClientBuilder::connector_layer`: a connection to an address
|
||||
/// `Allowed` does not permit fails before the request is sent.
|
||||
#[derive(Clone)]
|
||||
pub struct Guard(Allowed);
|
||||
|
||||
impl Guard {
|
||||
/// A guard letting through what `allowed` lists, besides the public internet.
|
||||
#[must_use]
|
||||
pub fn new(allowed: Allowed) -> Self {
|
||||
Self(allowed)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> tower::Layer<S> for Guard {
|
||||
type Service = Guarded<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Guarded<S> {
|
||||
Guarded { inner, allowed: self.0.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The connector `Guard` wraps, checking each connection it makes.
|
||||
#[derive(Clone)]
|
||||
pub struct Guarded<S> {
|
||||
inner: S,
|
||||
allowed: Allowed,
|
||||
}
|
||||
|
||||
impl<S, R> tower::Service<R> for Guarded<S>
|
||||
where
|
||||
S: tower::Service<R>,
|
||||
S::Response: Connection,
|
||||
S::Error: Into<BoxError>,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = BoxError;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<S::Response, BoxError>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
|
||||
self.inner.poll_ready(cx).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: R) -> Self::Future {
|
||||
let connecting = self.inner.call(req);
|
||||
let allowed = self.allowed.clone();
|
||||
Box::pin(async move {
|
||||
let conn = connecting.await.map_err(Into::into)?;
|
||||
let mut extras = Extensions::new();
|
||||
conn.connected().get_extras(&mut extras);
|
||||
// Refused when the peer is unknown too: a connection this cannot check is one it
|
||||
// cannot vouch for.
|
||||
match extras.get::<HttpInfo>().map(|i| i.remote_addr().ip()) {
|
||||
Some(ip) if allowed.permits(ip) => Ok(conn),
|
||||
Some(ip) => {
|
||||
Err(format!("{ip} is an internal address, not fetched (fetch_internal in config.toml)").into())
|
||||
}
|
||||
None => Err("the connection's address is unknown, so it is not fetched".into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_addresses_are_known() {
|
||||
for a in [
|
||||
"127.0.0.1",
|
||||
"10.1.2.3",
|
||||
"172.16.0.1",
|
||||
"192.168.1.130",
|
||||
"169.254.169.254",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
"::1",
|
||||
"::",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"64:ff9b::a00:1",
|
||||
] {
|
||||
assert!(is_internal(ip(a)), "{a}");
|
||||
}
|
||||
for a in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111", "64:ff9b::808:808", "100.128.0.1"] {
|
||||
assert!(!is_internal(ip(a)), "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_allowlist_lets_through_what_it_names_and_no_more() {
|
||||
let a = Allowed::parse(&["192.168.1.0/24".into(), "127.0.0.1".into(), "fd00::/8".into()]).unwrap();
|
||||
assert!(a.permits(ip("192.168.1.20")));
|
||||
assert!(a.permits(ip("127.0.0.1")));
|
||||
assert!(a.permits(ip("::ffff:127.0.0.1")), "the same address written as IPv6");
|
||||
assert!(a.permits(ip("fd12::1")));
|
||||
assert!(!a.permits(ip("192.168.2.1")));
|
||||
assert!(!a.permits(ip("127.0.0.2")));
|
||||
assert!(a.permits(ip("1.1.1.1")), "public addresses need no listing");
|
||||
assert!(Allowed::default().permits(ip("1.1.1.1")));
|
||||
assert!(!Allowed::default().permits(ip("10.0.0.1")));
|
||||
for bad in ["nas.lan", "10.0.0.0/33", "10.0.0.0/x", ""] {
|
||||
assert!(Allowed::parse(&[bad.into()]).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn answers() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let _ = sock.read(&mut [0u8; 1024]).await;
|
||||
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nsecret").await;
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/")
|
||||
}
|
||||
|
||||
fn client(allowed: &[&str]) -> reqwest::Client {
|
||||
let allowed = Allowed::parse(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
|
||||
reqwest::Client::builder().connector_layer(Guard::new(allowed)).build().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetch_from_loopback_is_refused_unless_listed() {
|
||||
let url = answers().await;
|
||||
let err = client(&[]).get(&url).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
|
||||
let body = client(&["127.0.0.1"]).get(&url).send().await.unwrap().text().await.unwrap();
|
||||
assert_eq!(body, "secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_redirect_to_loopback_is_refused() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let inner = answers().await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let _ = sock.read(&mut [0u8; 1024]).await;
|
||||
let r = format!("HTTP/1.1 302 Found\r\nLocation: {inner}\r\nContent-Length: 0\r\n\r\n");
|
||||
let _ = sock.write_all(r.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
// 127.0.0.2 stands for a public address: listed, it is let through, and the redirect it
|
||||
// sends to 127.0.0.1 is not.
|
||||
let err = client(&["127.0.0.2"]).get(format!("http://{addr}/")).send().await.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("127.0.0.1 is an internal address"), "{err:?}");
|
||||
}
|
||||
|
||||
/// Run by hand: `cargo test -p ipx-feed guard -- --ignored`. HTTPS gives the peer's address
|
||||
/// too; were it lost under TLS, every https fetch would be refused.
|
||||
#[tokio::test]
|
||||
#[ignore = "reaches the internet"]
|
||||
async fn https_is_checked_and_let_through() {
|
||||
let r = client(&[]).get("https://example.com/").send().await.unwrap();
|
||||
assert!(r.status().is_success());
|
||||
}
|
||||
}
|
||||
67
crates/feed/src/jev.rs
Normal file
67
crates/feed/src/jev.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! Asking TypeSafe's Jev which Apple category a feed belongs to.
|
||||
|
||||
use crate::parse::ParsedFeed;
|
||||
use crate::{category, fetch};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
/// TypeSafe's System One endpoint, where Jev answers questions about a piece of state.
|
||||
pub const JEV_URL: &str = "https://api.typesafe.ai/v1/systemone";
|
||||
|
||||
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
||||
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
|
||||
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
||||
/// is taken however unsure, since an admin can change it and asking again would cost a call a scan.
|
||||
pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) -> Result<String> {
|
||||
let items: Vec<&str> = feed.entries.iter().filter_map(|e| e.title.as_deref()).take(15).collect();
|
||||
// A subcategory says which category it is in; without that, "Games" or "Drama" is ambiguous.
|
||||
let mut criteria = serde_json::Map::new();
|
||||
for (category, subs) in category::CATEGORIES {
|
||||
criteria.insert(category.to_string(), serde_json::Value::Null);
|
||||
for sub in *subs {
|
||||
criteria.insert(sub.to_string(), format!("Within {category}").into());
|
||||
}
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": { "title": feed.title, "recent_items": items },
|
||||
"questions": { "category": {
|
||||
"type": "choice",
|
||||
"instructions": "Which category best describes what this feed publishes?",
|
||||
"criteria": criteria,
|
||||
}},
|
||||
});
|
||||
let answer: serde_json::Value = client
|
||||
.post(JEV_URL)
|
||||
.bearer_auth(key)
|
||||
.json(&body)
|
||||
.timeout(fetch::FEED_TIMEOUT)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
picked(&answer)
|
||||
}
|
||||
|
||||
pub(crate) fn picked(answer: &serde_json::Value) -> Result<String> {
|
||||
let choice = answer["answers"]["category"]["choice"].as_str().unwrap_or_default();
|
||||
category::CATEGORIES
|
||||
.iter()
|
||||
.flat_map(|(c, subs)| std::iter::once(c).chain(subs.iter()))
|
||||
.find(|c| **c == choice)
|
||||
.map(|c| c.to_string())
|
||||
.ok_or_else(|| anyhow!("no category in the answer: {answer}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn picked_takes_only_an_apple_category() {
|
||||
let answer = |c: &str| serde_json::json!({"answers": {"category": {"type": "choice", "choice": c}}});
|
||||
assert_eq!(super::picked(&answer("Technology")).unwrap(), "Technology");
|
||||
assert_eq!(super::picked(&answer("Tech News")).unwrap(), "Tech News", "a subcategory");
|
||||
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
|
||||
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
|
||||
}
|
||||
}
|
||||
26
crates/feed/src/lib.rs
Normal file
26
crates/feed/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
|
||||
//! FeedData.__getFeed / __getEntries.
|
||||
|
||||
mod artwork;
|
||||
mod category;
|
||||
mod discover;
|
||||
mod failure;
|
||||
mod fetch;
|
||||
pub mod guard;
|
||||
mod jev;
|
||||
mod outlines;
|
||||
mod parse;
|
||||
mod patreon;
|
||||
mod privacy;
|
||||
mod wordpress;
|
||||
|
||||
pub use artwork::{is_image, prefer_https, site_icon};
|
||||
pub use category::{categories, category_path};
|
||||
pub use discover::{expand_input, find_feed};
|
||||
pub use failure::{Failure, explain_failure, failure_kind, is_temporary_lookup_failure};
|
||||
pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch};
|
||||
pub use jev::{JEV_URL, classify};
|
||||
pub use outlines::{collect_outlines, is_opml, opml_title, parse_opml, read_opml};
|
||||
pub use parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key};
|
||||
pub use patreon::{is_patreon_creator, patreon_shows, same_feed};
|
||||
pub use privacy::{redact, redact_text, url_looks_private};
|
||||
111
crates/feed/src/outlines.rs
Normal file
111
crates/feed/src/outlines.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! OPML documents: recognising one, reading it safely, and the feeds it lists.
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
/// 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.
|
||||
#[must_use]
|
||||
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")
|
||||
}
|
||||
|
||||
/// How deep an OPML document's elements may nest. Real ones are a folder or two deep.
|
||||
pub(crate) const MAX_OPML_DEPTH: usize = 100;
|
||||
|
||||
/// Parses an OPML document, refusing one nested deeper than `MAX_OPML_DEPTH`. The opml crate's
|
||||
/// reader recurses once per level with no limit, and a few MB of `<outline>` overflowed the
|
||||
/// stack, which aborts the whole daemon, and again on each restart for a subscribed feed (#322).
|
||||
pub fn read_opml(text: &str) -> Result<opml::OPML> {
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_str(text);
|
||||
let mut depth = 0usize;
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(_)) => {
|
||||
depth += 1;
|
||||
if depth > MAX_OPML_DEPTH {
|
||||
anyhow::bail!("that OPML nests more than {MAX_OPML_DEPTH} levels deep");
|
||||
}
|
||||
}
|
||||
Ok(Event::End(_)) => depth = depth.saturating_sub(1),
|
||||
// Malformed XML is the opml crate's to describe.
|
||||
Ok(Event::Eof) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
opml::OPML::from_str(text).map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
/// 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 = read_opml(&text).map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
|
||||
let mut out = vec![];
|
||||
collect_outlines(&doc.body.outlines, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
|
||||
pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
|
||||
for o in outlines {
|
||||
if let Some(url) = &o.xml_url {
|
||||
let title = o.title.clone().unwrap_or_else(|| o.text.clone());
|
||||
out.push((title, url.clone()));
|
||||
}
|
||||
collect_outlines(&o.outlines, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The <head><title> of an OPML document.
|
||||
#[must_use]
|
||||
pub fn opml_title(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let doc = read_opml(&text).ok()?;
|
||||
doc.head.and_then(|h| h.title).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_opml_is_refused_before_it_is_parsed() {
|
||||
let nest = |n: usize| {
|
||||
format!(
|
||||
r#"<opml version="2.0"><head><title>T</title></head><body>{}<outline text="f" xmlUrl="https://e.com/f"/>{}</body></opml>"#,
|
||||
r#"<outline text="a">"#.repeat(n),
|
||||
"</outline>".repeat(n)
|
||||
)
|
||||
};
|
||||
assert_eq!(parse_opml(nest(5).as_bytes()).unwrap().len(), 1, "a few folders deep is fine");
|
||||
// Deep enough to overflow a 2 MiB stack in the opml crate's reader without the check.
|
||||
let deep = nest(100_000);
|
||||
assert!(parse_opml(deep.as_bytes()).unwrap_err().to_string().contains("levels deep"));
|
||||
assert_eq!(opml_title(deep.as_bytes()), None);
|
||||
}
|
||||
|
||||
#[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")));
|
||||
}
|
||||
}
|
||||
980
crates/feed/src/parse.rs
Normal file
980
crates/feed/src/parse.rs
Normal file
@@ -0,0 +1,980 @@
|
||||
//! RSS and Atom, read into `ParsedFeed`: titles as text, notes, enclosures, artwork, dates and durations.
|
||||
|
||||
use crate::{discover, wordpress};
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A feed as `parse` reads it, RSS or Atom alike: the channel's own details and its items.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ParsedFeed {
|
||||
pub title: Option<String>,
|
||||
/// RSS's `<ttl>`: how long the publisher asks to be left alone between reads.
|
||||
pub ttl_mins: Option<u64>,
|
||||
/// The show's artwork, `<itunes:image>` before the channel's `<image>`.
|
||||
pub image: Option<String>,
|
||||
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
||||
pub site: Option<String>,
|
||||
/// The channel's first `<itunes:category>`, for the Directory's categories.
|
||||
pub category: Option<String>,
|
||||
/// What the feed says it is, for its page in the Directory (#130): RSS's `<description>`,
|
||||
/// or iTunes' summary where that is empty, or Atom's subtitle. HTML as often as not.
|
||||
pub description: Option<String>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
/// One item of a feed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Entry {
|
||||
/// What tells this item from the feed's others: its `<guid>`, or failing that its link, its
|
||||
/// enclosure's address or its title (`pick_guid`).
|
||||
pub guid: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
/// Unix seconds.
|
||||
pub published: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
||||
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
||||
pub markdown: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
/// `<itunes:explicit>` said yes.
|
||||
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>,
|
||||
}
|
||||
|
||||
/// A file an item carries, an episode as a rule.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Enclosure {
|
||||
pub url: String,
|
||||
/// The type the feed declares, which may be missing or wrong.
|
||||
pub mime: Option<String>,
|
||||
/// The size the feed declares, in bytes.
|
||||
pub length: Option<i64>,
|
||||
}
|
||||
|
||||
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
|
||||
#[tracing::instrument(skip_all, fields(bytes = bytes.len()))]
|
||||
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
||||
if bytes.trim_ascii_start().starts_with(b"[")
|
||||
&& let Ok(posts) = serde_json::from_slice::<Vec<serde_json::Value>>(bytes)
|
||||
{
|
||||
return Ok(wordpress::from_wp_posts(&posts));
|
||||
}
|
||||
match rss::Channel::read_from(bytes) {
|
||||
Ok(ch) => Ok(from_rss(ch, bytes)),
|
||||
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
||||
Ok(feed) => Ok(from_atom(feed)),
|
||||
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
|
||||
// "&". 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 discover::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})"),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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}\""))
|
||||
}
|
||||
|
||||
/// Escapes every `&` that does not already start a recognized XML entity
|
||||
/// (`&`, `<`, `>`, `"`, `'`, or a numeric reference like `'`).
|
||||
pub(crate) 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"&");
|
||||
} 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.
|
||||
pub(crate) 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 "&" 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
|
||||
}
|
||||
|
||||
pub(crate) fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
||||
let per_item = enclosures_by_item(bytes);
|
||||
let explicit = ch.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||
|
||||
let entries = ch
|
||||
.items()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, item)| {
|
||||
// Straight from the XML, so an item with several keeps all of them. Falls
|
||||
// back to the parsed one if the scan and the parser disagree on item count.
|
||||
let mut enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
|
||||
item.enclosure()
|
||||
.into_iter()
|
||||
.map(|e| Enclosure {
|
||||
url: e.url().trim().to_owned(),
|
||||
mime: non_empty(Some(e.mime_type())),
|
||||
length: e.length().parse().ok(),
|
||||
})
|
||||
.filter(|e| !e.url.is_empty())
|
||||
.collect()
|
||||
});
|
||||
drop_player_repeats(&mut enclosures);
|
||||
|
||||
let guid = pick_guid(
|
||||
item.guid().map(|g| g.value()),
|
||||
item.link(),
|
||||
enclosures.first().map(|e| e.url.as_str()),
|
||||
item.title(),
|
||||
)?;
|
||||
|
||||
let entry_explicit = item.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
|
||||
let it = item.itunes_ext();
|
||||
|
||||
Some(Entry {
|
||||
guid,
|
||||
title: title_text(item.title()),
|
||||
link: non_empty(item.link()),
|
||||
published: item.pub_date().and_then(parse_date),
|
||||
// Content wins over description, as __getEntries preferred entry.content. The
|
||||
// source namespace's Markdown copy only when there is neither (#284).
|
||||
description: notes(item.content(), item.description(), source_markdown(item)),
|
||||
markdown: non_empty(source_markdown(item))
|
||||
.filter(|_| non_empty(item.content()).or_else(|| non_empty(item.description())).is_some()),
|
||||
categories: item
|
||||
.categories()
|
||||
.iter()
|
||||
.map(|c| c.name().to_owned())
|
||||
.filter(|c| !c.is_empty() && !c.starts_with("http"))
|
||||
.collect(),
|
||||
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,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ParsedFeed {
|
||||
title: title_text(Some(ch.title())),
|
||||
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())),
|
||||
site: non_empty(Some(ch.link().trim())),
|
||||
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
||||
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
||||
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
||||
description: non_empty(Some(ch.description())).or_else(|| non_empty(ch.itunes_ext().and_then(|i| i.summary()))),
|
||||
category: ch
|
||||
.itunes_ext()
|
||||
.and_then(|i| i.categories().first())
|
||||
.map(|c| c.subcategory().filter(|s| !s.text().trim().is_empty()).unwrap_or(c))
|
||||
.and_then(|c| non_empty(Some(c.text().trim()))),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
||||
let entries = feed
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
// Atom carries enclosures as <link rel="enclosure">.
|
||||
let mut enclosures: Vec<Enclosure> = e
|
||||
.links()
|
||||
.iter()
|
||||
.filter(|l| l.rel() == "enclosure")
|
||||
.map(|l| Enclosure {
|
||||
url: l.href().trim().to_owned(),
|
||||
mime: non_empty(l.mime_type()),
|
||||
length: l.length().and_then(|s| s.parse().ok()),
|
||||
})
|
||||
.filter(|e| !e.url.is_empty())
|
||||
.collect();
|
||||
drop_player_repeats(&mut enclosures);
|
||||
|
||||
let alt = e.links().iter().find(|l| l.rel() == "alternate" || l.rel().is_empty()).map(|l| l.href());
|
||||
|
||||
let guid =
|
||||
pick_guid(Some(e.id()), alt, enclosures.first().map(|x| x.url.as_str()), Some(e.title().as_str()))?;
|
||||
|
||||
Some(Entry {
|
||||
guid,
|
||||
title: title_text(Some(e.title().as_str())),
|
||||
link: alt.map(str::to_owned),
|
||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||
description: notes(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str()), None),
|
||||
markdown: None,
|
||||
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
||||
explicit: false,
|
||||
image: None,
|
||||
duration: None,
|
||||
episode: None,
|
||||
season: None,
|
||||
enclosures,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ParsedFeed {
|
||||
title: title_text(Some(feed.title().as_str())),
|
||||
ttl_mins: None,
|
||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||
site: feed
|
||||
.links()
|
||||
.iter()
|
||||
.find(|l| l.rel() == "alternate")
|
||||
.map(|l| l.href().trim().to_owned())
|
||||
.filter(|h| !h.is_empty()),
|
||||
category: None,
|
||||
description: non_empty(feed.subtitle().map(|t| t.as_str())),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
/// The original fell back to hashing the title or description. A guid, permalink or
|
||||
/// enclosure URL is a stable identifier already, so no hashing is needed; an entry with
|
||||
/// none of them has nothing to download and is dropped.
|
||||
pub(crate) fn pick_guid(
|
||||
guid: Option<&str>,
|
||||
link: Option<&str>,
|
||||
enclosure: Option<&str>,
|
||||
title: Option<&str>,
|
||||
) -> Option<String> {
|
||||
[guid, link, enclosure, title].into_iter().flatten().map(str::trim).find(|s| !s.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn is_yes(s: &str) -> bool {
|
||||
matches!(s.trim().to_ascii_lowercase().as_str(), "yes" | "true" | "explicit")
|
||||
}
|
||||
|
||||
pub(crate) fn non_empty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// WordPress numbers each audio player on a page by adding `?_=N` to its file's URL, so a post
|
||||
/// that embeds the file it encloses lists the same file twice: Rands in Repose's "The Promotion
|
||||
/// Paradox" was downloaded twice and offered two play buttons for one mp3. The first stays.
|
||||
pub(crate) fn drop_player_repeats(encs: &mut Vec<Enclosure>) {
|
||||
let mut seen = HashSet::new();
|
||||
encs.retain(|e| seen.insert(same_file_key(&e.url)));
|
||||
}
|
||||
|
||||
/// An enclosure URL without WordPress's player number, for telling repeats of one file apart
|
||||
/// from different files.
|
||||
#[must_use]
|
||||
pub fn same_file_key(url: &str) -> String {
|
||||
let Ok(mut u) = url::Url::parse(url) else { return url.to_owned() };
|
||||
let kept: Vec<(String, String)> = u
|
||||
.query_pairs()
|
||||
.filter(|(k, v)| !(k == "_" && !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())))
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
u.set_query(None);
|
||||
} else {
|
||||
u.query_pairs_mut().clear().extend_pairs(kept);
|
||||
}
|
||||
u.to_string()
|
||||
}
|
||||
|
||||
/// A title as plain text. An Atom title of `type="html"`, or an RSS one in CDATA, comes through
|
||||
/// the XML parser with its HTML entities intact: The Verge's "Meta’s" reached the page as
|
||||
/// typed. Decoded one entity at a time, so an `&` that starts none, as in "Q&A", stays as it is
|
||||
/// instead of failing the whole title.
|
||||
pub(crate) fn title_text(s: Option<&str>) -> Option<String> {
|
||||
let s = non_empty(s)?;
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s.as_str();
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at]);
|
||||
rest = &rest[at..];
|
||||
let len = 1 + rest[1..].find(|c: char| !(c.is_ascii_alphanumeric() || c == '#')).unwrap_or(rest.len() - 1);
|
||||
let decoded = rest[len..]
|
||||
.starts_with(';')
|
||||
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
|
||||
.flatten();
|
||||
match decoded {
|
||||
Some(v) => {
|
||||
out.push_str(&v);
|
||||
rest = &rest[len + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
non_empty(Some(&out))
|
||||
}
|
||||
|
||||
/// An item's notes as HTML: `body`, with Markdown made HTML, whether the feed wrote its text in
|
||||
/// Markdown or sent only a Markdown copy (#284). The page cleans it as it does any feed's HTML.
|
||||
pub(crate) fn notes(content: Option<&str>, description: Option<&str>, markdown: Option<&str>) -> Option<String> {
|
||||
match body(content, description) {
|
||||
Some(b) if is_markdown(&b) => Some(markdown_html(&b)),
|
||||
Some(b) => Some(b),
|
||||
None => non_empty(markdown).map(|m| markdown_html(&m)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<source:markdown>`, from https://source.scripting.com/: the item's text in Markdown.
|
||||
///
|
||||
/// shortcut: found by the `source` prefix the namespace's own docs and feeds use; a feed binding
|
||||
/// it to another prefix is not read, until one turns up.
|
||||
pub(crate) fn source_markdown(item: &rss::Item) -> Option<&str> {
|
||||
item.extensions().get("source")?.get("markdown")?.first()?.value()
|
||||
}
|
||||
|
||||
/// Text with no HTML tag and some Markdown: a link, a heading, a list, a quote, code or bold.
|
||||
/// Plain text without any is left as it was, since rendering it would change nothing but
|
||||
/// could still read a line starting "1984." as a list.
|
||||
pub(crate) fn is_markdown(s: &str) -> bool {
|
||||
let tag = s
|
||||
.match_indices('<')
|
||||
.any(|(i, _)| s[i + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!'));
|
||||
let block = |l: &str| ["# ", "## ", "### ", "- ", "* ", "> ", "```"].iter().any(|m| l.trim_start().starts_with(m));
|
||||
!tag && (s.contains("](") || s.contains("**") || s.lines().any(block))
|
||||
}
|
||||
|
||||
/// Markdown made HTML, uncleaned: whoever shows it cleans it as any feed's HTML.
|
||||
#[must_use]
|
||||
pub fn markdown_html(md: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH));
|
||||
out
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||
Some(c) => Some(match subtitle(&c, description) {
|
||||
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||
None => c,
|
||||
}),
|
||||
None => non_empty(description).or_else(|| non_empty(content)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||
/// description found in the body is not shown twice.
|
||||
///
|
||||
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||
/// `entries` column.
|
||||
pub(crate) fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||
let d = title_text(description)?;
|
||||
if d.contains('<') || d.chars().count() > 300 {
|
||||
return None;
|
||||
}
|
||||
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||
// description ends in "…" or "[...]".
|
||||
let words = |s: &str| {
|
||||
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||
};
|
||||
let want = words(&d);
|
||||
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||
}
|
||||
|
||||
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||
pub(crate) fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
out.push(' ');
|
||||
}
|
||||
_ if !in_tag => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||
/// start was cut off.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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").
|
||||
pub(crate) 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.
|
||||
pub(crate) fn parse_date(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
DateTime::parse_from_rfc2822(s).or_else(|_| DateTime::parse_from_rfc3339(s)).ok().map(|d| d.timestamp())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::failure;
|
||||
|
||||
#[test]
|
||||
fn parses_rss_with_itunes_extensions() {
|
||||
let bytes = include_bytes!("../../../tests/data/rss2.xml");
|
||||
let feed = parse(bytes).unwrap();
|
||||
|
||||
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
||||
assert_eq!(feed.ttl_mins, Some(45));
|
||||
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
|
||||
assert_eq!(feed.description.as_deref(), Some("A synthetic feed used by the parser tests."));
|
||||
assert_eq!(feed.entries.len(), 3);
|
||||
|
||||
let ep = &feed.entries[0];
|
||||
assert_eq!(ep.guid, "https://example.com/ep/1");
|
||||
assert_eq!(ep.title.as_deref(), Some("Episode One"));
|
||||
assert_eq!(ep.published, Some(1_078_016_400));
|
||||
assert_eq!(ep.categories, vec!["Tech"]);
|
||||
assert!(!ep.explicit);
|
||||
assert_eq!(
|
||||
ep.enclosures,
|
||||
vec![Enclosure {
|
||||
url: "https://example.com/ep1.mp3".into(),
|
||||
mime: Some("audio/mpeg".into()),
|
||||
length: Some(12_345_678),
|
||||
}]
|
||||
);
|
||||
|
||||
assert!(feed.entries[1].explicit, "entry-level itunes:explicit=yes");
|
||||
assert_eq!(feed.entries[2].enclosures[0].mime.as_deref(), Some("application/x-bittorrent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_says_what_it_is_in_itunes_summary_or_atom_subtitle() {
|
||||
// An empty <description> gives way to iTunes' summary (#130).
|
||||
let rss = br#"<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel><title>R</title><description> </description><itunes:summary>Said here.</itunes:summary></channel></rss>"#;
|
||||
assert_eq!(parse(rss).unwrap().description.as_deref(), Some("Said here."));
|
||||
let atom = br#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>A</title>
|
||||
<subtitle>Or here.</subtitle><id>urn:a</id><updated>2026-01-01T00:00:00Z</updated></feed>"#;
|
||||
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_notes_are_made_html_and_html_is_left_alone() {
|
||||
let feed = |items: &str| {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>{items}</channel></rss>"#
|
||||
);
|
||||
parse(xml.as_bytes()).unwrap().entries.into_iter().map(|e| e.description).collect::<Vec<_>>()
|
||||
};
|
||||
let got = feed(
|
||||
r#"<item><guid>a</guid><description># Hi
|
||||
|
||||
Some **bold** and [a link](https://x.example/).</description></item>
|
||||
<item><guid>b</guid><source:markdown>Only *Markdown* here.</source:markdown></item>
|
||||
<item><guid>c</guid><description><p>HTML **as sent**</p></description><source:markdown>ignored</source:markdown></item>
|
||||
<item><guid>d</guid><description>1984. Plain text, left as it is.</description></item>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].as_deref(),
|
||||
Some("<h1>Hi</h1>\n<p>Some <strong>bold</strong> and <a href=\"https://x.example/\">a link</a>.</p>\n")
|
||||
);
|
||||
assert_eq!(got[1].as_deref(), Some("<p>Only <em>Markdown</em> here.</p>\n"), "the Markdown copy, alone");
|
||||
assert_eq!(got[2].as_deref(), Some("<p>HTML **as sent**</p>"), "HTML wins, untouched");
|
||||
assert_eq!(got[3].as_deref(), Some("1984. Plain text, left as it is."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_sent_beside_html_is_kept_for_the_page_to_switch_to() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>
|
||||
<item><guid>both</guid><description><p>Hi</p></description><source:markdown>*Hi*</source:markdown></item>
|
||||
<item><guid>alone</guid><source:markdown>*Hi*</source:markdown></item></channel></rss>"#;
|
||||
let got: Vec<_> = parse(xml).unwrap().entries.into_iter().map(|e| e.markdown).collect();
|
||||
assert_eq!(got, [Some("*Hi*".to_owned()), None], "only beside HTML; alone, it is the description (#284)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
||||
<item><title>The Promotion Paradox</title><guid>p</guid>
|
||||
<enclosure url="https://x/ep.mp3" length="1" type="audio/mpeg"/>
|
||||
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
|
||||
<enclosure url="https://x/other.mp3?_=3&key=k" length="1" type="audio/mpeg"/>
|
||||
</item></channel></rss>"#;
|
||||
let urls: Vec<String> = parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
||||
assert_eq!(
|
||||
urls,
|
||||
["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"],
|
||||
"the repeat goes, a different file stays"
|
||||
);
|
||||
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
|
||||
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_are_read_as_text_not_html() {
|
||||
// The Verge: an Atom title of type="html", its entity inside CDATA.
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"><title type="text">V</title><id>v</id>
|
||||
<updated>2026-09-15T00:00:00Z</updated>
|
||||
<entry><title type="html"><![CDATA[Meta’s new One]]></title><id>e1</id>
|
||||
<updated>2026-09-15T00:00:00Z</updated></entry></feed>"#;
|
||||
assert_eq!(parse(xml).unwrap().entries[0].title.as_deref(), Some("Meta\u{2019}s new One"));
|
||||
// HTML names as well as numbers; a bare `&` and an unknown name are left as they are.
|
||||
assert_eq!(
|
||||
title_text(Some("Peña & “Q&A” &bogus; AT&T;")).as_deref(),
|
||||
Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;")
|
||||
);
|
||||
}
|
||||
|
||||
#[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("<p>Notes</p>")).as_deref(),
|
||||
Some("<p>Notes</p>"),
|
||||
"a whole body wins"
|
||||
);
|
||||
assert_eq!(
|
||||
body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).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]
|
||||
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||
Some("<p>The post.</p>"),
|
||||
"a description with markup in it is notes, not a subtitle",
|
||||
);
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||
);
|
||||
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||
for d in [
|
||||
"This week we talk about tape, drums and a very long list.",
|
||||
"This week we talk about tape, drums…",
|
||||
"This week we talk about\ntape [...]",
|
||||
] {
|
||||
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||
}
|
||||
let long = "word ".repeat(80);
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some(&long)).as_deref(),
|
||||
Some("<p>The post.</p>"),
|
||||
"a long description is notes, not a subtitle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_level_explicit_overrides_entries() {
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel><title>X</title><link>https://x</link><description>d</description>
|
||||
<itunes:explicit>yes</itunes:explicit>
|
||||
<item><title>a</title><guid>g1</guid>
|
||||
<enclosure url="https://x/a.mp3" length="1" type="audio/mpeg"/></item>
|
||||
</channel></rss>"#;
|
||||
let feed = parse(xml).unwrap();
|
||||
assert!(feed.entries[0].explicit, "the entry says nothing; the feed-level flag must still mark it explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_are_named_by_kind_for_the_log() {
|
||||
let k = |m: &str| failure::failure_kind(m);
|
||||
assert_eq!(k("HTTP 404 Not Found"), ("404".into(), Some(404)));
|
||||
assert_eq!(k("HTTP 503 Service Unavailable"), ("503".into(), Some(503)));
|
||||
assert_eq!(
|
||||
k("connecting: error following redirect for url (https://www.toddstashwick.com/): too many redirects").0,
|
||||
"redirect_loop"
|
||||
);
|
||||
assert_eq!(k("connecting: dns error: failed to lookup address information").0, "dns");
|
||||
assert_eq!(k("operation timed out").0, "timeout");
|
||||
assert_eq!(k("something new").0, "other");
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn parses_atom_enclosure_links() {
|
||||
let bytes = include_bytes!("../../../tests/data/atom.xml");
|
||||
let feed = parse(bytes).unwrap();
|
||||
|
||||
assert_eq!(feed.title.as_deref(), Some("Atom Cast"));
|
||||
assert_eq!(feed.entries.len(), 1);
|
||||
let ep = &feed.entries[0];
|
||||
assert_eq!(ep.guid, "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a");
|
||||
assert_eq!(ep.link.as_deref(), Some("https://example.org/ep/1"));
|
||||
assert_eq!(ep.published, Some(1_078_016_400));
|
||||
assert_eq!(
|
||||
ep.enclosures,
|
||||
vec![Enclosure {
|
||||
url: "https://example.org/ep1.m4a".into(),
|
||||
mime: Some("audio/mp4".into()),
|
||||
length: Some(9_876_543),
|
||||
}],
|
||||
"rel=enclosure only; the alternate link must not become an enclosure"
|
||||
);
|
||||
}
|
||||
|
||||
#[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&b <x> ' / c&d");
|
||||
assert_eq!(out, b"a&b <x> ' / c&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 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&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]
|
||||
fn rejects_html_masquerading_as_a_feed() {
|
||||
assert!(parse(b"<html><body>nope</body></html>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guid_falls_back_through_link_then_enclosure() {
|
||||
assert_eq!(pick_guid(Some(" "), Some("l"), Some("e"), None).as_deref(), Some("l"));
|
||||
assert_eq!(pick_guid(None, None, Some("e"), Some("t")).as_deref(), Some("e"));
|
||||
assert_eq!(pick_guid(None, None, None, None), None);
|
||||
}
|
||||
}
|
||||
147
crates/feed/src/patreon.rs
Normal file
147
crates/feed/src/patreon.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Patreon: a creator's feed, its shows, and when two Patreon addresses are the same feed.
|
||||
|
||||
use crate::fetch;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
/// 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.
|
||||
pub(crate) 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.
|
||||
#[must_use]
|
||||
pub fn is_patreon_creator(url: &str) -> bool {
|
||||
matches!(patreon_parts(url), Some((_, None)))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn same_feed(a: &str, b: &str) -> bool {
|
||||
// Parsing both is the slow part, and only Patreon's addresses can differ and still match.
|
||||
a == b
|
||||
|| (a.contains("patreon.com")
|
||||
&& b.contains("patreon.com")
|
||||
&& 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).timeout(fetch::FEED_TIMEOUT).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.
|
||||
pub(crate) 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).
|
||||
pub(crate) type PatreonShows = (Option<String>, Vec<(String, String)>);
|
||||
|
||||
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
|
||||
pub(crate) fn parse_patreon_shows(json: &[u8]) -> Result<PatreonShows> {
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discover;
|
||||
|
||||
#[test]
|
||||
fn a_patreon_creator_is_a_list_of_its_shows() {
|
||||
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
||||
assert_eq!(discover::expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
||||
assert_eq!(discover::expand_input("https://example.com/rss"), "https://example.com/rss");
|
||||
assert_eq!(discover::expand_input("http://example.com/rss"), "http://example.com/rss");
|
||||
assert_eq!(discover::expand_input(" cnn.com "), "https://cnn.com");
|
||||
assert_eq!(discover::expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
|
||||
assert_eq!(discover::expand_input("//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());
|
||||
}
|
||||
}
|
||||
105
crates/feed/src/privacy.rs
Normal file
105
crates/feed/src/privacy.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Addresses that carry someone's access: taking it out of what is logged, and keeping such feeds private.
|
||||
|
||||
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
||||
/// each hop is seen. The second value is where the feed now is when every hop said so for good
|
||||
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
|
||||
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
|
||||
/// `system-proxy` reads http_proxy/https_proxy.
|
||||
/// `auth` is a username and password, sent only to the feed's own host.
|
||||
/// An address as a trace may keep it: without its query or user and password, where a private
|
||||
/// feed carries its credential (Patreon's `?auth=`), which Tempo kept for anyone reading it (#225).
|
||||
/// shortcut: a token in the path (`/feed/<token>.xml`) stays; strip paths too if a feed does that.
|
||||
#[must_use]
|
||||
pub fn redact(url: &str) -> String {
|
||||
match url::Url::parse(url) {
|
||||
Ok(mut u) => {
|
||||
u.set_query(None);
|
||||
u.set_fragment(None);
|
||||
let _ = u.set_username("");
|
||||
let _ = u.set_password(None);
|
||||
u.to_string()
|
||||
}
|
||||
// Not an address ipx could fetch; keep what comes before anything that could be a query.
|
||||
Err(_) => url.split(['?', '#', '@']).next().unwrap_or_default().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with each address in it redacted, for what is logged and sent to every open page: an
|
||||
/// error from reqwest names the whole address it fetched, a private feed's key with it (#238).
|
||||
#[must_use]
|
||||
pub fn redact_text(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() {
|
||||
out.push_str(&rest[..i]);
|
||||
let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n);
|
||||
out.push_str(&redact(&rest[i..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// An address that carries a subscriber's own access: a paid-feed host, credentials in the URL,
|
||||
/// or a key in the query. ipx leaves such a feed out of anything others see, and the directory
|
||||
/// refuses one sent to it (#202).
|
||||
///
|
||||
/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
|
||||
/// through; a per-feed private flag is the upgrade (#205).
|
||||
#[must_use]
|
||||
pub fn url_looks_private(feed_url: &str) -> bool {
|
||||
let Ok(u) = url::Url::parse(feed_url) else { return true };
|
||||
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
|
||||
// long path segment alone proves nothing (acast's public show ids look the same). So a
|
||||
// feed from one of them is private whatever its URL looks like. Supercast is why this
|
||||
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
|
||||
let host = u.host_str().unwrap_or("");
|
||||
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|
||||
|| !u.username().is_empty()
|
||||
|| u.password().is_some()
|
||||
|| u.query_pairs().any(|(k, _)| {
|
||||
let k = k.to_ascii_lowercase();
|
||||
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"].iter().any(|w| k.contains(w))
|
||||
})
|
||||
}
|
||||
|
||||
/// Services whose feeds are always one subscriber's own.
|
||||
pub(crate) const PAID_HOSTS: &[&str] =
|
||||
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn redact_drops_what_carries_a_credential() {
|
||||
assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x");
|
||||
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
|
||||
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
|
||||
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
||||
// As reqwest words a failure, and with two addresses and none.
|
||||
assert_eq!(
|
||||
super::redact_text(
|
||||
"connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"
|
||||
),
|
||||
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
|
||||
);
|
||||
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
|
||||
assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_carrying_someones_access_looks_private() {
|
||||
let private = super::url_looks_private;
|
||||
assert!(!private("https://feeds.twit.tv/twit.xml"));
|
||||
// Patreon's shape: the key is a query parameter.
|
||||
assert!(private("https://www.patreon.com/rss/x?auth=abc123&show=2073588"));
|
||||
assert!(private("https://example.com/rss?api_key=abc"));
|
||||
// Paid-feed services put the key in the path; the host gives them away.
|
||||
assert!(private("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx"));
|
||||
assert!(private("https://someshow.supportingcast.fm/content/abc123.rss"));
|
||||
// A long path segment alone is not a key: acast's public show ids look the same.
|
||||
assert!(!private("https://feeds.acast.com/public/shows/0123456789abcdef01234567"));
|
||||
assert!(private("https://ray:hunter2@example.com/rss"));
|
||||
assert!(private("not a url"), "unparseable is not safe to list");
|
||||
}
|
||||
}
|
||||
86
crates/feed/src/wordpress.rs
Normal file
86
crates/feed/src/wordpress.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! WordPress.com sites that answer their feed with a browser check, read from WordPress.com's API instead (#317).
|
||||
|
||||
use crate::fetch::Fetched;
|
||||
use crate::parse::{Entry, ParsedFeed};
|
||||
use crate::{fetch, parse};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
/// WordPress.com's browser check, in place of the feed it was asked for.
|
||||
pub(crate) fn is_wpcom_challenge(body: &[u8]) -> bool {
|
||||
String::from_utf8_lossy(&body[..body.len().min(4096)]).contains("/__challenge/")
|
||||
}
|
||||
|
||||
/// A WordPress.com site's latest posts, from WordPress.com's API, which `parse` reads as a feed.
|
||||
/// shortcut: the API's default page of ten posts, as many as WordPress puts in a feed; a feed
|
||||
/// set to carry more gets ten here.
|
||||
pub(crate) async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
|
||||
let api = format!("https://public-api.wordpress.com/wp/v2/sites/{host}/posts");
|
||||
let resp = client
|
||||
.get(&api)
|
||||
.timeout(fetch::FEED_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP 403 Forbidden, a browser check; connecting to WordPress.com's API in its place")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"HTTP 403 Forbidden, a browser check, and WordPress.com's API in its place answered HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
let bytes = fetch::read_capped(resp).await.context("reading WordPress.com's API")?;
|
||||
fetch::FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
fetch::FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(Fetched::Body { bytes, etag: None, last_modified: None })
|
||||
}
|
||||
|
||||
/// WordPress's posts as its REST API sends them (`wpcom_posts`). The guid is the one the site's
|
||||
/// own feed gives each post, so they are the items already stored. No title for the feed: the
|
||||
/// stored one stays.
|
||||
pub(crate) fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
|
||||
let entries = posts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
Some(Entry {
|
||||
guid: parse::non_empty(p["guid"]["rendered"].as_str())
|
||||
.or_else(|| parse::non_empty(p["link"].as_str()))?,
|
||||
title: parse::title_text(p["title"]["rendered"].as_str()),
|
||||
link: parse::non_empty(p["link"].as_str()),
|
||||
published: p["date_gmt"]
|
||||
.as_str()
|
||||
.and_then(|d| NaiveDateTime::parse_from_str(d, "%Y-%m-%dT%H:%M:%S").ok())
|
||||
.map(|d| d.and_utc().timestamp()),
|
||||
description: parse::non_empty(p["content"]["rendered"].as_str()),
|
||||
image: parse::non_empty(p["jetpack_featured_media_url"].as_str()),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ParsedFeed { entries, ..Default::default() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn reads_wordpress_posts_as_a_feed() {
|
||||
// As WordPress.com's API sends them, trimmed (#317).
|
||||
let json = br#"[{"id":90248260,"date_gmt":"2026-10-09T13:34:00",
|
||||
"guid":{"rendered":"https:\/\/pelgranepress.com\/?p=90248260"},
|
||||
"link":"https:\/\/pelgranepress.com\/2026\/10\/09\/whisky\/",
|
||||
"title":{"rendered":"Baby’s First Whisky"},"content":{"rendered":"<p>Notes<\/p>"},
|
||||
"jetpack_featured_media_url":""}]"#;
|
||||
let feed = super::parse(json).expect("WordPress posts parse as a feed");
|
||||
assert_eq!(feed.title, None);
|
||||
let e = &feed.entries[0];
|
||||
assert_eq!(e.guid, "https://pelgranepress.com/?p=90248260");
|
||||
assert_eq!(e.title.as_deref(), Some("Baby\u{2019}s First Whisky"));
|
||||
assert_eq!(e.link.as_deref(), Some("https://pelgranepress.com/2026/10/09/whisky/"));
|
||||
assert_eq!(e.published, Some(1_791_552_840));
|
||||
assert_eq!(e.description.as_deref(), Some("<p>Notes</p>"));
|
||||
assert_eq!(e.image, None);
|
||||
assert!(super::is_wpcom_challenge(
|
||||
b"<!DOCTYPE html><title>Checking your browser...</title><script src=\"/__challenge/probes.js\">"
|
||||
));
|
||||
assert!(!super::is_wpcom_challenge(b"<html>Forbidden</html>"));
|
||||
}
|
||||
}
|
||||
969
crates/ipx/CHANGELOG.md
Normal file
969
crates/ipx/CHANGELOG.md
Normal file
@@ -0,0 +1,969 @@
|
||||
# Changelog: ipx
|
||||
|
||||
The daemon: the API, scans, downloads, DeadAir and the database. The page has its own changelog,
|
||||
[web/CHANGELOG.md](../../web/CHANGELOG.md), and the directory service its own,
|
||||
[crates/directory/CHANGELOG.md](../directory/CHANGELOG.md). Until 0.12.0 this was the changelog of
|
||||
all of ipx, the page included; they were split with the services (#207).
|
||||
|
||||
Its version is in `crates/ipx/Cargo.toml`, and a release is tagged `vX.Y.Z`.
|
||||
|
||||
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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.1] - 2026-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Log lines about starting, stopping, accounts and the configuration name the part of iPX that wrote them in `target` (`ipx::daemon`, `ipx::cli`, `ipx::catalogue`, ...), where they all said `ipx` (#356).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A feed larger than 50 MB is read again, up to 200 MB: a podcast with a long back catalogue had stopped updating (#357).
|
||||
|
||||
## [1.5.0] - 2026-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Traces show more of where a request's time goes: a page of items' query and its files apart, artwork's cache, check and fetch, the status counts, and each part of changing or leaving a feed (#350).
|
||||
- A page of items finds its files by feed as well as item: it read every file's row in the database, about a quarter of a second a page (#344).
|
||||
- A page of items asks for its count beside the items, and the feed list asks its five questions at once, where each waited for the one before (#346).
|
||||
- A Signal's counts of items and of unread ones are worked out in one pass, not two (#348).
|
||||
- Artwork kept on disk is read and written off the threads that answer requests, and two requests for the same missing artwork fetch it once (#345, #349).
|
||||
- Unread lists and counts start from each person's oldest unread item in each feed, which the daily cleanup works out, instead of reading the whole back catalogue and everything you have read (#347, #352).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A WordPress.com site that answers its feed with a browser check (HTTP 403, "Checking your browser...") is read from WordPress.com's API instead, so Pelgrane Press's feed no longer fails for hours at a time. The items are the ones the feed gave: same ids (#317).
|
||||
|
||||
### Security
|
||||
|
||||
- An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331).
|
||||
- A downloaded file opens in the browser only when it is audio, video, an image other than SVG, a PDF or plain text, and is saved otherwise. An SVG or web page a podcast sent ran its script as iPX, signed in as whoever opened it (#319).
|
||||
- An OPML nested more than 100 levels deep is refused, uploaded or fetched. A deep enough one stopped iPX, and a subscribed one stopped it again each time it restarted (#322).
|
||||
- A `.torrent` file larger than 10 MB, or slower than the torrent stall limit, is refused. iPX read one into memory however large it was (#330).
|
||||
- Feeds, episodes, artwork and `.torrent` files are no longer fetched from internal addresses (loopback, the LAN, Docker's networks), redirects included, unless config.toml's `fetch_internal` lists them. Adding a feed could read what a service inside the network answered (#320).
|
||||
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
||||
- A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321).
|
||||
- A show kept from a private OPML or Patreon list stays private, with the list's username and password, once the list is removed or left. It became public and was shared to the directory (#332).
|
||||
- A feed's username and password go to its own host alone: not to its episodes on other hosts, nor to the feeds an OPML lists on other hosts, which took the OPML's password on every check (#327, #328).
|
||||
- A check of one feed asks at most eight hosts whether its artwork is on https. A feed could name artwork on as many hosts as it liked, each asked in turn for up to 20 seconds, and hold up every other feed's check for hours (#339).
|
||||
- An OPML or Patreon list may bring in up to 2,000 feeds; one with more shows an error and adds none. A list of tens of thousands held up checking for everyone, and adding its feeds read the whole feeds table once for each (#337).
|
||||
- A request that changes something is refused when a browser sent it from another site. A page on another site, a sibling subdomain included, could mark everything read, start downloads, subscribe you to a Directory feed or sign you out (#336).
|
||||
- `ipx user passwd` signs the account out of every browser, and says how many of its API tokens still work. A session signed in with the old password went on working (#334).
|
||||
- Live updates about a feed go only to the people who read it, and admins. Everyone signed in had every feed's, errors included, which carried a paid feed's address with its key (#325).
|
||||
- An open page stops getting live updates within half a minute of its account being removed. They went on until the page was closed (#335).
|
||||
- Only someone subscribed to a feed, or an admin, may mark it private. Anyone signed in could keep anyone's feed, or a whole OPML's, out of the directory, and only an admin could undo it (#333).
|
||||
- An OPML upload may add up to 2,000 feeds; one with more is refused and adds none. Importing tens of thousands held up every change to the feed list for minutes and left them all in it (#338).
|
||||
- Password checks stay a few at a time when the person signing in gives up before the answer. Each sign-in that hung up let another start, so a flood of them used every core (#324).
|
||||
- Jev picks at most 20 icons an hour for each person, on the server's key; past that the page says to choose one by hand. Anyone signed in could have it pick without end, on the server's bill, holding back everyone's Signals (#340).
|
||||
- Each person's Signals on an item are judged in a call of their own, on the server's key too. One person's description was in the question that decided another's verdicts (#343).
|
||||
|
||||
## [1.4.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- An item whose feed sends Markdown beside its HTML, as Scripting News does with `<source:markdown>`, keeps it: `markdown` and `markdown_html` in the API's items (#285).
|
||||
- An item written in Markdown shows formatted, not as Markdown's raw characters: a description in Markdown, or, with no other text, RSS's `<source:markdown>`. HTML a feed sends is left as it is (#284).
|
||||
- A show from an OPML subscription or a Patreon creator can be kept: `kept` on `PATCH /api/feeds/{id}` and in the feed list. When the OPML drops a kept show, or you leave the OPML, it stays on your list as a feed of its own (#280).
|
||||
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, by person and by whose key paid so the server's bill can be split, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275, #278).
|
||||
- An admin setting for how many days back a new Signal reads (#276).
|
||||
- A list's trace says which filter, sort and page it was asked for, and whether a search, so a slow list can be told from the others (#287).
|
||||
- A feed, an OPML subscription or a Patreon creator can be left out of All Subscriptions: `hide_from_all` on `PATCH /api/feeds/{id}` and in the feed list. Set on an OPML or creator it covers every show in it; Signals and Currently Listening still read them (#288).
|
||||
- A feed, an OPML subscription or a Patreon creator can be kept out of Signals: `hide_from_signals` on `PATCH /api/feeds/{id}` and in the feed list. Jev is not asked about its items, so they cost nothing, and they are left out of each Signal's list and counts (#289).
|
||||
- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).
|
||||
|
||||
### Changed
|
||||
|
||||
- `GET /api/feeds/{id}/entries` for an OPML subscription or a Patreon creator lists the items of the shows in it that you subscribe to, together, where it listed nothing (#283).
|
||||
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
|
||||
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
|
||||
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).
|
||||
- Signals fill faster and cost less: up to ten calls to TypeSafe at once instead of one, paced under TypeSafe's limits of 80 calls and 100,000 input tokens a second, with every call held back while TypeSafe answers 429 (#277), everyone's Signals on an item in one call on the server's key, and an item's text cut at 1,000 characters instead of 2,000 (#275).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Artwork comes quickly: an image already kept is served without asking the database again, and the database finds which feed names an image by an index instead of reading every item, which took over half a second an image (#272).
|
||||
- The Directory shows the artwork of podcasts nobody on this server subscribes to, where it drew their initials (#273).
|
||||
- Artwork a publisher sends without an image type, or as `application/octet-stream`, is shown when it is a JPEG, PNG, GIF or WebP, instead of initials (#279).
|
||||
- Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274).
|
||||
- With the downloads folder out of reach during a clean-up, as when the array stops, every downloaded episode was taken as deleted by hand and forgotten; the clean-up now leaves them alone until the folder is back (#293).
|
||||
- A feed address that serves something larger than 50 MB, such as an episode's file given as a feed, is refused as too large, instead of read whole into memory at every check (#295).
|
||||
- Searching items for `%` or `_` finds those characters, instead of treating them as wildcards that matched nearly everything (#296).
|
||||
|
||||
### Security
|
||||
|
||||
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one, in every connection ipx makes (#281).
|
||||
- The address of a file being downloaded, which for Patreon carries its signature, is sent to open pages and written to the log without its query, as other addresses already were (#292).
|
||||
|
||||
## [1.3.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||
- With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`, traces go to the collector compressed; `/metrics` answers gzipped, and a snapshot goes to the directory gzipped (#253).
|
||||
- A Directory request's trace shows the call to the directory whole, with the size of its answer, and the steps that match it against your subscriptions (#250).
|
||||
- The page's report of how long a list took to show after a click (`list_shown`, with the view and filter) is logged under that click's trace (#246).
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed that sends the same body again, as Patreon and Substack do whatever they are asked, is taken as unchanged instead of read again, and, without a schedule of its own, checked less often the longer it stays the same, up to every four hours, back on schedule when it changes (#242).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the daemon keeps all twenty of its database connections open, so a page load no longer waits for new ones, 80-330 ms each: the feed list and item lists took 0.3-1.6 s now and then, where their queries take 5-30 ms (#241).
|
||||
- Currently Listening and the Pinned filter answer in a few milliseconds: they read every item on the server, 1.3 s for Currently Listening (#249).
|
||||
|
||||
## [1.2.0] - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
|
||||
|
||||
### Changed
|
||||
|
||||
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
|
||||
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
|
||||
- A scan's trace names the feed each fetch is for (#229).
|
||||
|
||||
### Security
|
||||
|
||||
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
|
||||
|
||||
## [1.1.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- `/metrics` counts the feeds fetched and the bytes of their bodies (`ipx_feed_fetches_total`, `ipx_feed_bytes_total`), and a feed's trace says how large its body was (#224).
|
||||
|
||||
### Changed
|
||||
|
||||
- ipx uses mimalloc instead of glibc's allocator, which gives freed memory back to the system: with glibc's, the memory a night of scans needed stayed held until a restart (#224).
|
||||
- `docker-compose.yml` has no healthchecks: Docker runs them with exec, which can fail on the host while ipx is well. The library's counts in `/metrics` are kept every 30 s by the daemon itself (#218).
|
||||
|
||||
### Security
|
||||
|
||||
- Traces no longer keep a feed's or file's address with its query or password, where private feeds carry their credentials, such as Patreon's `auth` (#225).
|
||||
|
||||
## [1.0.0] - 2026-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- With a directory token that may administer it, ipx's admins remove a feed from the directory (blocking it) or check it now, list and unblock what is blocked, and list and drop the servers sharing (#206).
|
||||
- ipx passes its trace on when it calls the directory, so a Directory page is one trace from the browser to the directory (#213).
|
||||
|
||||
### Changed
|
||||
|
||||
- The Directory comes from a directory service that iPX servers share, set up in Admin, Directory with its address and this server's token, instead of only this server's feeds. Sharing this server's public feeds with it is a setting, never a private one, and subscriber counts are every sharing server's. ipx sends the directory its feeds when they change, and every half hour (#205, #211).
|
||||
- A feed can be marked private, which keeps it out of the directory. A feed with a login, or whose address carries a key, is private already (#205).
|
||||
- ipx serves no page: the page is ipx-web's, which passes the API on to ipx, and ipx is built without node (#203).
|
||||
- `docker-compose.yml` is a sample to start from, ipx alone on SQLite with its folders beside the file, instead of a copy of the author's server (#208).
|
||||
- The source is a Cargo workspace: the daemon in `crates/ipx`, feed fetching and parsing in `crates/feed`. `cargo build --release` still makes `target/release/ipx` (#202).
|
||||
|
||||
## [0.12.0] - 2026-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- With `[web] metrics_bind` set, ipx serves Prometheus metrics on a port and thread of their own: the database pool, the worker's job and queue, jobs done and their time, scans, downloads and requests in progress, download speed, live-update listeners and missed updates, DeadAir's backlog, the artwork cache's fill, hit rate and evictions, memory, open files and the library's counts. (#194, #195, #198, #199).
|
||||
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192).
|
||||
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
|
||||
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
|
||||
|
||||
### Changed
|
||||
|
||||
- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space; the copies already made are removed (#179, #181, #196).
|
||||
- DeadAir is in the same box as a show's download settings, below them, and stays there when automatic downloads are off (#185).
|
||||
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
|
||||
- On Postgres, ipx keeps up to 20 connections to the database instead of 10, for the bursts of a page load asking for many covers at once (#200).
|
||||
- Traces of the disk sweep, episode lists and DeadAir's work on each file show each step, and the sweep's trace says what it removed (#187).
|
||||
|
||||
### Fixed
|
||||
|
||||
- All Subscriptions, and every item list, comes up faster, most of all the first time after a quiet spell: it took half a second to two (#188).
|
||||
- Checking for new episodes no longer waits up to 12 seconds each time on a clear-out of dead feeds from the Directory, which now runs once a day (#193).
|
||||
- A page left open no longer misses updates after a check for new episodes: the note that each feed not due was skipped, one per feed, filled the queue they share (#197).
|
||||
|
||||
### Security
|
||||
|
||||
- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189).
|
||||
|
||||
## [0.11.0] - 2026-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is ("Cutting dead air…" until it is done), and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172, #173, #174, #176).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A downloaded file's play, save, share and delete buttons stay on one row in the Files pane, instead of delete wrapping underneath (#175).
|
||||
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.
|
||||
- Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part.
|
||||
- Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why.
|
||||
|
||||
## [0.10.3] - 2026-10-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Swiping past the last item shows the theme's accent colour, without the words "No more items".
|
||||
- An empty list and the reader with nothing picked say nothing, instead of "Nothing here yet — try Scan now." and "Pick an item to read it."; a search that finds nothing still says so.
|
||||
- In Settings, the API tokens sit in a box of their own.
|
||||
- A feed's settings are redesigned: downloading on its own is a switch with what depends on it beneath, how many is Default, Newest or All shown on a strip of the show's own episodes, words to pick or hide are chips, the feed's address is at the top with a copy button, and Save is always in reach. Its folder and Directory category are no longer in it.
|
||||
- The feed list's traces show each of its database queries, so it can be told where its time goes.
|
||||
- Saving a feed's settings asks the database for your subscription once, not twice.
|
||||
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code.
|
||||
- A pinned item or feed's pin button is filled with the theme's accent, in the reader, on the feed's page and in the toolbar, so pinned no longer looks like not pinned.
|
||||
- A feed's page in the Directory says how many people here subscribe only when someone does, without "Nobody here subscribes yet".
|
||||
|
||||
### Fixed
|
||||
|
||||
- The unread counts catch up when you come back to the page: an item read on another device, or while the phone app was in the background, no longer stays counted as unread until a reload.
|
||||
- A feed set to download 0 of its newest items downloads all of them, the whole back catalogue, as the server's 0 does; it downloaded nothing.
|
||||
- The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server, and asks for a group's title once, not once for every feed in it.
|
||||
- Downloading or deleting a file the server no longer has answers "not found" instead of a server error.
|
||||
- A feed's page in the Directory, and subscribing from it, no longer wait while the whole Directory is put together to check that the one feed is in it.
|
||||
- An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good.
|
||||
- Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one.
|
||||
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
|
||||
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.
|
||||
- Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile.
|
||||
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.
|
||||
- A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart.
|
||||
- On a phone, the closed feed list no longer shades the left edge of the page with its shadow.
|
||||
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it. In Glass the bar is solid on a phone, in the colour its glass showed.
|
||||
- A feed's buttons stay on one row on a narrow phone, and the reader's buttons stay together instead of breaking across lines under a long feed name.
|
||||
- A moment's DNS failure no longer marks a feed as failing for a day or calls it gone: iPX tries it again in ten minutes. One failed lookup one morning left 205 podcasts unread, uncategorised and labelled gone until the next day. A name that does not resolve is said to be maybe gone, and only once it has been failing a day, on its Directory page as on its own.
|
||||
|
||||
## [0.10.2] - 2026-10-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- A publisher's correction to an item already in iPX, a retitled episode, mended text, new artwork or a fixed length, arrives on the next read. Since 0.9.1 an item kept what it first said: Ain't It Cool News's items each showed the text of the one before, long after the feed was fixed.
|
||||
- A feed's page in the Directory says when the feed is failing, and why, and no longer says nobody here subscribes to a feed someone does.
|
||||
|
||||
## [0.10.1] - 2026-10-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- A feed whose address answers with nothing, as a lapsed domain does behind a DNS filter's block page, shows as failing, with what to do about it, instead of as a show that has not posted yet.
|
||||
- The search box finds episodes in Currently Listening. It did nothing there.
|
||||
- `ipx add`, `ipx rm` and `ipx import`, run while the daemon runs, reach it at once. It kept its old list of feeds and wrote it back at its next change, undoing them.
|
||||
- On SQLite, several requests are answered at once instead of one at a time. With 25 people browsing, a page of a feed's items comes back in 10ms rather than 130.
|
||||
- On Postgres, a write no longer waits for the database server's disk. A scan read about one feed a second and now reads 1,500 in 20 seconds.
|
||||
- Checking every feed no longer makes every icon in the feed list flash while it runs: the list keeps the icons it has already drawn.
|
||||
|
||||
### Security
|
||||
|
||||
- A flood of sign-in attempts no longer stalls the site for everyone else. Passwords are checked a few at a time, away from the threads that answer every other request; forty wrong passwords at a time made everything else take four seconds.
|
||||
- A wrong password is refused in the same time whether or not the name is an account here, so how long it takes no longer tells anyone which names are.
|
||||
|
||||
## [0.10.0] - 2026-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- API tokens: in Settings, make a named token that lets a script or an agent use iPX as you, sent as `Authorization: Bearer`, and revoke it there. Each shows when it was last used.
|
||||
- In the Directory, a feed you do not subscribe to opens a page of its own: its cover, its category, how many people here subscribe, what the feed says it is, and its latest episodes or posts, with a button to subscribe. Before, only its subscribe button did anything.
|
||||
- Feeds that don't set a category, as most blogs don't, now get one automatically, so they show up under a category in the Directory. iPX picks the closest of Apple's podcast categories from the feed's title and latest headlines. This happens once per feed: the first time a new feed is read, or for a feed you already had, the next time you force a refresh. It needs `TYPESAFE_KEY` set. An admin can change the category in the feed's settings.
|
||||
- Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default).
|
||||
- `ipx add --list --category News <url>` puts a feed in the Directory for anyone to subscribe to,
|
||||
and it stays there when its last subscriber leaves. Run for a feed already in the catalogue,
|
||||
it lists it or sets its category.
|
||||
- Feeds nobody subscribes to that are dead for a month or quiet for a year are removed, so the
|
||||
Directory lists feeds worth taking.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Directory is laid out as Apple's is. It opens on the ten most subscribed feeds, ranked, and the categories as tiles showing some of their shows' covers. A category opens a page of its own with its most subscribed feeds, its subcategories (Tech News under News, Video Games under Leisure) and its feeds; See all lists every one, A to Z, Z to A or by most subscribers. This replaces the grid of every feed under rows of category buttons, and Popular, which is gone from the feed list with its key, g p.
|
||||
- A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin.
|
||||
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
|
||||
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
|
||||
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
|
||||
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
|
||||
- The Directory lists feeds nobody subscribes to yet; its most subscribed still lists what people subscribe to.
|
||||
A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A
|
||||
listed feed downloads nothing until someone subscribes.
|
||||
- The Directory loads faster: it asked the database three questions per feed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A database on SQLite, the default, no longer waits for the disk after nearly every write. On a slow disk a scan of 1,500 feeds took most of an hour and now takes half a minute. Postgres is unaffected.
|
||||
- The search box finds a feed in the Directory by name. It did nothing there.
|
||||
- Feeds that file themselves under Apple's Football or Soccer show under Sports, and those under the old Software How-To under Technology, instead of as categories of their own.
|
||||
- The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting.
|
||||
- Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX.
|
||||
- An iPhone adding the site to its home screen finds the icon at the first address it tries.
|
||||
- A feed whose server hangs no longer holds up every scan: a feed gets 30 seconds, and connecting anywhere 10.
|
||||
|
||||
### Security
|
||||
|
||||
- A feed's items and files reach only the people who subscribe to it. Anyone signed in could read any feed's items, a paid feed's included, with the addresses of its files, which can carry the subscriber's key, and could play, download or delete its files.
|
||||
|
||||
## [0.9.1] - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- `IPX_LOG_FORMAT=json` logs one JSON object a line, for Loki and the like: a request's method,
|
||||
route, status and `duration_ms`, a scan's or download's details, a failure's kind in `error.type`
|
||||
(and its HTTP status in `http.response.status_code`), and each line's `trace_id` and `span_id`.
|
||||
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
|
||||
requests to a collector such as Tempo; a scan's shows the time each feed spends fetching, on its
|
||||
artwork and in the database.
|
||||
- A feed with no artwork of its own, or whose artwork is missing, shows its website's icon, or the
|
||||
site's `/favicon.ico`, and follows the site when it changes. It is asked for again only when the
|
||||
artwork changes or you refresh the feed.
|
||||
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
|
||||
while any of yours is.
|
||||
- Adding a website's address, with or without `https://`, subscribes to the feed that site links.
|
||||
An address is checked before it is added, and anything that is neither a feed nor a page linking
|
||||
one is refused with the reason, instead of being added and failing on every check.
|
||||
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
|
||||
the version, for a dashboard such as Homepage.
|
||||
|
||||
### Changed
|
||||
|
||||
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
|
||||
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list every minute.
|
||||
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
|
||||
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
|
||||
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
|
||||
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
|
||||
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
|
||||
chosen one yet starts from the theme your account had.
|
||||
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
|
||||
orange of its needle.
|
||||
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
|
||||
feed list, and left out on a phone, for the search box. It follows the page, dark in a dark theme
|
||||
and light in a light one, as the browser tab's icon does, and hovering it shows iPX's version,
|
||||
which says so between releases: 0.9.1-dev, not 0.9.0.
|
||||
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
|
||||
|
||||
### Removed
|
||||
|
||||
- `ipx copy-db`, the one-off move from SQLite to Postgres. Going back needs only the old `state.db`.
|
||||
- Starting up no longer looks for files downloaded twice before 0.6.0 read WordPress's player
|
||||
links correctly; that clean-up has run.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
|
||||
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
|
||||
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
|
||||
- The feed list loads several times faster: it was asking the database six questions per feed.
|
||||
- Artwork a feed offers only over plain http shows on the https site too.
|
||||
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
|
||||
its counts, and its artwork greyed out once it has failed for a day.
|
||||
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
|
||||
- The read button shows whether an item is read, as the pin beside it shows whether it is
|
||||
pinned: a tick when read, an envelope when not. It used to show the opposite.
|
||||
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
|
||||
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
|
||||
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
|
||||
a feed that has not changed is still read again.
|
||||
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
|
||||
a second pull meanwhile does nothing, instead of no sign at all that the check started.
|
||||
- Settings no longer lists the server's download folder, which only an admin can change, on
|
||||
the admin page.
|
||||
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or
|
||||
back to the list. The items read on the way leave the Unread tab once you close the reader.
|
||||
|
||||
## [0.9.0] - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
|
||||
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
|
||||
version. The 2004 icon is still at /icon.png.
|
||||
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
|
||||
list of its own; an item with one of those words or phrases in its title or text is hidden
|
||||
from you and is not downloaded on your account. Other people's lists never hide anything from
|
||||
you.
|
||||
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
|
||||
elsewhere they copy the link. A link that looks like it carries your own access to a paid
|
||||
feed asks before it goes anywhere.
|
||||
|
||||
### Changed
|
||||
|
||||
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
|
||||
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
|
||||
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
|
||||
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
|
||||
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
|
||||
keep their outlines.
|
||||
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
|
||||
one along the far edges, and a soft sheen from the top-left corner.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
|
||||
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
||||
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
||||
them.
|
||||
- Swiping between items on a phone takes a quarter of the screen, so a diagonal scroll no longer
|
||||
jumps ahead by accident, and no longer shows the item list through the reader: the next or
|
||||
previous item sits beside the one you are reading and comes across with it, following your
|
||||
finger, the end of the list says so, and swiping right from the first item draws the reader off
|
||||
the list, dimmed until it is uncovered. A swipe from the screen's left edge stays with the page,
|
||||
rather than Safari taking it as Back.
|
||||
|
||||
## [0.8.4] - 2026-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
|
||||
goes solid when the system asks for less transparency or more contrast.
|
||||
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
|
||||
plays through the host's own player instead of the page's, so it keeps going when the screen
|
||||
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
|
||||
work as they always did. Video still plays in the page. In a browser nothing changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
|
||||
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
|
||||
home screen. In landscape the bars keep clear of the notch as well.
|
||||
|
||||
## [0.8.3] - 2026-09-19
|
||||
|
||||
### Security
|
||||
|
||||
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||
says where the token is kept instead: `[web] token` in config.toml.
|
||||
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||
docs/sso.md.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||
now on have it.
|
||||
|
||||
## [0.8.2] - 2026-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
|
||||
contrast, black and white with every colour at 7:1 or more.
|
||||
|
||||
### Changed
|
||||
|
||||
- The themes in Settings are listed in alphabetical order.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
|
||||
read comfortably. A failed-action message is easier to read in every theme.
|
||||
|
||||
## [0.8.1] - 2026-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
|
||||
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
|
||||
"Downloaded" toast is only for a file on your screen.
|
||||
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
|
||||
- The Classic theme is listed in Settings as just "Classic".
|
||||
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
|
||||
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
|
||||
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
|
||||
drawing at desktop width.
|
||||
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
|
||||
- In Nordic's dark half, button and toolbar borders show.
|
||||
- A zero unread count on the selected feed no longer disappears into the selection.
|
||||
|
||||
## [0.8.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
|
||||
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
|
||||
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
|
||||
across, everything in one go.
|
||||
- The catalogue of feeds and the server settings the admin page edits are kept in the database
|
||||
rather than config.toml, which keeps where things are, the torrent settings and who may sign
|
||||
in. The first start takes them from config.toml and trims it, keeping the original as
|
||||
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
|
||||
|
||||
### Changed
|
||||
|
||||
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
|
||||
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
|
||||
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
|
||||
be opened by a 0.7 release first, which brings its tables up to date.
|
||||
|
||||
## [0.7.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
|
||||
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
|
||||
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
|
||||
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
|
||||
A theme chosen before this carries over.
|
||||
- Your theme is kept on your account rather than in the browser, so it follows you to another
|
||||
browser or computer, and the page arrives in it with no flash of the default. The theme a
|
||||
browser already had is saved to your account the first time you load the page.
|
||||
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
|
||||
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
|
||||
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
||||
swipe the item you are reading left for the next one and right for the one before, or back
|
||||
to the list from the first.
|
||||
- A favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on their own,
|
||||
and on white for an iPhone's home screen.
|
||||
|
||||
### Changed
|
||||
|
||||
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
|
||||
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
|
||||
Settings is now yours alone: your theme and your subscriptions.
|
||||
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
|
||||
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
|
||||
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
|
||||
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
|
||||
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
|
||||
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
|
||||
them, where long notes left them looking missing.
|
||||
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
|
||||
- The page is served minified, about a quarter smaller, with its script in a file of its own,
|
||||
`/app.js`, which your browser keeps between visits and fetches again only when an update changes
|
||||
it. The script is TypeScript in `web/src`, type-checked and built with swc; building ipx needs
|
||||
node.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching tabs straight after marking everything read no longer shows the previous tab's
|
||||
items: of two lists asked for at once, only the later one is shown.
|
||||
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
|
||||
higher than a downloaded one's.
|
||||
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
|
||||
now show: ipx asks for them without saying it is the page showing them.
|
||||
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
|
||||
pause, as the player bar's does, and pause it when pressed.
|
||||
- An item you open stays read. A list refresh that crossed with marking it read could put its
|
||||
unread dot back until the next refresh.
|
||||
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
|
||||
one, rather than a few read items lingering until a refresh cleared them.
|
||||
- An image or link in a post given relative to the post, such as The Observation Deck's, now
|
||||
points at the post's site rather than at ipx, and shows.
|
||||
|
||||
## [0.6.1] - 2026-09-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Time left, and when an episode counts as finished, go by the length your player measured
|
||||
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
|
||||
- A player left open in another tab or on another device no longer saves its older place over
|
||||
where you have got to since, which could drop an episode out of Currently Listening.
|
||||
|
||||
## [0.6.0] - 2026-09-15
|
||||
|
||||
### Added
|
||||
|
||||
- Each episode in Currently Listening has a cross that takes it off the list. It forgets where you
|
||||
got to, so playing it again starts from the beginning.
|
||||
- An admin can give a feed a Directory category in its settings (`category` in config.toml), for
|
||||
the blogs and other feeds that name none of their own. A feed's own iTunes category still wins.
|
||||
- Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds,
|
||||
g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list.
|
||||
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
|
||||
of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
|
||||
both filter in place.
|
||||
|
||||
### Changed
|
||||
|
||||
- Currently Listening marks the episode in the player with the EQ bars, as the item list does,
|
||||
and its progress and time left move as it plays. Each row says how much is left.
|
||||
- Directory shows each feed as its cover art in a grid, title and subscriber count underneath,
|
||||
instead of a list. Popular and the Add a feed dialog keep their rows.
|
||||
- The pages are set in Inter, served by ipx itself. Classic keeps Lucida Grande.
|
||||
- Keeping an item is now pinning it: a thumbtack in place of the flag, and Pin, Pinned and Unpin
|
||||
in place of Keep, Kept and Stop keeping. A pinned item is still never deleted.
|
||||
- Currently Listening is its own place in the feed list, below Popular, instead of a section at
|
||||
the bottom of the Popular page.
|
||||
- The first scan after upgrading fetches every feed in full once, on its usual schedule, so each
|
||||
picks up its category without waiting for the publisher to change something.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An episode that fails to load, or is paused before it has, no longer forgets where you left off
|
||||
in it, and so no longer drops out of Currently Listening.
|
||||
- Currently Listening lists the episodes you have started. It left out anything marked read, and
|
||||
opening an episode marks it read, so it usually showed nothing. An episode now leaves the list
|
||||
once 90% of it has played.
|
||||
- A WordPress post that embeds the file it encloses no longer lists, and downloads, that file
|
||||
twice. Items that already had it twice are folded into one at startup, and the spare copy
|
||||
deleted.
|
||||
- The pinned column's heading lines up with the pins under it, and every heading sits a pixel
|
||||
further right, over its column.
|
||||
- The feeds left behind by an OPML subscription removed before ipx retired them are cleared at
|
||||
startup: forgotten if nothing was downloaded, kept as orphaned if something was. Feeds from it
|
||||
that have since been given their own settings stay as they are, with their items. Removing an
|
||||
OPML or Patreon subscription no longer deletes the items of a feed inside it that has its own
|
||||
settings.
|
||||
- Titles that arrive as HTML, such as The Verge's, no longer show their entities as text:
|
||||
"Meta’s" reads "Meta’s". Titles already stored are corrected the next time their feed
|
||||
changes.
|
||||
|
||||
## [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.
|
||||
|
||||
### 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 `&` (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, unsubscribe and scan; play, mark
|
||||
read and keep for the selected item, showing its state; 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. 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; its size is in KB for small files instead of "0 MB".
|
||||
- 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, one meaning per icon: minus unsubscribes,
|
||||
x closes or cancels, plus adds or subscribes, a dialog's confirm button carries the icon of what
|
||||
it does, Keep is a flag, as it was in the original, mark unread is an envelope, and Subscribed is
|
||||
a green check. 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 words and font characters such as
|
||||
⟳ ⤓ ↗, which came out thin and tiny and differed from font to font, and the feed header's x for
|
||||
unsubscribe, which read as closing the page.
|
||||
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
|
||||
|
||||
### 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 something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
|
||||
refused before anything changes.
|
||||
|
||||
### 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. Opening an item marks it
|
||||
read; playing it marks it read only at the end or past 90%.
|
||||
- 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`), set by an
|
||||
admin, as are the global settings.
|
||||
- `[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
|
||||
|
||||
- Feeds from an OPML live in the database, not `config.toml`.
|
||||
- Torrents run detached, two at a time.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The daemon ignored SIGTERM until the current download finished.
|
||||
- A torrent could freeze scanning for up to an hour.
|
||||
- Image enclosures were downloaded as episodes.
|
||||
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
|
||||
- An item with several enclosures kept only the last.
|
||||
|
||||
### 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/ipx/compare/v1.5.0...main
|
||||
[1.5.1]: https://git.sdf1.net/rays/ipx/compare/v1.5.0...v1.5.1
|
||||
[1.5.0]: https://git.sdf1.net/rays/ipx/compare/v1.4.0...v1.5.0
|
||||
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...v1.4.0
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
||||
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
|
||||
[0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0
|
||||
[0.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3
|
||||
[0.10.2]: https://git.sdf1.net/rays/ipx/compare/v0.10.1...v0.10.2
|
||||
[0.10.1]: https://git.sdf1.net/rays/ipx/compare/v0.10.0...v0.10.1
|
||||
[0.10.0]: https://git.sdf1.net/rays/ipx/compare/v0.9.1...v0.10.0
|
||||
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
|
||||
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
|
||||
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
|
||||
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
|
||||
[0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
|
||||
[0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
|
||||
[0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
|
||||
[0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0
|
||||
39
crates/ipx/Cargo.toml
Normal file
39
crates/ipx/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "1.5.1"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ipx-feed.workspace = true
|
||||
ipx-directory.workspace = true
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
flate2.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
futures-util.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
librqbit.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
opml.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
reqwest.workspace = true
|
||||
sea-orm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
220
crates/ipx/src/access.rs
Normal file
220
crates/ipx/src/access.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
|
||||
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
|
||||
//! address is the Docker gateway: any container there could send the header and be anyone.
|
||||
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
|
||||
//! takes the network out of the question.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use jsonwebtoken::jwk::JwkSet;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
||||
|
||||
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
|
||||
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
|
||||
/// made-up key ids cannot turn every request into a request to Cloudflare.
|
||||
const REFETCH_EVERY: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Keys {
|
||||
cache: Mutex<Cache>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
keys: HashMap<String, DecodingKey>,
|
||||
fetched: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Claims {
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
impl Keys {
|
||||
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||
self.verify_with(team, aud, token, || fetch(client, team)).await
|
||||
}
|
||||
|
||||
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||
match fetch(client, team).await {
|
||||
Ok(set) => self.store(set),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = Result<JwkSet>>,
|
||||
{
|
||||
let kid = jsonwebtoken::decode_header(token).ok()?.kid?;
|
||||
let key = match self.key(&kid) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
if !self.may_refetch() {
|
||||
return None;
|
||||
}
|
||||
match fetch().await {
|
||||
Ok(set) => self.store(set),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.key(&kid)?
|
||||
}
|
||||
};
|
||||
// RS256 only: a token that names HS256 or none is refused here, before its signature
|
||||
// is looked at, rather than checked with the public key as if it were a secret.
|
||||
let mut v = Validation::new(Algorithm::RS256);
|
||||
v.set_audience(&[aud]);
|
||||
v.set_issuer(&[format!("https://{team}")]);
|
||||
v.validate_nbf = true;
|
||||
match jsonwebtoken::decode::<Claims>(token, &key, &v) {
|
||||
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "refused a Cloudflare Access token");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&self, kid: &str) -> Option<DecodingKey> {
|
||||
self.cache.lock().expect("a panic elsewhere poisoned this lock").keys.get(kid).cloned()
|
||||
}
|
||||
|
||||
/// Takes the slot as it answers, so two requests at once do not both fetch.
|
||||
fn may_refetch(&self) -> bool {
|
||||
let mut c = self.cache.lock().expect("a panic elsewhere poisoned this lock");
|
||||
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
|
||||
return false;
|
||||
}
|
||||
c.fetched = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||
fn store(&self, set: JwkSet) {
|
||||
let keys =
|
||||
set.keys.iter().filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))).collect();
|
||||
let mut c = self.cache.lock().expect("a panic elsewhere poisoned this lock");
|
||||
c.keys = keys;
|
||||
c.fetched = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||
client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("fetching {url}"))?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await
|
||||
.context("reading the signing keys")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{EncodingKey, Header};
|
||||
|
||||
const TEAM: &str = "team.cloudflareaccess.com";
|
||||
const AUD: &str = "aud-tag";
|
||||
|
||||
fn jwks() -> JwkSet {
|
||||
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
|
||||
}
|
||||
|
||||
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||
let mut h = Header::new(alg);
|
||||
h.kid = Some("k1".into());
|
||||
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||
jsonwebtoken::encode(&h, &claims, &k).unwrap()
|
||||
}
|
||||
|
||||
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||
let now = jsonwebtoken::get_current_timestamp() as i64;
|
||||
serde_json::json!({
|
||||
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
|
||||
})
|
||||
}
|
||||
|
||||
const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
|
||||
const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
|
||||
|
||||
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||
let keys = Keys::default();
|
||||
keys.store(jwks());
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
|
||||
|
||||
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
|
||||
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
|
||||
|
||||
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
|
||||
assert_eq!(check(&keys, &expired).await, None, "expired");
|
||||
|
||||
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
|
||||
|
||||
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", ok.split('.').nth(1).unwrap());
|
||||
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_key_refetches_once_a_minute_at_most() {
|
||||
let keys = Keys::default();
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||
let count = || {
|
||||
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
async { Ok(jwks()) }
|
||||
};
|
||||
assert_eq!(
|
||||
keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(),
|
||||
Some("rays@sdf1.net"),
|
||||
"a key not cached yet is fetched"
|
||||
);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
// A made-up key id straight after: not fetched again.
|
||||
let mut h = Header::new(Algorithm::RS256);
|
||||
h.kid = Some("nobody".into());
|
||||
let stray = jsonwebtoken::encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||
let count = || {
|
||||
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
async { Ok(jwks()) }
|
||||
};
|
||||
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
|
||||
let keys = Keys::default();
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
141
crates/ipx/src/art.rs
Normal file
141
crates/ipx/src/art.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
||||
//! of each publisher's server for every visit, and still there when a publisher is not.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub fn dir() -> PathBuf {
|
||||
crate::config::data_dir().join("art")
|
||||
}
|
||||
|
||||
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
|
||||
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
|
||||
// only makes the cache miss once and refill; a collision among a few thousand images is about
|
||||
// one in 10^12. Move to a SHA if either ever matters.
|
||||
pub fn path(url: &str) -> PathBuf {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
url.hash(&mut h);
|
||||
dir().join(format!("{:016x}", h.finish()))
|
||||
}
|
||||
|
||||
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
|
||||
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
|
||||
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
|
||||
let body = std::fs::read(file).ok()?;
|
||||
if let Ok(f) = File::options().write(true).open(file) {
|
||||
let _ = f.set_modified(SystemTime::now());
|
||||
}
|
||||
Some((kind, body))
|
||||
}
|
||||
|
||||
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
|
||||
/// never reads half of one.
|
||||
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
|
||||
let tmp = file.with_extension("part");
|
||||
std::fs::write(&tmp, body)?;
|
||||
std::fs::write(file.with_extension("type"), kind)?;
|
||||
std::fs::rename(&tmp, file)
|
||||
}
|
||||
|
||||
/// How long an address that could not be fetched is answered from memory before it is tried again.
|
||||
const RETRY: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
/// Addresses that could not be fetched lately, and why. Without it a page shown again fetched
|
||||
/// them again: five covers too big to keep were downloaded from Patreon on every load (#169).
|
||||
// ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most
|
||||
// one per artwork address a feed or item names; move it to disk or prune it on trim if that grows.
|
||||
static FAILED: LazyLock<Mutex<HashMap<String, (Instant, String)>>> = LazyLock::new(Default::default);
|
||||
|
||||
/// Why `url` could not be fetched, if it failed less than an hour ago.
|
||||
pub fn failed(url: &str) -> Option<String> {
|
||||
failed_as_of(url, Instant::now())
|
||||
}
|
||||
|
||||
fn failed_as_of(url: &str, now: Instant) -> Option<String> {
|
||||
let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match map.get(url) {
|
||||
Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()),
|
||||
Some(_) => {
|
||||
map.remove(url);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail(url: &str, why: &str) {
|
||||
FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned()));
|
||||
}
|
||||
|
||||
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
|
||||
/// Returns how many went.
|
||||
pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
||||
let mut kept: Vec<(SystemTime, u64, PathBuf)> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_none())
|
||||
.filter_map(|p| {
|
||||
let m = p.metadata().ok()?;
|
||||
Some((m.modified().ok()?, m.len(), p))
|
||||
})
|
||||
.collect();
|
||||
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
|
||||
kept.sort();
|
||||
let mut gone = 0;
|
||||
for (_, n, p) in kept {
|
||||
if total <= limit {
|
||||
break;
|
||||
}
|
||||
let _ = std::fs::remove_file(p.with_extension("type"));
|
||||
if std::fs::remove_file(&p).is_ok() {
|
||||
total -= n;
|
||||
gone += 1;
|
||||
}
|
||||
}
|
||||
crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed);
|
||||
crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
gone
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_least_recently_used_go_first_until_it_fits() {
|
||||
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
|
||||
let f = d.join(name);
|
||||
write(&f, "image/png", &[0; 1000]).unwrap();
|
||||
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
|
||||
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
// Shown just now: no longer the oldest.
|
||||
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
|
||||
assert_eq!(trim(&d, 2000), 1);
|
||||
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
|
||||
assert!(d.join("old").exists() && d.join("new").exists());
|
||||
assert_eq!(trim(&d, 0), 2);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_is_remembered_for_an_hour() {
|
||||
let u = "https://example.com/a-failure-is-remembered.png";
|
||||
assert_eq!(failed(u), None);
|
||||
fail(u, "larger than 5 MB");
|
||||
assert_eq!(failed(u).as_deref(), Some("larger than 5 MB"));
|
||||
assert_eq!(failed_as_of(u, Instant::now() + RETRY), None);
|
||||
// Asked for after the hour, it is forgotten, not just ignored.
|
||||
assert_eq!(failed(u), None);
|
||||
}
|
||||
}
|
||||
222
crates/ipx/src/auth.rs
Normal file
222
crates/ipx/src/auth.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! 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::{Context, Result, bail};
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::phc::PasswordHash;
|
||||
use argon2::password_hash::{PasswordHasher, PasswordVerifier};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
||||
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
|
||||
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
|
||||
static CHECKS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
|
||||
Arc::new(Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1))))
|
||||
});
|
||||
|
||||
/// What a name that is not an account is checked against, so that refusing it takes as long as
|
||||
/// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the
|
||||
/// time told anyone which names are accounts here (#138).
|
||||
static DECOY: LazyLock<String> =
|
||||
LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
|
||||
|
||||
/// A sign-in's password against the account's hash, or against `DECOY` when there is no account
|
||||
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
|
||||
/// a time; see `CHECKS`.
|
||||
pub async fn check_password(password: String, stored: Option<String>) -> bool {
|
||||
let Ok(turn) = CHECKS.clone().acquire_owned().await else { return false };
|
||||
// The turn goes with the check, not with this future: a client that hung up dropped the
|
||||
// future, freeing the turn while the check ran on, so disconnecting sign-ins ran any number
|
||||
// at once (#324).
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _turn = turn;
|
||||
match stored {
|
||||
Some(h) => verify_password(&password, &h),
|
||||
None => {
|
||||
verify_password(&password, &DECOY);
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log
|
||||
/// or a file says what it opens (#123).
|
||||
pub fn new_api_token() -> String {
|
||||
format!("ipx_{}", new_session_token())
|
||||
}
|
||||
|
||||
/// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a
|
||||
/// password, so there is nothing to guess from a stolen hash.
|
||||
pub fn api_token_hash(token: &str) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
|
||||
use std::io::Read;
|
||||
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())
|
||||
}
|
||||
|
||||
/// The first admin's password: `IPX_ADMIN_PASSWORD` when set, or a random one written to
|
||||
/// `admin-password` beside config.toml, readable by the daemon's user alone. Every install began
|
||||
/// as admin / ipodderx, published in the README, and nothing made anyone change it (#321).
|
||||
/// Returns the password and where it can be found, for the log, which never shows it.
|
||||
pub(crate) fn first_password(config_dir: &Path, from_env: Option<String>) -> Result<(String, String)> {
|
||||
if let Some(p) = from_env.filter(|p| !p.is_empty()) {
|
||||
return Ok((p, "with the password in IPX_ADMIN_PASSWORD".into()));
|
||||
}
|
||||
let password = crate::auth::new_session_token()[..24].to_owned();
|
||||
let file = config_dir.join("admin-password");
|
||||
write_private(&file, &password)?;
|
||||
Ok((password, format!("with the password in {}", file.display())))
|
||||
}
|
||||
|
||||
/// Writes a secret only its owner may read: created 0600, never readable for a moment between.
|
||||
pub(crate) fn write_private(file: &Path, text: &str) -> Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
|
||||
let _ = std::fs::remove_file(file);
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(file)
|
||||
.with_context(|| format!("writing {}", file.display()))?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sign_in_without_an_account_is_checked_all_the_same() {
|
||||
let h = hash_password("correct horse battery").unwrap();
|
||||
assert!(check_password("correct horse battery".into(), Some(h.clone())).await);
|
||||
assert!(!check_password("wrong".into(), Some(h)).await);
|
||||
assert!(!check_password("correct horse battery".into(), None).await);
|
||||
// A decoy that does not parse is refused before any hashing, and the time says so (#138).
|
||||
assert!(PasswordHash::new(&DECOY).is_ok());
|
||||
}
|
||||
|
||||
#[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 api_tokens_are_prefixed_and_hash_to_hex() {
|
||||
let t = new_api_token();
|
||||
assert!(t.starts_with("ipx_") && t.len() == 68);
|
||||
let h = api_token_hash(&t);
|
||||
assert_eq!(h.len(), 64);
|
||||
assert_eq!(h, api_token_hash(&t), "the same token, the same hash");
|
||||
assert_ne!(h, api_token_hash(&new_api_token()));
|
||||
assert_eq!(
|
||||
api_token_hash("abc"),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
"SHA-256"
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_admin_gets_a_password_nobody_else_knows() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("ipx-first-password-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
let (a, told) = first_password(&dir, None).unwrap();
|
||||
let file = dir.join("admin-password");
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), a);
|
||||
assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert!(told.contains("admin-password") && !told.contains(&a), "the log says where, not what: {told}");
|
||||
assert_eq!(a.len(), 24);
|
||||
assert_ne!(a, "ipodderx");
|
||||
assert_ne!(first_password(&dir, None).unwrap().0, a, "each new database gets its own");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let (b, told) = first_password(&dir, Some("chosen".into())).unwrap();
|
||||
assert_eq!(b, "chosen");
|
||||
assert!(!told.contains("chosen"));
|
||||
assert!(!file.exists(), "a password set in the environment is not written anywhere");
|
||||
assert_eq!(first_password(&dir, Some(String::new())).unwrap().0.len(), 24, "empty means unset");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
616
crates/ipx/src/catalogue.rs
Normal file
616
crates/ipx/src/catalogue.rs
Normal file
@@ -0,0 +1,616 @@
|
||||
//! The feeds the server knows and who reads them: adding, removing, leaving, and what an
|
||||
//! OPML or Patreon list leaves behind when it goes.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Event;
|
||||
use crate::{cli, config, db, feed, lists};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use url::Url;
|
||||
|
||||
/// Subscribes to one feed, naming it from its own title.
|
||||
pub(crate) async fn add(
|
||||
ctx: &Ctx,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
category: Option<String>,
|
||||
list: bool,
|
||||
) -> Result<()> {
|
||||
let url = feed::find_feed(&ctx.client, &feed::expand_input(url)).await?;
|
||||
let (id, existed) = add_to_catalogue(ctx, &url, folder, keywords).await?;
|
||||
// Listing a feed the catalogue already has, or giving it a category, is the point of asking
|
||||
// again: it changes those, and nothing else. A feed an OPML lists has no entry to change.
|
||||
if list || category.is_some() {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
if let Some(f) = cfg.feeds.get_mut(&id) {
|
||||
f.listed |= list;
|
||||
if category.is_some() {
|
||||
f.category = category;
|
||||
}
|
||||
ctx.store_cfg(cfg).await?;
|
||||
if existed {
|
||||
println!("{id} is already in the catalogue; updated its Directory listing");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if existed {
|
||||
anyhow::bail!("already subscribed as {id:?}");
|
||||
}
|
||||
println!("added {id}{}", if list { ", listed in the Directory" } else { "" });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
|
||||
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add`
|
||||
/// and the page's Add feed alike, which were the same steps written twice (#161); each finds the
|
||||
/// feed first, to say its own way why an address is not one.
|
||||
pub async fn add_to_catalogue(
|
||||
ctx: &Ctx,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
) -> Result<(String, bool)> {
|
||||
// Taken before the duplicate check, so two people adding the same feed at once add it once.
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
if let Some(existing) = subscriptions(ctx).await?.into_iter().find(|s| feed::same_feed(&s.cfg.url, url)) {
|
||||
return Ok((existing.id, true));
|
||||
}
|
||||
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
||||
ctx.store_cfg(cfg).await?;
|
||||
Ok((id, false))
|
||||
}
|
||||
|
||||
/// Returns the new feed id. Adding checks the address is a feed first (`feed::find_feed`); this
|
||||
/// still names one it cannot read from its URL rather than failing.
|
||||
pub(crate) async fn add_one(
|
||||
ctx: &Ctx,
|
||||
cfg: &mut config::Config,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
) -> Result<String> {
|
||||
let probe = config::Feed {
|
||||
url: url.to_owned(),
|
||||
folder: folder.clone(),
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
keywords: keywords.clone(),
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
|
||||
let title = match feed::fetch(&ctx.feed_client, &probe.url, None, None, None).await.map(|(got, _)| got) {
|
||||
// An OPML subscription is named from its own <head><title>, not by trying to
|
||||
// parse it as a feed and falling back to the hostname.
|
||||
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
|
||||
feed::opml_title(&bytes).unwrap_or_else(|| url_stem(url))
|
||||
}
|
||||
Ok(feed::Fetched::Body { bytes, .. }) => {
|
||||
feed::parse(&bytes).ok().and_then(|f| f.title).unwrap_or_else(|| url_stem(url))
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(url, "could not read the feed; naming it from its URL");
|
||||
url_stem(url)
|
||||
}
|
||||
};
|
||||
|
||||
// Slugs must be unique across derived feeds too, or a new feed can collide with one
|
||||
// an OPML already introduced.
|
||||
let mut taken: BTreeMap<String, config::Feed> =
|
||||
subscriptions(ctx).await?.into_iter().map(|s| (s.id, s.cfg)).collect();
|
||||
// A removed feed keeps its rows, so its id is only free again for the same feed: re-adding
|
||||
// it gets its history back, and a different feed does not inherit someone else's.
|
||||
for (id, other) in ctx.db.feed_urls().await? {
|
||||
if !feed::same_feed(&other, url) {
|
||||
taken.entry(id).or_insert_with(|| probe.clone());
|
||||
}
|
||||
}
|
||||
let id = config::unique_slug(&title, |s| taken.contains_key(s));
|
||||
cfg.feeds.insert(id.clone(), probe);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Host plus last path segment, for naming a feed we could not read.
|
||||
pub(crate) fn url_stem(url: &str) -> String {
|
||||
Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_owned)).unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
let Some(parent) = cfg.feeds.remove(feed) else {
|
||||
// Derived from an OPML: drop it here, though the subscription will list it again
|
||||
// on the next read unless the OPML itself goes.
|
||||
ctx.db.drop_managed(feed).await?;
|
||||
println!("removed {feed}; it came from an OPML subscription and may return on the next read");
|
||||
return Ok(());
|
||||
};
|
||||
ctx.store_cfg(cfg).await?;
|
||||
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
||||
println!("removed {feed}; downloads and history kept");
|
||||
retire_group(ctx, feed, Some(&parent)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
let text = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
|
||||
// The CLI speaks for the operator, as the shared web token does.
|
||||
let admin = ctx
|
||||
.db
|
||||
.users()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|u| u.is_admin)
|
||||
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
|
||||
let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
|
||||
let (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&doc)?, admin.id).await?;
|
||||
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The configuration ipx runs with: config.toml for where things are and who may sign in, the
|
||||
/// database for the feeds and the server settings (issue #18). The first time a database holds
|
||||
/// neither, it takes them from config.toml, which is then cut down to the rest, the original kept
|
||||
/// beside it as config.toml.pre-database.
|
||||
pub(crate) async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &Path) -> Result<config::Config> {
|
||||
for _ in 0..2 {
|
||||
if let Some((stored, feeds)) = db.stored_config().await? {
|
||||
if config::Config::file_holds_stored(path) {
|
||||
tracing::warn!(
|
||||
"config.toml still lists feeds or server settings; they are ignored, since the \
|
||||
database holds them now. Change them in the web UI, or with ipx add and rm."
|
||||
);
|
||||
}
|
||||
stored.apply(&mut cfg);
|
||||
cfg.feeds = feeds;
|
||||
return Ok(cfg);
|
||||
}
|
||||
if db.import_config(&cfg).await? {
|
||||
if path.exists() {
|
||||
let original = path.with_extension("toml.pre-database");
|
||||
if !original.exists() {
|
||||
std::fs::copy(path, &original)
|
||||
.with_context(|| format!("keeping the original as {}", original.display()))?;
|
||||
}
|
||||
cfg.save_bootstrap(path)?;
|
||||
}
|
||||
tracing::info!(
|
||||
feeds = cfg.feeds.len(),
|
||||
"moved the feeds and server settings from config.toml into the database"
|
||||
);
|
||||
return Ok(cfg);
|
||||
}
|
||||
// Another ipx imported between our look and our insert; the next pass reads its copy.
|
||||
}
|
||||
anyhow::bail!("the database says it holds the configuration and then that it does not")
|
||||
}
|
||||
|
||||
pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
||||
let mut doc = opml::OPML {
|
||||
head: Some(opml::Head { title: Some("ipx subscriptions".into()), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
for (id, feed) in &ctx.cfg().feeds {
|
||||
let title = ctx.db.feed_summary(id).await.ok().and_then(|s| s.title).unwrap_or_else(|| id.clone());
|
||||
doc.add_feed(&title, &feed.url);
|
||||
}
|
||||
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
|
||||
std::fs::write(file, xml).with_context(|| format!("writing {}", file.display()))?;
|
||||
println!("exported {} feed(s) to {}", ctx.cfg().feeds.len(), file.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list(ctx: &Ctx) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
if cfg.feeds.is_empty() {
|
||||
println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI.");
|
||||
return Ok(());
|
||||
}
|
||||
for (id, feed) in &cfg.feeds {
|
||||
let s = ctx.db.feed_summary(id).await?;
|
||||
// The id on a line of its own, labelled: first on the title's line, antirez.com's id
|
||||
// "feed" read as a heading and 'ipx fetch antirez' was tried instead (issue #82).
|
||||
println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id));
|
||||
println!(" id {id}");
|
||||
println!(" url {}", feed.url);
|
||||
println!(" last checked {}", cli::ago(s.last_checked));
|
||||
println!(" entries {} ({} downloaded)", s.entries, s.downloaded);
|
||||
if let Some(err) = &s.last_error {
|
||||
println!(" last error {err}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes feeds nobody subscribes to that are dead (failing for 30 days) or quiet (nothing new
|
||||
/// in a year), from the catalogue and the database, so the Directory lists feeds worth taking.
|
||||
/// A feed listed in it with no subscribers stays for as long as it works and publishes.
|
||||
pub(crate) async fn clean_directory(ctx: &Ctx) -> Result<()> {
|
||||
const DEAD: i64 = 30 * 86_400;
|
||||
const QUIET: i64 = 365 * 86_400;
|
||||
let now = db::now();
|
||||
// Once a day, for feeds dead a month or quiet a year: before every scan, its query took 1 to
|
||||
// 12 s every few minutes, and the scans and downloads waited behind it (#193).
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
if now - ctx.directory_cleaned.load(Relaxed) < 86_400 {
|
||||
return Ok(());
|
||||
}
|
||||
ctx.directory_cleaned.store(now, Relaxed);
|
||||
let stale = ctx.db.stale_unsubscribed(now - DEAD, now - QUIET).await?;
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
// Every one removed, so not `any`, which would stop at the first.
|
||||
let mut removed = std::collections::HashMap::new();
|
||||
for id in &stale {
|
||||
if let Some(f) = cfg.feeds.remove(id) {
|
||||
removed.insert(id.clone(), f);
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
ctx.store_cfg(cfg).await?;
|
||||
}
|
||||
for id in &stale {
|
||||
retire_group(ctx, id, removed.get(id)).await?;
|
||||
ctx.db.forget_feed(id).await?;
|
||||
tracing::info!(
|
||||
feed = id,
|
||||
"removed a feed nobody subscribes to that is dead or has published nothing in a year"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One feed to scan: either an entry you wrote in config.toml, or one derived from an
|
||||
/// OPML subscription and held only in the database.
|
||||
pub struct Sub {
|
||||
pub id: String,
|
||||
pub cfg: config::Feed,
|
||||
/// True when it came from an OPML and has no config entry of its own.
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
/// Everything to scan: your config entries, plus whatever the OPML subscriptions listed.
|
||||
///
|
||||
/// A derived feed borrows its parent's settings wholesale. That is why it needs no config
|
||||
/// entry -- there is nothing to store but its URL and where it came from.
|
||||
pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||
let cfg = ctx.cfg();
|
||||
let mut out: Vec<Sub> =
|
||||
cfg.feeds.iter().map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false }).collect();
|
||||
|
||||
// A group's title, once a group: asked for each feed in it, three queries a feed, it was about
|
||||
// 96 for The Glass Cannon Network's 32 shows on every feed list, live update and scan (#159).
|
||||
let mut group_titles = std::collections::HashMap::<String, String>::new();
|
||||
for m in ctx.db.managed_feeds().await? {
|
||||
if cfg.feeds.contains_key(&m.id) {
|
||||
continue; // promoted to config at some point; that entry wins
|
||||
}
|
||||
let parent = cfg.feeds.get(&m.group_id);
|
||||
if parent.is_none() {
|
||||
// The OPML or Patreon feed this was derived from is no longer in config --
|
||||
// removing it should have retired these rows too (see `retire_group`), but
|
||||
// skip them here regardless so a row that slips through is never scanned.
|
||||
continue;
|
||||
}
|
||||
let base = match parent.and_then(|p| p.folder.clone()) {
|
||||
Some(folder) => folder,
|
||||
None => {
|
||||
if !group_titles.contains_key(&m.group_id) {
|
||||
let title = ctx.db.feed_summary(&m.group_id).await.ok().and_then(|s| s.title);
|
||||
group_titles.insert(m.group_id.clone(), title.unwrap_or_else(|| m.group_id.clone()));
|
||||
}
|
||||
group_titles[&m.group_id].clone()
|
||||
}
|
||||
};
|
||||
let title = m.title.clone().unwrap_or_else(|| m.id.clone());
|
||||
let (username, password, password_env) = parent.map(|p| p.credentials_for(&m.url)).unwrap_or_default();
|
||||
out.push(Sub {
|
||||
id: m.id.clone(),
|
||||
cfg: config::Feed {
|
||||
url: m.url.clone(),
|
||||
folder: Some(format!("{base}/{title}")),
|
||||
group: Some(m.group_id.clone()),
|
||||
media_types: parent.and_then(|p| p.media_types.clone()),
|
||||
schedule: parent.and_then(|p| p.schedule.clone()),
|
||||
keywords: parent.map(|p| p.keywords.clone()).unwrap_or_default(),
|
||||
allow_explicit: parent.is_some_and(|p| p.allow_explicit),
|
||||
auto_download: parent.is_none_or(|p| p.auto_download),
|
||||
max_new_per_check: parent.and_then(|p| p.max_new_per_check),
|
||||
username,
|
||||
password,
|
||||
password_env,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
managed: true,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's
|
||||
/// lists are left alone.
|
||||
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
|
||||
use tracing::Instrument;
|
||||
ctx.db.unsubscribe(user_id, id).instrument(db::step("unsubscribe")).await?;
|
||||
// What they kept stays on their list, a feed of its own once nobody reads the list (#280).
|
||||
let kept = ctx.db.kept_feeds(user_id).instrument(db::step("kept")).await?;
|
||||
let all = subscriptions(ctx).instrument(db::step("subscriptions")).await?;
|
||||
let unkept = all.iter().filter(|s| s.cfg.group.as_deref() == Some(id) && !kept.contains(&s.id));
|
||||
for child in unkept {
|
||||
ctx.db.unsubscribe(user_id, &child.id).instrument(db::step("unsubscribe")).await?;
|
||||
}
|
||||
if ctx.db.subscriber_counts().instrument(db::step("subscriber_counts")).await?.contains_key(id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
||||
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
||||
// admin listed stays in the Directory, for the next person.
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
if cfg.feeds.get(id).is_some_and(|f| f.listed) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(parent) = cfg.feeds.remove(id) else {
|
||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||
// read unless you unsubscribe from the OPML itself.
|
||||
return ctx.db.drop_managed(id).await;
|
||||
};
|
||||
ctx.store_cfg(cfg).await?;
|
||||
retire_group(ctx, id, Some(&parent)).await
|
||||
}
|
||||
|
||||
/// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys
|
||||
/// took the subscriptions with the account, but not the feed: one only they read stayed in the
|
||||
/// catalogue with nobody subscribed, downloading every new episode for no one (#157).
|
||||
pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> {
|
||||
let catalogue = subscriptions(ctx).await?;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
for id in &mine {
|
||||
// A feed in a group of theirs goes with the group.
|
||||
let in_theirs = catalogue.iter().any(|s| &s.id == id && s.cfg.group.as_ref().is_some_and(|g| mine.contains(g)));
|
||||
if !in_theirs {
|
||||
leave(ctx, user_id, id).await?;
|
||||
}
|
||||
}
|
||||
ctx.db.delete_user(user_id).await
|
||||
}
|
||||
|
||||
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
|
||||
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
|
||||
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
|
||||
/// itself is removed, since `subscriptions()` would otherwise keep scanning them under a
|
||||
/// fallback policy meant for a feed with no parent at all. A feed promoted to config is not
|
||||
/// derived any more, so it is only unmanaged. `parent` is the list's settings as they were before
|
||||
/// it was removed, None when that is no longer known.
|
||||
pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Feed>) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
let managed = ctx.db.managed_feeds().await?;
|
||||
let derived = managed.into_iter().filter(|m| m.group_id == parent_id);
|
||||
for m in derived {
|
||||
if cfg.feeds.contains_key(&m.id) {
|
||||
// Scanned from its config entry and still read. Dropped as derived, its stored
|
||||
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
||||
ctx.db.unmanage(&m.id).await?;
|
||||
} else if promote_kept(ctx, &m, parent).await? {
|
||||
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
|
||||
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||
ctx.db.set_orphaned(&m.id, true).await?;
|
||||
} else {
|
||||
ctx.db.drop_managed(&m.id).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes a feed from an OPML or Patreon list a feed of its own, for those who kept it (#280): a
|
||||
/// catalogue entry in no group, which the list no longer adds or takes away. Whoever had it only
|
||||
/// through the list, without keeping it, loses it, as they would have when the list dropped it.
|
||||
/// False, changing nothing, when nobody kept it. `base` is the list's settings: most callers have
|
||||
/// removed the list from config by now, so they pass them.
|
||||
pub(crate) async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&config::Feed>) -> Result<bool> {
|
||||
let subs = ctx.db.subscribers_of(&m.id).await?;
|
||||
if !subs.iter().any(|(_, kept)| *kept) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
// The list's own settings carry over; its folder and group do not.
|
||||
let (username, password, password_env) = base.map(|p| p.credentials_for(&m.url)).unwrap_or_default();
|
||||
cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
|
||||
url: m.url.clone(),
|
||||
folder: None,
|
||||
group: None,
|
||||
media_types: base.and_then(|p| p.media_types.clone()),
|
||||
schedule: base.and_then(|p| p.schedule.clone()),
|
||||
keywords: base.map(|p| p.keywords.clone()).unwrap_or_default(),
|
||||
allow_explicit: base.is_some_and(|p| p.allow_explicit),
|
||||
auto_download: base.is_none_or(|p| p.auto_download),
|
||||
max_new_per_check: base.and_then(|p| p.max_new_per_check),
|
||||
username,
|
||||
password,
|
||||
password_env,
|
||||
category: None,
|
||||
listed: false,
|
||||
// Looked up after the list was removed, private was always false, and a private OPML's
|
||||
// kept show went to the directory (#332). A list no longer known may have been private.
|
||||
private: base.is_none_or(|p| p.private),
|
||||
});
|
||||
ctx.store_cfg(cfg).await?;
|
||||
ctx.db.unmanage(&m.id).await?;
|
||||
ctx.db.set_orphaned(&m.id, false).await?;
|
||||
for (user, kept) in subs {
|
||||
if !kept {
|
||||
ctx.db.unsubscribe(user, &m.id).await?;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Retires every group whose parent is gone from config, and returns how many derived rows that
|
||||
/// dropped or unmanaged. An OPML removed before `retire_group` existed left its feeds behind:
|
||||
/// davewiner's 922 were skipped by every scan and never cleared, and their stale errors were
|
||||
/// most of the ones stored.
|
||||
pub(crate) async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
||||
let cfg = ctx.cfg();
|
||||
let before = ctx.db.managed_feeds().await?;
|
||||
let stranded: std::collections::BTreeSet<&str> =
|
||||
before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect();
|
||||
for group in stranded {
|
||||
retire_group(ctx, group, None).await?;
|
||||
}
|
||||
Ok(before.len() - ctx.db.managed_feeds().await?.len())
|
||||
}
|
||||
|
||||
/// A feed that answered from a new address after permanent redirects moves there in the
|
||||
/// catalogue, so it is read from there and no longer redirected every time. One an OPML lists
|
||||
/// is left alone, since the OPML would only put the old address back; so is a move onto an
|
||||
/// address another feed already has.
|
||||
pub(crate) async fn follow_move(ctx: &Ctx, id: &str, to: &str) -> Result<()> {
|
||||
// Said in the event below, not logged here: the event is the log line.
|
||||
if subscriptions(ctx).await?.iter().any(|s| s.id != id && feed::same_feed(&s.cfg.url, to)) {
|
||||
tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it");
|
||||
return Ok(());
|
||||
}
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) };
|
||||
let from = std::mem::replace(&mut f.url, to.to_owned());
|
||||
ctx.store_cfg(cfg).await?;
|
||||
ctx.out.emit(Event::FeedMoved { feed: id.to_owned(), from, to: to.to_owned() });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
|
||||
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[general]\nschedule = \"every 2h\"\n[web]\ntoken = \"t\"\n[feeds.show]\nurl = \"http://x/show.xml\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let db = db::Db::memory().await.unwrap();
|
||||
|
||||
let first = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
|
||||
assert_eq!(first.feeds.keys().collect::<Vec<_>>(), ["show"]);
|
||||
assert_eq!(first.general.schedule, "every 2h");
|
||||
assert!(dir.join("config.toml.pre-database").exists(), "the original is kept");
|
||||
assert!(!config::Config::file_holds_stored(&path), "and the file no longer lists them");
|
||||
|
||||
// The next start reads them from the database, the trimmed file notwithstanding.
|
||||
let next = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
|
||||
assert_eq!(next.feeds.keys().collect::<Vec<_>>(), ["show"]);
|
||||
assert_eq!(next.general.schedule, "every 2h");
|
||||
assert_eq!(next.web.token, "t");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_feeds_leave_the_directory_once_a_day_not_before_every_scan() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let dead = |id: &str| {
|
||||
format!("INSERT INTO feeds (id, url, error_since, managed) VALUES ('{id}', 'u-{id}', 100, false)")
|
||||
};
|
||||
let listed = async || ctx.db.feed_urls().await.unwrap().into_iter().map(|(id, _)| id).collect::<Vec<_>>();
|
||||
ctx.db.sql(&dead("a")).await.unwrap();
|
||||
clean_directory(&ctx).await.unwrap();
|
||||
assert!(listed().await.is_empty(), "the first pass since starting looks");
|
||||
ctx.db.sql(&dead("b")).await.unwrap();
|
||||
clean_directory(&ctx).await.unwrap();
|
||||
assert_eq!(listed().await, ["b"], "not again the same day (#193)");
|
||||
ctx.directory_cleaned.store(db::now() - 86_401, std::sync::atomic::Ordering::Relaxed);
|
||||
clean_directory(&ctx).await.unwrap();
|
||||
assert!(listed().await.is_empty(), "a day on, it looks again");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_removed_account_takes_the_feeds_only_it_read_with_it() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
for id in ["only-theirs", "shared", "listed"] {
|
||||
cfg.feeds.insert(id.into(), config::Feed { listed: id == "listed", ..feed() });
|
||||
}
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
let gone = ctx.db.create_user("gone", None, false).await.unwrap();
|
||||
let stays = ctx.db.create_user("stays", None, true).await.unwrap();
|
||||
for id in ["only-theirs", "shared", "listed"] {
|
||||
ctx.db.subscribe(gone, id).await.unwrap();
|
||||
}
|
||||
ctx.db.subscribe(stays, "shared").await.unwrap();
|
||||
|
||||
remove_user(&ctx, gone).await.unwrap();
|
||||
assert!(ctx.db.user_by_id(gone).await.unwrap().is_none());
|
||||
// Nobody reads it now: out of the catalogue, not downloading for no one (#157).
|
||||
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["listed", "shared"]);
|
||||
assert!(ctx.db.subscription(stays, "shared").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() {
|
||||
// davewiner: the OPML subscription left config.toml, but its 922 derived rows
|
||||
// stayed in the database and kept being scanned under the no-parent fallback.
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
ctx.db.upsert_managed("child", "http://x/child.xml", "Child", "gone-opml").await.unwrap();
|
||||
assert!(
|
||||
subscriptions(&ctx).await.unwrap().iter().all(|s| s.id != "child"),
|
||||
"a derived feed whose parent is gone from config must not be scanned"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
ctx.db.upsert_managed("empty", "http://x/empty.xml", "Empty", "parent").await.unwrap();
|
||||
ctx.db.upsert_managed("has-file", "http://x/has-file.xml", "Has File", "parent").await.unwrap();
|
||||
let enc = feed::Enclosure { url: "http://x/ep.mp3".into(), mime: None, length: None };
|
||||
ctx.db.record_enclosure("has-file", "g1", &enc).await.unwrap();
|
||||
ctx.db.mark_downloaded(&enc.url, std::path::Path::new("/downloads/ep.mp3"), 1).await.unwrap();
|
||||
|
||||
retire_group(&ctx, "parent", None).await.unwrap();
|
||||
|
||||
let managed = ctx.db.managed_feeds().await.unwrap();
|
||||
assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten");
|
||||
assert!(managed.iter().any(|m| m.id == "has-file"), "has a file on disk, so it is kept");
|
||||
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
|
||||
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
|
||||
// promoted to config since still said managed = 1.
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert("promoted".into(), feed());
|
||||
cfg.feeds.insert("live-opml".into(), feed());
|
||||
let ctx = test_ctx(cfg).await;
|
||||
ctx.db.upsert_managed("promoted", "http://x/p.xml", "Promoted", "gone-opml").await.unwrap();
|
||||
ctx.db.upsert_managed("empty", "http://x/e.xml", "Empty", "gone-opml").await.unwrap();
|
||||
ctx.db.upsert_managed("listed", "http://x/l.xml", "Listed", "live-opml").await.unwrap();
|
||||
ctx.db.record_entry("promoted", &feed::Entry { guid: "g1".into(), ..Default::default() }).await.unwrap();
|
||||
|
||||
assert_eq!(retire_stranded(&ctx).await.unwrap(), 2, "empty dropped, promoted unmanaged");
|
||||
|
||||
let managed: Vec<String> = ctx.db.managed_feeds().await.unwrap().into_iter().map(|m| m.id).collect();
|
||||
assert_eq!(managed, ["listed"], "a group still in config is left alone");
|
||||
assert_eq!(ctx.db.feed_summary("promoted").await.unwrap().entries, 1, "its entries survive");
|
||||
}
|
||||
}
|
||||
234
crates/ipx/src/cli.rs
Normal file
234
crates/ipx/src/cli.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! The command line: its commands, and what each does when no daemon takes it.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Command as Cmd;
|
||||
use crate::{catalogue, daemon, db, metrics, retention, scan};
|
||||
use anyhow::Result;
|
||||
use chrono::DateTime;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ipx", version, about = "A headless podcatcher")]
|
||||
pub(crate) struct Cli {
|
||||
/// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml)
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) config: Option<PathBuf>,
|
||||
|
||||
/// Do the work here even if a daemon is running
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) local: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
/// Show configured feeds and their state
|
||||
List,
|
||||
/// Scan feeds for new entries
|
||||
Fetch {
|
||||
/// Only this feed id
|
||||
feed: Option<String>,
|
||||
/// Read the feed in full now, even when it is not due and has not changed
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Delete old or over-quota downloads
|
||||
Reap {
|
||||
/// Show what would go, delete nothing
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Counts of feeds, pending and downloaded enclosures
|
||||
Status,
|
||||
/// Subscribe to a feed
|
||||
Add {
|
||||
url: String,
|
||||
/// Download folder name (default: the feed title)
|
||||
#[arg(long)]
|
||||
folder: Option<String>,
|
||||
/// Only take enclosures matching these keywords
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
keywords: Vec<String>,
|
||||
/// The Directory's category, for a feed that names none of its own (News, Technology)
|
||||
#[arg(long)]
|
||||
category: Option<String>,
|
||||
/// Put it in the Directory for anyone to subscribe to, and keep it there when nobody does
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
},
|
||||
/// Unsubscribe. Downloads and history are left alone.
|
||||
Rm { feed: String },
|
||||
/// Add every feed in an OPML file
|
||||
Import { file: PathBuf },
|
||||
/// Write subscriptions out as OPML
|
||||
Export { file: PathBuf },
|
||||
/// Add, list or remove the accounts that can sign in to the web UI
|
||||
User {
|
||||
#[command(subcommand)]
|
||||
cmd: UserCmd,
|
||||
},
|
||||
/// Run the scheduler and serve the control socket
|
||||
Daemon {
|
||||
/// Serve the web UI on this address, overriding [web] in the config
|
||||
#[arg(long, value_name = "ADDR")]
|
||||
web: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum UserCmd {
|
||||
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray`
|
||||
Add {
|
||||
name: String,
|
||||
/// May manage other accounts, and is who the shared web token signs in as
|
||||
#[arg(long)]
|
||||
admin: bool,
|
||||
/// Sign-in comes from the proxy instead, so there is no password to set
|
||||
#[arg(long)]
|
||||
no_password: bool,
|
||||
},
|
||||
/// Show the accounts and how each one signs in
|
||||
List,
|
||||
/// Replace a password, read from stdin
|
||||
Passwd { name: String },
|
||||
/// Delete an account and everything it knows: its subscriptions and read state
|
||||
Rm { name: String },
|
||||
/// Rename an account, keeping its feeds, read state and admin rights. This is how an
|
||||
/// account made before the proxy takes the name the proxy signs it in as
|
||||
Rename { name: String, new_name: String },
|
||||
}
|
||||
|
||||
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
|
||||
/// listing.
|
||||
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
let read_password = || -> Result<String> {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
||||
if pw.is_empty() {
|
||||
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
|
||||
}
|
||||
Ok(pw)
|
||||
};
|
||||
|
||||
match cmd {
|
||||
UserCmd::Add { name, admin, no_password } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("a name is required");
|
||||
}
|
||||
if ctx.db.user_by_name(&name).await?.is_some() {
|
||||
anyhow::bail!("{name} already exists");
|
||||
}
|
||||
let hash = if no_password { None } else { Some(crate::auth::hash_password(&read_password()?)?) };
|
||||
// The first account runs the place; there is nobody else to grant it.
|
||||
let first = ctx.db.users().await?.is_empty();
|
||||
ctx.db.create_user(&name, hash.as_deref(), admin || first).await?;
|
||||
println!(
|
||||
"added {name}{}{}",
|
||||
if admin || first { " (admin)" } else { "" },
|
||||
if no_password { ", signs in through the proxy" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::List => {
|
||||
let users = ctx.db.users().await?;
|
||||
if users.is_empty() {
|
||||
println!("no accounts yet: ipx user add <name>");
|
||||
}
|
||||
for u in users {
|
||||
let added = u
|
||||
.created
|
||||
.and_then(|t| DateTime::from_timestamp(t, 0))
|
||||
.map_or("?".into(), |d| d.format("%Y-%m-%d").to_string());
|
||||
let seen = u.last_login.map_or("never signed in".into(), |t| format!("signed in {}", ago(Some(t))));
|
||||
println!(
|
||||
"{:<20} {:<6} {:<11} added {added} {seen}",
|
||||
u.name,
|
||||
if u.is_admin { "admin" } else { "" },
|
||||
if u.pass_hash.is_some() { "password" } else { "proxy only" },
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Passwd { name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?;
|
||||
println!("password changed for {name}, and every browser signed in as {name} signed out");
|
||||
let tokens = ctx.db.api_tokens(user.id).await?.len();
|
||||
if tokens > 0 {
|
||||
println!(
|
||||
"{tokens} API token(s) of {name}'s still work: remove any that may be someone else's in Settings"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Rename { name, new_name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
// The same rules as a name the proxy vouches for, or the proxy would never find it.
|
||||
let new_name = crate::auth::name_from_header(&new_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
|
||||
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
if ctx.db.user_by_name(&new_name).await?.is_some() {
|
||||
anyhow::bail!("{new_name} already exists");
|
||||
}
|
||||
ctx.db.rename_user(user.id, &new_name).await?;
|
||||
println!("renamed {name} to {new_name}");
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Rm { name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
catalogue::remove_user(ctx, user.id).await?;
|
||||
println!("removed {name}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||
// What the worker is on, for /metrics: the healthcheck answers while it is stuck (#195).
|
||||
let _job = metrics::STATS.job(match &cmd {
|
||||
Cmd::Fetch { .. } => "scan",
|
||||
Cmd::Reap { .. } => "reap",
|
||||
Cmd::Download { .. } => "download",
|
||||
Cmd::Status => "status",
|
||||
Cmd::Reload => "reload",
|
||||
});
|
||||
match cmd {
|
||||
Cmd::Fetch { feed, force, feeds } => {
|
||||
// Make room before pulling more down, as the original did per download.
|
||||
retention::reap(ctx, /* dry_run */ false, /* standalone */ false).await?;
|
||||
scan::fetch(ctx, feed.as_deref(), force, &feeds).await?;
|
||||
metrics::STATS.last_scan_done.store(db::now(), std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Reap { dry_run } => retention::reap(ctx, dry_run, /* standalone */ true).await,
|
||||
Cmd::Download { enclosure } => scan::download_one(ctx, enclosure).await,
|
||||
Cmd::Status => {
|
||||
ctx.out.emit(daemon::status(ctx).await);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Reload => daemon::reload(ctx).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ago(t: Option<i64>) -> String {
|
||||
let Some(t) = t else { return "never".into() };
|
||||
format!("{} ago", duration((db::now() - t).max(0) as u64))
|
||||
}
|
||||
|
||||
pub(crate) fn duration(secs: u64) -> String {
|
||||
match secs {
|
||||
s if s < 90 => format!("{s}s"),
|
||||
s if s < 5400 => format!("{}m", s / 60),
|
||||
s if s < 172_800 => format!("{}h", s / 3600),
|
||||
s => format!("{}d", s / 86_400),
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::Permissions;
|
||||
use std::path::{Path, PathBuf};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
@@ -26,9 +28,6 @@ pub struct General {
|
||||
/// How often to re-check feeds: "every 30m", "every 4h", "90" (minutes), "1d".
|
||||
/// A feed's own `schedule` overrides this.
|
||||
pub schedule: String,
|
||||
/// Superseded by `schedule`. Still read so existing configs keep working.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interval_mins: Option<u64>,
|
||||
pub organize: Organize,
|
||||
/// 0 = unlimited.
|
||||
pub max_total_gb: f64,
|
||||
@@ -42,6 +41,27 @@ pub struct General {
|
||||
/// 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>,
|
||||
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
|
||||
/// every publisher; the least recently shown goes first. 0 keeps none.
|
||||
pub art_cache_mb: u64,
|
||||
/// The directory service the Directory shows (#205), as `https://directory.ipxreader.app`. Empty
|
||||
/// for none: then the Directory says so, and feeds are added by their address.
|
||||
pub directory_url: String,
|
||||
/// The token the directory gave this server. Never sent to the page: it says only whether
|
||||
/// one is set.
|
||||
pub directory_token: String,
|
||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||
pub directory_share: bool,
|
||||
/// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than
|
||||
/// on each person's own (#271). Without that key it changes nothing.
|
||||
pub signals_on_server: bool,
|
||||
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a
|
||||
/// month of production's feeds was about 8,000.
|
||||
pub signal_days: u64,
|
||||
/// Internal addresses and networks feeds, episodes and artwork may still be fetched from, as
|
||||
/// `["192.168.1.20", "10.0.0.0/8"]`: everything off the public internet is refused otherwise,
|
||||
/// since a feed or anyone signed in names what is fetched (#320). Addresses, not names.
|
||||
pub fetch_internal: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -81,10 +101,25 @@ pub struct Web {
|
||||
/// 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>,
|
||||
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||
/// host any container can send one from the gateway's address.
|
||||
pub access_team: String,
|
||||
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||
/// leaves the signature unchecked.
|
||||
pub access_aud: 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,
|
||||
/// Where to serve /metrics for Prometheus (#194), on a thread of its own, without sign-in:
|
||||
/// `0.0.0.0:9187`. Empty serves none.
|
||||
pub metrics_bind: String,
|
||||
}
|
||||
|
||||
impl Default for Web {
|
||||
@@ -95,8 +130,12 @@ impl Default for Web {
|
||||
token: String::new(),
|
||||
trusted_header: String::new(),
|
||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||
access_team: String::new(),
|
||||
access_aud: String::new(),
|
||||
auto_create_users: true,
|
||||
sign_out_url: String::new(),
|
||||
session_days: 30,
|
||||
metrics_bind: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +144,12 @@ impl Web {
|
||||
pub fn binds_publicly(&self) -> bool {
|
||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||
}
|
||||
|
||||
/// Both halves of the Access check, or None while either is unset.
|
||||
pub fn access(&self) -> Option<(&str, &str)> {
|
||||
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -113,6 +158,10 @@ pub struct Feed {
|
||||
/// Download folder name; defaults to the sanitized feed title.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub folder: Option<String>,
|
||||
/// The Directory's category for a feed that names none of its own, as most blogs do not.
|
||||
/// The feed's own iTunes category wins where there is one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
/// Every whitespace-separated word of a keyword must appear in the
|
||||
/// url/title/description/categories for an enclosure to be taken.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -141,6 +190,14 @@ pub struct Feed {
|
||||
/// Preferred over `password`: name of an env var holding the password.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password_env: Option<String>,
|
||||
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
|
||||
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub listed: bool,
|
||||
/// Never sent to the directory (#205), whatever its address looks like: someone's own
|
||||
/// access to a paid show that `feed::url_looks_private` does not recognise.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -153,31 +210,31 @@ impl Default for General {
|
||||
download_dir: home().join("Podcasts"),
|
||||
socket: default_socket(),
|
||||
schedule: "every 60m".into(),
|
||||
interval_mins: None,
|
||||
organize: Organize::Feed,
|
||||
max_total_gb: 0.0,
|
||||
max_age_days: 0,
|
||||
max_new_per_check: 3,
|
||||
media_types: vec!["audio".into(), "video".into()],
|
||||
art_cache_mb: 500,
|
||||
directory_url: String::new(),
|
||||
directory_token: String::new(),
|
||||
directory_share: false,
|
||||
signals_on_server: false,
|
||||
signal_days: 7,
|
||||
fetch_internal: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Torrent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
seed_ratio: 1.0,
|
||||
seed_time_mins: 60,
|
||||
port_range: "6881-6889".into(),
|
||||
stall_mins: 30,
|
||||
}
|
||||
Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30 }
|
||||
}
|
||||
}
|
||||
|
||||
impl General {
|
||||
/// Minutes between checks. Falls back to the legacy `interval_mins`, then to an hour.
|
||||
/// A malformed value warns rather than stopping the daemon.
|
||||
/// 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;
|
||||
@@ -185,7 +242,7 @@ impl General {
|
||||
if !self.schedule.trim().is_empty() {
|
||||
tracing::warn!(schedule = %self.schedule, "unrecognised schedule; using the default");
|
||||
}
|
||||
self.interval_mins.filter(|n| *n > 0).unwrap_or(60)
|
||||
60
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +297,32 @@ impl Feed {
|
||||
}
|
||||
self.password.clone()
|
||||
}
|
||||
|
||||
/// What `feed::fetch` sends the feed's host: a username, and its password if there is one.
|
||||
pub fn auth(&self) -> Option<(&str, Option<String>)> {
|
||||
self.username.as_deref().map(|u| (u, self.password()))
|
||||
}
|
||||
|
||||
/// `auth()`, for a URL on the feed's own host only. The credential is for whoever issued it,
|
||||
/// and an enclosure, or a feed an OPML lists, can be on any host its author names (#327, #328).
|
||||
pub fn auth_for(&self, url: &str) -> Option<(&str, Option<String>)> {
|
||||
same_host(&self.url, url).then(|| self.auth()).flatten()
|
||||
}
|
||||
|
||||
/// The username, password and password_env a feed at `url` takes from this one, its OPML or
|
||||
/// Patreon list: all of them on the list's own host, none elsewhere.
|
||||
pub fn credentials_for(&self, url: &str) -> (Option<String>, Option<String>, Option<String>) {
|
||||
if same_host(&self.url, url) {
|
||||
(self.username.clone(), self.password.clone(), self.password_env.clone())
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_host(a: &str, b: &str) -> bool {
|
||||
let host = |u: &str| Url::parse(u).ok().and_then(|u| u.host_str().map(str::to_ascii_lowercase));
|
||||
host(a).is_some_and(|h| host(b).as_ref() == Some(&h))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -248,29 +331,136 @@ impl Config {
|
||||
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut cfg: Self =
|
||||
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
|
||||
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
|
||||
/// or decides who gets in, and stays in config.toml.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Stored {
|
||||
pub schedule: String,
|
||||
pub max_total_gb: f64,
|
||||
pub max_age_days: u64,
|
||||
pub max_new_per_check: usize,
|
||||
pub media_types: Vec<String>,
|
||||
/// Settings saved before it existed have none: they get the default.
|
||||
#[serde(default = "art_cache_mb")]
|
||||
pub art_cache_mb: u64,
|
||||
#[serde(default)]
|
||||
pub directory_url: String,
|
||||
#[serde(default)]
|
||||
pub directory_token: String,
|
||||
#[serde(default)]
|
||||
pub directory_share: bool,
|
||||
#[serde(default)]
|
||||
pub signals_on_server: bool,
|
||||
#[serde(default = "signal_days")]
|
||||
pub signal_days: u64,
|
||||
}
|
||||
|
||||
fn signal_days() -> u64 {
|
||||
General::default().signal_days
|
||||
}
|
||||
|
||||
fn art_cache_mb() -> u64 {
|
||||
General::default().art_cache_mb
|
||||
}
|
||||
|
||||
/// `[general]` keys that live in the database once it holds the configuration.
|
||||
const STORED_KEYS: [&str; 11] = [
|
||||
"schedule",
|
||||
"max_total_gb",
|
||||
"max_age_days",
|
||||
"max_new_per_check",
|
||||
"media_types",
|
||||
"art_cache_mb",
|
||||
"directory_url",
|
||||
"directory_token",
|
||||
"directory_share",
|
||||
"signals_on_server",
|
||||
"signal_days",
|
||||
];
|
||||
|
||||
impl Stored {
|
||||
pub fn of(cfg: &Config) -> Self {
|
||||
let g = &cfg.general;
|
||||
Self {
|
||||
schedule: g.schedule.clone(),
|
||||
max_total_gb: g.max_total_gb,
|
||||
max_age_days: g.max_age_days,
|
||||
max_new_per_check: g.max_new_per_check,
|
||||
media_types: g.media_types.clone(),
|
||||
art_cache_mb: g.art_cache_mb,
|
||||
directory_url: g.directory_url.clone(),
|
||||
directory_token: g.directory_token.clone(),
|
||||
directory_share: g.directory_share,
|
||||
signals_on_server: g.signals_on_server,
|
||||
signal_days: g.signal_days,
|
||||
}
|
||||
let text = toml::to_string_pretty(self)?;
|
||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
// Passwords may live in here.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(self, cfg: &mut Config) {
|
||||
let g = &mut cfg.general;
|
||||
g.schedule = self.schedule;
|
||||
g.max_total_gb = self.max_total_gb;
|
||||
g.max_age_days = self.max_age_days;
|
||||
g.max_new_per_check = self.max_new_per_check;
|
||||
g.media_types = self.media_types;
|
||||
g.art_cache_mb = self.art_cache_mb;
|
||||
g.directory_url = self.directory_url;
|
||||
g.directory_token = self.directory_token;
|
||||
g.directory_share = self.directory_share;
|
||||
g.signals_on_server = self.signals_on_server;
|
||||
g.signal_days = self.signal_days;
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// config.toml as it is kept once the database holds the feeds and server settings: the same
|
||||
/// file without `[feeds]` or the `[general]` keys in `Stored`.
|
||||
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
|
||||
let mut v = toml::Value::try_from(self)?;
|
||||
if let Some(t) = v.as_table_mut() {
|
||||
t.remove("feeds");
|
||||
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
|
||||
for k in STORED_KEYS {
|
||||
g.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
write_private(path, &toml::to_string_pretty(&v)?)
|
||||
}
|
||||
|
||||
/// Whether config.toml still lists feeds or server settings, which the database now holds:
|
||||
/// an edit there would otherwise go unnoticed.
|
||||
pub fn file_holds_stored(path: &Path) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return false };
|
||||
let Ok(v) = text.parse::<toml::Table>() else { return false };
|
||||
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|
||||
|| v.get("general")
|
||||
.and_then(|g| g.as_table())
|
||||
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
|
||||
fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
||||
@@ -278,9 +468,7 @@ pub fn config_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("IPX_CONFIG") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| home().join(".config"))
|
||||
.join("ipx/config.toml")
|
||||
xdg("XDG_CONFIG_HOME", ".config").join("ipx/config.toml")
|
||||
}
|
||||
|
||||
/// `$IPX_DATA_DIR`, else `$XDG_DATA_HOME/ipx`.
|
||||
@@ -288,9 +476,7 @@ pub fn data_dir() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("IPX_DATA_DIR") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| home().join(".local/share"))
|
||||
.join("ipx")
|
||||
xdg("XDG_DATA_HOME", ".local/share").join("ipx")
|
||||
}
|
||||
|
||||
fn default_socket() -> PathBuf {
|
||||
@@ -338,16 +524,21 @@ pub fn slug(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// `slug`, with a numeric suffix if that id is taken.
|
||||
pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
|
||||
pub fn unique_slug(text: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
let base = slug(text);
|
||||
if !taken.contains_key(&base) {
|
||||
if !taken(&base) {
|
||||
return base;
|
||||
}
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap()
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).expect("an unending count finds a free name")
|
||||
}
|
||||
|
||||
/// `$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 {
|
||||
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 {
|
||||
@@ -361,12 +552,60 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feeds_password_goes_to_its_own_host_alone() {
|
||||
let paid = Feed {
|
||||
url: "https://Members.example.com/feed.xml".into(),
|
||||
username: Some("me".into()),
|
||||
password: Some("pw".into()),
|
||||
..toml::from_str("url = 'x'").unwrap()
|
||||
};
|
||||
assert_eq!(paid.auth_for("https://members.example.com/ep1.mp3"), Some(("me", Some("pw".into()))));
|
||||
assert_eq!(paid.auth_for("https://cdn.example.net/ep1.mp3"), None, "an enclosure elsewhere (#328)");
|
||||
assert_eq!(paid.auth_for("https://members.example.com.evil.net/x"), None);
|
||||
assert_eq!(paid.auth_for("not a url"), None);
|
||||
assert_eq!(paid.credentials_for("https://members.example.com/other.xml").0.as_deref(), Some("me"));
|
||||
assert_eq!(paid.credentials_for("https://elsewhere.org/feed.xml"), (None, None, None), "an OPML's feed (#327)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[general]
|
||||
download_dir = "/downloads"
|
||||
schedule = "every 2h"
|
||||
max_new_per_check = 7
|
||||
media_types = ["audio"]
|
||||
[web]
|
||||
bind = "0.0.0.0:8099"
|
||||
token = "t"
|
||||
[feeds.show]
|
||||
url = "http://x/show.xml"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
|
||||
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
|
||||
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
|
||||
cfg.save_bootstrap(&path).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!Config::file_holds_stored(&path), "{text}");
|
||||
let back: Config = toml::from_str(&text).unwrap();
|
||||
assert!(back.feeds.is_empty());
|
||||
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
|
||||
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_config_and_applies_defaults() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[general]
|
||||
download_dir = "/tmp/pods"
|
||||
# A key older versions read. An old config that still has it has to load.
|
||||
interval_mins = 45
|
||||
|
||||
[feeds.example]
|
||||
url = "https://example.com/feed.xml"
|
||||
@@ -390,10 +629,20 @@ mod tests {
|
||||
#[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),
|
||||
("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:?}");
|
||||
}
|
||||
@@ -403,22 +652,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_falls_back_through_legacy_then_default() {
|
||||
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);
|
||||
|
||||
// A config written before `schedule` existed still works.
|
||||
// Empty or garbage must not stop the daemon.
|
||||
g.schedule = String::new();
|
||||
g.interval_mins = Some(45);
|
||||
assert_eq!(g.interval(), 45);
|
||||
|
||||
// Garbage must not stop the daemon.
|
||||
assert_eq!(g.interval(), 60);
|
||||
g.schedule = "whenever".into();
|
||||
assert_eq!(g.interval(), 45);
|
||||
g.interval_mins = None;
|
||||
assert_eq!(g.interval(), 60);
|
||||
}
|
||||
|
||||
@@ -454,6 +698,12 @@ mod tests {
|
||||
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_saved_before_the_art_cache_keep_the_default() {
|
||||
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
|
||||
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_readable_and_unique() {
|
||||
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
||||
@@ -464,12 +714,27 @@ mod tests {
|
||||
assert!(slug(&"x".repeat(100)).len() <= 40);
|
||||
|
||||
let mut taken = BTreeMap::new();
|
||||
taken.insert("the-daily".to_string(), Feed {
|
||||
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,
|
||||
password: None, password_env: None,
|
||||
});
|
||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||
taken.insert(
|
||||
"the-daily".to_string(),
|
||||
Feed {
|
||||
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,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(unique_slug("The Daily", |s| taken.contains_key(s)), "the-daily-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -487,11 +752,15 @@ mod tests {
|
||||
username: Some("ray".into()),
|
||||
password: Some("literal".into()),
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||
|
||||
unsafe { std::env::set_var("IPX_TEST_PASS", "from-env") };
|
||||
f.password_env = Some("IPX_TEST_PASS".into());
|
||||
assert_eq!(f.password().as_deref(), Some("from-env"));
|
||||
// A variable every test run already has: setting one is unsafe while other tests'
|
||||
// threads read the environment.
|
||||
f.password_env = Some("PATH".into());
|
||||
assert_eq!(f.password(), std::env::var("PATH").ok(), "password_env wins over password");
|
||||
}
|
||||
}
|
||||
261
crates/ipx/src/ctx.rs
Normal file
261
crates/ipx/src/ctx.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! What every command shares: the configuration, the database, the clients, and the state the
|
||||
//! daemon's tasks hand each other.
|
||||
|
||||
use crate::ipc::{Emitter, Event};
|
||||
use crate::{config, db, directory, feed, scan, torrent};
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{MutexGuard, Notify, OnceCell, Semaphore};
|
||||
|
||||
/// Everything a command needs. One per process.
|
||||
pub struct Ctx {
|
||||
/// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks
|
||||
/// up feed changes without a restart. Callers take a snapshot; no guard is ever held
|
||||
/// across an await.
|
||||
pub cfg: std::sync::RwLock<Arc<config::Config>>,
|
||||
pub db: db::Db,
|
||||
/// For anything a feed or a person names: refuses internal addresses (#320).
|
||||
pub client: reqwest::Client,
|
||||
/// For the services this server is set up to call, which are on its own network by design:
|
||||
/// the directory, as `http://ipx-directory:8100`. Never for an address a feed or a person names.
|
||||
pub local_client: reqwest::Client,
|
||||
/// For feeds only, refusing internal addresses as `client` does: follows no redirects, so `feed::fetch` sees each hop and can tell a feed
|
||||
/// that moved for good from one sent elsewhere for now.
|
||||
pub feed_client: reqwest::Client,
|
||||
pub out: Emitter,
|
||||
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
|
||||
/// rude to do for a config that has never seen a torrent.
|
||||
pub torrents: OnceCell<torrent::Torrents>,
|
||||
/// Caps how many torrents run at once when they are detached.
|
||||
pub torrent_slots: Arc<Semaphore>,
|
||||
/// Needed because a subscribed OPML rewrites the feed list as it syncs.
|
||||
pub config_path: PathBuf,
|
||||
/// Run torrents off the command worker. A torrent takes minutes to fetch metadata and
|
||||
/// then seeds for up to an hour, and the worker is sequential -- inline, one torrent
|
||||
/// stops every feed scan, every HTTP download and every status command behind it.
|
||||
/// A one-shot CLI run keeps them inline, or the process would exit mid-download.
|
||||
pub detach_torrents: bool,
|
||||
/// Feeds whose host could not be looked up for the moment, and when to try them again: a
|
||||
/// failure that says nothing about the feed, kept off it and out of the database (#143).
|
||||
/// Forgotten on a restart, which only makes the retry sooner.
|
||||
pub retry: std::sync::Mutex<HashMap<String, i64>>,
|
||||
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
|
||||
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
|
||||
/// another add spent a minute fetching was gone once that one stored (#153).
|
||||
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
|
||||
// own, and a change in flight here can still store over theirs; take the copy from the
|
||||
// database rather than memory if that ever happens.
|
||||
pub cfg_write: tokio::sync::Mutex<()>,
|
||||
/// Wakes DeadAir's maker when a show is turned on, so its copies start at once instead of
|
||||
/// at the next pass (#171).
|
||||
pub deadair: Notify,
|
||||
/// Wakes the Signals judge when there are new items or a Signal is made or changed (#254).
|
||||
pub signals: Notify,
|
||||
/// People whose TypeSafe key was refused, passed over until they save another.
|
||||
pub signals_refused: std::sync::Mutex<HashSet<i64>>,
|
||||
/// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since
|
||||
/// starting.
|
||||
pub directory_cleaned: AtomicI64,
|
||||
/// Sends the snapshot to the directory now, as when its settings change (#205).
|
||||
pub directory_wake: Notify,
|
||||
/// When the snapshot last went and what came of it, for the admin page.
|
||||
pub directory_shared: std::sync::Mutex<Option<directory::Shared>>,
|
||||
/// Send the snapshot even if it has not changed: after an unblock or a dropped snapshot the
|
||||
/// directory needs it again, though nothing here differs (#206).
|
||||
pub directory_resend: AtomicBool,
|
||||
}
|
||||
|
||||
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
|
||||
pub struct CfgEdit<'a> {
|
||||
pub(crate) cfg: config::Config,
|
||||
pub(crate) _held: MutexGuard<'a, ()>,
|
||||
}
|
||||
|
||||
impl Deref for CfgEdit<'_> {
|
||||
type Target = config::Config;
|
||||
fn deref(&self) -> &config::Config {
|
||||
&self.cfg
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for CfgEdit<'_> {
|
||||
fn deref_mut(&mut self) -> &mut config::Config {
|
||||
&mut self.cfg
|
||||
}
|
||||
}
|
||||
|
||||
/// How soon a feed whose host could not be looked up is tried again (#143).
|
||||
pub(crate) const RETRY_LOOKUP_SECS: i64 = 600;
|
||||
|
||||
impl Ctx {
|
||||
/// Everything a command shares, built once at start: the clients refuse internal addresses
|
||||
/// (#320) but `local_client`, and a daemon sends its events to the socket and the page.
|
||||
pub(crate) fn new(
|
||||
cfg: config::Config,
|
||||
db: db::Db,
|
||||
config_path: PathBuf,
|
||||
is_daemon: bool,
|
||||
events: &broadcast::Sender<Event>,
|
||||
) -> Result<Self> {
|
||||
let guard = feed::guard::Guard::new(feed::guard::Allowed::parse(&cfg.general.fetch_internal)?);
|
||||
Ok(Ctx {
|
||||
cfg: std::sync::RwLock::new(Arc::new(cfg)),
|
||||
db,
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
// Connecting only, so it bounds a download's start, not a long download (#108).
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Two minutes without a byte is a stalled download, however long it is: unbounded, one
|
||||
// held the worker, and every scan behind it, for good, while the daemon read healthy (#152).
|
||||
.read_timeout(Duration::from_secs(120))
|
||||
.connector_layer(guard.clone())
|
||||
.build()?,
|
||||
local_client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()?,
|
||||
feed_client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(feed::FEED_SILENCE)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connector_layer(guard)
|
||||
.build()?,
|
||||
out: if is_daemon {
|
||||
Emitter::socket(events.clone(), /* print */ false)
|
||||
} else {
|
||||
Emitter::terminal()
|
||||
},
|
||||
torrents: OnceCell::new(),
|
||||
torrent_slots: Arc::new(Semaphore::new(2)),
|
||||
config_path,
|
||||
detach_torrents: is_daemon,
|
||||
retry: Default::default(),
|
||||
cfg_write: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// When a feed is next due, taking a lookup retry into account: not before it.
|
||||
pub(crate) fn due(
|
||||
&self,
|
||||
cfg: &config::Config,
|
||||
feed: &config::Feed,
|
||||
id: &str,
|
||||
state: &db::HttpState,
|
||||
subscribed: bool,
|
||||
) -> Option<i64> {
|
||||
let at = scan::due_at(cfg, feed, state, subscribed);
|
||||
match self.retry.lock().expect("a panic elsewhere poisoned this lock").get(id) {
|
||||
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
|
||||
None => at,
|
||||
}
|
||||
}
|
||||
|
||||
/// A scan's failure, recorded on the feed, unless it was only a moment's failure to look its
|
||||
/// host up: on 2026-10-05 a DNS outage on Tower failed 204 feeds in a minute with
|
||||
/// "Temporary failure in name resolution", and each, read once a day and backed off as a
|
||||
/// failing feed, sat a day failing, unread and labelled gone (#143). Tried again in minutes
|
||||
/// instead, with nothing on the feed to say it failed.
|
||||
pub(crate) async fn feed_failed(&self, id: &str, url: &str, msg: &str) -> Result<()> {
|
||||
if feed::is_temporary_lookup_failure(msg) {
|
||||
self.retry
|
||||
.lock()
|
||||
.expect("a panic elsewhere poisoned this lock")
|
||||
.insert(id.to_owned(), db::now() + RETRY_LOOKUP_SECS);
|
||||
return Ok(());
|
||||
}
|
||||
self.db.set_feed_error(id, url, msg).await
|
||||
}
|
||||
|
||||
pub fn cfg(&self) -> Arc<config::Config> {
|
||||
self.cfg.read().expect("a panic elsewhere poisoned this lock").clone()
|
||||
}
|
||||
|
||||
/// The config as it stands, to change and then keep with `store_cfg`; any other change waits
|
||||
/// until then.
|
||||
pub async fn edit_cfg(&self) -> CfgEdit<'_> {
|
||||
// A step of its own: waiting here is waiting on another change to the catalogue.
|
||||
let held = tracing::Instrument::instrument(self.cfg_write.lock(), db::step("cfg_lock")).await;
|
||||
CfgEdit { cfg: (*self.cfg()).clone(), _held: held }
|
||||
}
|
||||
|
||||
/// Keeps a changed catalogue or server settings: in the database, and for everything running
|
||||
/// here from now on. What config.toml's save and reload did, before the database held them.
|
||||
pub async fn store_cfg(&self, edit: CfgEdit<'_>) -> Result<()> {
|
||||
tracing::Instrument::instrument(self.db.store_config(&edit.cfg), db::step("store_config")).await?;
|
||||
self.set_cfg(edit.cfg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_cfg(&self, cfg: config::Config) {
|
||||
*self.cfg.write().expect("a panic elsewhere poisoned this lock") = Arc::new(cfg);
|
||||
tracing::info!("config reloaded");
|
||||
// A feed added, removed, made private or given a category, or the directory's own
|
||||
// settings changed: the directory hears of it now, not at the next half hour (#205).
|
||||
self.directory_wake.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) async fn torrents(&self) -> Result<&torrent::Torrents> {
|
||||
let cfg = self.cfg();
|
||||
self.torrents.get_or_try_init(|| torrent::Torrents::new(&cfg)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_changes_to_the_catalogue_at_once_both_keep() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
// A slow change, as adding a feed is while it fetches, and a quick one made meanwhile.
|
||||
let slow = {
|
||||
let ctx = ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
cfg.feeds.insert("a".into(), feed());
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
})
|
||||
};
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
cfg.feeds.insert("b".into(), feed());
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
slow.await.unwrap();
|
||||
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["a", "b"], "neither stored over the other (#153)");
|
||||
let (_, stored) = ctx.db.stored_config().await.unwrap().unwrap();
|
||||
assert_eq!(stored.keys().collect::<Vec<_>>(), ["a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_moments_lookup_failure_is_tried_again_soon_and_not_held_against_the_feed() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||
ctx.feed_failed("npr", "https://feeds.npr.org/x", blip).await.unwrap();
|
||||
// Nothing on the feed: no error, no run of failures to back off from or call it gone by.
|
||||
let s = ctx.db.feed_summary("npr").await.unwrap();
|
||||
assert!(s.last_error.is_none() && s.error_since.is_none(), "{:?}", s.last_error);
|
||||
// Due again in minutes, though a feed nobody subscribes to is otherwise read once a day.
|
||||
let state = db::HttpState { last_checked: Some(db::now() - 2 * 86_400), ..Default::default() };
|
||||
let due = ctx.due(&config::Config::default(), &feed(), "npr", &state, false).unwrap();
|
||||
assert!((due - db::now() - RETRY_LOOKUP_SECS).abs() <= 2, "{due}");
|
||||
// A real failure is the feed's.
|
||||
ctx.feed_failed("gone", "https://x/feed", "HTTP 404 Not Found").await.unwrap();
|
||||
assert_eq!(ctx.db.feed_summary("gone").await.unwrap().last_error.as_deref(), Some("HTTP 404 Not Found"));
|
||||
}
|
||||
}
|
||||
356
crates/ipx/src/daemon.rs
Normal file
356
crates/ipx/src/daemon.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
//! The daemon: its worker, its web server, and how it stops.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::{Command as Cmd, Event};
|
||||
use crate::{access, auth, catalogue, cli, config, db, deadair, directory, ipc, metrics, signals, web};
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// The catalogue and server settings as the database now has them, in place of the copy in
|
||||
/// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where
|
||||
/// things are and who gets in.
|
||||
pub(crate) async fn reload(ctx: &Ctx) -> Result<()> {
|
||||
let _held = ctx.cfg_write.lock().await;
|
||||
let cfg = catalogue::assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?;
|
||||
ctx.set_cfg(cfg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with
|
||||
/// this directly rather than through the job queue.
|
||||
pub(crate) async fn status(ctx: &Ctx) -> Event {
|
||||
match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await {
|
||||
Ok((pending, downloaded)) => {
|
||||
let feeds = tracing::Instrument::instrument(catalogue::subscriptions(ctx), db::step("subscriptions"))
|
||||
.await
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
// Kept for /metrics, which asks the database nothing (#195); the daemon counts every 30 s.
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let st = &metrics::STATS;
|
||||
st.feeds.store(feeds as i64, Relaxed);
|
||||
st.pending.store(pending, Relaxed);
|
||||
st.downloaded.store(downloaded, Relaxed);
|
||||
Event::Status { feeds, pending, downloaded }
|
||||
}
|
||||
Err(e) => Event::Error { msg: format!("{e:#}") },
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn daemon(
|
||||
ctx: Arc<Ctx>,
|
||||
config_path: PathBuf,
|
||||
web_addr: Option<String>,
|
||||
events: broadcast::Sender<Event>,
|
||||
) -> Result<()> {
|
||||
let socket = ctx.cfg().general.socket.clone();
|
||||
if ipc::daemon_is_live(&socket).await {
|
||||
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
||||
}
|
||||
|
||||
// A database with nobody in it cannot be signed into.
|
||||
if ctx.db.users().await?.is_empty() {
|
||||
let dir = ctx.config_path.parent().unwrap_or(Path::new("."));
|
||||
let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?;
|
||||
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?;
|
||||
tracing::warn!(
|
||||
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipx user passwd admin`"
|
||||
);
|
||||
}
|
||||
|
||||
// Unread lists start from here, and a reap may be a day off (#347). Slower without, never wrong.
|
||||
if let Err(e) = ctx.db.advance_unread_from().await {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not work out where unread items start");
|
||||
}
|
||||
|
||||
if let Some(admin) = ctx.db.users().await?.into_iter().find(|u| u.is_admin) {
|
||||
let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect();
|
||||
match ctx.db.adopt_catalogue(admin.id, &catalogue).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(user = %admin.name, feeds = n, "subscribed the first admin to the catalogue"),
|
||||
Err(e) => tracing::error!(error = %e, "could not subscribe the first admin to the catalogue"),
|
||||
}
|
||||
}
|
||||
|
||||
match ctx.db.requeue_interrupted().await {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not requeue interrupted downloads"),
|
||||
}
|
||||
|
||||
match catalogue::retire_stranded(&ctx).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
|
||||
}
|
||||
|
||||
// Room for any one request's commands: a web request waits inside itself for a free place,
|
||||
// and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and
|
||||
// every other request that queues work waited behind them (#154).
|
||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(1024);
|
||||
|
||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
||||
// status and reload are answered by the socket itself; everything else waits its turn in the
|
||||
// queue.
|
||||
let answer: ipc::StatusFn = {
|
||||
let ctx = ctx.clone();
|
||||
Arc::new(move |cmd| {
|
||||
let ctx = ctx.clone();
|
||||
Box::pin(async move {
|
||||
if matches!(cmd, Cmd::Reload)
|
||||
&& let Err(e) = reload(&ctx).await
|
||||
{
|
||||
return Event::Error { msg: format!("reading the catalogue again: {e:#}") };
|
||||
}
|
||||
status(&ctx).await
|
||||
})
|
||||
})
|
||||
};
|
||||
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
|
||||
|
||||
// One command at a time: the queue is what keeps two scans from overlapping.
|
||||
tracing::info!(feeds = catalogue::subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0), "daemon started");
|
||||
|
||||
// A signal has to be able to interrupt work in progress, not just the wait between
|
||||
// jobs. Racing `shutdown()` in the outer select only cancels branch selection: once
|
||||
// inside a long download the daemon stopped listening and had to be SIGKILLed.
|
||||
let (tx_stop, rx_stop) = tokio::sync::watch::channel(false);
|
||||
tokio::spawn(async move {
|
||||
shutdown().await;
|
||||
let _ = tx_stop.send(true);
|
||||
});
|
||||
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
|
||||
// must not wait behind it.
|
||||
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(signals::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
|
||||
// The library's counts for /metrics. The Docker healthcheck asked for them every 30 s until
|
||||
// it was removed: Docker runs it with exec, which failed on Tower whenever root was logged
|
||||
// out (#218).
|
||||
{
|
||||
let (ctx, mut stop) = (ctx.clone(), rx_stop.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut every = tokio::time::interval(Duration::from_secs(30));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = every.tick() => { status(&ctx).await; }
|
||||
_ = stop.changed() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
||||
async fn until_stopped(ctx: &Ctx, rx: &watch::Receiver<bool>, job: impl Future<Output = Result<()>>) -> bool {
|
||||
let mut stop = rx.clone();
|
||||
tokio::select! {
|
||||
_ = stop.changed() => {
|
||||
tracing::info!("signal received; abandoning the job in progress");
|
||||
false
|
||||
}
|
||||
result = job => {
|
||||
if let Err(e) = result {
|
||||
ctx.out.emit(Event::Error { msg: format!("{e:#}") });
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stop = rx_stop.clone();
|
||||
// The first pass at once, as the minute's tick did: what came due while it was down.
|
||||
let mut first = true;
|
||||
loop {
|
||||
if *stop.borrow() {
|
||||
break;
|
||||
}
|
||||
let wait = if std::mem::take(&mut first) { Duration::ZERO } else { until_next_scan(&ctx).await };
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = stop.changed() => break,
|
||||
Some(cmd) = rx_cmd.recv() => {
|
||||
// Both halves of the protocol are logged under one target so the UI can
|
||||
// show the conversation on its own: this is everything arriving, whatever
|
||||
// the source -- a socket client, the CLI proxying, or the web UI.
|
||||
tracing::info!(
|
||||
target: "ipx::io",
|
||||
"-> {}",
|
||||
serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}"))
|
||||
);
|
||||
if !until_stopped(&ctx, &rx_stop, cli::run(&ctx, cmd)).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(wait) => {
|
||||
// Per-feed schedule and TTL decide what actually gets polled.
|
||||
let job = cli::run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
|
||||
if !until_stopped(&ctx, &rx_stop, job).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.abort();
|
||||
if let Some(w) = web {
|
||||
w.abort();
|
||||
}
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
tracing::info!("daemon stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts the web UI when it is switched on, minting and saving a token if there is none.
|
||||
pub(crate) async fn start_web(
|
||||
ctx: &Arc<Ctx>,
|
||||
config_path: &Path,
|
||||
web_addr: Option<String>,
|
||||
cmds: &mpsc::Sender<Cmd>,
|
||||
events: &broadcast::Sender<Event>,
|
||||
) -> Result<Option<JoinHandle<()>>> {
|
||||
let cfg = ctx.cfg();
|
||||
let enabled = cfg.web.enabled || web_addr.is_some();
|
||||
if !enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let bind = web_addr.unwrap_or_else(|| cfg.web.bind.clone());
|
||||
|
||||
if cfg.web.token.is_empty() {
|
||||
let mut fresh = (*cfg).clone();
|
||||
fresh.web.enabled = true;
|
||||
fresh.web.bind = bind.clone();
|
||||
fresh.web.token = crate::auth::new_session_token();
|
||||
// The token is config.toml's, not the database's: it decides who gets in.
|
||||
fresh.save_bootstrap(config_path)?;
|
||||
ctx.set_cfg(fresh.clone());
|
||||
// The token signs in as the admin, and whatever reads this process's output (docker logs,
|
||||
// for one) is wider than who reads config.toml. So say where it is, never what it is.
|
||||
// Logged, not printed, so a JSON log stays one object a line (#91).
|
||||
tracing::info!(
|
||||
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
|
||||
config_path.display()
|
||||
);
|
||||
} else {
|
||||
tracing::info!("web ui at http://{bind}/ (the sign-in token is [web] token in {})", config_path.display());
|
||||
}
|
||||
|
||||
// Bound beyond localhost, as a container has to be for its port to be published. Worth a
|
||||
// warning only when nothing but a password stands in front of it: it said "the token is all
|
||||
// that guards it" on every start of production, behind Cloudflare Access, and was the only
|
||||
// warning in a healthy log (#106).
|
||||
if ctx.cfg().web.binds_publicly() {
|
||||
if ctx.cfg().web.access().is_some() {
|
||||
tracing::info!(
|
||||
bind,
|
||||
"web ui is reachable off this machine; signing in takes an account's password or the admin token, or Cloudflare Access through a trusted proxy"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
bind,
|
||||
"web ui is reachable off this machine; an account's password or the admin token is all that guards it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let access = Arc::new(access::Keys::default());
|
||||
if let Some((team, _)) = ctx.cfg().web.access() {
|
||||
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
|
||||
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
|
||||
}
|
||||
let playing = deadair::Playing::default();
|
||||
if !cfg.web.metrics_bind.is_empty() {
|
||||
let sources = metrics::Sources {
|
||||
ctx: ctx.clone(),
|
||||
cmds: cmds.clone(),
|
||||
events: events.clone(),
|
||||
playing: playing.clone(),
|
||||
runtime: Handle::current(),
|
||||
started: Instant::now(),
|
||||
};
|
||||
let at = metrics::spawn(&cfg.web.metrics_bind, sources)?;
|
||||
tracing::info!(%at, "serving metrics for Prometheus at /metrics");
|
||||
}
|
||||
let state = web::WebState { ctx: ctx.clone(), cmds: cmds.clone(), events: events.clone(), access, playing };
|
||||
Ok(Some(tokio::spawn(async move {
|
||||
if let Err(e) = web::serve(state, &bind).await {
|
||||
tracing::error!(error = %format!("{e:#}"), "web ui stopped");
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown() {
|
||||
use tokio::signal::unix;
|
||||
use tokio::signal::unix::SignalKind;
|
||||
let mut term = match unix::signal(SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::future::pending().await,
|
||||
};
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
_ = term.recv() => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
|
||||
/// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding
|
||||
/// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the
|
||||
/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a
|
||||
/// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway.
|
||||
pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
|
||||
let next = async {
|
||||
let cfg = ctx.cfg();
|
||||
let subscribed = ctx.db.subscriber_counts().await?;
|
||||
let states = ctx.db.http_states().await?;
|
||||
anyhow::Ok(
|
||||
catalogue::subscriptions(ctx)
|
||||
.await?
|
||||
.iter()
|
||||
// A feed with no row yet has never been checked: due now.
|
||||
.map(|s| {
|
||||
let never = db::HttpState::default();
|
||||
let state = states.get(&s.id).unwrap_or(&never);
|
||||
ctx.due(&cfg, &s.cfg, &s.id, state, subscribed.contains_key(&s.id)).unwrap_or(0)
|
||||
})
|
||||
.min(),
|
||||
)
|
||||
};
|
||||
let wait = match next.await {
|
||||
Ok(Some(at)) => (at - db::now()).max(0) as u64,
|
||||
Ok(None) => u64::MAX,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not work out when the next feed is due");
|
||||
0
|
||||
}
|
||||
};
|
||||
std::time::Duration::from_secs(wait.clamp(30, 600))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reload_takes_the_catalogue_another_ipx_wrote() {
|
||||
let with = |id: &str| {
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert(id.into(), config::Feed { url: format!("http://x/{id}.xml"), ..feed() });
|
||||
cfg
|
||||
};
|
||||
let held = with("kept-in-memory");
|
||||
let ctx = test_ctx(held.clone()).await;
|
||||
ctx.db.store_config(&held).await.unwrap();
|
||||
// `ipx add` and `ipx rm`, from a process of their own (#120).
|
||||
ctx.db.store_config(&with("added-by-the-cli")).await.unwrap();
|
||||
reload(&ctx).await.unwrap();
|
||||
let urls: Vec<String> = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect();
|
||||
assert_eq!(urls, vec!["http://x/added-by-the-cli.xml".to_string()]);
|
||||
}
|
||||
}
|
||||
4233
crates/ipx/src/db.rs
Normal file
4233
crates/ipx/src/db.rs
Normal file
File diff suppressed because it is too large
Load Diff
958
crates/ipx/src/deadair.rs
Normal file
958
crates/ipx/src/deadair.rs
Normal file
@@ -0,0 +1,958 @@
|
||||
//! DeadAir (issue #171): podcasts play with their long silences shortened, so they finish
|
||||
//! sooner without sounding sped up.
|
||||
//!
|
||||
//! Nothing is copied (#181). DeadAir finds a file's silences once, in the background, and keeps a
|
||||
//! `Plan` for each pause length a person can choose (#182): the frames kept, as byte ranges of
|
||||
//! the original. The player gets an HLS playlist of those ranges (`playlist`), which Safari plays
|
||||
//! itself and other browsers through hls.js, with no gap at the cuts. Seeking past each silence
|
||||
//! instead left about 1.3 s of nothing at every one (#178); a copy made with ffmpeg cost as much
|
||||
//! disk again as the original and could only have one pause length.
|
||||
//!
|
||||
//! Positions stay in the original's time. What a plan leaves out is stored as `Cut`s of the
|
||||
//! original's time, and the server converts both ways (`to_copy`, `to_original`): the iOS and
|
||||
//! Android apps save positions themselves, from their own player, so converting in the page
|
||||
//! would miss them.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::db;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use axum::body::Bytes;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::io::SeekFrom;
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
|
||||
/// Quieter than this for longer than `MIN_SILENCE` is a silence: what the measurements on the
|
||||
/// issue used. A podcast's room tone sits well below it and speech well above.
|
||||
const THRESHOLD: &str = "-38dB";
|
||||
const MIN_SILENCE: f64 = 0.35;
|
||||
/// The pauses a person can have DeadAir leave where it cuts a silence, in ms (#182). Half of it
|
||||
/// is kept at each end of the silence, so the frames on both sides of a cut are silent. Decoding
|
||||
/// blends a frame with the one before it, and an MP3 frame can begin in the bytes of the frame
|
||||
/// before (the bit reservoir): the first frame after a cut decodes against the wrong neighbour,
|
||||
/// which nobody hears between two silent frames and everyone hears at the start of a word.
|
||||
pub const PAUSES: [i64; 4] = [200, 300, 400, 500];
|
||||
/// 0.2 s, the first DeadAir's, made speech sound rushed.
|
||||
pub const DEFAULT_PAUSE: i64 = 300;
|
||||
/// The longest piece of a playlist, in seconds: about what HLS players expect, so a long stretch
|
||||
/// of talk is not one request for megabytes.
|
||||
const MAX_PIECE: f64 = 10.0;
|
||||
/// How long the maker waits when there is nothing to do: a download or a feed turned on is
|
||||
/// picked up this long after, at most.
|
||||
const IDLE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A span of the original, in milliseconds, that DeadAir leaves out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Cut(pub i64, pub i64);
|
||||
|
||||
/// What one pause length leaves of a file: what it cuts, and what it keeps as pieces of the
|
||||
/// original, (byte offset, bytes, seconds).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Plan {
|
||||
pub cuts: Vec<Cut>,
|
||||
pub pieces: Vec<(u64, u64, f64)>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub fn saved_ms(&self) -> i64 {
|
||||
saved(&self.cuts)
|
||||
}
|
||||
}
|
||||
|
||||
/// The length of the ID3v2 tag a file starts with, 0 if it has none.
|
||||
pub fn id3_len(head: &[u8]) -> u64 {
|
||||
if head.len() < 10 || &head[..3] != b"ID3" {
|
||||
return 0;
|
||||
}
|
||||
// Seven bits a byte, so no byte of the size looks like the start of a frame.
|
||||
let size = head[6..10].iter().fold(0u64, |n, b| n << 7 | u64::from(b & 0x7f));
|
||||
let footer = if head[5] & 0x10 != 0 { 10 } else { 0 };
|
||||
10 + size + footer
|
||||
}
|
||||
|
||||
/// A file as `plan` plays it, to save (#186): its ID3 tag, so the title and artwork come too, then
|
||||
/// the pieces one after another. MP3 and ADTS frames each stand alone, so that is a file any
|
||||
/// player plays. Read as it is sent and never stored, as the playlist is: (its length, its bytes).
|
||||
pub async fn kept(
|
||||
path: &Path,
|
||||
plan: &Plan,
|
||||
) -> std::io::Result<(u64, impl futures_util::Stream<Item = std::io::Result<Bytes>> + use<>)> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = File::open(path).await?;
|
||||
let mut head = [0u8; 10];
|
||||
let n = file.read(&mut head).await?;
|
||||
// Only up to the first frame kept: past the tag can be the Xing frame, which gives the
|
||||
// original's length.
|
||||
let first = plan.pieces.first().map_or(0, |p| p.0);
|
||||
let tag = id3_len(&head[..n]).min(first);
|
||||
let ranges: VecDeque<(u64, u64)> =
|
||||
std::iter::once((0, tag)).chain(plan.pieces.iter().map(|&(o, n, _)| (o, n))).filter(|r| r.1 > 0).collect();
|
||||
let len = ranges.iter().map(|r| r.1).sum();
|
||||
let body = futures_util::stream::try_unfold((file, ranges), |(mut f, mut left)| async move {
|
||||
let Some((off, n)) = left.pop_front() else { return Ok(None) };
|
||||
let mut buf = vec![0; n.min(1 << 16) as usize];
|
||||
f.seek(SeekFrom::Start(off)).await?;
|
||||
f.read_exact(&mut buf).await?;
|
||||
let got = buf.len() as u64;
|
||||
if got < n {
|
||||
left.push_front((off + got, n - got));
|
||||
}
|
||||
Ok(Some((Bytes::from(buf), (f, left))))
|
||||
});
|
||||
Ok((len, body))
|
||||
}
|
||||
|
||||
/// The silences silencedetect reported, as (start, end) seconds. One still open at the end of
|
||||
/// the file has no end and is left alone.
|
||||
pub fn parse_silences(log: &str) -> Vec<(f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = None;
|
||||
for line in log.lines() {
|
||||
if let Some(v) = after(line, "silence_start: ") {
|
||||
start = Some(v);
|
||||
} else if let Some(v) = after(line, "silence_end: ")
|
||||
&& let Some(s) = start.take()
|
||||
{
|
||||
out.push((s, v));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn after(line: &str, key: &str) -> Option<f64> {
|
||||
let rest = &line[line.find(key)? + key.len()..];
|
||||
rest.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same
|
||||
/// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
|
||||
/// silence, `keep` seconds in from each end, go.
|
||||
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64, keep: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
|
||||
let spans: Vec<(u64, u64)> = silences
|
||||
.iter()
|
||||
.filter(|(s, e)| e - s >= MIN_SILENCE)
|
||||
.filter_map(|&(s, e)| {
|
||||
let a = ((s + keep - first) / frame).ceil().max(0.0) as u64;
|
||||
let b = ((e - keep - first) / frame).floor() as i64 - 1;
|
||||
(b >= a as i64).then_some((a, b as u64))
|
||||
})
|
||||
.collect();
|
||||
let ms = |n: u64| ((first + n as f64 * frame) * 1000.0).round() as i64;
|
||||
let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
|
||||
(spans, cuts)
|
||||
}
|
||||
|
||||
/// The frames kept, as pieces of the file: runs of frames lying end to end in it, none past
|
||||
/// `MAX_PIECE`. `frames` is each frame's (byte offset, bytes); `drop` is from `plan`.
|
||||
pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u64, u64, f64)> {
|
||||
let most = ((MAX_PIECE / frame).floor() as usize).max(1);
|
||||
let mut out = Vec::new();
|
||||
let push = |out: &mut Vec<(u64, u64, f64)>, (first, n): (usize, usize)| {
|
||||
let (start, (pos, size)) = (frames[first].0, frames[first + n - 1]);
|
||||
out.push((start, pos + size - start, n as f64 * frame));
|
||||
};
|
||||
let mut drops = drop.iter().peekable();
|
||||
let mut run: Option<(usize, usize)> = None;
|
||||
for (n, &(pos, _)) in frames.iter().enumerate() {
|
||||
while drops.peek().is_some_and(|&&(_, b)| (b as usize) < n) {
|
||||
drops.next();
|
||||
}
|
||||
let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
|
||||
// Something between two frames (an ID3 tag partway through) ends a piece too.
|
||||
let joins = !dropped && run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
|
||||
if !joins && let Some(r) = run.take() {
|
||||
push(&mut out, r);
|
||||
}
|
||||
if !dropped {
|
||||
run = Some(run.map_or((n, 1), |(f, c)| (f, c + 1)));
|
||||
}
|
||||
}
|
||||
if let Some(r) = run {
|
||||
push(&mut out, r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The HLS playlist for a plan: each piece a byte range of `uri`, the original.
|
||||
pub fn playlist(plan: &Plan, uri: &str) -> String {
|
||||
let longest = plan.pieces.iter().map(|p| p.2).fold(0.0, f64::max);
|
||||
let mut s = format!(
|
||||
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:{}\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n",
|
||||
longest.ceil() as u64
|
||||
);
|
||||
for &(off, len, secs) in &plan.pieces {
|
||||
let _ = write!(s, "#EXTINF:{secs:.6},\n#EXT-X-BYTERANGE:{len}@{off}\n{uri}\n");
|
||||
}
|
||||
s + "#EXT-X-ENDLIST\n"
|
||||
}
|
||||
|
||||
/// A time in the original, in ms, as the same moment in the shortened version. A time inside a
|
||||
/// cut is where the cut was made.
|
||||
pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
|
||||
let mut out = ms;
|
||||
for &Cut(s, e) in cuts {
|
||||
if ms >= e {
|
||||
out -= e - s;
|
||||
} else {
|
||||
if ms > s {
|
||||
out -= ms - s;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A time in the shortened version, in ms, as the same moment in the original.
|
||||
pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
|
||||
let mut out = ms;
|
||||
for &Cut(s, e) in cuts {
|
||||
if out >= s {
|
||||
out += e - s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn saved(cuts: &[Cut]) -> i64 {
|
||||
cuts.iter().map(|Cut(s, e)| e - s).sum()
|
||||
}
|
||||
|
||||
/// Whether someone with these settings plays a feed shortened. Off for every feed until they
|
||||
/// turn it on for that one, or for the group it came in (an OPML or a Patreon creator), which a
|
||||
/// feed of the group can still say otherwise to.
|
||||
pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool {
|
||||
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The settings of each person with DeadAir on for something, and each managed feed's group,
|
||||
/// which is what `wants` and `pause` need.
|
||||
pub struct Wanting {
|
||||
pub people: HashMap<i64, HashMap<String, bool>>,
|
||||
pub pauses: HashMap<(i64, String), i64>,
|
||||
pub groups: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Wanting {
|
||||
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
|
||||
let mut people: HashMap<i64, HashMap<String, bool>> = HashMap::new();
|
||||
let mut pauses = HashMap::new();
|
||||
for (user, feed, wanted, pause) in ctx.db.deadair_prefs(only).await? {
|
||||
if let Some(p) = pause {
|
||||
pauses.insert((user, feed.clone()), p);
|
||||
}
|
||||
people.entry(user).or_default().insert(feed, wanted);
|
||||
}
|
||||
people.retain(|_, p| p.values().any(|&w| w));
|
||||
let groups = if people.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect()
|
||||
};
|
||||
Ok(Self { people, pauses, groups })
|
||||
}
|
||||
|
||||
pub fn wants(&self, user: i64, feed: &str) -> bool {
|
||||
self.people.get(&user).is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
|
||||
}
|
||||
|
||||
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
|
||||
pub fn pause(&self, user: i64, feed: &str) -> i64 {
|
||||
let group = self.groups.get(feed);
|
||||
self.pauses
|
||||
.get(&(user, feed.to_owned()))
|
||||
.or_else(|| group.and_then(|g| self.pauses.get(&(user, g.clone()))))
|
||||
.copied()
|
||||
.unwrap_or(DEFAULT_PAUSE)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan someone plays a file with: their pause's, for a file in a feed they have DeadAir on
|
||||
/// for, once its silences are found. None if it cuts nothing.
|
||||
fn chosen(wanting: &Wanting, user: i64, feed: &str, plans: &BTreeMap<i64, Plan>) -> Option<Plan> {
|
||||
if !wanting.wants(user, feed) {
|
||||
return None;
|
||||
}
|
||||
plans.get(&wanting.pause(user, feed)).filter(|p| !p.cuts.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// What each person is playing shortened, by (person, feed, guid): set when they are handed the
|
||||
/// playlist and cleared when `/media` serves them the original, so a saved position is converted
|
||||
/// from the clock it was measured in, whether the page saved it or a native app did.
|
||||
// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
|
||||
// for the playlist is then taken as the original's, which resumes a little early, never late.
|
||||
pub type Playing = Arc<std::sync::Mutex<HashMap<(i64, String, String), Arc<Plan>>>>;
|
||||
|
||||
/// The plan for one file as this person would play it, for the playlist route.
|
||||
pub async fn plan_for(ctx: &Ctx, user: i64, feed: &str, enclosure: i64) -> Result<Option<Plan>> {
|
||||
let wanting = Wanting::load(ctx, Some(user)).await?;
|
||||
let plans = ctx.db.plans(&[enclosure]).await?;
|
||||
Ok(plans.get(&enclosure).and_then(|p| chosen(&wanting, user, feed, p)))
|
||||
}
|
||||
|
||||
/// Puts a page of entries in the clock of what this person will play: for each file they play
|
||||
/// shortened, `deadair` is the seconds it saves, and the entry's position and length are the
|
||||
/// shortened version's. The page plays `/media/{id}/deadair.m3u8` for a file that has it.
|
||||
pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
|
||||
let wanting = Wanting::load(ctx, Some(user)).await?;
|
||||
if wanting.people.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ids: Vec<i64> = rows
|
||||
.iter()
|
||||
.filter(|r| wanting.wants(user, &r.feed_id))
|
||||
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
|
||||
.collect();
|
||||
let plans = ctx.db.plans(&ids).await?;
|
||||
for r in rows.iter_mut() {
|
||||
// Heard through already: the maker passes it over, so nothing is coming.
|
||||
let finished = r.duration.is_some_and(|d| d > 0 && r.position * 10 >= d * 9);
|
||||
let mut converted = false;
|
||||
for e in &mut r.enclosures {
|
||||
e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !plans.contains_key(&e.id) && !finished;
|
||||
let Some(p) = plans.get(&e.id).and_then(|p| chosen(&wanting, user, &r.feed_id, p)) else { continue };
|
||||
e.deadair = Some(p.saved_ms() / 1000);
|
||||
// One position an item, however many files: the first played shortened sets the clock.
|
||||
if !std::mem::replace(&mut converted, true) {
|
||||
r.position = to_copy(&p.cuts, r.position * 1000) / 1000;
|
||||
r.duration = r.duration.map(|d| to_copy(&p.cuts, d * 1000) / 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A position and length a player saved, in the clock of what it was playing, as the
|
||||
/// original's. Past 75% of the shortened version, the episode also counts towards the time
|
||||
/// DeadAir has saved this person.
|
||||
pub async fn on_save(
|
||||
ctx: &Ctx,
|
||||
playing: &Playing,
|
||||
user: i64,
|
||||
feed: &str,
|
||||
guid: &str,
|
||||
secs: i64,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(i64, Option<i64>)> {
|
||||
let plan = playing
|
||||
.lock()
|
||||
.expect("a panic elsewhere poisoned this lock")
|
||||
.get(&(user, feed.to_owned(), guid.to_owned()))
|
||||
.cloned();
|
||||
let Some(p) = plan else { return Ok((secs, duration)) };
|
||||
let original = |s: i64| to_original(&p.cuts, s * 1000) / 1000;
|
||||
let (secs, duration) = (original(secs), duration.map(original));
|
||||
let len = match duration {
|
||||
Some(d) => Some(d),
|
||||
None => ctx.db.entry_duration(user, feed, guid).await?,
|
||||
};
|
||||
if let Some(len) = len
|
||||
&& len > 0
|
||||
&& secs * 4 >= len * 3
|
||||
{
|
||||
ctx.db.count_deadair(user, feed, guid, p.saved_ms()).await?;
|
||||
}
|
||||
Ok((secs, duration))
|
||||
}
|
||||
|
||||
/// Finds silences until stopped: in the next file anyone wants shortened, or a wait of `IDLE`
|
||||
/// when there is none.
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
if !have_ffmpeg().await {
|
||||
tracing::warn!("ffmpeg is not installed, so DeadAir shortens nothing");
|
||||
return;
|
||||
}
|
||||
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded
|
||||
// as the file having nothing to cut was never tried again, even once a fix was deployed (#174).
|
||||
let mut failed = HashSet::new();
|
||||
loop {
|
||||
// Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
|
||||
let done = tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
done = next(&ctx, &mut failed) => done,
|
||||
};
|
||||
match done {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not read a file"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
_ = ctx.deadair.notified() => {}
|
||||
_ = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn have_ffmpeg() -> bool {
|
||||
tokio::process::Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
/// Plans one file someone wants shortened, if there is one. A file everyone who would play it
|
||||
/// shortened has already heard through gets none: finished episodes are rarely played again.
|
||||
// shortcut: those stay on the list and are looked at again each pass, one query each; record
|
||||
// them as passed over if a large library makes the pass slow.
|
||||
async fn next(ctx: &Ctx, failed: &mut HashSet<i64>) -> Result<bool> {
|
||||
// Nobody with DeadAir on for anything, which is most passes: nothing more to ask the database.
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let st = &crate::metrics::STATS;
|
||||
let wanting = Wanting::load(ctx, None).await?;
|
||||
if wanting.people.is_empty() {
|
||||
st.deadair_backlog.store(0, Relaxed);
|
||||
return Ok(false);
|
||||
}
|
||||
let todo = ctx.db.deadair_todo().await?;
|
||||
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
|
||||
// per-file subscription and finished checks below.
|
||||
let wanted = |t: &crate::db::DeadAirTodo| {
|
||||
!failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id))
|
||||
};
|
||||
st.deadair_backlog.store(todo.iter().filter(|t| wanted(t)).count() as i64, Relaxed);
|
||||
for t in todo {
|
||||
if failed.contains(&t.id) {
|
||||
continue;
|
||||
}
|
||||
let users: Vec<i64> = wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
|
||||
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
|
||||
continue;
|
||||
}
|
||||
let finished = ctx.db.finished(&t.feed_id, &t.guid).await?;
|
||||
if users.iter().all(|u| finished.contains(u)) {
|
||||
continue;
|
||||
}
|
||||
let src = crate::download::playable(Path::new(&t.path));
|
||||
let started = Instant::now();
|
||||
// A trace of its own for each file looked at, not for the passes that find nothing to do,
|
||||
// which are most of them.
|
||||
let span = tracing::info_span!("deadair", feed = t.feed_id, enclosure = t.id, cuts = tracing::field::Empty);
|
||||
st.deadair_busy_since.store(crate::db::now(), Relaxed);
|
||||
let analysed = analyse(&src).instrument(span.clone()).await;
|
||||
st.deadair_busy_since.store(0, Relaxed);
|
||||
let plans = match analysed {
|
||||
Ok(p) => p.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not read a file");
|
||||
failed.insert(t.id);
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
ctx.db
|
||||
.store_plans(t.id, &serde_json::to_string(&plans)?)
|
||||
.instrument(span.in_scope(|| db::step("store_plans")))
|
||||
.await?;
|
||||
let usual = plans.get(&DEFAULT_PAUSE);
|
||||
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
|
||||
let saved_ms = usual.map_or(0, Plan::saved_ms);
|
||||
ctx.out.emit(crate::ipc::Event::DeadairDone {
|
||||
feed: t.feed_id.clone(),
|
||||
enclosure: t.id,
|
||||
saved_s: saved_ms / 1000,
|
||||
});
|
||||
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
|
||||
tracing::info!(
|
||||
feed = t.feed_id,
|
||||
path = %src.display(),
|
||||
cuts = usual.map_or(0, |p| p.cuts.len()),
|
||||
saved_s = saved_ms / 1000,
|
||||
took_ms = started.elapsed().as_millis() as u64,
|
||||
"DeadAir found the silences"
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// A plan for each of `PAUSES`, for one file. None for a file DeadAir does not apply to: video,
|
||||
/// anything but MP3 or AAC on its own (ADTS; AAC in MP4 has no byte ranges a playlist can
|
||||
/// use), or one with no silence long enough to cut.
|
||||
pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
|
||||
let Some((first, frame)) = probe(src).instrument(db::step("probe")).await? else { return Ok(None) };
|
||||
let path = src.display().to_string();
|
||||
let log = ffmpeg(&[
|
||||
"-hide_banner",
|
||||
"-nostats",
|
||||
"-i",
|
||||
&path,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-af",
|
||||
&format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"),
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
])
|
||||
.instrument(db::step("silencedetect"))
|
||||
.await
|
||||
.context("finding the silences")?;
|
||||
let silences = parse_silences(&log);
|
||||
let frames = frame_bytes(&path).instrument(db::step("frame_bytes")).await?;
|
||||
let plans: BTreeMap<i64, Plan> = PAUSES
|
||||
.iter()
|
||||
.map(|&pause| {
|
||||
let (drop, cuts) = plan(&silences, first, frame, pause as f64 / 2000.0);
|
||||
(pause, Plan { cuts, pieces: pieces(&frames, &drop, frame) })
|
||||
})
|
||||
.collect();
|
||||
Ok(plans.values().any(|p| !p.cuts.is_empty()).then_some(plans))
|
||||
}
|
||||
|
||||
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
|
||||
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path],
|
||||
)
|
||||
.await?;
|
||||
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
|
||||
out.lines()
|
||||
.map(|l| {
|
||||
let field = |k: &str| l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok());
|
||||
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
|
||||
/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
|
||||
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
|
||||
let path = src.display().to_string();
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic",
|
||||
"-of",
|
||||
"json",
|
||||
&path,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
|
||||
let streams = v["streams"].as_array().cloned().unwrap_or_default();
|
||||
let video = streams.iter().any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
||||
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
|
||||
let format = v["format"]["format_name"].as_str().unwrap_or("");
|
||||
let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
|
||||
if video || !(plain("mp3") || plain("aac")) {
|
||||
return Ok(None);
|
||||
}
|
||||
let out = run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"packet=pts_time,duration_time",
|
||||
"-read_intervals",
|
||||
"%+#1",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
&path,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
|
||||
match (f.next(), f.next()) {
|
||||
(Some(Ok(first)), Some(Ok(frame))) if frame > 0.0 => Ok(Some((first, frame))),
|
||||
_ => bail!("no audio frame in {path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs ffmpeg and gives back what it wrote to stderr, where silencedetect reports.
|
||||
async fn ffmpeg(args: &[&str]) -> Result<String> {
|
||||
let out = command("ffmpeg").args(["-nostdin", "-threads", "1"]).args(args).output().await?;
|
||||
let log = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
if !out.status.success() {
|
||||
bail!("ffmpeg failed: {}", log.lines().rev().take(3).collect::<Vec<_>>().join(" / "));
|
||||
}
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
async fn run_out(prog: &str, args: &[&str]) -> Result<String> {
|
||||
let out = command(prog).args(args).output().await.with_context(|| format!("running {prog}"))?;
|
||||
if !out.status.success() {
|
||||
bail!("{prog} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn command(prog: &str) -> tokio::process::Command {
|
||||
let mut c = tokio::process::Command::new(prog);
|
||||
c.kill_on_drop(true).stdin(Stdio::null());
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn silences_are_read_from_the_log_and_an_open_one_is_left() {
|
||||
let log = "[silencedetect @ 0x1] silence_start: 1\n\
|
||||
[silencedetect @ 0x1] silence_end: 3.00002 | silence_duration: 2.00002\n\
|
||||
[silencedetect @ 0x1] silence_start: 10.5\n";
|
||||
assert_eq!(parse_silences(log), vec![(1.0, 3.00002)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() {
|
||||
// MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch; a 0.3 s pause.
|
||||
let frame = 1152.0 / 44100.0;
|
||||
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame, 0.15);
|
||||
assert_eq!(frames, vec![(45, 108)]);
|
||||
let Cut(s, e) = cuts[0];
|
||||
assert!(s >= 1150 && e <= 2850, "{cuts:?}");
|
||||
assert!(s - 1150 < 27 && 2850 - e < 27, "no more than a frame kept beyond half the pause: {cuts:?}");
|
||||
// AAC starts a frame early, for the encoder's priming.
|
||||
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0, 0.15);
|
||||
assert_eq!(frames, vec![(51, 122)]);
|
||||
// A silence the margins use up entirely is left alone.
|
||||
assert!(plan(&[(1.0, 1.35)], 0.0, frame, 0.15).0.is_empty());
|
||||
// A longer pause cuts less of the same silence.
|
||||
assert!(saved(&plan(&[(1.0, 3.0)], 0.0, frame, 0.25).1) < saved(&cuts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_are_the_frames_kept_end_to_end_and_never_too_long() {
|
||||
// 1000 frames of 100 bytes after a 50-byte tag, an ID3 tag of 7 bytes before frame 600.
|
||||
let frames: Vec<(u64, u64)> =
|
||||
(0..1000u64).map(|n| (50 + n * 100 + if n >= 600 { 7 } else { 0 }, 100)).collect();
|
||||
let frame = 0.026;
|
||||
let p = pieces(&frames, &[(100, 199), (900, 999)], frame);
|
||||
let at: Vec<u64> = p.iter().map(|x| x.0).collect();
|
||||
// 0-99; 200-583 and 584-599, split at 384 frames (10 s); 600-899 after the tag.
|
||||
assert_eq!(at, [50, 20_050, 58_450, 60_057]);
|
||||
assert_eq!(p[0], (50, 10_000, 100.0 * frame));
|
||||
assert_eq!(p[2].1, 1600, "frames 584-599");
|
||||
assert!(p.iter().all(|x| x.2 <= MAX_PIECE));
|
||||
let kept: u64 = p.iter().map(|x| x.1).sum();
|
||||
assert_eq!(kept, 800 * 100, "every kept frame once, the tag in none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_playlist_lists_each_piece_as_a_range_of_the_original() {
|
||||
let plan = Plan { cuts: vec![Cut(1000, 2000)], pieces: vec![(50, 400, 1.0), (500, 4000, 9.5)] };
|
||||
assert_eq!(
|
||||
playlist(&plan, "deadair.mp3"),
|
||||
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:10\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n\
|
||||
#EXTINF:1.000000,\n#EXT-X-BYTERANGE:400@50\ndeadair.mp3\n\
|
||||
#EXTINF:9.500000,\n#EXT-X-BYTERANGE:4000@500\ndeadair.mp3\n#EXT-X-ENDLIST\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_convert_both_ways() {
|
||||
let cuts = [Cut(10_000, 20_000), Cut(30_000, 35_000)];
|
||||
assert_eq!(to_copy(&cuts, 5_000), 5_000);
|
||||
assert_eq!(to_copy(&cuts, 15_000), 10_000, "inside a cut is where it was made");
|
||||
assert_eq!(to_copy(&cuts, 25_000), 15_000);
|
||||
assert_eq!(to_copy(&cuts, 40_000), 25_000);
|
||||
for t in [0, 5_000, 10_000, 15_000, 19_999, 20_000, 25_000, 40_000] {
|
||||
let back = to_original(&cuts, to_copy(&cuts, t));
|
||||
// A time inside a cut comes back as the cut's end: the shortened version has nothing between.
|
||||
let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
|
||||
assert_eq!(back, expect, "{t}");
|
||||
}
|
||||
assert_eq!(saved(&cuts), 15_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_for_every_feed_until_turned_on_for_it_or_its_group() {
|
||||
let p = |v: &[(&str, bool)]| v.iter().map(|(k, w)| (k.to_string(), *w)).collect::<HashMap<_, _>>();
|
||||
assert!(!wants(&p(&[]), "a", None), "off until turned on for the feed");
|
||||
assert!(!wants(&p(&[("b", true)]), "a", None), "another feed's says nothing of this one");
|
||||
assert!(wants(&p(&[("a", true)]), "a", None));
|
||||
assert!(wants(&p(&[("g", true)]), "a", Some("g")), "on for its group");
|
||||
assert!(!wants(&p(&[("g", true), ("a", false)]), "a", Some("g")), "kept out of its group's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pause_is_the_feeds_then_its_groups_then_the_default() {
|
||||
let w = Wanting {
|
||||
people: HashMap::new(),
|
||||
pauses: [((1, "a".to_string()), 500), ((1, "g".to_string()), 400)].into(),
|
||||
groups: [("b".to_string(), "g".to_string())].into(),
|
||||
};
|
||||
assert_eq!(w.pause(1, "a"), 500);
|
||||
assert_eq!(w.pause(1, "b"), 400, "its group's");
|
||||
assert_eq!(w.pause(1, "c"), DEFAULT_PAUSE);
|
||||
assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id3_len_reads_the_tags_size() {
|
||||
assert_eq!(id3_len(b"ID3\x04\x00\x00\x00\x00\x02\x01xx"), 10 + 257);
|
||||
assert_eq!(id3_len(b"ID3\x04\x00\x10\x00\x00\x02\x01"), 10 + 257 + 10, "with a footer");
|
||||
assert_eq!(id3_len(b"\xff\xfb\x90\x00\x00\x00\x00\x00\x00\x00"), 0);
|
||||
assert_eq!(id3_len(b"ID3"), 0);
|
||||
}
|
||||
|
||||
/// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on,
|
||||
/// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not
|
||||
/// installed.
|
||||
#[tokio::test]
|
||||
async fn the_playlist_of_a_file_is_shorter_by_what_was_cut_and_plays() {
|
||||
if !have_ffmpeg().await {
|
||||
eprintln!("no ffmpeg; skipped");
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
|
||||
let src = dir.join(format!("t.{ext}"));
|
||||
ffmpeg(&[
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"aevalsrc='if(lt(mod(t,3),1),0.5*sin(2*PI*440*t),0)':d=12:s=44100",
|
||||
"-c:a",
|
||||
codec,
|
||||
&src.display().to_string(),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
let plans = analyse(&src).await.unwrap();
|
||||
if ext == "m4a" {
|
||||
assert!(plans.is_none(), "AAC in MP4 has no byte ranges to play");
|
||||
continue;
|
||||
}
|
||||
let plans = plans.expect("plans");
|
||||
assert_eq!(plans.keys().copied().collect::<Vec<_>>(), PAUSES);
|
||||
let p = &plans[&DEFAULT_PAUSE];
|
||||
assert_eq!(p.cuts.len(), 4, "{ext}: {:?}", p.cuts);
|
||||
// About 1.7 s from each of the four 2 s silences, and less for a longer pause.
|
||||
let cut = p.saved_ms();
|
||||
assert!((6_500..6_900).contains(&cut), "{ext}: {cut}");
|
||||
assert!(plans[&500].saved_ms() < cut && cut < plans[&200].saved_ms());
|
||||
let list = dir.join(format!("{ext}.m3u8"));
|
||||
std::fs::write(&list, playlist(p, &format!("t.{ext}"))).unwrap();
|
||||
// Its frames added up: a raw AAC file's duration is only guessed from its bitrate.
|
||||
let len = |p: &Path| {
|
||||
let p = p.display().to_string();
|
||||
async move {
|
||||
run_out(
|
||||
"ffprobe",
|
||||
&[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"packet=duration_time",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
&p,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
|
||||
.sum::<f64>()
|
||||
}
|
||||
};
|
||||
let short = len(&src).await - len(&list).await;
|
||||
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
|
||||
// Decodes without a complaint.
|
||||
assert!(
|
||||
ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"])
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Saved as a file of its own (#186): as long as the playlist, and it plays.
|
||||
let (n, body) = kept(&src, p).await.unwrap();
|
||||
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
|
||||
let bytes = parts.concat();
|
||||
assert_eq!(bytes.len() as u64, n);
|
||||
if ext == "mp3" {
|
||||
assert!(bytes.starts_with(b"ID3"), "the original's tag comes too");
|
||||
}
|
||||
let saved = dir.join(format!("saved.{ext}"));
|
||||
std::fs::write(&saved, &bytes).unwrap();
|
||||
assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}");
|
||||
assert!(
|
||||
ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"])
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
use crate::config;
|
||||
use crate::test_util::test_ctx;
|
||||
|
||||
#[tokio::test]
|
||||
async fn deadair_hands_out_each_persons_clock_and_saves_in_the_originals() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let db = &ctx.db;
|
||||
let u = db.create_user("dee", None, false).await.unwrap();
|
||||
db.subscribe(u, "f").await.unwrap();
|
||||
for sql in [
|
||||
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||
] {
|
||||
db.sql(sql).await.unwrap();
|
||||
}
|
||||
// Two minutes cut at the usual pause, both before the place saved; 40 s at the longest.
|
||||
let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
||||
"500": {"cuts": [[60000,100000]], "pieces": []}}"#;
|
||||
db.store_plans(7, plans).await.unwrap();
|
||||
db.set_position(u, "f", "g", 300, None).await.unwrap();
|
||||
let order = db::order_sql("published", "desc");
|
||||
let page = || {
|
||||
let (order, ctx) = (order.clone(), &ctx);
|
||||
async move {
|
||||
let mut rows = ctx.db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||
for_reader(ctx, u, &mut rows).await.unwrap();
|
||||
rows.remove(0)
|
||||
}
|
||||
};
|
||||
let off = page().await;
|
||||
assert_eq!((off.position, off.enclosures[0].deadair), (300, None), "off until turned on for the feed");
|
||||
assert!(!off.enclosures[0].deadair_pending);
|
||||
|
||||
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||
db.sql("DELETE FROM deadair_plans").await.unwrap();
|
||||
let waiting = page().await;
|
||||
assert!(waiting.enclosures[0].deadair_pending, "on, and its silences not found yet");
|
||||
db.store_plans(7, plans).await.unwrap();
|
||||
let on = page().await;
|
||||
assert!(!on.enclosures[0].deadair_pending);
|
||||
assert_eq!((on.position, on.duration, on.enclosures[0].deadair), (180, Some(480), Some(120)));
|
||||
// A longer pause of their own: their clock, and the playlist, are that plan's (#182).
|
||||
db.set_deadair_pause(u, "f", 500).await.unwrap();
|
||||
let long = page().await;
|
||||
assert_eq!((long.position, long.duration, long.enclosures[0].deadair), (260, Some(560), Some(40)));
|
||||
assert_eq!(plan_for(&ctx, u, "f", 7).await.unwrap().unwrap().saved_ms(), 40_000);
|
||||
db.set_deadair_pause(u, "f", 300).await.unwrap();
|
||||
|
||||
// Saved from the playlist at 3:20 of its 8:00: 5:20 of the original's 10:00, short of 75%.
|
||||
let playing = Playing::default();
|
||||
let plan = plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
|
||||
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
|
||||
let saved = on_save(&ctx, &playing, u, "f", "g", 200, Some(480)).await.unwrap();
|
||||
assert_eq!(saved, (320, Some(600)));
|
||||
assert_eq!(db.deadair_total(u).await.unwrap(), 0);
|
||||
// Past 75% (6:00 of the playlist is 8:00 of the original), counted once.
|
||||
for _ in 0..2 {
|
||||
on_save(&ctx, &playing, u, "f", "g", 360, None).await.unwrap();
|
||||
}
|
||||
assert_eq!(db.deadair_total(u).await.unwrap(), 120_000);
|
||||
// Playing the original again: saved as it is.
|
||||
playing.lock().unwrap().clear();
|
||||
assert_eq!(on_save(&ctx, &playing, u, "f", "g", 200, Some(600)).await.unwrap(), (200, Some(600)));
|
||||
// Turned off again: the original's clock, and no playlist.
|
||||
db.set_deadair(u, "f", Some(false)).await.unwrap();
|
||||
assert_eq!(page().await.enclosures[0].deadair, None);
|
||||
assert!(plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_people_play_one_file_each_in_their_own_version_and_place() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let db = &ctx.db;
|
||||
for sql in [
|
||||
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||
] {
|
||||
db.sql(sql).await.unwrap();
|
||||
}
|
||||
db.store_plans(
|
||||
7,
|
||||
r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
||||
"500": {"cuts": [[60000,100000]], "pieces": []}}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
|
||||
// one, and the original.
|
||||
let (usual, long, plain) = (
|
||||
db.create_user("usual", None, false).await.unwrap(),
|
||||
db.create_user("long", None, false).await.unwrap(),
|
||||
db.create_user("plain", None, false).await.unwrap(),
|
||||
);
|
||||
for u in [usual, long, plain] {
|
||||
db.subscribe(u, "f").await.unwrap();
|
||||
}
|
||||
for u in [usual, long] {
|
||||
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||
}
|
||||
db.set_deadair_pause(long, "f", 500).await.unwrap();
|
||||
// Each is handed their own playlist, as /media/7/deadair.m3u8 does.
|
||||
let playing = Playing::default();
|
||||
for u in [usual, long] {
|
||||
let plan = plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
|
||||
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
|
||||
}
|
||||
assert!(plan_for(&ctx, plain, "f", 7).await.unwrap().is_none());
|
||||
// All three save 3:20 of what they hear, as the position route does: each is read in
|
||||
// that person's own clock and kept as a place in the original, theirs alone.
|
||||
let mut kept = vec![];
|
||||
for u in [usual, long, plain] {
|
||||
let (secs, len) = on_save(&ctx, &playing, u, "f", "g", 200, None).await.unwrap();
|
||||
db.set_position(u, "f", "g", secs, len).await.unwrap();
|
||||
kept.push(secs);
|
||||
}
|
||||
assert_eq!(kept, [320, 240, 200], "two minutes cut before 3:20, forty seconds, none");
|
||||
// Each comes back to 3:20 of their own version, whatever the others saved.
|
||||
let order = db::order_sql("published", "desc");
|
||||
for (u, len) in [(usual, 480), (long, 560), (plain, 600)] {
|
||||
let mut rows = db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||
for_reader(&ctx, u, &mut rows).await.unwrap();
|
||||
assert_eq!((rows[0].position, rows[0].duration), (200, Some(len)), "user {u}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A real episode, for trying the cut on something other than a test tone:
|
||||
/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. Each pause's
|
||||
/// playlist is left beside it, `ep.mp3.300.m3u8`, to listen to.
|
||||
#[cfg(test)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn real_file() {
|
||||
let src = std::path::PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
|
||||
let t = std::time::Instant::now();
|
||||
let plans = analyse(&src).await.unwrap().expect("plans");
|
||||
eprintln!("analysed in {:.1?}", t.elapsed());
|
||||
let name = src.file_name().unwrap().to_string_lossy().into_owned();
|
||||
for (pause, p) in &plans {
|
||||
std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
|
||||
eprintln!(
|
||||
"{pause} ms: {} cuts, {:.1} min saved, {} pieces",
|
||||
p.cuts.len(),
|
||||
p.saved_ms() as f64 / 60_000.0,
|
||||
p.pieces.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
374
crates/ipx/src/directory.rs
Normal file
374
crates/ipx/src/directory.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
//! The Directory, from the directory service (#205): ipx asks it on its people's behalf, so the
|
||||
//! page never talks to it and never sees its token, and sends it the public feeds people here
|
||||
//! subscribe to when an admin has turned sharing on.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use ipx_directory::{FeedPage, Listing, Me, SnapshotEntry, SnapshotResult};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::catalogue::Sub;
|
||||
use crate::ctx::Ctx;
|
||||
use crate::{config, feed};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::Instrument;
|
||||
|
||||
/// How often the snapshot goes when nothing has woken the sender sooner.
|
||||
const SHARE_EVERY: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// What went wrong asking the directory, in words for an admin.
|
||||
#[derive(Debug)]
|
||||
pub enum DirError {
|
||||
/// No address, or no token, in the settings.
|
||||
NotConfigured,
|
||||
/// It answered, refusing: 401 is a token it does not know, 403 one without the access asked for.
|
||||
Refused(u16, String),
|
||||
/// It did not answer, or not with what was asked for.
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
impl Display for DirError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DirError::NotConfigured => {
|
||||
write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory.")
|
||||
}
|
||||
DirError::Refused(401, _) => {
|
||||
write!(f, "The directory does not accept this server's token. It may have been revoked.")
|
||||
}
|
||||
DirError::Refused(403, msg) => write!(f, "The directory refused: {msg}."),
|
||||
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
|
||||
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
|
||||
DirError::Unreachable(e) => write!(f, "The directory could not be reached: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the snapshot last went, and what came of it, for the admin page.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Shared {
|
||||
pub at: i64,
|
||||
#[serde(flatten)]
|
||||
pub result: Option<SnapshotResult>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn target(cfg: &config::Config) -> Option<(String, String)> {
|
||||
let url = cfg.general.directory_url.trim().trim_end_matches('/');
|
||||
let token = cfg.general.directory_token.trim();
|
||||
(!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned()))
|
||||
}
|
||||
|
||||
pub fn configured(cfg: &config::Config) -> bool {
|
||||
target(cfg).is_some()
|
||||
}
|
||||
|
||||
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
|
||||
/// has answered, so a slow read of a large answer showed under no span (#250).
|
||||
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
|
||||
async fn call<T: DeserializeOwned>(
|
||||
ctx: &Ctx,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<&[SnapshotEntry]>,
|
||||
) -> Result<T, DirError> {
|
||||
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
|
||||
let mut req =
|
||||
ctx.local_client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(Duration::from_secs(20));
|
||||
// The directory's spans join this request's trace (#213).
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
// A snapshot is about 100 KB of JSON, sent gzipped; the directory unpacks it (#253).
|
||||
if let Some(b) = body {
|
||||
use std::io::Write as _;
|
||||
let json = serde_json::to_vec(b).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&json).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let packed = gz.finish().map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
req = req
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.header(reqwest::header::CONTENT_ENCODING, "gzip")
|
||||
.body(packed);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let msg = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
return Err(DirError::Refused(status.as_u16(), msg));
|
||||
}
|
||||
// A removal or an unblock answers 204, with nothing to read: that reads as null, which is ().
|
||||
let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
tracing::Span::current().record("http.response.body.size", body.len());
|
||||
serde_json::from_slice(if body.is_empty() { b"null" } else { &body })
|
||||
.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||
}
|
||||
|
||||
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
|
||||
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC)
|
||||
}
|
||||
|
||||
pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/me", None).await
|
||||
}
|
||||
|
||||
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/feeds", None).await?;
|
||||
note_art(got.iter());
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
|
||||
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await?;
|
||||
note_art(got.iter());
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
|
||||
let got: FeedPage = call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await?;
|
||||
note_art(std::iter::once(&got.listing));
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
/// Artwork the directory has listed, which `/api/art` serves though no feed here names it: the
|
||||
/// Directory shows podcasts nobody here subscribes to, and drew their initials instead (#273).
|
||||
///
|
||||
/// shortcut: in memory, one address a listed feed, forgotten on a restart until the Directory is
|
||||
/// next read, which the page does when it loads; a table if that ever shows as initials.
|
||||
static LISTED_ART: LazyLock<std::sync::Mutex<HashSet<String>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn note_art<'a>(listings: impl Iterator<Item = &'a Listing>) {
|
||||
let mut art = LISTED_ART.lock().unwrap_or_else(|e| e.into_inner());
|
||||
art.extend(listings.filter_map(|l| l.image.clone()));
|
||||
}
|
||||
|
||||
pub fn listed_art(url: &str) -> bool {
|
||||
LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()).contains(url)
|
||||
}
|
||||
|
||||
// Administering the directory (#206): each needs an rwa token, which the directory checks.
|
||||
|
||||
/// Removes a feed and blocks it, so no snapshot brings it back.
|
||||
pub async fn remove(ctx: &Ctx, id: &str) -> Result<(), DirError> {
|
||||
call(ctx, reqwest::Method::DELETE, &format!("/v1/feeds/{}", seg(id)), None).await
|
||||
}
|
||||
|
||||
pub async fn refresh(ctx: &Ctx, id: &str) -> Result<serde::de::IgnoredAny, DirError> {
|
||||
call(ctx, reqwest::Method::POST, &format!("/v1/feeds/{}/refresh", seg(id)), None).await
|
||||
}
|
||||
|
||||
pub async fn blocked(ctx: &Ctx) -> Result<Vec<ipx_directory::Blocked>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/blocked", None).await
|
||||
}
|
||||
|
||||
pub async fn unblock(ctx: &Ctx, id: &str) -> Result<(), DirError> {
|
||||
call(ctx, reqwest::Method::DELETE, &format!("/v1/blocked/{}", seg(id)), None).await
|
||||
}
|
||||
|
||||
pub async fn instances(ctx: &Ctx) -> Result<Vec<ipx_directory::Instance>, DirError> {
|
||||
call(ctx, reqwest::Method::GET, "/v1/instances", None).await
|
||||
}
|
||||
|
||||
/// Drops one server's snapshot, and its counts with it. It comes back with that server's next
|
||||
/// one: to keep a server out, revoke its token.
|
||||
pub async fn drop_instance(ctx: &Ctx, name: &str) -> Result<(), DirError> {
|
||||
call(ctx, reqwest::Method::DELETE, &format!("/v1/instances/{}", seg(name)), None).await
|
||||
}
|
||||
|
||||
/// Has the snapshot sent again now, changed or not.
|
||||
pub fn resend_now(ctx: &Ctx) {
|
||||
ctx.directory_resend.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
ctx.directory_wake.notify_one();
|
||||
}
|
||||
|
||||
/// Whether this server's token may administer the directory.
|
||||
pub async fn can_admin(ctx: &Ctx) -> bool {
|
||||
me(ctx).await.is_ok_and(|m| m.access == ipx_directory::config::Access::Rwa)
|
||||
}
|
||||
|
||||
/// Whether a feed is someone's own: marked private, given a username or password, or at an
|
||||
/// address that carries a key (`feed::url_looks_private`).
|
||||
pub fn looks_private(feed: &config::Feed) -> bool {
|
||||
feed.private
|
||||
|| feed.username.is_some()
|
||||
|| feed.password.is_some()
|
||||
|| feed.password_env.is_some()
|
||||
|| feed::url_looks_private(&feed.url)
|
||||
}
|
||||
|
||||
/// What the snapshot lists: every feed in the catalogue, with how many people here subscribe,
|
||||
/// except an OPML or a Patreon creator itself, which is sent as the feeds in it, and anything
|
||||
/// private, a feed inside a private OPML included. An OPML not read yet is not known to be one
|
||||
/// and goes too; the directory lists only what it has read as a feed.
|
||||
pub fn snapshot_entries(catalogue: &[Sub], counts: &HashMap<String, i64>) -> Vec<SnapshotEntry> {
|
||||
let by_id: HashMap<&str, &config::Feed> = catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||
let is_group: HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
||||
catalogue
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
!is_group.contains(s.id.as_str())
|
||||
&& !feed::is_patreon_creator(&s.cfg.url)
|
||||
&& !looks_private(&s.cfg)
|
||||
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
|
||||
})
|
||||
.map(|s| SnapshotEntry {
|
||||
url: s.cfg.url.clone(),
|
||||
subscribers: counts.get(&s.id).copied().unwrap_or(0),
|
||||
category: s.cfg.category.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sends the snapshot now.
|
||||
async fn snapshot(ctx: &Ctx) -> Result<Vec<SnapshotEntry>, DirError> {
|
||||
let catalogue = crate::catalogue::subscriptions(ctx).await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let counts = ctx.db.subscriber_counts().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
Ok(snapshot_entries(&catalogue, &counts))
|
||||
}
|
||||
|
||||
pub async fn share(ctx: &Ctx, entries: &[SnapshotEntry]) -> Result<SnapshotResult, DirError> {
|
||||
let r: SnapshotResult = call(ctx, reqwest::Method::POST, "/v1/snapshot", Some(entries)).await?;
|
||||
if r.refused_private > 0 {
|
||||
// The directory caught what this side should have: worth knowing which check differs.
|
||||
tracing::warn!(refused = r.refused_private, "the directory refused feeds as private that ipx sent");
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Sends the snapshot every half hour while sharing is on, and at once when the settings change.
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
// What went last, where, and when: a scan wakes this every half minute or so, and the same
|
||||
// 1,500 feeds went each time, the directory rewriting its rows for nothing (#211).
|
||||
let mut last: Option<(Vec<u8>, Instant)> = None;
|
||||
loop {
|
||||
let cfg = ctx.cfg();
|
||||
if cfg.general.directory_share && configured(&cfg) {
|
||||
let got = match snapshot(&ctx).await {
|
||||
Ok(entries) => {
|
||||
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
|
||||
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
|
||||
if forced || resend(last.as_ref(), &key) {
|
||||
let span =
|
||||
tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
||||
let got = Instrument::instrument(share(&ctx, &entries), span).await;
|
||||
if got.is_ok() {
|
||||
last = Some((key, Instant::now()));
|
||||
}
|
||||
Some(got)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => Some(Err(e)),
|
||||
};
|
||||
if let Some(got) = got {
|
||||
match &got {
|
||||
Ok(r) => tracing::info!(
|
||||
accepted = r.accepted,
|
||||
refused_private = r.refused_private,
|
||||
refused_blocked = r.refused_blocked,
|
||||
"shared with the directory"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
|
||||
}
|
||||
let (result, error) = match got {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
};
|
||||
*ctx.directory_shared.lock().expect("a panic elsewhere poisoned this lock") =
|
||||
Some(Shared { at: crate::db::now(), result, error });
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(SHARE_EVERY) => {}
|
||||
_ = ctx.directory_wake.notified() => {}
|
||||
_ = stop.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to send the snapshot `key` stands for: one that changed, or went to another directory
|
||||
/// or with another token, goes now; the same again only once SHARE_EVERY has passed, so a
|
||||
/// directory that lost it, or dropped this server's counts, has it back within the half hour.
|
||||
fn resend(last: Option<&(Vec<u8>, Instant)>, key: &[u8]) -> bool {
|
||||
last.is_none_or(|(k, at)| k != key || at.elapsed() >= SHARE_EVERY)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artwork_the_directory_listed_is_let_through_and_nothing_else() {
|
||||
let listing = |image: Option<&str>| Listing {
|
||||
id: "x".into(),
|
||||
url: "https://x.example/feed".into(),
|
||||
title: None,
|
||||
image: image.map(str::to_owned),
|
||||
category: None,
|
||||
subcategory: None,
|
||||
podcast: true,
|
||||
subscribers: 1,
|
||||
};
|
||||
note_art([listing(Some("https://img.example/cover.jpg")), listing(None)].iter());
|
||||
assert!(listed_art("https://img.example/cover.jpg"));
|
||||
assert!(!listed_art("http://192.168.1.1/admin"));
|
||||
}
|
||||
|
||||
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
|
||||
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
|
||||
edit(&mut cfg);
|
||||
Sub { id: id.into(), cfg, managed: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_holds_public_feeds_and_never_a_private_one() {
|
||||
let catalogue = vec![
|
||||
sub("public", "https://example.com/feed.xml", |_| {}),
|
||||
sub("marked", "https://example.com/mine.xml", |f| f.private = true),
|
||||
sub("password", "https://example.com/locked.xml", |f| f.username = Some("me".into())),
|
||||
sub("paid", "https://feeds.supercast.com/feeds/abc", |_| {}),
|
||||
sub("keyed", "https://example.com/feed?key=k", |_| {}),
|
||||
// An OPML: sent as the feeds in it, never itself.
|
||||
sub("list", "https://example.com/list.opml", |_| {}),
|
||||
sub("in-list", "https://example.com/a.xml", |f| f.group = Some("list".into())),
|
||||
// Inside a private OPML: as private as the OPML.
|
||||
sub("secret-list", "https://example.com/l.opml?token=t", |_| {}),
|
||||
sub("in-secret", "https://example.com/b.xml", |f| f.group = Some("secret-list".into())),
|
||||
];
|
||||
let counts = HashMap::from([("public".to_string(), 3), ("marked".to_string(), 1)]);
|
||||
let got = snapshot_entries(&catalogue, &counts);
|
||||
let urls: Vec<(&str, i64)> = got.iter().map(|e| (e.url.as_str(), e.subscribers)).collect();
|
||||
assert_eq!(urls, [("https://example.com/feed.xml", 3), ("https://example.com/a.xml", 0)]);
|
||||
let patreon = vec![sub("creator", "https://www.patreon.com/c/someone", |_| {})];
|
||||
assert!(snapshot_entries(&patreon, &counts).is_empty(), "a Patreon creator goes as its shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_asked_without_an_address_and_a_token() {
|
||||
let mut cfg = config::Config::default();
|
||||
assert!(!configured(&cfg));
|
||||
cfg.general.directory_url = "https://ipx-dir.example/".into();
|
||||
assert!(!configured(&cfg));
|
||||
cfg.general.directory_token = "t".into();
|
||||
assert_eq!(target(&cfg), Some(("https://ipx-dir.example".into(), "t".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_snapshot_is_not_sent_again_within_the_half_hour() {
|
||||
let now = std::time::Instant::now();
|
||||
assert!(resend(None, b"a"));
|
||||
assert!(!resend(Some(&(b"a".to_vec(), now)), b"a"));
|
||||
assert!(resend(Some(&(b"a".to_vec(), now)), b"b"));
|
||||
if let Some(long_ago) = now.checked_sub(SHARE_EVERY) {
|
||||
assert!(resend(Some(&(b"a".to_vec(), long_ago)), b"a"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::config::{Config, Feed as FeedCfg, Organize};
|
||||
use chrono::Local;
|
||||
use tokio::fs::File;
|
||||
use url::Url;
|
||||
|
||||
/// Forbidden characters that were separating words: they become "-" so the words stay
|
||||
/// apart. The original's stringCleaning() deleted them, turning "Show | Series" into
|
||||
@@ -83,18 +86,10 @@ pub fn filename_for(url: &str, content_disposition: Option<&str>) -> String {
|
||||
if let Some(name) = content_disposition.and_then(disposition_filename) {
|
||||
return sanitize(&name);
|
||||
}
|
||||
let from_url = url::Url::parse(url)
|
||||
let from_url = Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.path_segments()
|
||||
.and_then(|s| s.filter(|p| !p.is_empty()).next_back())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.map(|seg| {
|
||||
percent_encoding::percent_decode_str(&seg)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.and_then(|u| u.path_segments().and_then(|mut s| s.rfind(|p| !p.is_empty())).map(str::to_owned))
|
||||
.map(|seg| percent_encoding::percent_decode_str(&seg).decode_utf8_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
sanitize(&from_url)
|
||||
}
|
||||
@@ -105,11 +100,7 @@ fn disposition_filename(header: &str) -> Option<String> {
|
||||
// RFC 5987 form wins when both are present.
|
||||
if let Some(v) = part.strip_prefix("filename*=") {
|
||||
let raw = v.rsplit('\'').next().unwrap_or(v);
|
||||
return Some(
|
||||
percent_encoding::percent_decode_str(raw)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
return Some(percent_encoding::percent_decode_str(raw).decode_utf8_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
for part in header.split(';') {
|
||||
@@ -145,8 +136,8 @@ pub async fn download(
|
||||
mut on_progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Downloaded> {
|
||||
let mut req = client.get(url);
|
||||
if let Some(user) = &feed_cfg.username {
|
||||
req = req.basic_auth(user, feed_cfg.password());
|
||||
if let Some((user, password)) = feed_cfg.auth_for(url) {
|
||||
req = req.basic_auth(user, password);
|
||||
}
|
||||
let resp = req.send().await.context("connecting")?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -155,27 +146,23 @@ pub async fn download(
|
||||
|
||||
let name = filename_for(
|
||||
resp.url().as_str(),
|
||||
resp.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
resp.headers().get(reqwest::header::CONTENT_DISPOSITION).and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let total = resp.content_length();
|
||||
|
||||
let tmp_dir = cfg.general.download_dir.join(".ipx-incomplete");
|
||||
tokio::fs::create_dir_all(&tmp_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
||||
tokio::fs::create_dir_all(&tmp_dir).await.with_context(|| format!("creating {}", tmp_dir.display()))?;
|
||||
let tmp = tmp_dir.join(&name);
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", tmp.display()))?;
|
||||
let mut file = File::create(&tmp).await.with_context(|| format!("creating {}", tmp.display()))?;
|
||||
let mut done: u64 = 0;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("reading body")?;
|
||||
file.write_all(&chunk).await?;
|
||||
done += chunk.len() as u64;
|
||||
crate::metrics::STATS.download_bytes.fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
on_progress(done, total);
|
||||
}
|
||||
file.flush().await?;
|
||||
@@ -197,16 +184,42 @@ pub async fn download(
|
||||
/// Moves a completed download into its folder. Same filesystem as the incomplete dir, so
|
||||
/// this is a rename rather than the original's copy-then-unlink.
|
||||
pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
tokio::fs::create_dir_all(dir).await.with_context(|| format!("creating {}", dir.display()))?;
|
||||
let dest = unique_path(dir, &d.name);
|
||||
tokio::fs::rename(&d.tmp, &dest)
|
||||
.await
|
||||
.with_context(|| format!("moving into {}", dest.display()))?;
|
||||
tokio::fs::rename(&d.tmp, &dest).await.with_context(|| format!("moving into {}", dest.display()))?;
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Deletes what an enclosure's path names: a file, or the folder a torrent of several files came
|
||||
/// down as. `remove_file` refused the folder, so the reaper warned on every pass and freed nothing,
|
||||
/// and Delete failed (#158).
|
||||
pub fn remove(path: &Path) -> std::io::Result<()> {
|
||||
if path.is_dir() {
|
||||
return std::fs::remove_dir_all(path);
|
||||
}
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
|
||||
/// The file to play for an enclosure's path: the path itself, or for a torrent of several files,
|
||||
/// the largest file in its folder, which is the episode (#158).
|
||||
pub fn playable(path: &Path) -> PathBuf {
|
||||
fn largest(dir: &Path, best: &mut Option<(u64, PathBuf)>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for e in entries.flatten() {
|
||||
match e.metadata() {
|
||||
Ok(m) if m.is_dir() => largest(&e.path(), best),
|
||||
Ok(m) if best.as_ref().is_none_or(|(n, _)| m.len() > *n) => *best = Some((m.len(), e.path())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut best = None;
|
||||
if path.is_dir() {
|
||||
largest(path, &mut best);
|
||||
}
|
||||
best.map_or_else(|| path.to_owned(), |(_, p)| p)
|
||||
}
|
||||
|
||||
/// Advertised as a torrent, before spending any bandwidth on it.
|
||||
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
|
||||
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
|
||||
@@ -223,7 +236,7 @@ enum Sniffed {
|
||||
/// 2008 and so always answered 'data'.
|
||||
async fn sniff(path: &Path) -> Result<Sniffed> {
|
||||
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);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&head);
|
||||
@@ -236,7 +249,7 @@ async fn sniff(path: &Path) -> Result<Sniffed> {
|
||||
|
||||
async fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut f = tokio::fs::File::open(path).await?;
|
||||
let mut f = File::open(path).await?;
|
||||
let mut buf = vec![0u8; n];
|
||||
let read = f.read(&mut buf).await?;
|
||||
buf.truncate(read);
|
||||
@@ -249,15 +262,8 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
let stem = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(name);
|
||||
let ext = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{e}"))
|
||||
.unwrap_or_default();
|
||||
let stem = Path::new(name).file_stem().and_then(|s| s.to_str()).unwrap_or(name);
|
||||
let ext = Path::new(name).extension().and_then(|e| e.to_str()).map(|e| format!(".{e}")).unwrap_or_default();
|
||||
for n in 2..1000 {
|
||||
let candidate = dir.join(format!("{stem}-{n}{ext}"));
|
||||
if !candidate.exists() {
|
||||
@@ -274,14 +280,9 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
||||
/// letting the sanitizer eat the separator.
|
||||
pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str>) -> String {
|
||||
match cfg.general.organize {
|
||||
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
|
||||
Organize::Date => Local::now().format("%m-%d-%Y").to_string(),
|
||||
Organize::Feed => {
|
||||
let raw = feed_cfg
|
||||
.folder
|
||||
.as_deref()
|
||||
.or(title)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(id);
|
||||
let raw = feed_cfg.folder.as_deref().or(title).filter(|s| !s.trim().is_empty()).unwrap_or(id);
|
||||
raw.split('/')
|
||||
.map(str::trim)
|
||||
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
|
||||
@@ -305,6 +306,26 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
|
||||
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
|
||||
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
|
||||
/// "Trump's" is by "trump".
|
||||
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
|
||||
// Letters and digits only, each run of anything else one space, padded so a phrase matches
|
||||
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
|
||||
let norm = |s: &str| {
|
||||
let mut out = String::from(" ");
|
||||
let words = s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty());
|
||||
for w in words {
|
||||
out.push_str(&w.to_lowercase());
|
||||
out.push(' ');
|
||||
}
|
||||
out
|
||||
};
|
||||
let hay = norm(&haystacks.join(" "));
|
||||
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -359,19 +380,13 @@ mod tests {
|
||||
"ep1.mp3",
|
||||
"query string is not part of the name"
|
||||
);
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None),
|
||||
"My Show Ep 1.mp3"
|
||||
);
|
||||
assert_eq!(filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None), "My Show Ep 1.mp3");
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/dl?id=9", Some("attachment; filename=\"real name.mp3\"")),
|
||||
"real name.mp3",
|
||||
"Content-Disposition overrides the URL"
|
||||
);
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")),
|
||||
"café.mp3"
|
||||
);
|
||||
assert_eq!(filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")), "café.mp3");
|
||||
assert_eq!(filename_for("https://x.com/", None), "download");
|
||||
assert_eq!(
|
||||
filename_for("https://x.com/a/../../etc/passwd", None),
|
||||
@@ -385,9 +400,21 @@ mod tests {
|
||||
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,
|
||||
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,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
};
|
||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||
|
||||
@@ -396,6 +423,20 @@ mod tests {
|
||||
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_torrent_folder_plays_its_largest_file_and_deletes_whole() {
|
||||
let d = std::env::temp_dir().join(format!("ipx-torrent-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("show/extras")).unwrap();
|
||||
std::fs::write(d.join("show/cover.jpg"), [0; 10]).unwrap();
|
||||
std::fs::write(d.join("show/extras/episode.mp3"), [0; 100]).unwrap();
|
||||
assert_eq!(playable(&d.join("show")), d.join("show/extras/episode.mp3"));
|
||||
assert_eq!(playable(&d.join("show/cover.jpg")), d.join("show/cover.jpg"), "a file is itself");
|
||||
remove(&d.join("show")).unwrap();
|
||||
assert!(!d.join("show").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
|
||||
let kws = vec!["deep dive".to_string(), "interview".to_string()];
|
||||
@@ -404,4 +445,16 @@ mod tests {
|
||||
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
||||
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_matches_whole_words_and_phrases() {
|
||||
let words = vec!["AI".to_string(), "new york".to_string()];
|
||||
assert!(blocked(&words, &["What AI means now"]));
|
||||
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
|
||||
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
|
||||
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
|
||||
assert!(!blocked(&words, &["New Yorkshire"]));
|
||||
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
|
||||
assert!(!blocked(&[], &["anything"]));
|
||||
}
|
||||
}
|
||||
515
crates/ipx/src/entity.rs
Normal file
515
crates/ipx/src/entity.rs
Normal file
@@ -0,0 +1,515 @@
|
||||
//! The database's tables as SeaORM entities: the one description of the schema, from which
|
||||
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see
|
||||
//! `db::create_missing`). Times are Unix seconds.
|
||||
|
||||
pub mod feeds {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "feeds")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub url: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub title: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub image: Option<String>,
|
||||
/// The channel's first <itunes:category>, for the Directory.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub category: Option<String>,
|
||||
/// What the feed says it is, as it says it, for its page in the Directory. Added after
|
||||
/// the table was, so `create_missing` adds it to a database without it.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub etag: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub last_modified: Option<String>,
|
||||
pub last_checked: Option<i64>,
|
||||
pub ttl_mins: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub last_error: Option<String>,
|
||||
/// When the current run of failures began; NULL while the feed is healthy. Kept through
|
||||
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
|
||||
/// later) from a feed that has been down for a day.
|
||||
pub error_since: Option<i64>,
|
||||
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub orphaned: bool,
|
||||
/// The OPML subscription this feed came from.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub group_id: Option<String>,
|
||||
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
|
||||
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
|
||||
/// are re-derived instead. Customising one promotes it to config.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub managed: bool,
|
||||
/// SHA-256 of the body last read, in hex: the same body again is read as "not modified"
|
||||
/// (#242). Added after the table was, so `create_missing` adds it, and `same_since`.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub body_hash: Option<String>,
|
||||
/// When the body last changed: the longer ago, the less often the feed is checked (#242).
|
||||
pub same_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod entries {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "entries")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub title: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub link: Option<String>,
|
||||
pub published: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
pub first_seen: i64,
|
||||
/// The item's Markdown, when its feed sends it beside the HTML (#285).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub markdown: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub image: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub season: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod enclosures {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "enclosures")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub guid: String,
|
||||
/// The dedupe key, and the reason one file serves every subscriber. A reaped file keeps
|
||||
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
|
||||
#[sea_orm(unique, column_type = "Text")]
|
||||
pub url: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub mime: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub path: Option<String>,
|
||||
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
|
||||
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
|
||||
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
|
||||
/// why), then 'downloading', 'done', 'error', 'reaped'.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub state: String,
|
||||
#[sea_orm(default_value = 0)]
|
||||
pub bytes_done: i64,
|
||||
pub downloaded_at: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod users {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
/// Unique without regard to case: `db::create_missing` adds the index on lower(name), which
|
||||
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub name: String,
|
||||
/// NULL for someone who only ever arrives through the proxy: there is no password to
|
||||
/// check, and leaving it empty is not the same as leaving it unset.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub pass_hash: Option<String>,
|
||||
#[sea_orm(default_value = false)]
|
||||
pub is_admin: bool,
|
||||
/// For whoever maintains the server. NULL where it is not known.
|
||||
pub created: Option<i64>,
|
||||
pub last_login: Option<i64>,
|
||||
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub theme: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub theme_mode: Option<String>,
|
||||
/// Their own TypeSafe key, which Jev judges their Signals' items on (#254). Never sent
|
||||
/// back to the page.
|
||||
// shortcut: kept as it is, since every call needs it whole; encrypt it with a key of the
|
||||
// server's if the database is ever shared with anything else.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub typesafe_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// A table of one person's rows, gone when they are.
|
||||
macro_rules! owned_by_user {
|
||||
() => {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::users::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
};
|
||||
}
|
||||
|
||||
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
|
||||
/// part that is not. NULL in a column means: follow the feed's own setting.
|
||||
pub mod subscriptions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "subscriptions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
/// JSON array of strings; NULL follows the feed.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub keywords: Option<String>,
|
||||
pub auto_download: Option<bool>,
|
||||
pub allow_explicit: Option<bool>,
|
||||
pub max_new_per_check: Option<i64>,
|
||||
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub pinned: bool,
|
||||
/// A feed from an OPML or Patreon list this person keeps when the list drops it, or when
|
||||
/// they leave the list: it becomes a feed of its own instead of going (#280).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub kept: bool,
|
||||
/// Left out of All Subscriptions: this feed, or on an OPML or Patreon creator every show
|
||||
/// in it (#288).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_all: bool,
|
||||
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
|
||||
#[sea_orm(default_value = false)]
|
||||
pub hide_from_signals: bool,
|
||||
/// Every item of the feed first seen before this is read by this person (#347): unread
|
||||
/// lists start here instead of reading the whole back catalogue. NULL knows nothing, and
|
||||
/// the daily reap moves it up (`Db::advance_unread_from`).
|
||||
pub unread_from: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// Read, kept and how far in. One row per person per item, created on first touch; an item
|
||||
/// nobody has touched has no row at all, which is what unread means.
|
||||
pub mod entry_state {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "entry_state")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
#[sea_orm(default_value = false)]
|
||||
pub read: bool,
|
||||
#[sea_orm(default_value = false)]
|
||||
pub flagged: bool,
|
||||
#[sea_orm(default_value = 0)]
|
||||
pub position: i64,
|
||||
/// The length this person's player measured, beside the position it is measured against.
|
||||
pub duration: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
|
||||
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
|
||||
/// every feed they read.
|
||||
pub mod blocklists {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "blocklists")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
/// JSON array of strings.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub words: String,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// The items someone's block lists hide from them, worked out whenever a list or the feed
|
||||
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
|
||||
pub mod hidden {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "hidden")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// A feed someone makes from their own subscriptions (#254): the items Jev says fit its
|
||||
/// keywords, whichever feed they came from.
|
||||
pub mod signals {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signals")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub user_id: i64,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub name: String,
|
||||
/// JSON array of strings.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub keywords: String,
|
||||
/// What its owner means by it, in a sentence of their own, when the name and the words
|
||||
/// are not enough (#259).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
|
||||
/// initials (#261).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub icon: Option<String>,
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// What Jev said of each item a Signal has been asked about, fitting or not: an item with no row
|
||||
/// here is still to be asked.
|
||||
pub mod signal_verdicts {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signal_verdicts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub signal_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
pub hit: bool,
|
||||
/// A picked-out item to ask about again, since its Signal changed (#276). It stays in the
|
||||
/// Signal's list until Jev has answered the new question.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub recheck: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::signals::Entity",
|
||||
from = "Column::SignalId",
|
||||
to = "super::signals::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod sessions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "sessions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub token: String,
|
||||
pub user_id: i64,
|
||||
pub seen: i64,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
|
||||
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
|
||||
pub mod api_tokens {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "api_tokens")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub user_id: i64,
|
||||
/// What its owner called it, to tell one from another when revoking.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub name: String,
|
||||
#[sea_orm(unique, column_type = "Text")]
|
||||
pub hash: String,
|
||||
pub created: i64,
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
||||
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
||||
pub mod catalogue {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "catalogue")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub spec: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
|
||||
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
|
||||
/// configuration has moved in (issue #18).
|
||||
pub mod settings {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "settings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub name: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
|
||||
/// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
|
||||
/// AAC, nothing to cut), so that one is looked at once and not every pass.
|
||||
pub mod deadair_plans {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "deadair_plans")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub enclosure_id: i64,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub plans: String,
|
||||
/// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
|
||||
pub made_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// Who has DeadAir on, for which feeds: it is off for every feed until someone turns it on for
|
||||
/// that one or its group, and `wanted` false keeps one feed of a group out of the group's.
|
||||
pub mod deadair_prefs {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "deadair_prefs")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
pub wanted: bool,
|
||||
/// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
|
||||
pub pause_ms: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// The time DeadAir has saved someone: one row per episode they got 75% through on a copy, so
|
||||
/// replaying it never counts twice. Their total is the sum.
|
||||
pub mod deadair_saved {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "deadair_saved")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
pub saved_ms: i64,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
656
crates/ipx/src/ipc.rs
Normal file
656
crates/ipx/src/ipc.rs
Normal file
@@ -0,0 +1,656 @@
|
||||
//! Unix-socket control and event stream. Replaces printMSG's `;;1;;1;;100.00;;42.31`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
/// One JSON object per line, `ev` naming the variant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "ev", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
FeedStart {
|
||||
feed: String,
|
||||
},
|
||||
FeedSkip {
|
||||
feed: String,
|
||||
reason: String,
|
||||
},
|
||||
FeedDone {
|
||||
feed: String,
|
||||
new: usize,
|
||||
downloaded: usize,
|
||||
failed: usize,
|
||||
torrents: usize,
|
||||
},
|
||||
FeedError {
|
||||
feed: String,
|
||||
msg: String,
|
||||
},
|
||||
/// The feed answered from a new address after redirects that all said it moved for good,
|
||||
/// and the catalogue now has that address (#115).
|
||||
FeedMoved {
|
||||
feed: String,
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
Progress {
|
||||
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,
|
||||
file: String,
|
||||
done: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total: Option<u64>,
|
||||
},
|
||||
DownloadDone {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
url: String,
|
||||
path: String,
|
||||
bytes: u64,
|
||||
},
|
||||
DownloadError {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
url: String,
|
||||
msg: String,
|
||||
},
|
||||
TorrentDeferred {
|
||||
feed: String,
|
||||
url: String,
|
||||
},
|
||||
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
|
||||
DeadairDone {
|
||||
feed: String,
|
||||
enclosure: i64,
|
||||
saved_s: i64,
|
||||
},
|
||||
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
||||
SignalHits {
|
||||
user: i64,
|
||||
hits: usize,
|
||||
},
|
||||
Reaped {
|
||||
path: String,
|
||||
bytes: u64,
|
||||
},
|
||||
/// Terminal: a client that asked for work stops reading here.
|
||||
ScanDone {
|
||||
feeds: usize,
|
||||
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
|
||||
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
|
||||
#[serde(default)]
|
||||
only: Option<String>,
|
||||
#[serde(default)]
|
||||
forced: bool,
|
||||
},
|
||||
ReapDone {
|
||||
files: usize,
|
||||
bytes: u64,
|
||||
},
|
||||
Status {
|
||||
feeds: usize,
|
||||
pending: i64,
|
||||
downloaded: i64,
|
||||
},
|
||||
Error {
|
||||
msg: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The feed this is about, for sending it only to those who read that feed.
|
||||
pub fn feed(&self) -> Option<&str> {
|
||||
match self {
|
||||
Event::FeedStart { feed }
|
||||
| Event::FeedSkip { feed, .. }
|
||||
| Event::FeedDone { feed, .. }
|
||||
| Event::FeedError { feed, .. }
|
||||
| Event::FeedMoved { feed, .. }
|
||||
| Event::Progress { feed, .. }
|
||||
| Event::DownloadDone { feed, .. }
|
||||
| Event::DownloadError { feed, .. }
|
||||
| Event::TorrentDeferred { feed, .. }
|
||||
| Event::DeadairDone { feed, .. } => Some(feed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a page signed in as this person is sent it. Everyone had every event, so a key in
|
||||
/// a paid feed's path, which `redacted` keeps, reached people who do not read the feed (#325).
|
||||
/// An admin has them all, for the log.
|
||||
pub fn is_for(&self, user: &crate::db::User, mine: &HashSet<String>) -> bool {
|
||||
if user.is_admin {
|
||||
return true;
|
||||
}
|
||||
match self {
|
||||
Event::SignalHits { user: u, .. } => *u == user.id,
|
||||
// Paths of files on disk, everyone's.
|
||||
Event::Reaped { .. } => false,
|
||||
ev => ev.feed().is_none_or(|f| mine.contains(f)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. })
|
||||
}
|
||||
|
||||
/// The human rendering, for a terminal rather than a UI.
|
||||
pub fn human(&self) -> Option<String> {
|
||||
Some(match self {
|
||||
Event::FeedSkip { feed, reason } => format!("{feed}: {reason}"),
|
||||
Event::FeedDone { feed, new, downloaded, failed, torrents } => format!(
|
||||
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
|
||||
),
|
||||
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
|
||||
Event::FeedMoved { feed, from, to } => format!("{feed}: moved for good from {from} to {to}; following it"),
|
||||
Event::Progress { file, done, total, .. } => match total {
|
||||
Some(t) if *t > 0 => format!(
|
||||
" {file}: {:.1}% ({:.1}/{:.1} MB)",
|
||||
*done as f64 / *t as f64 * 100.0,
|
||||
*done as f64 / 1_048_576.0,
|
||||
*t as f64 / 1_048_576.0
|
||||
),
|
||||
_ => format!(" {file}: {:.1} MB", *done as f64 / 1_048_576.0),
|
||||
},
|
||||
Event::DownloadDone { path, .. } => format!(" saved {path}"),
|
||||
Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"),
|
||||
Event::Reaped { path, bytes } => {
|
||||
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::ReapDone { files, bytes } => {
|
||||
format!("deleted {files} old file(s), {:.1} MB", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::Status { feeds, pending, downloaded } => {
|
||||
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
|
||||
}
|
||||
Event::Error { msg } => format!("error: {msg}"),
|
||||
// Noise in a terminal; a UI still gets them on the socket.
|
||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||
Event::DeadairDone { feed, saved_s, .. } => {
|
||||
format!("{feed}: DeadAir found the silences, {saved_s} s shorter")
|
||||
}
|
||||
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
|
||||
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "snake_case")]
|
||||
pub enum Command {
|
||||
Fetch {
|
||||
#[serde(default)]
|
||||
feed: Option<String>,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
|
||||
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
|
||||
/// Empty is every feed, as the schedule and the CLI mean it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
feeds: Vec<String>,
|
||||
},
|
||||
Reap {
|
||||
#[serde(default)]
|
||||
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,
|
||||
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm`
|
||||
/// and `import` change in a process of their own. A daemon keeps them in memory, and with
|
||||
/// its old copy wrote a removed feed back, and dropped an added one, at its next change of
|
||||
/// its own (#120). Answered, as status is, with the counts after it.
|
||||
Reload,
|
||||
}
|
||||
|
||||
/// Where events go: the socket, the terminal, or both.
|
||||
#[derive(Clone)]
|
||||
pub struct Emitter {
|
||||
tx: Option<broadcast::Sender<Event>>,
|
||||
print: bool,
|
||||
}
|
||||
|
||||
impl Emitter {
|
||||
pub fn terminal() -> Self {
|
||||
Self { tx: None, print: true }
|
||||
}
|
||||
|
||||
pub fn socket(tx: broadcast::Sender<Event>, print: bool) -> Self {
|
||||
Self { tx: Some(tx), print }
|
||||
}
|
||||
|
||||
pub fn emit(&self, e: Event) {
|
||||
let e = e.redacted();
|
||||
// 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.
|
||||
log_event(&e, self.tx.is_some());
|
||||
// A feed not due changes nothing a listener shows, and a scan has one for nearly every
|
||||
// feed: 1,500 at once overflowed the channel's 1,024 for a page slow to read (#197).
|
||||
let routine = matches!(&e, Event::FeedSkip { reason, .. } if reason.starts_with("not due"));
|
||||
if let Some(tx) = &self.tx
|
||||
&& !routine
|
||||
{
|
||||
// An error here only means nobody is listening yet.
|
||||
let _ = tx.send(e.clone());
|
||||
}
|
||||
if self.print
|
||||
&& let Some(line) = e.human()
|
||||
{
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
|
||||
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
|
||||
/// so do the ones in an error's words (#238).
|
||||
fn redacted(mut self) -> Self {
|
||||
match &mut self {
|
||||
Event::DownloadError { url, msg, .. } => {
|
||||
*url = crate::feed::redact(url);
|
||||
*msg = crate::feed::redact_text(msg);
|
||||
}
|
||||
// Progress and DownloadDone too: a Patreon file's address carries its signature (#292).
|
||||
Event::TorrentDeferred { url, .. } | Event::Progress { url, .. } | Event::DownloadDone { url, .. } => {
|
||||
*url = crate::feed::redact(url)
|
||||
}
|
||||
Event::FeedMoved { from, to, .. } => {
|
||||
*from = crate::feed::redact(from);
|
||||
*to = crate::feed::redact(to);
|
||||
}
|
||||
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = crate::feed::redact_text(msg),
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
|
||||
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
|
||||
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
|
||||
/// Level by how much it matters: with 80-odd feeds in an OPML subscription, a line per feed per
|
||||
/// tick for "not due yet" would push everything worth reading out of the log view in minutes,
|
||||
/// and Progress fires on every whole percent. `wire` also logs the event as it goes on the
|
||||
/// socket, at debug: the admin page's Daemon I/O tab shows it, production's log leaves it out.
|
||||
fn log_event(e: &Event, wire: bool) {
|
||||
let json = serde_json::to_string(e).unwrap_or_default();
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
|
||||
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
|
||||
let n = |k: &str| v.get(k).and_then(|x| x.as_u64());
|
||||
let (kind, code) = match e {
|
||||
Event::FeedError { msg, .. } | Event::DownloadError { msg, .. } | Event::Error { msg } => {
|
||||
let (k, c) = crate::feed::failure_kind(msg);
|
||||
(Some(k), c)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
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,
|
||||
};
|
||||
macro_rules! line {
|
||||
($level:ident, $target:literal, $text:expr) => {
|
||||
tracing::$level!(
|
||||
target: $target,
|
||||
ev = s("ev"), feed = s("feed"), msg = s("msg"), url = s("url"), reason = s("reason"), from = s("from"), to = s("to"),
|
||||
new = n("new"), downloaded = n("downloaded"), failed = n("failed"),
|
||||
torrents = n("torrents"), bytes = n("bytes"), feeds = n("feeds"),
|
||||
pending = n("pending"), enclosure = n("enclosure"), files = n("files"),
|
||||
"error.type" = kind, "http.response.status_code" = code,
|
||||
"{}", $text
|
||||
)
|
||||
};
|
||||
}
|
||||
// `ipx status`'s answer: a reply on the socket rather than work done, so it
|
||||
// stays with the rest of the conversation, and the Scans tab stays about scans.
|
||||
if matches!(e, Event::Status { .. }) {
|
||||
return line!(info, "ipx::io", format!("<- {json}"));
|
||||
}
|
||||
if wire {
|
||||
tracing::debug!(target: "ipx::io", "<- {json}");
|
||||
}
|
||||
let text = e.human().map(|l| l.trim().to_owned()).unwrap_or_else(|| json.clone());
|
||||
if kind.is_some() {
|
||||
line!(warn, "ipx::scan", text)
|
||||
} else if routine {
|
||||
line!(debug, "ipx::scan", text)
|
||||
} else {
|
||||
line!(info, "ipx::scan", text)
|
||||
}
|
||||
}
|
||||
|
||||
/// True when something is already listening -- i.e. a daemon owns this socket.
|
||||
pub async fn daemon_is_live(path: &Path) -> bool {
|
||||
UnixStream::connect(path).await.is_ok()
|
||||
}
|
||||
|
||||
/// Answers `status` and `reload` 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; a reload left waiting would let the web UI write the old catalogue back in
|
||||
/// the meantime. 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.
|
||||
/// A future, since reading the counts is a database query.
|
||||
pub type StatusFn = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>;
|
||||
|
||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||
pub async fn serve(
|
||||
path: PathBuf,
|
||||
events: broadcast::Sender<Event>,
|
||||
cmds: mpsc::Sender<Command>,
|
||||
status: StatusFn,
|
||||
) -> Result<()> {
|
||||
// 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.
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path).with_context(|| format!("removing stale socket {}", path.display()))?;
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&path).with_context(|| format!("binding {}", path.display()))?;
|
||||
tracing::info!(socket = %path.display(), "listening");
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let rx = events.subscribe();
|
||||
let cmds = cmds.clone();
|
||||
let status = status.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle(stream, rx, cmds, status).await {
|
||||
tracing::debug!(error = %e, "client gone");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
stream: UnixStream,
|
||||
mut rx: broadcast::Receiver<Event>,
|
||||
cmds: mpsc::Sender<Command>,
|
||||
status: StatusFn,
|
||||
) -> Result<()> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
|
||||
// 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 {
|
||||
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();
|
||||
line.push('\n');
|
||||
if write.write_all(line.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Commands in.
|
||||
let mut lines = BufReader::new(read).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<Command>(line) {
|
||||
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
||||
Ok(cmd @ (Command::Status | Command::Reload)) => {
|
||||
tracing::info!(target: "ipx::io", "-> {line}");
|
||||
let ev = status(cmd).await;
|
||||
log_event(&ev, /* wire */ true);
|
||||
let _ = reply.send(ev).await;
|
||||
}
|
||||
Ok(cmd) => {
|
||||
if cmds.send(cmd).await.is_err() {
|
||||
break; // Worker is gone; so are we.
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, line, "bad command"),
|
||||
}
|
||||
}
|
||||
writer.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tells a running daemon to read the catalogue again (see Command::Reload), and waits for it.
|
||||
pub async fn reload(path: &Path) -> Result<()> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
let (read, mut write) = stream.into_split();
|
||||
write.write_all(b"{\"cmd\":\"reload\"}\n").await?;
|
||||
let mut lines = BufReader::new(read).lines();
|
||||
let answer = async {
|
||||
// Other clients' events come here too; the answer is the status, or an error.
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
match serde_json::from_str::<Event>(&line) {
|
||||
Ok(Event::Status { .. }) => return Ok(()),
|
||||
Ok(Event::Error { msg }) => anyhow::bail!("{msg}"),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
anyhow::bail!("the daemon closed the connection without answering")
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(10), answer).await.context("the daemon did not answer in 10s")?
|
||||
}
|
||||
|
||||
/// Sends one command to a running daemon and prints the events it produces.
|
||||
pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut line = serde_json::to_string(cmd)?;
|
||||
line.push('\n');
|
||||
write.write_all(line.as_bytes()).await?;
|
||||
|
||||
// ponytail: the event stream is a broadcast, so a busy daemon's other work shows up
|
||||
// here too. Fine for a CLI; a UI that cares would want per-request ids.
|
||||
let mut lines = BufReader::new(read).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let Ok(ev) = serde_json::from_str::<Event>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(text) = ev.human() {
|
||||
println!("{text}");
|
||||
}
|
||||
if ev.is_terminal() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_feeds_events_go_to_those_who_read_it() {
|
||||
let person = |id, is_admin| crate::db::User {
|
||||
id,
|
||||
name: "x".into(),
|
||||
pass_hash: None,
|
||||
is_admin,
|
||||
created: None,
|
||||
last_login: None,
|
||||
};
|
||||
let (me, admin) = (person(1, false), person(2, true));
|
||||
let mine: std::collections::HashSet<String> = ["mine".to_string()].into();
|
||||
let error = |feed: &str| Event::FeedError {
|
||||
feed: feed.into(),
|
||||
msg: "error sending request for url (https://feeds.supercast.com/feeds/KEY)".into(),
|
||||
};
|
||||
assert!(error("mine").is_for(&me, &mine));
|
||||
assert!(!error("theirs").is_for(&me, &mine), "a paid feed's key in its path (#325)");
|
||||
assert!(error("theirs").is_for(&admin, &mine), "an admin's log has everything");
|
||||
assert!(Event::ScanDone { feeds: 3, only: None, forced: false }.is_for(&me, &mine));
|
||||
assert!(Event::SignalHits { user: 1, hits: 2 }.is_for(&me, &mine));
|
||||
assert!(!Event::SignalHits { user: 3, hits: 2 }.is_for(&me, &mine));
|
||||
assert!(!Event::Reaped { path: "/downloads/Theirs/ep.mp3".into(), bytes: 1 }.is_for(&me, &mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_not_due_stays_off_the_live_updates() {
|
||||
let (tx, mut rx) = broadcast::channel(4);
|
||||
let out = Emitter::socket(tx, false);
|
||||
for i in 0..10 {
|
||||
out.emit(Event::FeedSkip { feed: format!("f{i}"), reason: "not due for 5m".into() });
|
||||
}
|
||||
out.emit(Event::FeedSkip { feed: "g".into(), reason: "not modified".into() });
|
||||
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_goes_out_names_no_private_feeds_key() {
|
||||
let (tx, mut rx) = broadcast::channel(8);
|
||||
let out = Emitter::socket(tx, false);
|
||||
let key = "auth=SECRET";
|
||||
out.emit(Event::FeedError {
|
||||
feed: "f".into(),
|
||||
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
|
||||
});
|
||||
out.emit(Event::DownloadError {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
|
||||
});
|
||||
out.emit(Event::FeedMoved {
|
||||
feed: "f".into(),
|
||||
from: format!("https://x/rss?{key}"),
|
||||
to: format!("https://y/rss?{key}"),
|
||||
});
|
||||
out.emit(Event::Progress {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
file: "ep.mp3".into(),
|
||||
done: 1,
|
||||
total: None,
|
||||
});
|
||||
out.emit(Event::DownloadDone {
|
||||
feed: "f".into(),
|
||||
enclosure: 1,
|
||||
url: format!("https://cdn/ep.mp3?{key}"),
|
||||
path: "/downloads/ep.mp3".into(),
|
||||
bytes: 1,
|
||||
});
|
||||
// What every open page is sent, whoever's feed it is (#238, #292).
|
||||
for _ in 0..5 {
|
||||
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
|
||||
assert!(!sent.contains("SECRET"), "{sent}");
|
||||
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
||||
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||
let wire = serde_json::to_value(&e).unwrap();
|
||||
assert_eq!(
|
||||
(wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()),
|
||||
(Some("feed_moved"), Some("http://a/f"), Some("https://a/f"))
|
||||
);
|
||||
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_parse_from_the_wire_form() {
|
||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
||||
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
|
||||
|
||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
||||
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
|
||||
|
||||
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_serialise_to_the_documented_shape() {
|
||||
let ev = Event::Progress {
|
||||
feed: "atp".into(),
|
||||
enclosure: 42,
|
||||
url: "https://x/ep.mp3".into(),
|
||||
file: "ep.mp3".into(),
|
||||
done: 10_485_760,
|
||||
total: Some(52_428_800),
|
||||
};
|
||||
let json = serde_json::to_string(&ev).unwrap();
|
||||
assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}");
|
||||
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.
|
||||
let ev =
|
||||
Event::Progress { feed: "a".into(), enclosure: 1, url: "u".into(), file: "f".into(), done: 1, total: None };
|
||||
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_completion_events_end_a_client_session() {
|
||||
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
|
||||
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
||||
assert!(!Event::FeedDone { feed: "a".into(), new: 0, downloaded: 0, failed: 0, torrents: 0 }.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(|_| Box::pin(async { 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");
|
||||
}
|
||||
}
|
||||
270
crates/ipx/src/lists.rs
Normal file
270
crates/ipx/src/lists.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! OPML: importing a file's feeds, and keeping a subscribed list's feeds in step with it.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::scan::Outcome;
|
||||
use crate::{catalogue, config, feed};
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The feeds an OPML document lists, up to as many as a subscribed list may bring in: each becomes
|
||||
/// a feed in the catalogue, scanned for everyone, and one upload of tens of thousands held the
|
||||
/// catalogue for minutes and stayed in it (#338).
|
||||
pub fn opml_feeds(doc: &opml::OPML) -> Result<Vec<(String, String)>> {
|
||||
let mut found = vec![];
|
||||
feed::collect_outlines(&doc.body.outlines, &mut found);
|
||||
anyhow::ensure!(
|
||||
found.len() <= MAX_LISTED,
|
||||
"it lists {} feeds, more than the {MAX_LISTED} one may have",
|
||||
found.len()
|
||||
);
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Subscribes one person to every feed in an OPML document, for the CLI and the web alike.
|
||||
/// A feed already in the catalogue costs nothing; an unknown one is added under the OPML's
|
||||
/// title rather than refetching each. Returns (newly subscribed, already subscribed).
|
||||
///
|
||||
/// Before accounts, importing only added unknown URLs to config.toml. Once subscriptions
|
||||
/// decided what each person sees, that imported nothing at all for a feed someone else
|
||||
/// already had, and a new one had no subscriber, so it was never scanned.
|
||||
///
|
||||
/// The caller parses the document and lists its feeds (`opml_feeds`), so each refuses a file in
|
||||
/// its own terms, before anything is touched: a 400 from the web, a message from the CLI.
|
||||
pub async fn subscribe_opml(ctx: &Ctx, found: Vec<(String, String)>, user_id: i64) -> Result<(usize, usize)> {
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
// By address, once: each feed looked through every subscription and the whole catalogue, and
|
||||
// the catalogue's lock is held throughout (#338).
|
||||
let mut known = HashMap::new();
|
||||
for s in catalogue::subscriptions(ctx).await? {
|
||||
// The first with an address wins, as a search in order found it.
|
||||
known.entry(s.cfg.url).or_insert(s.id);
|
||||
}
|
||||
let mut ids = vec![];
|
||||
let mut grew = false;
|
||||
for (title, url) in found {
|
||||
// Known already, or the same URL listed twice in one file.
|
||||
let existing = known.get(&url).cloned();
|
||||
let id = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = config::unique_slug(&title, |s| cfg.feeds.contains_key(s));
|
||||
known.insert(url.clone(), id.clone());
|
||||
cfg.feeds.insert(
|
||||
id.clone(),
|
||||
config::Feed {
|
||||
url,
|
||||
folder: None,
|
||||
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,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
},
|
||||
);
|
||||
grew = true;
|
||||
id
|
||||
}
|
||||
};
|
||||
ids.push(id);
|
||||
}
|
||||
if grew {
|
||||
ctx.store_cfg(cfg).await?;
|
||||
}
|
||||
|
||||
let (mut added, mut had) = (0, 0);
|
||||
for id in ids {
|
||||
if ctx.db.subscription(user_id, &id).await?.is_some() {
|
||||
had += 1;
|
||||
} else {
|
||||
ctx.db.subscribe(user_id, &id).await?;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Ok((added, had))
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_opml(ctx: &Arc<Ctx>, parent_id: &str, bytes: &[u8]) -> Result<Outcome> {
|
||||
let listed = feed::parse_opml(bytes)?;
|
||||
if let Some(title) = feed::opml_title(bytes) {
|
||||
ctx.db.set_title(parent_id, &title).await?;
|
||||
}
|
||||
sync_group(ctx, parent_id, &listed).await
|
||||
}
|
||||
|
||||
/// How many feeds an OPML or Patreon list may bring in.
|
||||
pub(crate) const MAX_LISTED: usize = 2000;
|
||||
|
||||
/// Brings the feed list in step with a list of feeds: a subscribed OPML, or a Patreon
|
||||
/// creator's shows.
|
||||
///
|
||||
/// New entries are added under the list's group and folder. An entry that has gone from
|
||||
/// the list is unsubscribed *only if nothing was ever downloaded for it* -- otherwise it
|
||||
/// is kept and flagged, because dropping it would orphan files on disk with nothing in
|
||||
/// the UI to explain them.
|
||||
pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(String, String)]) -> Result<Outcome> {
|
||||
// Each is a feed scanned for everyone subscribed to the list, and the list is its author's to
|
||||
// write: one of tens of thousands held the scan for hours (#337). davewiner's had 922.
|
||||
anyhow::ensure!(
|
||||
listed.len() <= MAX_LISTED,
|
||||
"it lists {} feeds, more than the {MAX_LISTED} a list may have",
|
||||
listed.len()
|
||||
);
|
||||
let cfg = ctx.cfg();
|
||||
let existing = ctx.db.managed_feeds().await?;
|
||||
let mut added = vec![];
|
||||
// Read once, not once a new feed: each read is the whole feeds table.
|
||||
let known = ctx.db.feed_urls().await?;
|
||||
let fixed: HashSet<&str> = cfg.feeds.keys().chain(existing.iter().map(|m| &m.id)).map(String::as_str).collect();
|
||||
let known_ids: HashSet<&str> = known.iter().map(|(id, _)| id.as_str()).collect();
|
||||
|
||||
for (title, url) in listed {
|
||||
// Already known, whether derived or promoted into the config.
|
||||
if let Some(m) = existing.iter().find(|m| &m.url == url) {
|
||||
ctx.db.upsert_managed(&m.id, url, title, parent_id).await?;
|
||||
continue;
|
||||
}
|
||||
// A Patreon show you added by hand may be spelled differently from the one listed.
|
||||
if cfg.feeds.values().any(|f| feed::same_feed(&f.url, url)) {
|
||||
continue;
|
||||
}
|
||||
// A removed feed keeps its rows, so its id is only free again for the same feed.
|
||||
let its_own: Vec<&str> =
|
||||
known.iter().filter(|(_, u)| feed::same_feed(u, url)).map(|(id, _)| id.as_str()).collect();
|
||||
let id = config::unique_slug(title, |s| {
|
||||
fixed.contains(s) || added.iter().any(|a| a == s) || (known_ids.contains(s) && !its_own.contains(&s))
|
||||
});
|
||||
ctx.db.upsert_managed(&id, url, title, parent_id).await?;
|
||||
added.push(id);
|
||||
}
|
||||
|
||||
// Whoever subscribes to the OPML subscribes to what it lists: that is what taking a
|
||||
// subscription means. Their own feeds are untouched.
|
||||
let managed = ctx.db.managed_feeds().await?;
|
||||
let list_and_feeds = managed
|
||||
.iter()
|
||||
.filter(|m| m.group_id == parent_id)
|
||||
.map(|m| m.id.clone())
|
||||
.chain(std::iter::once(parent_id.to_string()));
|
||||
for id in list_and_feeds {
|
||||
for user in ctx.db.users().await? {
|
||||
if ctx.db.subscription(user.id, parent_id).await?.is_some() {
|
||||
ctx.db.subscribe(user.id, &id).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anything in this group the OPML no longer lists.
|
||||
let mut removed = 0;
|
||||
let mut kept = 0;
|
||||
let parent = ctx.cfg().feeds.get(parent_id).cloned();
|
||||
let in_group = existing.iter().filter(|m| m.group_id == parent_id);
|
||||
for m in in_group {
|
||||
if listed.iter().any(|(_, u)| u == &m.url) {
|
||||
continue;
|
||||
}
|
||||
if catalogue::promote_kept(ctx, m, parent.as_ref()).await? {
|
||||
kept += 1;
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
|
||||
continue;
|
||||
}
|
||||
if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||
// Never orphan a downloaded file: keep the feed and say why in the UI.
|
||||
ctx.db.set_orphaned(&m.id, true).await?;
|
||||
kept += 1;
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML but has downloads; keeping it");
|
||||
} else {
|
||||
ctx.db.drop_managed(&m.id).await?;
|
||||
removed += 1;
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML with nothing downloaded; removed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Outcome::Opml { added, removed, kept, total: listed.len() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::{feed, test_ctx};
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_opml_upload_is_capped_and_takes_a_repeated_address_once() {
|
||||
let opml = |n: usize| {
|
||||
let outlines: String =
|
||||
(0..n).map(|i| format!(r#"<outline text="Same" xmlUrl="http://x/{}.xml"/>"#, i % 3)).collect();
|
||||
feed::read_opml(&format!(r#"<opml version="2.0"><head/><body>{outlines}</body></opml>"#)).unwrap()
|
||||
};
|
||||
assert!(opml_feeds(&opml(MAX_LISTED + 1)).unwrap_err().to_string().contains("more than the 2000"));
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let me = ctx.db.create_user("me", None, false).await.unwrap();
|
||||
// Six outlines, three addresses, one title: three feeds, named apart.
|
||||
let (added, had) = subscribe_opml(&ctx, opml_feeds(&opml(6)).unwrap(), me).await.unwrap();
|
||||
let ids: std::collections::BTreeSet<String> = ctx.cfg().feeds.keys().cloned().collect();
|
||||
assert_eq!(ids, ["same", "same-2", "same-3"].map(String::from).into());
|
||||
assert_eq!((added, had), (3, 3));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_list_of_more_feeds_than_the_limit_is_refused() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
let listed: Vec<(String, String)> =
|
||||
(0..=MAX_LISTED).map(|n| (format!("Show {n}"), format!("http://x/{n}.xml"))).collect();
|
||||
let err = sync_group(&ctx, "opml", &listed).await.err().expect("refused");
|
||||
assert!(err.to_string().contains("more than the 2000"), "{err:#}");
|
||||
assert!(ctx.db.managed_feeds().await.unwrap().is_empty(), "nothing taken from it");
|
||||
// At the limit, all of them, each its own id.
|
||||
sync_group(&ctx, "opml", &listed[..MAX_LISTED]).await.unwrap();
|
||||
let ids: std::collections::HashSet<String> =
|
||||
ctx.db.managed_feeds().await.unwrap().into_iter().map(|m| m.id).collect();
|
||||
assert_eq!(ids.len(), MAX_LISTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() {
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert("opml".into(), config::Feed { private: true, ..feed() });
|
||||
let ctx = Arc::new(test_ctx(cfg).await);
|
||||
let (me, you) = (
|
||||
ctx.db.create_user("me", None, false).await.unwrap(),
|
||||
ctx.db.create_user("you", None, false).await.unwrap(),
|
||||
);
|
||||
for id in ["opml", "a", "b"] {
|
||||
if id != "opml" {
|
||||
ctx.db.upsert_managed(id, &format!("http://x/{id}.xml"), id, "opml").await.unwrap();
|
||||
}
|
||||
for u in [me, you] {
|
||||
ctx.db.subscribe(u, id).await.unwrap();
|
||||
}
|
||||
}
|
||||
assert!(ctx.db.set_kept(me, "a", true).await.unwrap());
|
||||
assert!(ctx.db.set_kept(me, "b", true).await.unwrap());
|
||||
|
||||
// The OPML drops a: mine to keep, a feed of its own; yours, unkept, goes.
|
||||
sync_group(&ctx, "opml", &[("b".into(), "http://x/b.xml".into())]).await.unwrap();
|
||||
let a = ctx.cfg().feeds.get("a").cloned().expect("a is in the catalogue");
|
||||
assert_eq!((a.url.as_str(), a.group, a.private), ("http://x/a.xml", None, true));
|
||||
assert!(!ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "a"));
|
||||
assert!(ctx.db.subscription(me, "a").await.unwrap().is_some());
|
||||
assert!(ctx.db.subscription(you, "a").await.unwrap().is_none());
|
||||
|
||||
// Leaving the OPML takes what I did not keep and leaves b, still the OPML's while you read it.
|
||||
catalogue::leave(&ctx, me, "opml").await.unwrap();
|
||||
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
|
||||
assert!(ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "b"));
|
||||
// Once nobody reads it, b is mine alone, and yours goes with the OPML.
|
||||
catalogue::leave(&ctx, you, "opml").await.unwrap();
|
||||
// Still private: the OPML is gone from config by the time b is promoted (#332).
|
||||
assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none() && f.private));
|
||||
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
|
||||
assert!(ctx.db.subscription(you, "b").await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
//! to read. Capturing inside the tracing pipeline works the same either way.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::Context;
|
||||
@@ -27,9 +29,8 @@ struct Ring {
|
||||
next_seq: u64,
|
||||
}
|
||||
|
||||
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
|
||||
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
|
||||
});
|
||||
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() {
|
||||
@@ -57,12 +58,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
|
||||
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();
|
||||
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);
|
||||
@@ -73,7 +69,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
|
||||
/// A tracing layer that mirrors every event into the ring.
|
||||
pub struct RingLayer;
|
||||
|
||||
impl<S: tracing::Subscriber> Layer<S> for RingLayer {
|
||||
impl<S: Subscriber> Layer<S> for RingLayer {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut v = Collect::default();
|
||||
event.record(&mut v);
|
||||
@@ -108,21 +104,14 @@ impl Collect {
|
||||
}
|
||||
|
||||
impl Visit for Collect {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn 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());
|
||||
}
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
self.add(field, value.to_string());
|
||||
}
|
||||
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||
self.add(field, value.to_string());
|
||||
}
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
self.add(field, value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -137,10 +126,7 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
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);
|
||||
89
crates/ipx/src/logging.rs
Normal file
89
crates/ipx/src/logging.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Log lines as JSON for Loki, carrying the trace and span they belong to.
|
||||
|
||||
use crate::logbuf;
|
||||
use std::io::IsTerminal;
|
||||
use tracing::Subscriber;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt::format::JsonFields;
|
||||
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// The JSON log line with the trace and span it belongs to (#91), so a line in Loki leads to its
|
||||
/// trace in Tempo. The JSON formatter cannot take a field of its own, so the ids go on the end of
|
||||
/// the object it writes. A line outside any traced span, or with no trace exporter, is unchanged.
|
||||
pub(crate) struct WithTrace<F>(F);
|
||||
|
||||
impl<S, N, F> FormatEvent<S, N> for WithTrace<F>
|
||||
where
|
||||
F: FormatEvent<S, N>,
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
N: for<'w> FormatFields<'w> + 'static,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &FmtContext<'_, S, N>,
|
||||
mut w: tracing_subscriber::fmt::format::Writer<'_>,
|
||||
ev: &tracing::Event<'_>,
|
||||
) -> std::fmt::Result {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
let current = tracing::Span::current();
|
||||
let sc = if current.is_none() { None } else { Some(current.context().span().span_context().clone()) };
|
||||
let Some(sc) = sc.filter(|c| c.is_valid()) else {
|
||||
return self.0.format_event(ctx, w, ev);
|
||||
};
|
||||
let mut line = String::new();
|
||||
self.0.format_event(ctx, tracing_subscriber::fmt::format::Writer::new(&mut line), ev)?;
|
||||
match line.trim_end().strip_suffix('}') {
|
||||
Some(body) => writeln!(w, r#"{body},"trace_id":"{}","span_id":"{}"}}"#, sc.trace_id(), sc.span_id()),
|
||||
None => w.write_str(&line),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up logging: to stderr, as text or as JSON (`IPX_LOG_FORMAT`), into the ring the page's log
|
||||
/// reads, and to the trace exporter when there is one.
|
||||
pub(crate) fn init(otel: Option<&opentelemetry_sdk::trace::SdkTracerProvider>) {
|
||||
// Everything goes to stderr as before, and is mirrored into a ring the UI can read.
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
|
||||
// keeps debug as well, so the log view can show protocol traffic and routine
|
||||
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
|
||||
let stderr_filter = || EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into());
|
||||
// One JSON object a line for Loki (#91), with each event's fields as its own; text
|
||||
// otherwise, for someone reading a terminal.
|
||||
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
|
||||
let ui_filter = EnvFilter::try_from_env("IPX_UI_LOG").unwrap_or_else(|_| "ipx=debug".into());
|
||||
tracing_subscriber::registry()
|
||||
.with((!json).then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
// Colour for a terminal only: in docker logs and Loki the escapes are noise
|
||||
// every query has to strip (#88).
|
||||
.with_ansi(IsTerminal::is_terminal(&std::io::stderr()))
|
||||
.with_filter(stderr_filter())
|
||||
}))
|
||||
.with(json.then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.fmt_fields(JsonFields::new())
|
||||
.event_format(WithTrace(
|
||||
tracing_subscriber::fmt::format()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_current_span(true)
|
||||
.with_span_list(false),
|
||||
))
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(stderr_filter())
|
||||
}))
|
||||
.with(logbuf::RingLayer.with_filter(ui_filter))
|
||||
.with(otel.as_ref().map(|p| {
|
||||
use opentelemetry::trace::TracerProvider;
|
||||
tracing_opentelemetry::layer()
|
||||
.with_tracer(p.tracer("ipx"))
|
||||
.with_filter(EnvFilter::new("ipx=info,ipx_feed=info"))
|
||||
}))
|
||||
.init();
|
||||
}
|
||||
113
crates/ipx/src/main.rs
Normal file
113
crates/ipx/src/main.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! ipx, the daemon and its command line. The code is in the modules below; this only starts it.
|
||||
|
||||
mod access;
|
||||
mod art;
|
||||
mod auth;
|
||||
mod catalogue;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod ctx;
|
||||
mod daemon;
|
||||
mod db;
|
||||
mod deadair;
|
||||
mod directory;
|
||||
mod download;
|
||||
mod entity;
|
||||
// The crate root's import, so `crate::feed` reaches it from every module as before.
|
||||
use ipx_feed as feed;
|
||||
mod ipc;
|
||||
mod lists;
|
||||
mod logbuf;
|
||||
mod logging;
|
||||
mod metrics;
|
||||
mod retention;
|
||||
mod scan;
|
||||
mod signals;
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
mod torrent;
|
||||
mod web;
|
||||
|
||||
use crate::cli::{Cli, Command, UserCmd};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Command as Cmd;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
|
||||
// looking at.
|
||||
let otel = match cli.command {
|
||||
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
|
||||
_ => None,
|
||||
};
|
||||
logging::init(otel.as_ref());
|
||||
|
||||
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
|
||||
let cfg = config::Config::load(&config_path)?;
|
||||
// The daemon holds its connections open; a one-off command opens what it uses.
|
||||
let keep_open = matches!(cli.command, Command::Daemon { .. });
|
||||
let db = db::Db::open(&db::location(), keep_open).await?;
|
||||
|
||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||
let wire_cmd = match &cli.command {
|
||||
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
|
||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||
Command::Status => Some(Cmd::Status),
|
||||
Command::List
|
||||
| Command::Daemon { .. }
|
||||
| Command::User { .. }
|
||||
| Command::Add { .. }
|
||||
| Command::Rm { .. }
|
||||
| Command::Import { .. }
|
||||
| Command::Export { .. } => None,
|
||||
};
|
||||
if let Some(cmd) = &wire_cmd
|
||||
&& !cli.local
|
||||
&& ipc::daemon_is_live(&cfg.general.socket).await
|
||||
{
|
||||
return ipc::proxy(&cfg.general.socket, cmd).await;
|
||||
}
|
||||
|
||||
let socket = cfg.general.socket.clone();
|
||||
let cfg = catalogue::assemble_config(&db, cfg, &config_path).await?;
|
||||
|
||||
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||
// These write the catalogue from a process of their own, which a running daemon, holding its
|
||||
// own copy, would write over at its next change (#120).
|
||||
let changes_catalogue = matches!(
|
||||
cli.command,
|
||||
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
|
||||
);
|
||||
let (events, _) = broadcast::channel(1024);
|
||||
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
|
||||
|
||||
let result = match cli.command {
|
||||
Command::List => catalogue::list(&ctx).await,
|
||||
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events).await,
|
||||
Command::Add { url, folder, keywords, category, list } => {
|
||||
catalogue::add(&ctx, &url, folder, keywords, category, list).await
|
||||
}
|
||||
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
|
||||
Command::User { cmd } => cli::user_cmd(&ctx, cmd).await,
|
||||
Command::Import { file } => catalogue::import(&ctx, &file).await,
|
||||
Command::Export { file } => catalogue::export(&ctx, &file).await,
|
||||
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||
};
|
||||
if result.is_ok()
|
||||
&& changes_catalogue
|
||||
&& ipc::daemon_is_live(&socket).await
|
||||
&& let Err(e) = ipc::reload(&socket).await
|
||||
{
|
||||
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
|
||||
}
|
||||
// The batch exporter holds the last few seconds of spans; without this they are lost.
|
||||
if let Some(p) = otel {
|
||||
let _ = p.shutdown();
|
||||
}
|
||||
result
|
||||
}
|
||||
569
crates/ipx/src/metrics.rs
Normal file
569
crates/ipx/src/metrics.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
//! Prometheus metrics for what logs and traces cannot show: the daemon's own state between
|
||||
//! requests, such as how many database connections are in use (#194). Served on a port and a
|
||||
//! thread of their own, with a runtime of their own, and made only of what is already in memory:
|
||||
//! a scrape neither waits for the web server's threads or the database, whose pool running dry
|
||||
//! is one of the things it is there to show, nor holds anything up itself. No sign-in, as
|
||||
//! Prometheus exporters have none: checking a token is a database lookup. Written out by hand:
|
||||
//! the text format is a few lines, and a client library would be a dependency for them.
|
||||
|
||||
use axum::Router;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64};
|
||||
use std::time::Instant;
|
||||
use tokio::runtime::Handle;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
/// What the daemon counts as it works, for /metrics to read without asking anything (#195).
|
||||
/// Global: the web layer, the worker, downloads, artwork and DeadAir each add to it, and a handle
|
||||
/// threaded through all of them was more code than the counts.
|
||||
pub static STATS: Stats = Stats::new();
|
||||
|
||||
pub struct Stats {
|
||||
pub http_in_flight: AtomicI64,
|
||||
/// Live updates dropped for a page or socket client that fell behind the broadcast.
|
||||
pub events_missed: AtomicU64,
|
||||
pub downloads_active: AtomicI64,
|
||||
pub download_bytes: AtomicU64,
|
||||
/// Feeds a scan is fetching at this moment, up to its AHEAD.
|
||||
pub feeds_fetching: AtomicI64,
|
||||
/// When a scan last finished, Unix seconds; 0 for not since starting.
|
||||
pub last_scan_done: AtomicI64,
|
||||
/// Files someone wants shortened that DeadAir has not looked at, as of its last pass.
|
||||
pub deadair_backlog: AtomicI64,
|
||||
/// When DeadAir started on the file it is on, Unix seconds; 0 when it is on none.
|
||||
pub deadair_busy_since: AtomicI64,
|
||||
pub art_cache_bytes: AtomicU64,
|
||||
/// Images the sweep threw out to keep under the limit: kept bytes sit at the limit by design,
|
||||
/// so this is what says whether the limit is too small.
|
||||
pub art_evicted: AtomicU64,
|
||||
/// Artwork served from disk, and fetched from the publisher because it was not there.
|
||||
pub art_hits: AtomicU64,
|
||||
pub art_misses: AtomicU64,
|
||||
/// The library as the daemon last counted it, every 30 s; -1 until it has.
|
||||
pub feeds: AtomicI64,
|
||||
pub pending: AtomicI64,
|
||||
pub downloaded: AtomicI64,
|
||||
/// When the Signals judge last finished a pass, Unix seconds; 0 for not since starting.
|
||||
pub signals_last_pass: AtomicI64,
|
||||
/// Calls to Jev by how they ended, in `JEV_RESULTS`' order, and their microseconds all told.
|
||||
pub jev_calls: [AtomicU64; 5],
|
||||
pub jev_micros: AtomicU64,
|
||||
/// Microseconds calls waited for their turn under TypeSafe's limits (#277), part of `jev_micros`.
|
||||
pub jev_paced_micros: AtomicU64,
|
||||
/// What each person's Signals, icons and key checks used, and whose key paid (#278): items
|
||||
/// judged, Signals they fit, and TypeSafe's input and output tokens. Floats: a call shared by
|
||||
/// several people on the server's key is split between them.
|
||||
people: std::sync::Mutex<BTreeMap<(String, &'static str), [f64; 4]>>,
|
||||
/// The worker's job now, and since when.
|
||||
job: std::sync::Mutex<Option<(&'static str, Instant)>>,
|
||||
/// Jobs finished, by kind: how many, and their seconds all told.
|
||||
jobs: std::sync::Mutex<BTreeMap<&'static str, (u64, f64)>>,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
http_in_flight: AtomicI64::new(0),
|
||||
events_missed: AtomicU64::new(0),
|
||||
downloads_active: AtomicI64::new(0),
|
||||
download_bytes: AtomicU64::new(0),
|
||||
feeds_fetching: AtomicI64::new(0),
|
||||
last_scan_done: AtomicI64::new(0),
|
||||
deadair_backlog: AtomicI64::new(0),
|
||||
deadair_busy_since: AtomicI64::new(0),
|
||||
art_cache_bytes: AtomicU64::new(0),
|
||||
art_evicted: AtomicU64::new(0),
|
||||
art_hits: AtomicU64::new(0),
|
||||
art_misses: AtomicU64::new(0),
|
||||
feeds: AtomicI64::new(-1),
|
||||
pending: AtomicI64::new(-1),
|
||||
downloaded: AtomicI64::new(-1),
|
||||
signals_last_pass: AtomicI64::new(0),
|
||||
jev_calls: [const { AtomicU64::new(0) }; 5],
|
||||
jev_micros: AtomicU64::new(0),
|
||||
jev_paced_micros: AtomicU64::new(0),
|
||||
people: std::sync::Mutex::new(BTreeMap::new()),
|
||||
job: std::sync::Mutex::new(None),
|
||||
jobs: std::sync::Mutex::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds to what `who` used on `key` ("server" or "own"): items judged, fits, input and output
|
||||
/// tokens.
|
||||
pub fn bill(&self, who: &str, key: &'static str, used: [f64; 4]) {
|
||||
let mut people = self.people.lock().expect("a panic elsewhere poisoned this lock");
|
||||
let row = people.entry((who.to_owned(), key)).or_default();
|
||||
for (r, u) in row.iter_mut().zip(used) {
|
||||
*r += u;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
|
||||
/// ends: finished, failed or stopped.
|
||||
pub fn job(&'static self, kind: &'static str) -> Job {
|
||||
*self.job.lock().expect("a panic elsewhere poisoned this lock") = Some((kind, Instant::now()));
|
||||
Job(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Job(&'static Stats);
|
||||
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
if let Some((kind, since)) = self.0.job.lock().expect("a panic elsewhere poisoned this lock").take() {
|
||||
let mut jobs = self.0.jobs.lock().expect("a panic elsewhere poisoned this lock");
|
||||
let e = jobs.entry(kind).or_default();
|
||||
e.0 += 1;
|
||||
e.1 += since.elapsed().as_secs_f64();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a call to Jev ended: answered, its key refused (401-403), the request refused as
|
||||
/// malformed (400, 422), over TypeSafe's rate limits (429), or anything else, TypeSafe's 5xx and
|
||||
/// timeouts.
|
||||
pub const JEV_RESULTS: [&str; 5] = ["ok", "key_refused", "malformed", "rate_limited", "error"];
|
||||
|
||||
/// Counts one more of something in progress until dropped, however it ends.
|
||||
pub struct Busy(&'static AtomicI64);
|
||||
|
||||
pub fn busy(gauge: &'static AtomicI64) -> Busy {
|
||||
gauge.fetch_add(1, Relaxed);
|
||||
Busy(gauge)
|
||||
}
|
||||
|
||||
impl Drop for Busy {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the metrics are read from.
|
||||
pub struct Sources {
|
||||
pub ctx: Arc<crate::ctx::Ctx>,
|
||||
pub cmds: tokio::sync::mpsc::Sender<crate::ipc::Command>,
|
||||
pub events: tokio::sync::broadcast::Sender<crate::ipc::Event>,
|
||||
pub playing: crate::deadair::Playing,
|
||||
/// The daemon's runtime, not the metrics thread's own.
|
||||
pub runtime: Handle,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
/// Starts serving /metrics on `bind`, on a thread of its own, and says where.
|
||||
pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<SocketAddr> {
|
||||
use anyhow::Context as _;
|
||||
// Bound here, so a port already taken is the daemon's error at start, not a thread's later.
|
||||
let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding metrics to {bind}"))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let addr = listener.local_addr()?;
|
||||
let s = Arc::new(s);
|
||||
std::thread::Builder::new().name("metrics".into()).spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || {
|
||||
let s = s.clone();
|
||||
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||
}),
|
||||
)
|
||||
// Prometheus asks for gzip, every 15 s (#253).
|
||||
.layer(CompressionLayer::new().gzip(true));
|
||||
match tokio::net::TcpListener::from_std(listener) {
|
||||
Ok(l) => {
|
||||
if let Err(e) = axum::serve(l, app).await {
|
||||
tracing::error!(error = %e, "metrics stopped");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(error = %e, "metrics could not listen"),
|
||||
}
|
||||
});
|
||||
})?;
|
||||
Ok(addr)
|
||||
}
|
||||
|
||||
/// Every metric, from memory alone.
|
||||
pub fn render(s: &Sources) -> String {
|
||||
let mut out = String::new();
|
||||
let (open, idle, max) = s.ctx.db.pool();
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_db_connections",
|
||||
"Database connections open, in use or idle.",
|
||||
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)],
|
||||
);
|
||||
gauge(&mut out, "ipx_db_connections_max", "The most database connections the pool opens.", &[("", max as f64)]);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_worker_queue",
|
||||
"Commands waiting for the worker: scans, downloads and the like.",
|
||||
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_event_listeners",
|
||||
"Open pages and socket clients listening for live updates.",
|
||||
&[("", s.events.receiver_count() as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_torrent_slots_free",
|
||||
"Torrents that could start now.",
|
||||
&[("", s.ctx.torrent_slots.available_permits() as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_deadair_playing",
|
||||
"Episodes someone is playing through DeadAir's playlist.",
|
||||
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)],
|
||||
);
|
||||
work(
|
||||
&mut out,
|
||||
&STATS,
|
||||
s.events.len(),
|
||||
s.ctx.retry.lock().map_or(0, |r| r.len()),
|
||||
s.ctx.cfg().general.art_cache_mb * 1_048_576,
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_signals_keys_refused",
|
||||
"People whose TypeSafe key was refused, whose Signals wait until they save another.",
|
||||
&[("", s.ctx.signals_refused.lock().map_or(0, |r| r.len()) as f64)],
|
||||
);
|
||||
let rt = s.runtime.metrics();
|
||||
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
|
||||
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_runtime_queue",
|
||||
"Tasks waiting in the runtime's global queue.",
|
||||
&[("", rt.global_queue_depth() as f64)],
|
||||
);
|
||||
let (rss, fds) = ipx_directory::metrics::process();
|
||||
if let Some(v) = rss {
|
||||
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
|
||||
}
|
||||
if let Some(v) = fds {
|
||||
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
|
||||
}
|
||||
counter(
|
||||
&mut out,
|
||||
"ipx_feed_fetches_total",
|
||||
"Feeds fetched, answered 304 included.",
|
||||
&[("", ipx_feed::FETCHES.load(Relaxed) as f64)],
|
||||
);
|
||||
counter(
|
||||
&mut out,
|
||||
"ipx_feed_bytes_total",
|
||||
"Bytes of feed bodies fetched.",
|
||||
&[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
&mut out,
|
||||
"ipx_uptime_seconds",
|
||||
"Seconds since the daemon started.",
|
||||
&[("", s.started.elapsed().as_secs() as f64)],
|
||||
);
|
||||
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
|
||||
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The work in progress and done, from `STATS`: jobs, queues and what is in flight (#195).
|
||||
fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_limit: u64) {
|
||||
let now = crate::db::now();
|
||||
let job = *st.job.lock().expect("a panic elsewhere poisoned this lock");
|
||||
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
|
||||
if let Some((kind, since)) = job {
|
||||
let label = format!("kind=\"{kind}\"");
|
||||
gauge(
|
||||
out,
|
||||
"ipx_worker_job_seconds",
|
||||
"How long the worker has been on its job, by the job's kind.",
|
||||
&[(&label, since.elapsed().as_secs_f64())],
|
||||
);
|
||||
}
|
||||
let jobs = st.jobs.lock().expect("a panic elsewhere poisoned this lock").clone();
|
||||
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
|
||||
let pairs = |f: fn(&(u64, f64)) -> f64| {
|
||||
labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>()
|
||||
};
|
||||
counter(out, "ipx_jobs_total", "Jobs the worker has finished, by kind.", &pairs(|v| v.0 as f64));
|
||||
counter(out, "ipx_job_seconds_total", "Seconds the worker has spent on jobs, by kind.", &pairs(|v| v.1));
|
||||
let last = st.last_scan_done.load(Relaxed);
|
||||
if last > 0 {
|
||||
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
|
||||
}
|
||||
gauge(
|
||||
out,
|
||||
"ipx_http_requests_in_flight",
|
||||
"Web requests being answered.",
|
||||
&[("", st.http_in_flight.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_scan_feeds_fetching",
|
||||
"Feeds a scan is fetching at once.",
|
||||
&[("", st.feeds_fetching.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_downloads_active", "Files downloading now.", &[("", st.downloads_active.load(Relaxed) as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_download_bytes_total",
|
||||
"Bytes downloaded since the daemon started.",
|
||||
&[("", st.download_bytes.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_event_queue", "Live updates sent and not yet read by every listener.", &[("", event_queue as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_events_missed_total",
|
||||
"Live updates dropped for a listener that fell behind.",
|
||||
&[("", st.events_missed.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_deadair_backlog",
|
||||
"Files of shows someone has DeadAir on that it has not looked at, those already heard through included.",
|
||||
&[("", st.deadair_backlog.load(Relaxed) as f64)],
|
||||
);
|
||||
let since = st.deadair_busy_since.load(Relaxed);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_deadair_busy_seconds",
|
||||
"How long DeadAir has been on its file; 0 when on none.",
|
||||
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })],
|
||||
);
|
||||
gauge(out, "ipx_feed_retries", "Feeds waiting to try a failed name lookup again.", &[("", retries as f64)]);
|
||||
gauge(
|
||||
out,
|
||||
"ipx_art_cache_bytes",
|
||||
"Artwork kept on disk, as of the last sweep.",
|
||||
&[("", st.art_cache_bytes.load(Relaxed) as f64)],
|
||||
);
|
||||
gauge(out, "ipx_art_cache_limit_bytes", "The most artwork kept.", &[("", art_limit as f64)]);
|
||||
counter(
|
||||
out,
|
||||
"ipx_art_evicted_total",
|
||||
"Images thrown out to keep the artwork under its limit.",
|
||||
&[("", st.art_evicted.load(Relaxed) as f64)],
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_art_requests_total",
|
||||
"Artwork asked for: served from disk, or fetched from the publisher.",
|
||||
&[
|
||||
("result=\"hit\"", st.art_hits.load(Relaxed) as f64),
|
||||
("result=\"miss\"", st.art_misses.load(Relaxed) as f64),
|
||||
],
|
||||
);
|
||||
// By person and whose key paid (#278). An account's name is often an email address: /metrics
|
||||
// is on the LAN only, as Prometheus is.
|
||||
let people = st.people.lock().expect("a panic elsewhere poisoned this lock").clone();
|
||||
let labels: Vec<String> =
|
||||
people.keys().map(|(who, key)| format!("user=\"{}\",key=\"{key}\"", label(who))).collect();
|
||||
let by = |n: usize| labels.iter().zip(people.values()).map(|(l, v)| (l.as_str(), v[n])).collect::<Vec<_>>();
|
||||
counter(
|
||||
out,
|
||||
"ipx_signals_judged_total",
|
||||
"Items Jev has judged for someone's Signals, by person and whose key paid.",
|
||||
&by(0),
|
||||
);
|
||||
counter(out, "ipx_signals_hits_total", "Signals an item was found to fit, by person and whose key paid.", &by(1));
|
||||
let tokens: Vec<String> =
|
||||
labels.iter().flat_map(|l| [format!("kind=\"input\",{l}"), format!("kind=\"output\",{l}")]).collect();
|
||||
let used: Vec<(&str, f64)> =
|
||||
tokens.iter().zip(people.values().flat_map(|v| [v[2], v[3]])).map(|(l, v)| (l.as_str(), v)).collect();
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_tokens_total",
|
||||
"Tokens TypeSafe counted for calls to Jev, what they cost, by person and whose key paid.",
|
||||
&used,
|
||||
);
|
||||
let last = st.signals_last_pass.load(Relaxed);
|
||||
if last > 0 {
|
||||
gauge(
|
||||
out,
|
||||
"ipx_signals_last_pass_age_seconds",
|
||||
"Seconds since the Signals judge last finished a pass.",
|
||||
&[("", (now - last) as f64)],
|
||||
);
|
||||
}
|
||||
let labels: Vec<String> = JEV_RESULTS.iter().map(|r| format!("result=\"{r}\"")).collect();
|
||||
let calls: Vec<(&str, f64)> =
|
||||
labels.iter().zip(&st.jev_calls).map(|(l, c)| (l.as_str(), c.load(Relaxed) as f64)).collect();
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_requests_total",
|
||||
"Calls to TypeSafe's Jev, Signals' and their icons', by how they ended.",
|
||||
&calls,
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_seconds_total",
|
||||
"Seconds spent on calls to Jev, their wait for a turn included.",
|
||||
&[("", st.jev_micros.load(Relaxed) as f64 / 1e6)],
|
||||
);
|
||||
counter(
|
||||
out,
|
||||
"ipx_jev_paced_seconds_total",
|
||||
"Seconds calls to Jev waited for their turn under TypeSafe's rate limits.",
|
||||
&[("", st.jev_paced_micros.load(Relaxed) as f64 / 1e6)],
|
||||
);
|
||||
for (name, help, v) in [
|
||||
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
|
||||
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
|
||||
("ipx_files_downloaded", "Files on disk, as last counted.", &st.downloaded),
|
||||
] {
|
||||
let v = v.load(Relaxed);
|
||||
if v >= 0 {
|
||||
gauge(out, name, help, &[("", v as f64)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A label's value as Prometheus's text format reads it.
|
||||
fn label(v: &str) -> String {
|
||||
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
|
||||
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
write(out, "counter", name, help, samples);
|
||||
}
|
||||
|
||||
/// One gauge in Prometheus's text format: its help, its type, and a line for each set of labels
|
||||
/// (`state="idle"`, or "" for none).
|
||||
pub fn gauge(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
write(out, "gauge", name, help, samples);
|
||||
}
|
||||
|
||||
fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str, f64)]) {
|
||||
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
|
||||
for (labels, value) in samples {
|
||||
let labels = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
|
||||
let _ = writeln!(out, "{name}{labels} {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gauges_are_written_as_prometheus_reads_them() {
|
||||
let mut out = String::new();
|
||||
gauge(&mut out, "ipx_db_connections", "Connections.", &[("state=\"idle\"", 3.0), ("state=\"in_use\"", 1.0)]);
|
||||
gauge(&mut out, "ipx_feeds", "Feeds.", &[("", 1535.0)]);
|
||||
assert_eq!(
|
||||
out,
|
||||
"# HELP ipx_db_connections Connections.\n# TYPE ipx_db_connections gauge\n\
|
||||
ipx_db_connections{state=\"idle\"} 3\nipx_db_connections{state=\"in_use\"} 1\n\
|
||||
# HELP ipx_feeds Feeds.\n# TYPE ipx_feeds gauge\nipx_feeds 1535\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_and_work_in_progress_are_counted_however_they_end() {
|
||||
static T: Stats = Stats::new();
|
||||
{
|
||||
let _j = T.job("scan");
|
||||
let _b = busy(&T.downloads_active);
|
||||
assert_eq!(T.downloads_active.load(Relaxed), 1);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 0, 0, 0);
|
||||
assert!(
|
||||
out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
|
||||
assert_eq!(T.downloads_active.load(Relaxed), 0);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 3, 1, 10);
|
||||
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
|
||||
assert!(
|
||||
out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
|
||||
assert!(
|
||||
out.contains("ipx_jev_requests_total{result=\"key_refused\"} 0\n")
|
||||
&& !out.contains("ipx_signals_last_pass_age_seconds"),
|
||||
"{out}"
|
||||
);
|
||||
T.bill("sam@example.net", "server", [1.0, 1.0, 600.0, 50.0]);
|
||||
T.bill("sam@example.net", "server", [1.0, 0.0, 300.5, 25.0]);
|
||||
T.bill("a \"b\"", "own", [0.0, 0.0, 10.0, 1.0]);
|
||||
let mut out = String::new();
|
||||
work(&mut out, &T, 0, 0, 0);
|
||||
assert!(out.contains("ipx_signals_judged_total{user=\"sam@example.net\",key=\"server\"} 2\n"), "{out}");
|
||||
assert!(
|
||||
out.contains("ipx_jev_tokens_total{kind=\"input\",user=\"sam@example.net\",key=\"server\"} 900.5\n"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("ipx_jev_tokens_total{kind=\"output\",user=\"a \\\"b\\\"\",key=\"own\"} 1\n"), "{out}");
|
||||
// The library's counts are left out until the daemon has counted it.
|
||||
assert!(!out.contains("ipx_feeds "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_process_reads_its_memory_and_files_on_linux() {
|
||||
let (rss, fds) = ipx_directory::metrics::process();
|
||||
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
|
||||
}
|
||||
|
||||
use crate::config;
|
||||
use crate::test_util::test_ctx;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
#[tokio::test]
|
||||
async fn metrics_answer_from_a_thread_of_their_own() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
let (cmds, _rx) = mpsc::channel(8);
|
||||
let (events, _) = broadcast::channel(8);
|
||||
let sources = Sources {
|
||||
ctx: ctx.clone(),
|
||||
cmds,
|
||||
events,
|
||||
playing: Default::default(),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
started: std::time::Instant::now(),
|
||||
};
|
||||
let at = spawn("127.0.0.1:0", sources).unwrap();
|
||||
let text = ctx.client.get(format!("http://{at}/metrics")).send().await.unwrap().text().await.unwrap();
|
||||
// Every sample a name, optional labels and a number, as Prometheus parses them (#194).
|
||||
let samples = text.lines().filter(|l| !l.starts_with('#'));
|
||||
for line in samples {
|
||||
let (name, value) = line.rsplit_once(' ').unwrap();
|
||||
assert!(name.starts_with("ipx_") && value.parse::<f64>().is_ok(), "{line}");
|
||||
}
|
||||
for name in
|
||||
["ipx_db_connections{state=\"in_use\"}", "ipx_db_connections_max", "ipx_worker_queue", "ipx_runtime_tasks"]
|
||||
{
|
||||
assert!(text.contains(name), "{name}");
|
||||
}
|
||||
assert!(
|
||||
spawn(
|
||||
&at.to_string(),
|
||||
Sources {
|
||||
ctx: ctx.clone(),
|
||||
cmds: mpsc::channel(1).0,
|
||||
events: broadcast::channel(1).0,
|
||||
playing: Default::default(),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
started: std::time::Instant::now(),
|
||||
}
|
||||
)
|
||||
.is_err(),
|
||||
"a port already taken is an error at start"
|
||||
);
|
||||
}
|
||||
}
|
||||
290
crates/ipx/src/retention.rs
Normal file
290
crates/ipx/src/retention.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace").
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::ctx::Ctx;
|
||||
use crate::db;
|
||||
use crate::db::{Candidate, Db};
|
||||
use crate::ipc::Event;
|
||||
use crate::{art, catalogue};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
/// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did.
|
||||
pub const PAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
pub reconciled: usize,
|
||||
pub aged_out: Vec<Candidate>,
|
||||
pub over_quota: Vec<Candidate>,
|
||||
pub entries_pruned: usize,
|
||||
pub bytes_freed: u64,
|
||||
}
|
||||
|
||||
/// Oldest-first until enough is freed. Pure, so the ordering rule is testable without a disk.
|
||||
pub fn pick(candidates: &[Candidate], total: u64, limit: u64) -> Vec<Candidate> {
|
||||
let ceiling = limit.saturating_sub(PAD_BYTES);
|
||||
if total <= ceiling {
|
||||
return vec![];
|
||||
}
|
||||
let mut need = total - ceiling;
|
||||
let mut out = vec![];
|
||||
for c in candidates {
|
||||
if need == 0 {
|
||||
break;
|
||||
}
|
||||
let size = c.bytes.max(0) as u64;
|
||||
out.push(c.clone());
|
||||
need = need.saturating_sub(size);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Files older than the cutoff. Flagged ones are already excluded by the query.
|
||||
pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
||||
candidates.iter().filter(|c| c.age_key > 0 && c.age_key < cutoff).cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
let mut report = Report::default();
|
||||
|
||||
// Someone may have deleted a file by hand; the row must stop claiming it exists. Not while
|
||||
// the folder itself is gone (the array stopped, shfs dropped): every file would look deleted
|
||||
// and the whole library be forgotten at once (#293).
|
||||
// Each step is a span of the reap's trace, so a slow one says which part was slow.
|
||||
let missing = if cfg.general.download_dir.is_dir() {
|
||||
db.missing_files().instrument(db::step("missing_files")).await?
|
||||
} else {
|
||||
tracing::warn!(dir = %cfg.general.download_dir.display(), "the downloads folder is not there; left every file as it was");
|
||||
vec![]
|
||||
};
|
||||
for (id, path) in missing {
|
||||
if !dry_run {
|
||||
db.mark_reaped(id).await?;
|
||||
}
|
||||
tracing::debug!(path, "file gone, row reaped");
|
||||
report.reconciled += 1;
|
||||
}
|
||||
|
||||
for id in db.prune_abandoned_failures().instrument(db::step("prune_abandoned_failures")).await? {
|
||||
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
|
||||
}
|
||||
|
||||
let candidates = db.reap_candidates().instrument(db::step("reap_candidates")).await?;
|
||||
|
||||
if cfg.general.max_age_days > 0 {
|
||||
let cutoff = db::now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||
report.aged_out = aged(&candidates, cutoff);
|
||||
for c in &report.aged_out {
|
||||
report.bytes_freed += remove(db, c, dry_run).instrument(db::step("remove_aged")).await?;
|
||||
}
|
||||
if !dry_run {
|
||||
report.entries_pruned = db.prune_entries(cutoff).instrument(db::step("prune_entries")).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.general.max_total_gb > 0.0 {
|
||||
let limit = (cfg.general.max_total_gb * 1_073_741_824.0) as u64;
|
||||
// Re-read: the age sweep may already have freed enough.
|
||||
let remaining: Vec<Candidate> =
|
||||
candidates.iter().filter(|c| !report.aged_out.iter().any(|a| a.id == c.id)).cloned().collect();
|
||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||
report.over_quota = pick(&remaining, total, limit);
|
||||
for c in &report.over_quota {
|
||||
report.bytes_freed += remove(db, c, dry_run).instrument(db::step("remove_over_quota")).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
if dry_run {
|
||||
return Ok(c.bytes.max(0) as u64);
|
||||
}
|
||||
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
|
||||
let size = std::fs::metadata(&c.path).ok().filter(|m| m.is_file()).map_or(c.bytes.max(0) as u64, |m| m.len());
|
||||
// Missing is fine: the point is that it is gone.
|
||||
if let Err(e) = crate::download::remove(Path::new(&c.path))
|
||||
&& e.kind() != ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||
return Ok(0);
|
||||
}
|
||||
db.mark_reaped(c.id).await?;
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// `standalone` false means this is the sweep that runs before a scan: it reports what it
|
||||
/// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch`
|
||||
/// would stop reading before the scan had even started.
|
||||
#[tracing::instrument(name = "reap", skip_all, fields(dry_run = dry_run, files = tracing::field::Empty, bytes = tracing::field::Empty, entries = tracing::field::Empty))]
|
||||
pub(crate) async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
||||
let r = run(&ctx.cfg(), &ctx.db, dry_run).await?;
|
||||
let span = tracing::Span::current();
|
||||
span.record("files", r.aged_out.len() + r.over_quota.len());
|
||||
span.record("bytes", r.bytes_freed);
|
||||
span.record("entries", r.entries_pruned);
|
||||
if !dry_run {
|
||||
tracing::Instrument::instrument(catalogue::clean_directory(ctx), db::step("clean_directory")).await?;
|
||||
let moved =
|
||||
tracing::Instrument::instrument(ctx.db.advance_unread_from(), db::step("advance_unread_from")).await?;
|
||||
tracing::debug!(moved, "unread lists start from each person's oldest unread item");
|
||||
// Off the async threads: it walks every file kept, on FUSE (#345).
|
||||
let (span, limit) = (db::step("trim_artwork"), ctx.cfg().general.art_cache_mb * 1_048_576);
|
||||
let gone = tokio::task::spawn_blocking(move || span.in_scope(|| art::trim(&art::dir(), limit))).await?;
|
||||
if gone > 0 {
|
||||
tracing::debug!(gone, "trimmed the artwork kept on disk");
|
||||
}
|
||||
}
|
||||
let removed = r.aged_out.iter().chain(r.over_quota.iter());
|
||||
for c in removed {
|
||||
ctx.out.emit(Event::Reaped { path: c.path.clone(), bytes: c.bytes.max(0) as u64 });
|
||||
}
|
||||
if standalone {
|
||||
ctx.out.emit(Event::ReapDone { files: r.aged_out.len() + r.over_quota.len(), bytes: r.bytes_freed });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cand(id: i64, bytes: i64, age_key: i64, read: bool) -> Candidate {
|
||||
Candidate { id, url: format!("https://x/{id}"), path: format!("/tmp/{id}"), bytes, age_key, read }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_takes_oldest_first_and_stops_once_under() {
|
||||
let gb = 1_073_741_824u64;
|
||||
// Ten 1 GB files, oldest first as the query would return them.
|
||||
let cands: Vec<Candidate> = (1..=10).map(|i| cand(i, gb as i64, i, true)).collect();
|
||||
|
||||
// 10 GB used against a 6 GB quota: need 4 GB back, plus the 50 MB pad.
|
||||
let picked = pick(&cands, 10 * gb, 6 * gb);
|
||||
assert_eq!(
|
||||
picked.iter().map(|c| c.id).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3, 4, 5],
|
||||
"oldest first, one extra to clear the headroom pad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_deletes_nothing_when_under_quota() {
|
||||
let gb = 1_073_741_824u64;
|
||||
let cands: Vec<Candidate> = (1..=3).map(|i| cand(i, gb as i64, i, true)).collect();
|
||||
assert!(pick(&cands, 3 * gb, 50 * gb).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aged_only_takes_files_past_the_cutoff() {
|
||||
let cands = vec![cand(1, 10, 100, true), cand(2, 10, 500, true), cand(3, 10, 0, true)];
|
||||
let picked = aged(&cands, 200);
|
||||
assert_eq!(picked.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1]);
|
||||
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async 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().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||
('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', true, true),
|
||||
(2, 'f', 'keep', true, false),
|
||||
(1, 'f', 'half', true, false),
|
||||
(1, 'f', 'read', true, false),
|
||||
(2, 'f', 'read', true, false);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_marks_of_someone_who_left_count_for_nothing() {
|
||||
// ray and sam subscribe; left read one episode and pinned the others before unsubscribing.
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false),(3,'left',false);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||
('f', 'heard', 0), ('f', 'pinned', 0), ('f', 'old', 0);
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||
(3, 'f', 'heard', true, false),
|
||||
(1, 'f', 'heard', true, false),
|
||||
(3, 'f', 'pinned', false, true),
|
||||
(3, 'f', 'old', false, true);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
|
||||
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
|
||||
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
|
||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_downloads_folder_that_is_gone_forgets_no_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1, 'f', 'a', 'u1', '/nowhere/ipx-test/a.mp3', 'done');",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cfg = Config::default();
|
||||
cfg.general.download_dir = "/nowhere/ipx-test".into();
|
||||
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 0, "the folder is gone, not the file");
|
||||
|
||||
cfg.general.download_dir = std::env::temp_dir();
|
||||
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is not");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f');
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||
('f', 'has-file', 100),
|
||||
('f', 'no-file', 100),
|
||||
('f', 'flagged', 100),
|
||||
('f', 'recent', 900);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||
}
|
||||
}
|
||||
1056
crates/ipx/src/scan.rs
Normal file
1056
crates/ipx/src/scan.rs
Normal file
File diff suppressed because it is too large
Load Diff
688
crates/ipx/src/signals.rs
Normal file
688
crates/ipx/src/signals.rs
Normal file
@@ -0,0 +1,688 @@
|
||||
//! Signals (#254): feeds someone makes from their own subscriptions, holding the items TypeSafe's
|
||||
//! Jev says fit the keywords they gave. Jev is asked on their own key, once for each item, with a
|
||||
//! question for each of their Signals that has not had it yet, and what it says is kept in
|
||||
//! `signal_verdicts`, fitting or not, so nothing is asked twice.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use futures_util::StreamExt as _;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::db;
|
||||
use crate::db::{Ask, Unjudged};
|
||||
use crate::feed;
|
||||
use crate::metrics::STATS;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
|
||||
/// long the queue is held before people who got a key later are looked at.
|
||||
const BATCH: i64 = 50;
|
||||
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
|
||||
const IDLE: Duration = Duration::from_secs(300);
|
||||
/// Calls to Jev in flight at once (#275). One at a time, about 150 ms each, a new Signal's month
|
||||
/// of items took twenty minutes. Enough for `MAX_RATE` at that pace; the pace is `pace`'s to keep.
|
||||
const IN_FLIGHT: usize = 10;
|
||||
/// TypeSafe's default limits are 80 requests and 100,000 input tokens a second (#277). Calls are
|
||||
/// paced under both, with room left for the icon picks and key checks that share them.
|
||||
const MAX_RATE: f64 = 60.0;
|
||||
const MAX_TOKENS: f64 = 80_000.0;
|
||||
/// When the next call may start, for every call ipx makes to Jev.
|
||||
static NEXT_CALL: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
|
||||
/// How sure Jev has to be that an item is about the keywords.
|
||||
const THRESHOLD: f64 = 0.5;
|
||||
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
|
||||
/// items and six Signals, 1000 changed none of 330 verdicts and saved an eighth of the input
|
||||
/// tokens; 500 changed one, an Apple podcast's episode no longer about Apple.
|
||||
const TEXT_CHARS: usize = 1000;
|
||||
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
let done = tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
done = pass(&ctx) => done,
|
||||
};
|
||||
crate::metrics::STATS.signals_last_pass.store(crate::db::now(), Relaxed);
|
||||
match done {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "Signals could not be judged"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
_ = ctx.signals.notified() => {}
|
||||
_ = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key every Signal is judged on, when an admin has chosen that the server pays for Jev
|
||||
/// (#271) and the server has a key; None leaves each person to their own.
|
||||
pub fn server_key(ctx: &Ctx) -> Option<String> {
|
||||
if !ctx.cfg().general.signals_on_server {
|
||||
return None;
|
||||
}
|
||||
typesafe_key()
|
||||
}
|
||||
|
||||
/// The server's own TypeSafe key, `TYPESAFE_KEY`; set but empty is none, as the browser suite
|
||||
/// sets it so the test daemon never spends a real one.
|
||||
pub fn typesafe_key() -> Option<String> {
|
||||
std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty())
|
||||
}
|
||||
|
||||
/// One batch. True when there may be more waiting.
|
||||
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||
let server = server_key(ctx);
|
||||
// People's refused keys are not what is asked with when the server's is.
|
||||
let skip: Vec<i64> = if server.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
ctx.signals_refused.lock().expect("a panic elsewhere poisoned this lock").iter().copied().collect()
|
||||
};
|
||||
let days = ctx.cfg().general.signal_days;
|
||||
let items = ctx.db.unjudged(&skip, BATCH, server.is_some(), days).await?;
|
||||
if items.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let span = tracing::info_span!(
|
||||
"signals",
|
||||
entries = items.len(),
|
||||
calls = tracing::field::Empty,
|
||||
judged = tracing::field::Empty,
|
||||
hits = tracing::field::Empty
|
||||
);
|
||||
async {
|
||||
let groups = calls(&items);
|
||||
let paid = if server.is_some() { "server" } else { "own" };
|
||||
tracing::Span::current().record("calls", groups.len());
|
||||
let refused = std::sync::Mutex::new(HashSet::new());
|
||||
let stopped = AtomicBool::new(false);
|
||||
let mut hits_for = BTreeMap::<i64, usize>::new();
|
||||
let mut judged = 0;
|
||||
// Each call starts only when a place is free, so one that sees a key refused or the pass
|
||||
// stopped is not sent; those already in flight are let finish, and their answers kept.
|
||||
// By index: a closure taking a reference here is not Send for every lifetime, which
|
||||
// tokio::spawn asks of the judge's future.
|
||||
let mut answers = futures_util::stream::iter(0..groups.len())
|
||||
.map(|n| {
|
||||
let (group, refused, stopped, server) = (&groups[n], &refused, &stopped, &server);
|
||||
async move {
|
||||
let first = group[0];
|
||||
if stopped.load(Relaxed) || refused.lock().expect("a panic elsewhere poisoned this lock").contains(&first.user_id) {
|
||||
return None;
|
||||
}
|
||||
let key = server.as_deref().or(first.key.as_deref())?;
|
||||
Some((group, judge(ctx, group, key, paid).await))
|
||||
}
|
||||
})
|
||||
.buffer_unordered(IN_FLIGHT);
|
||||
while let Some(answer) = answers.next().await {
|
||||
let Some((group, answer)) = answer else { continue };
|
||||
let first = group[0];
|
||||
match answer {
|
||||
Ok(verdicts) => {
|
||||
for (item, verdicts) in group.iter().zip(verdicts) {
|
||||
judged += 1;
|
||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||
STATS.bill(&item.user_name, paid, [1.0, hits as f64, 0.0, 0.0]);
|
||||
if hits > 0 {
|
||||
*hits_for.entry(item.user_id).or_default() += hits;
|
||||
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||
}
|
||||
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(db::step("record_verdicts")).await?;
|
||||
}
|
||||
}
|
||||
// The server's key refused is everyone's: the queue waits for the next pass.
|
||||
Err(e) if refuses_key(&e) && server.is_some() => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
|
||||
}
|
||||
}
|
||||
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
|
||||
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
|
||||
Err(e) if refuses_key(&e) => {
|
||||
if refused.lock().expect("a panic elsewhere poisoned this lock").insert(first.user_id) {
|
||||
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||
}
|
||||
}
|
||||
// TypeSafe says the request itself is wrong, so every other item's would be too:
|
||||
// the rest wait for the next pass rather than each failing the same way (#257).
|
||||
Err(e) if malformed(&e) => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||
}
|
||||
}
|
||||
// Over TypeSafe's limits: what is in flight finishes, nothing more starts, and
|
||||
// the next pass waits for `back_off`.
|
||||
Err(e) if rate_limited(&e) => {
|
||||
if !stopped.swap(true, Relaxed) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "TypeSafe asked Signals to slow down");
|
||||
}
|
||||
}
|
||||
// Left unjudged, so it is asked again next pass.
|
||||
Err(e) => tracing::warn!(user = first.user_id, feed = %first.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
||||
}
|
||||
}
|
||||
drop(answers);
|
||||
let span = tracing::Span::current();
|
||||
span.record("judged", judged);
|
||||
span.record("hits", hits_for.values().sum::<usize>());
|
||||
// Once a pass, not once an item: a page fetches its counts again on each.
|
||||
for (user, hits) in hits_for {
|
||||
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
||||
}
|
||||
let refused = refused.into_inner().expect("a panic elsewhere poisoned this lock");
|
||||
let some_refused = !refused.is_empty();
|
||||
ctx.signals_refused.lock().expect("a panic elsewhere poisoned this lock").extend(refused);
|
||||
// A pass that only failed would otherwise go straight round again on the same items, and
|
||||
// one TypeSafe refused as malformed would ask again at once, however full.
|
||||
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
|
||||
/// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
|
||||
/// then one person's description was in the question that decided another's verdicts (#343).
|
||||
fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
|
||||
items.iter().map(|item| vec![item]).collect()
|
||||
}
|
||||
|
||||
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||
/// alone said only 422 (#257).
|
||||
#[derive(Debug)]
|
||||
struct Refused {
|
||||
status: u16,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Display for Refused {
|
||||
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
|
||||
write!(f, "TypeSafe answered {}: {}", self.status, self.body)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refused {}
|
||||
|
||||
fn status(e: &anyhow::Error) -> Option<u16> {
|
||||
e.downcast_ref::<Refused>().map(|r| r.status)
|
||||
}
|
||||
|
||||
/// Whether the batch was as long as `unjudged` gives, so more may be waiting. Its limit is on
|
||||
/// rows, one for each item and Signal: with two Signals, 50 is 25 items, and comparing the items
|
||||
/// with 50 took every batch for the last, 25 items every 5 minutes (#260).
|
||||
fn full(items: &[Unjudged]) -> bool {
|
||||
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
||||
}
|
||||
|
||||
pub fn refuses_key(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(401..=403))
|
||||
}
|
||||
|
||||
fn rate_limited(e: &anyhow::Error) -> bool {
|
||||
status(e) == Some(429)
|
||||
}
|
||||
|
||||
/// A call's turn: it starts at `next` or now, whichever is later, and holds the line for its
|
||||
/// share of a second under both limits. Gives when it starts and when the one after may.
|
||||
fn slot(
|
||||
next: tokio::time::Instant,
|
||||
now: tokio::time::Instant,
|
||||
tokens: f64,
|
||||
) -> (tokio::time::Instant, tokio::time::Instant) {
|
||||
let start = next.max(now);
|
||||
(start, start + Duration::from_secs_f64((1.0 / MAX_RATE).max(tokens / MAX_TOKENS)))
|
||||
}
|
||||
|
||||
/// Waits for this call's turn. Its tokens are guessed from the request, a token to three bytes
|
||||
/// of JSON, on the high side: TypeSafe says the true count only in the answer.
|
||||
async fn pace(body: &serde_json::Value) {
|
||||
let tokens = body.to_string().len() as f64 / 3.0;
|
||||
let start = {
|
||||
let mut next = NEXT_CALL.lock().await;
|
||||
let now = tokio::time::Instant::now();
|
||||
let (start, after) = slot(next.unwrap_or(now), now, tokens);
|
||||
*next = Some(after);
|
||||
start
|
||||
};
|
||||
let waited = start.saturating_duration_since(tokio::time::Instant::now());
|
||||
tokio::time::sleep_until(start).await;
|
||||
// How long the limits held this call back: near nothing unless ipx is near them (#277).
|
||||
STATS.jev_paced_micros.fetch_add(waited.as_micros() as u64, Relaxed);
|
||||
tracing::Span::current().record("paced_ms", waited.as_millis() as u64);
|
||||
}
|
||||
|
||||
/// TypeSafe said too many: nobody calls again until it says, or a second.
|
||||
async fn back_off(retry_after: Option<u64>) {
|
||||
let until = tokio::time::Instant::now() + Duration::from_secs(retry_after.unwrap_or(1).min(60));
|
||||
let mut next = NEXT_CALL.lock().await;
|
||||
*next = Some(next.map_or(until, |n| n.max(until)));
|
||||
}
|
||||
|
||||
fn malformed(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(400 | 422))
|
||||
}
|
||||
|
||||
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
|
||||
async fn judge(ctx: &Ctx, group: &[&Unjudged], key: &str, paid: &'static str) -> Result<Vec<Vec<(i64, bool)>>> {
|
||||
let asks: Vec<&Ask> = group.iter().flat_map(|i| &i.signals).collect();
|
||||
let span = tracing::info_span!(
|
||||
"jev",
|
||||
user = group[0].user_id,
|
||||
people = group.len(),
|
||||
signal_count = asks.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
|
||||
// A shared call is billed by each person's share of its questions, which are most of it.
|
||||
let [input, output] = usage(&answer);
|
||||
for item in group {
|
||||
let share = item.signals.len() as f64 / asks.len() as f64;
|
||||
STATS.bill(&item.user_name, paid, [0.0, 0.0, input * share, output * share]);
|
||||
}
|
||||
// A question Jev left unanswered stays unjudged and is asked again.
|
||||
Ok(group.iter().map(|i| i.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()).collect())
|
||||
}
|
||||
|
||||
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
|
||||
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
|
||||
fn request(item: &Unjudged, asks: &[&Ask]) -> serde_json::Value {
|
||||
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
|
||||
let state = format!(
|
||||
"Feed: {}\nTitle: {}\n\n{text}",
|
||||
item.feed_title.as_deref().unwrap_or(""),
|
||||
item.title.as_deref().unwrap_or("")
|
||||
);
|
||||
let questions: serde_json::Map<String, serde_json::Value> = asks.iter().map(|a| (key(a.id), question(a))).collect();
|
||||
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
||||
}
|
||||
|
||||
fn key(id: i64) -> String {
|
||||
format!("signal_{id}")
|
||||
}
|
||||
|
||||
/// One subject, named by the Signal and described by its words and its owner's sentence. Asked
|
||||
/// whether an item was about any one of its words, AI Development (ai, development, programming)
|
||||
/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and
|
||||
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
|
||||
/// mean: a sentence on its own was refused with a 422 (#257).
|
||||
fn question(a: &Ask) -> serde_json::Value {
|
||||
let mut said = format!(
|
||||
"The reader follows a subject they call \"{}\", described by these words: {}.",
|
||||
a.name,
|
||||
a.keywords.join(", ")
|
||||
);
|
||||
if let Some(d) = &a.description {
|
||||
said.push_str(&format!(" In their own words: {d}"));
|
||||
}
|
||||
serde_json::json!({
|
||||
"type": "noul",
|
||||
"instructions": format!("{said} Is this item about that subject?"),
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether Jev says the item fits Signal `id`; None when the answer does not say.
|
||||
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
||||
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
||||
}
|
||||
|
||||
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
|
||||
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||
/// server's page doing it, once a Signal.
|
||||
pub async fn pick_icon(
|
||||
ctx: &Ctx,
|
||||
who: &str,
|
||||
key: &str,
|
||||
name: &str,
|
||||
words: &[String],
|
||||
description: Option<&str>,
|
||||
candidates: &[(String, String)],
|
||||
) -> Result<String> {
|
||||
let span = tracing::info_span!(
|
||||
"jev_icon",
|
||||
candidates = candidates.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
called(span, async {
|
||||
let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?;
|
||||
let [input, output] = usage(&answer);
|
||||
STATS.bill(who, "server", [0.0, 0.0, input, output]);
|
||||
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Whether TypeSafe takes `key`, by one short question, before it is saved (#271): a key it
|
||||
/// refuses would sit saved with nothing ever picked out, and nobody told why.
|
||||
pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
|
||||
let span = tracing::info_span!(
|
||||
"jev_key_check",
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
"otel.status_code" = tracing::field::Empty,
|
||||
input_tokens = tracing::field::Empty,
|
||||
output_tokens = tracing::field::Empty,
|
||||
paced_ms = tracing::field::Empty,
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": "A reader is saving their key.",
|
||||
"questions": { "ok": { "type": "noul", "instructions": "Is this a key check?",
|
||||
"criteria": { "true": "It is.", "false": "It is not." } } },
|
||||
});
|
||||
let answer = called(span, ask(ctx, key, &body)).await?;
|
||||
let [input, output] = usage(&answer);
|
||||
STATS.bill(who, "own", [0.0, 0.0, input, output]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries
|
||||
/// this trace's `traceparent`, as every call out of ipx does.
|
||||
async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT);
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
pace(body).await;
|
||||
let r = req.send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if status == 429 {
|
||||
back_off(r.headers().get("retry-after").and_then(|v| v.to_str().ok()?.parse().ok())).await;
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
let answer: serde_json::Value = r.json().await?;
|
||||
// What a call cost, which TypeSafe says and nothing else would (#275); billed by the caller,
|
||||
// who knows whose it was (#278).
|
||||
for kind in ["input_tokens", "output_tokens"] {
|
||||
if let Some(t) = answer["usage"][kind].as_u64() {
|
||||
tracing::Span::current().record(kind, t);
|
||||
}
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Input and output tokens, as an answer counts them; 0 when it does not.
|
||||
fn usage(answer: &serde_json::Value) -> [f64; 2] {
|
||||
["input_tokens", "output_tokens"].map(|k| answer["usage"][k].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
|
||||
/// failed, which a field named `error` alone does not: Tempo's failed traces left Jev's out.
|
||||
async fn called<T>(span: tracing::Span, call: impl Future<Output = Result<T>>) -> Result<T> {
|
||||
let started = std::time::Instant::now();
|
||||
let r = call.instrument(span.clone()).await;
|
||||
STATS.jev_micros.fetch_add(started.elapsed().as_micros() as u64, Relaxed);
|
||||
STATS.jev_calls[outcome(&r)].fetch_add(1, Relaxed);
|
||||
if let Err(e) = &r {
|
||||
span.record("error", format!("{e:#}").as_str());
|
||||
span.record("otel.status_code", "ERROR");
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Which of `metrics::JEV_RESULTS` a call's result is.
|
||||
fn outcome<T>(r: &Result<T>) -> usize {
|
||||
match r {
|
||||
Ok(_) => 0,
|
||||
Err(e) if refuses_key(e) => 1,
|
||||
Err(e) if malformed(e) => 2,
|
||||
Err(e) if rate_limited(e) => 3,
|
||||
Err(_) => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_request(
|
||||
name: &str,
|
||||
words: &[String],
|
||||
description: Option<&str>,
|
||||
candidates: &[(String, String)],
|
||||
) -> serde_json::Value {
|
||||
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||
if let Some(d) = description {
|
||||
state.push_str(&format!("\nIn their words: {d}"));
|
||||
}
|
||||
let criteria: serde_json::Map<String, serde_json::Value> =
|
||||
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
|
||||
serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": state,
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": criteria,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
/// Jev's choice, only if it is one it was offered.
|
||||
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
|
||||
let c = answer["answers"]["icon"]["choice"].as_str()?;
|
||||
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
|
||||
}
|
||||
|
||||
/// Show notes are HTML: Jev is given their words, and an image's alt and title text, which is all
|
||||
/// the words some posts have. xkcd's is a comic and its hover text; dropping the tag whole sent Jev
|
||||
/// "Ground Effect" alone, an F1 aerodynamics term, and the comic went into Formula 1 (#267).
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut tag: Option<String> = None;
|
||||
for c in html.chars() {
|
||||
match (&mut tag, c) {
|
||||
(None, '<') => tag = Some(String::new()),
|
||||
(Some(t), '>') => {
|
||||
let mut said: Vec<&str> = Vec::new();
|
||||
for name in ["alt", "title"] {
|
||||
if let Some(v) = attr(t, name).filter(|v| !v.trim().is_empty() && !said.contains(v)) {
|
||||
said.push(v);
|
||||
}
|
||||
}
|
||||
for v in said {
|
||||
out.push(' ');
|
||||
out.push_str(v);
|
||||
}
|
||||
out.push(' ');
|
||||
tag = None;
|
||||
}
|
||||
(Some(t), c) => t.push(c),
|
||||
(None, c) => out.push(c),
|
||||
}
|
||||
}
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A quoted attribute's value in a tag's text, `alt="..."` or `alt='...'`.
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
for q in ['"', '\''] {
|
||||
let key = format!(" {name}={q}");
|
||||
if let Some(at) = lower.find(&key) {
|
||||
let rest = &tag[at + key.len()..];
|
||||
return rest.find(q).map(|end| &rest[..end]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_noul_of_a_half_or_more_is_a_fit() {
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": {
|
||||
"signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } });
|
||||
assert_eq!(hit(&answer, 3), Some(true));
|
||||
assert_eq!(hit(&answer, 4), Some(false));
|
||||
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_is_one_jev_was_offered() {
|
||||
let offered = vec![
|
||||
("solid/robot".to_string(), "Robot: ai, bot".to_string()),
|
||||
("solid/laptop-code".to_string(), "Laptop Code".to_string()),
|
||||
];
|
||||
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
|
||||
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
|
||||
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
|
||||
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_words_are_read_with_the_text() {
|
||||
let xkcd = r#"<img src="https://imgs.xkcd.com/comics/ground_effect.png" title="Runners wear sneakers." alt="Runners wear sneakers." referrerpolicy="no-referrer">"#;
|
||||
assert_eq!(text_of(xkcd), "Runners wear sneakers.", "alt and title once, when they say the same");
|
||||
assert_eq!(text_of("<p>Qualifying <b>report</b></p><img alt='A grid'>"), "Qualifying report A grid");
|
||||
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_is_counted_by_how_it_ended() {
|
||||
let refused = |status| -> Result<()> { Err(Refused { status, body: String::new() }.into()) };
|
||||
let named = |r: Result<()>| crate::metrics::JEV_RESULTS[outcome(&r)];
|
||||
assert_eq!(named(Ok(())), "ok");
|
||||
assert_eq!(named(refused(402)), "key_refused");
|
||||
assert_eq!(named(refused(422)), "malformed");
|
||||
assert_eq!(named(refused(503)), "error");
|
||||
assert_eq!(named(refused(429)), "rate_limited");
|
||||
assert_eq!(named(Err(anyhow::anyhow!("timed out"))), "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_persons_signals_never_share_a_call_with_anothers() {
|
||||
let row = |user_id, guid: &str| Unjudged {
|
||||
user_id,
|
||||
user_name: format!("u{user_id}"),
|
||||
key: None,
|
||||
feed_id: "f".into(),
|
||||
guid: guid.into(),
|
||||
feed_title: None,
|
||||
title: None,
|
||||
description: None,
|
||||
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
|
||||
};
|
||||
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
|
||||
let shape: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
|
||||
assert_eq!(shape, [vec![1], vec![2], vec![1]], "x is asked for each of them apart (#343)");
|
||||
let mine: Vec<&Ask> = calls(&items)[0].iter().flat_map(|i| &i.signals).collect();
|
||||
let r = request(&items[0], &mine);
|
||||
assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_are_paced_under_both_of_typesafes_limits() {
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let ms = |a: tokio::time::Instant| (a - t0).as_secs_f64() * 1000.0;
|
||||
// A small call holds the line for a 60th of a second, under 80 requests a second.
|
||||
let (start, next) = slot(t0, t0, 1000.0);
|
||||
assert_eq!((ms(start), ms(next).round()), (0.0, 17.0));
|
||||
// One of 8,000 tokens, a tenth of a second, under 100,000 tokens a second.
|
||||
let (start, next) = slot(next, t0, 8000.0);
|
||||
assert_eq!((ms(start).round(), ms(next).round()), (17.0, 117.0));
|
||||
// After a quiet spell, a call starts at once.
|
||||
let later = t0 + std::time::Duration::from_secs(5);
|
||||
assert_eq!(slot(next, later, 1000.0).0, later);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||
let item = |n: usize| Unjudged {
|
||||
user_id: 1,
|
||||
user_name: "a".into(),
|
||||
key: Some("k".into()),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: None,
|
||||
title: None,
|
||||
description: None,
|
||||
signals: (0..n as i64)
|
||||
.map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None })
|
||||
.collect(),
|
||||
};
|
||||
assert!(
|
||||
full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()),
|
||||
"25 items of two Signals each is the 50 asked for"
|
||||
);
|
||||
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||
let item = Unjudged {
|
||||
user_id: 1,
|
||||
user_name: "a".into(),
|
||||
key: Some("k".into()),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: Some("Pit Lane".into()),
|
||||
title: Some("Monaco".into()),
|
||||
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
||||
signals: vec![
|
||||
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
|
||||
Ask {
|
||||
id: 4,
|
||||
name: "Food".into(),
|
||||
keywords: vec!["cooking".into()],
|
||||
description: Some("Recipes, not restaurants.".into()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
|
||||
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
||||
let q = &r["questions"]["signal_3"];
|
||||
assert_eq!(q["type"], "noul");
|
||||
let says = q["instructions"].as_str().unwrap();
|
||||
assert!(
|
||||
says.contains("\"F1\"") && says.contains("formula 1, f1"),
|
||||
"one subject, by its name and its words: {says}"
|
||||
);
|
||||
assert!(!says.contains("own words"), "no sentence, none said");
|
||||
assert!(
|
||||
q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(),
|
||||
"TypeSafe takes an object, not a sentence"
|
||||
);
|
||||
assert!(
|
||||
r["questions"]["signal_4"]["instructions"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("In their own words: Recipes, not restaurants.")
|
||||
);
|
||||
}
|
||||
}
|
||||
104
crates/ipx/src/test_util.rs
Normal file
104
crates/ipx/src/test_util.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! What several modules' tests share: a context on a scratch database, feeds to put in it, and a
|
||||
//! server answering with feeds.
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::ipc::Emitter;
|
||||
use crate::{config, db};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn feed() -> config::Feed {
|
||||
// Whatever `ipx add` would write, which is the shape every code path sees.
|
||||
let mut cfg = config::Config::default();
|
||||
|
||||
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])
|
||||
}
|
||||
|
||||
/// The feed entry `add` builds, without the network round trip it does for a title.
|
||||
pub(crate) fn add_one_cfg(
|
||||
_cfg: &mut config::Config,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
) -> config::Feed {
|
||||
config::Feed {
|
||||
url: url.into(),
|
||||
folder,
|
||||
keywords,
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
group: None,
|
||||
media_types: None,
|
||||
schedule: None,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
private: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sub(kw: Option<&[&str]>, auto: Option<bool>, max: Option<i64>) -> db::Sub {
|
||||
db::Sub {
|
||||
feed_id: "f".into(),
|
||||
keywords: kw.map(|k| k.iter().map(|s| s.to_string()).collect()),
|
||||
auto_download: auto,
|
||||
allow_explicit: None,
|
||||
max_new_per_check: max,
|
||||
blocked: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn test_ctx(cfg: config::Config) -> Ctx {
|
||||
Ctx {
|
||||
cfg: std::sync::RwLock::new(Arc::new(cfg)),
|
||||
db: db::Db::memory().await.unwrap(),
|
||||
client: reqwest::Client::new(),
|
||||
local_client: reqwest::Client::new(),
|
||||
feed_client: reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap(),
|
||||
out: Emitter::terminal(),
|
||||
torrents: tokio::sync::OnceCell::new(),
|
||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||
config_path: PathBuf::new(),
|
||||
detach_torrents: false,
|
||||
retry: Default::default(),
|
||||
cfg_write: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers /empty with a 200 and nothing, /fixed with a feed of one item as it should read,
|
||||
/// anything else with the same item's text broken.
|
||||
pub(crate) async fn empty_or_feed_server() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]).into_owned();
|
||||
let body = if req.starts_with("GET /empty") {
|
||||
"\n"
|
||||
} else if req.starts_with("GET /fixed") {
|
||||
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>As it should read.</description></item></channel></rss>"
|
||||
} else {
|
||||
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>Somebody else's text.</description></item></channel></rss>"
|
||||
};
|
||||
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::Config;
|
||||
use librqbit::{ListenerOptions, ManagedTorrent};
|
||||
|
||||
pub struct Torrents {
|
||||
session: Arc<Session>,
|
||||
@@ -26,10 +27,7 @@ impl Torrents {
|
||||
let listen_addr: SocketAddr = format!("0.0.0.0:{lo}").parse()?;
|
||||
|
||||
let opts = SessionOptions {
|
||||
listen: Some(librqbit::ListenerOptions {
|
||||
listen_addr,
|
||||
..Default::default()
|
||||
}),
|
||||
listen: Some(ListenerOptions { listen_addr, ..Default::default() }),
|
||||
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -42,13 +40,15 @@ impl Torrents {
|
||||
}
|
||||
|
||||
/// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`,
|
||||
/// seeds it up to the configured limit, then stops.
|
||||
/// seeds it up to the configured limit, then stops. `dest_dir` is the torrent's own
|
||||
/// folder (`own_folder`), and it is the path returned: a file of several or one.
|
||||
///
|
||||
/// Downloading in place rather than into a staging dir is deliberate: seeding serves
|
||||
/// the files it downloaded, so moving them first would break it.
|
||||
pub async fn fetch(
|
||||
&self,
|
||||
cfg: &Config,
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
dest_dir: &Path,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
@@ -56,6 +56,15 @@ impl Torrents {
|
||||
tokio::fs::create_dir_all(dest_dir).await?;
|
||||
|
||||
let limit = stall_limit(cfg);
|
||||
let source = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
// Under the stall budget as well: a body sent a byte a minute stays inside the cap.
|
||||
let file = tokio::time::timeout(limit, torrent_file(client, url))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("no .torrent after {} minutes, gave up", limit.as_secs() / 60))??;
|
||||
AddTorrent::from_bytes(file)
|
||||
} else {
|
||||
AddTorrent::from_url(url)
|
||||
};
|
||||
|
||||
// Resolving a magnet's metadata happens inside add_torrent, and against a dead
|
||||
// swarm it never returns. The stall budget has to cover this phase too, or a
|
||||
@@ -63,7 +72,7 @@ impl Torrents {
|
||||
let added = tokio::time::timeout(
|
||||
limit,
|
||||
self.session.add_torrent(
|
||||
AddTorrent::from_url(url),
|
||||
source,
|
||||
Some(AddTorrentOptions {
|
||||
output_folder: Some(dest_dir.to_string_lossy().into_owned()),
|
||||
overwrite: true,
|
||||
@@ -72,12 +81,7 @@ impl Torrents {
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"no metadata after {} minutes, gave up",
|
||||
limit.as_secs() / 60
|
||||
)
|
||||
})?
|
||||
.map_err(|_| anyhow::anyhow!("no metadata after {} minutes, gave up", limit.as_secs() / 60))?
|
||||
.context("adding the torrent")?;
|
||||
|
||||
let (id, handle) = match added {
|
||||
@@ -111,21 +115,20 @@ impl Torrents {
|
||||
}
|
||||
}
|
||||
|
||||
let name = handle.name().unwrap_or_else(|| "torrent".to_owned());
|
||||
let path = handle.output_folder().join(&name);
|
||||
// The torrent's own name is the publisher's to choose, and librqbit checks it only when it
|
||||
// picks the folder itself: joined on, "/config" or ".." pointed Delete, the reaper and
|
||||
// /media at anything the daemon can reach (#318). The folder is ours, so it is the path.
|
||||
let path = dest_dir.to_owned();
|
||||
let size = handle.stats().total_bytes;
|
||||
|
||||
self.seed(cfg, &handle).await;
|
||||
self.session
|
||||
.delete(id.into(), false)
|
||||
.await
|
||||
.context("releasing the torrent")?;
|
||||
self.session.delete(id.into(), false).await.context("releasing the torrent")?;
|
||||
|
||||
Ok((path, size))
|
||||
}
|
||||
|
||||
/// Seeds until the ratio or the time limit is reached, whichever comes first.
|
||||
async fn seed(&self, cfg: &Config, handle: &Arc<librqbit::ManagedTorrent>) {
|
||||
async fn seed(&self, cfg: &Config, handle: &Arc<ManagedTorrent>) {
|
||||
if cfg.torrent.seed_ratio <= 0.0 || cfg.torrent.seed_time_mins == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -145,6 +148,30 @@ impl Torrents {
|
||||
}
|
||||
}
|
||||
|
||||
/// A .torrent file is metadata: a few hundred KB even for a large torrent.
|
||||
const MAX_TORRENT_FILE: usize = 10 << 20;
|
||||
|
||||
/// Fetches a .torrent file, up to `MAX_TORRENT_FILE`. Handed the URL, librqbit read the whole body
|
||||
/// into memory with no limit and no timeout, so a feed could serve one without end (#330).
|
||||
async fn torrent_file(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
|
||||
let mut resp = client.get(url).send().await.context("fetching the .torrent")?.error_for_status()?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(c) = resp.chunk().await.context("reading the .torrent")? {
|
||||
out.extend_from_slice(&c);
|
||||
if out.len() > MAX_TORRENT_FILE {
|
||||
bail!("the .torrent is larger than {} MB, too large to be one", MAX_TORRENT_FILE >> 20);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The folder an enclosure's torrent downloads into, one per enclosure inside the feed's folder.
|
||||
/// With `overwrite` on, a torrent written straight into the feed's folder could replace another
|
||||
/// enclosure's files of the same name, another feed's too when they share a folder (#331).
|
||||
pub fn own_folder(feed_dir: &Path, enclosure: i64) -> PathBuf {
|
||||
feed_dir.join(format!("torrent-{enclosure}"))
|
||||
}
|
||||
|
||||
/// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero.
|
||||
pub fn ratio(uploaded: u64, total: u64) -> f64 {
|
||||
if total == 0 {
|
||||
@@ -165,6 +192,32 @@ mod tests {
|
||||
assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_endless_torrent_file_stops_at_the_cap() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
let _ = sock.read(&mut [0u8; 2048]).await;
|
||||
// No length, no end: a body that goes on until the reader stops.
|
||||
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n").await;
|
||||
let chunk = vec![b'd'; 64 << 10];
|
||||
while sock.write_all(&chunk).await.is_ok() {}
|
||||
});
|
||||
let err = torrent_file(&reqwest::Client::new(), &format!("http://{addr}/ep.torrent")).await.unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_folder_is_one_folder_inside_the_feeds() {
|
||||
let feed = Path::new("/downloads/Show");
|
||||
let dir = own_folder(feed, 42);
|
||||
assert_eq!(dir.parent(), Some(feed));
|
||||
assert!(matches!(dir.components().next_back(), Some(std::path::Component::Normal(_))));
|
||||
assert_ne!(own_folder(feed, 43), dir, "two enclosures never share a folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_limit_is_never_zero() {
|
||||
let mut cfg = Config::default();
|
||||
2871
crates/ipx/src/web.rs
Normal file
2871
crates/ipx/src/web.rs
Normal file
File diff suppressed because it is too large
Load Diff
34
deny.toml
Normal file
34
deny.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
# cargo deny check: known vulnerabilities, licences and where crates come from. Run before a push
|
||||
# (CLAUDE.md, Tests). It reads Cargo.lock and the RustSec database, and builds nothing.
|
||||
|
||||
[graph]
|
||||
# Production runs on Linux; Windows and macOS-only crates are not ipx's concern.
|
||||
targets = ["x86_64-unknown-linux-gnu"]
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# ringbuf 0.4's double free needs an element whose Drop panics; librqbit-utp's buffer holds
|
||||
# only u8, which has no Drop. Its fix is ringbuf 0.5, which librqbit 9.0.1 cannot take (#281).
|
||||
{ id = "RUSTSEC-2026-0293", reason = "librqbit-utp's ring buffer holds u8: no Drop to panic" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
# Permissive licences only; anything else is a question for the owner before it comes in.
|
||||
allow = [
|
||||
"MIT", "MIT-0", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause",
|
||||
"ISC", "Zlib", "Unlicense", "0BSD", "CC0-1.0", "BSL-1.0", "Unicode-3.0", "MPL-2.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
# ipx's own crates have no licence field and are not published.
|
||||
private = { ignore = true }
|
||||
|
||||
[bans]
|
||||
# Two versions of one crate (syn 1 and 2, and 25 more) only cost build time, and a warning for
|
||||
# each on every run would be read by nobody.
|
||||
multiple-versions = "allow"
|
||||
wildcards = "deny"
|
||||
allow-wildcard-paths = true
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
@@ -1,24 +1,44 @@
|
||||
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both
|
||||
# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then
|
||||
# `docker compose up -d`, and sign in at http://<host>:8099 as admin, with the password the first
|
||||
# start writes to the config volume: `docker exec ipx cat /config/admin-password`.
|
||||
# docs/configuration.md has the rest, Postgres included.
|
||||
services:
|
||||
ipodderx:
|
||||
image: 192.168.1.130:5000/ipodderx:latest
|
||||
container_name: iPodderX
|
||||
ipx:
|
||||
image: ipx
|
||||
container_name: ipx
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Who owns the files it writes. 99:100 is Unraid's nobody:users.
|
||||
PUID: "99"
|
||||
PGID: "100"
|
||||
TZ: "America/Toronto"
|
||||
IPX_LOG: "ipx=info"
|
||||
TZ: "UTC"
|
||||
# For Postgres instead of SQLite:
|
||||
# IPX_DATABASE_URL: "postgres://ipx:password@db-host:5432/ipx"
|
||||
# No web port of its own: ipx-web is the way in, and passes the API on to it.
|
||||
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
|
||||
- ./config:/config # config.toml, written on first start
|
||||
- ./data:/data # state.db
|
||||
- ./downloads:/downloads
|
||||
|
||||
ipx-web:
|
||||
image: ipx-web
|
||||
container_name: ipx-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
IPX_UPSTREAM: "ipx:8099"
|
||||
# Behind Cloudflare Access only: where cloudflared's requests come from, the one address
|
||||
# whose Cf-Access-* headers are passed on. docs/sso.md says how to find it.
|
||||
# TUNNEL_PEER: "192.168.64.1"
|
||||
# Traces to an OpenTelemetry collector, a span for each request, continued into ipx:
|
||||
# WEB_TRACING: "on"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318"
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
|
||||
# OTEL_SERVICE_NAME: "ipx-web"
|
||||
ports:
|
||||
- "8099:8080" # the web UI
|
||||
depends_on:
|
||||
- ipx
|
||||
|
||||
228
docs/architecture.md
Normal file
228
docs/architecture.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# How it works
|
||||
|
||||
Three services, each a container of its own with its own version and changelog (#207):
|
||||
|
||||
| Service | What it is | Source | Image |
|
||||
|---|---|---|---|
|
||||
| ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` |
|
||||
| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` |
|
||||
| ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` |
|
||||
|
||||
The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds
|
||||
for both Rust services. Versions: ipx's in `crates/ipx/Cargo.toml`, the directory's in
|
||||
`crates/directory/Cargo.toml`, the page's in `package.json`; CLAUDE.md has the release steps.
|
||||
|
||||
## Traces and logs
|
||||
|
||||
A request is one trace (#213). The page starts one for each tap and sends `traceparent` with its
|
||||
requests (#190); Caddy's span continues it and passes it on to ipx; ipx's request span, its steps
|
||||
and its calls to the directory follow, and the directory's request span joins it. Scans,
|
||||
downloads, DeadAir's work, shares with the directory and the directory's refresher rounds are
|
||||
traces of their own. All three log one JSON object a line, for Loki; ipx's lines carry their trace
|
||||
id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web] metrics_bind`,
|
||||
#194). The exporter and the `traceparent` handling both Rust services use are in
|
||||
`crates/directory/src/otel.rs`.
|
||||
|
||||
## Modules
|
||||
|
||||
| File | Responsibility | What it replaced in the Python |
|
||||
|---|---|---|
|
||||
| `crates/ipx/src/main.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` |
|
||||
| `crates/ipx/src/cli.rs` | The commands, and what each does when no daemon takes it | `iPXAgent.py` |
|
||||
| `crates/ipx/src/ctx.rs` | `Ctx`, what every command shares: configuration, database, clients | — |
|
||||
| `crates/ipx/src/daemon.rs` | The daemon: worker loop, web server, shutdown, `status` | `iPXAgent.py` |
|
||||
| `crates/ipx/src/scan.rs` | When a feed is due, reading it, download policy, downloads and torrents | `iPXAgent.py` |
|
||||
| `crates/ipx/src/catalogue.rs` | Adding, removing and leaving feeds; subscriptions; retiring a list's feeds | — |
|
||||
| `crates/ipx/src/lists.rs` | OPML import, and keeping an OPML's or a Patreon creator's feeds in step | — |
|
||||
| `crates/ipx/src/logging.rs` | Logging setup, and JSON lines carrying their trace | — |
|
||||
| `crates/ipx/src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||
| `crates/ipx/src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||
| `crates/ipx/src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||
| `crates/feed/src/lib.rs` | ipx-feed's modules and what it exports | — |
|
||||
| `crates/feed/src/fetch.rs` | Conditional GET, redirects followed one by one, bounded bodies | `FeedData.__getFeed` |
|
||||
| `crates/feed/src/parse.rs` | RSS and Atom into `ParsedFeed`: titles, notes, enclosures, artwork, dates | `FeedData.__getEntries` |
|
||||
| `crates/feed/src/outlines.rs` | OPML: recognising, reading safely, listing its feeds | — |
|
||||
| `crates/feed/src/patreon.rs` | A Patreon creator's shows | — |
|
||||
| `crates/feed/src/discover.rs` | Finding a feed from an address, a site or a page | — |
|
||||
| `crates/feed/src/wordpress.rs` | WordPress.com's API for a site behind a browser check | — |
|
||||
| `crates/feed/src/failure.rs` | A failed read in words, and its kind for the log | — |
|
||||
| `crates/feed/src/privacy.rs` | `redact`, and `url_looks_private` | — |
|
||||
| `crates/feed/src/artwork.rs` | Artwork on https, and site icons | — |
|
||||
| `crates/feed/src/category.rs` | Apple's categories | — |
|
||||
| `crates/feed/src/jev.rs` | Jev's pick of a category | — |
|
||||
| `crates/feed/src/guard.rs` | The connection guard against internal addresses (#320) | — |
|
||||
| `crates/ipx/src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||
| `crates/ipx/src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||
| `crates/ipx/src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
|
||||
| `crates/ipx/src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
|
||||
| `crates/ipx/src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
||||
| `crates/ipx/src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
||||
| `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||
| `crates/ipx/src/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
|
||||
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
|
||||
| `crates/ipx/src/signals.rs` | Signals: asks Jev, on each person's TypeSafe key, which of their items fit their Signals | — |
|
||||
| `crates/directory/src/lib.rs` | The directory's modules and what it exports | — |
|
||||
| `crates/directory/src/api.rs` | The directory's HTTP API and its token check | — |
|
||||
| `crates/directory/src/directory.rs` | `Directory`: its store and tokens, the refresher, the config reloader | — |
|
||||
| `crates/directory/src/wire.rs` | What servers and the directory send each other | — |
|
||||
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
|
||||
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
|
||||
| `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — |
|
||||
| `web/index.html` | The app's markup | — |
|
||||
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||
| `web/app.css` | The stylesheet both pages share | — |
|
||||
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
||||
| `web/build.mjs` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — |
|
||||
| `web/boot.js` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
|
||||
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
|
||||
|
||||
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
||||
ipx-web rebuilt**, not ipx, and building it needs node and `npm ci` run once. ipx serves the API
|
||||
and the files, and no page.
|
||||
|
||||
## A scan
|
||||
|
||||
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
|
||||
Without a schedule of its own, a feed whose body has been the same for a while waits longer:
|
||||
a twelfth of how long it has been the same, up to four hours (#242).
|
||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there, and so does a
|
||||
body identical to the last one (`body_hash`), from a server that ignores the validators.
|
||||
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: the new ones, and those the feed now gives a different title, text, artwork,
|
||||
length or number, compared with what is stored for the items it lists (#141). A corrected item
|
||||
keeps its read state.
|
||||
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, category, description, etag, last_modified,
|
||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed,
|
||||
body_hash, same_since
|
||||
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)
|
||||
signals id, user_id, name, keywords
|
||||
signal_verdicts signal_id, feed_id, guid, hit PK (signal_id, feed_id, guid)
|
||||
```
|
||||
|
||||
A Signal (#254, [signals.md](signals.md)) is a feed someone makes from their own subscriptions. `signals::run`, a task of its
|
||||
own beside DeadAir's, woken by a scan with something new or a Signal made or changed, asks Jev
|
||||
about each item of the last 30 days that has no verdict yet for one of its owner's Signals: one
|
||||
call an item, a noul question a Signal, on the owner's `users.typesafe_key`. Every answer is kept,
|
||||
fitting or not, so nothing is asked twice; new keywords drop the Signal's verdicts. A Signal's
|
||||
items are `entries_where` with an `EXISTS` on its hits, so read state is the item's own.
|
||||
|
||||
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 they were dropped in 0.5.
|
||||
|
||||
Schema changes: the tables are the entities in `crates/ipx/src/entity.rs`, and `Db::open` creates whatever
|
||||
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
||||
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
||||
`create_missing`, looked for first and run only when it is missing, as `feeds.description`'s is,
|
||||
or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
||||
database the same way, so the tests run on the schema production gets. A database from before
|
||||
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
||||
|
||||
## 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`, `reload` (read the catalogue and settings again from the database, which
|
||||
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with
|
||||
`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` and `reload`: the
|
||||
socket answers them 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`. A feed's items and files (its entries, `download-latest`, `/api/enclosures/{id}` and
|
||||
`/media/{id}`) answer only someone who subscribes to the feed; anyone else gets a `404`.
|
||||
|
||||
| Route | |
|
||||
|---|---|
|
||||
| `GET /api/page-check`, `GET /api/login-check`, `GET /api/signin` | for Caddy (#203): whether the visitor may have the page in `X-Forwarded-Uri` (`204`), or where to send them; signing in with `?token=` |
|
||||
| `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in; `/api/me` also gives the version and the account's theme from before |
|
||||
| `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/directory/{id}` | a feed's page from the directory service (#205), by the directory's id: its description and latest twenty items, sanitized here, and why its last check failed, in words; never a file or its address |
|
||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the directory service's ten most subscribed and every feed it lists, A to Z, asked by ipx with its token (directory id, title, art, subscribers across every sharing server, whether it is yours and its id here, category, whether it carries audio or video; never a URL); subscribe by the directory's id, which takes the feed's address from the directory. `503` with no directory set up |
|
||||
| `DELETE /api/directory/{id}`, `POST /api/directory/{id}/refresh` | admin-only, and the directory refuses unless this server's token is `rwa` (#206): remove and block a feed; check it now. `GET /api/directory/{id}` says `can_admin` when both hold |
|
||||
| `GET /api/directory-admin`, `DELETE /api/directory-blocked/{id}`, `DELETE /api/directory-instances/{name}` | admin-only, `rwa` (#206): the servers sharing (name, feeds, subscribers) and the blocked feeds (id, title, when; never the address, often why it was blocked); unblock; drop a server's snapshot. Unblocking or dropping sends this server's snapshot again at once |
|
||||
| `GET /api/directory-status` | admin-only: the name and access the directory gives this server's token, or why it refused, and how the last snapshot went |
|
||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write. The directory's token is written, never read back: `directory_token_set` says whether there is one |
|
||||
| `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
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
node tests/load/run.js # k6: many people at once against a daemon with 1,500 generated 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.
|
||||
98
docs/cli.md
Normal file
98
docs/cli.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 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] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category |
|
||||
| `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 the HTTP API (the page is ipx-web's) |
|
||||
|
||||
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they
|
||||
then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
|
||||
its next change, undoing them. If it does not answer they say so: restart it.
|
||||
|
||||
## 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; signs the account out everywhere
|
||||
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** on the next daemon start, with a random password in `admin-password` beside
|
||||
config.toml (or `IPX_ADMIN_PASSWORD`), 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 + HTTP API
|
||||
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. On a machine
|
||||
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
|
||||
container's processes: stop the one you started by its PID instead (`kill <pid>`).
|
||||
|
||||
## 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.
|
||||
212
docs/configuration.md
Normal file
212
docs/configuration.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Configuration
|
||||
|
||||
Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
|
||||
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
|
||||
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
|
||||
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
|
||||
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
|
||||
take effect without a restart.
|
||||
|
||||
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
||||
settings from config.toml, then rewrites config.toml without them, keeping the original beside it
|
||||
as `config.toml.pre-database`. After that, feeds or those settings written into config.toml are
|
||||
ignored, with a warning in the log saying so. The sections below describe them as they were
|
||||
written in config.toml, which is still how a fresh install begins.
|
||||
|
||||
config.toml's default location is `$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 unless `IPX_DATABASE_URL` names a
|
||||
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
||||
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
|
||||
|
||||
ipx does not wait for the disk to confirm each write: SQLite syncs at checkpoints
|
||||
(`synchronous=NORMAL`), and ipx's own Postgres sessions set `synchronous_commit=off`, which leaves
|
||||
other databases on the server as they are. Waiting made a scan take most of a second a feed. A
|
||||
crash of ipx loses nothing; a power cut, or a crash of the Postgres server, can lose the last moment
|
||||
of changes, a read mark or a saved position, and never corrupts the database.
|
||||
|
||||
## `[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"]
|
||||
art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||
fetch_internal = [] # internal addresses fetches may reach, e.g. ["192.168.1.0/24"]
|
||||
```
|
||||
|
||||
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
|
||||
database as described above; `download_dir`, `socket`, `organize` and `fetch_internal` stay in config.toml.
|
||||
|
||||
* **`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. Without a schedule of its own,
|
||||
a feed that sends the same body check after check (Patreon, Substack, which ignore "not
|
||||
modified") is checked less often the longer it stays the same, up to every four hours, and is
|
||||
back on schedule once it changes. A feed that keeps failing
|
||||
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
||||
after its first success. A moment's failure to look its host up does not count: the network, not
|
||||
the feed, failed, and it is tried again in ten minutes. 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.
|
||||
* **`art_cache_mb`** — how much show and episode artwork iPX keeps on disk, in `art/` beside the
|
||||
database, so the page loads it from iPX rather than from every publisher's server. Over this,
|
||||
what has gone longest unshown goes first. `0` keeps none: artwork still comes through iPX, fetched
|
||||
each time. 500 by default.
|
||||
* **`max_new_per_check`** — how many of a feed's newest episodes are downloaded; older ones stay
|
||||
listed to download by hand. It stops a new subscription pulling a whole back catalogue.
|
||||
`0` means every episode, for an archive; set it on the feeds you want archived, since on the
|
||||
global default it applies to every feed: subscribing to an OPML of 80 feeds with no limit
|
||||
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.
|
||||
* **`fetch_internal`** — internal addresses and networks that feeds, episodes, artwork and
|
||||
`.torrent` files may still be fetched from, as `["192.168.1.20", "10.0.0.0/8"]`. Anything off
|
||||
the public internet (loopback, the LAN, Docker's networks, link-local) is refused otherwise,
|
||||
redirects included, because a feed's publisher or anyone signed in chooses what is fetched. List
|
||||
addresses, not names: a feed on `nas.lan` needs `nas.lan`'s address here. The directory is not
|
||||
affected. Empty by default; read at start.
|
||||
|
||||
## `[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"]
|
||||
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||
access_aud = "" # the Access application's AUD tag
|
||||
auto_create_users = true
|
||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||
session_days = 30
|
||||
metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no metrics
|
||||
```
|
||||
|
||||
* **`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.
|
||||
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||
* **`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.
|
||||
* **`metrics_bind`** — where to serve `/metrics` for Prometheus: database connections in use, the
|
||||
worker's queue, the runtime's tasks, memory and open files. It has a thread and a port of its own
|
||||
and no sign-in, so publish it to the LAN only. Empty, the default, serves none.
|
||||
|
||||
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>]`
|
||||
|
||||
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. 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
|
||||
category = "Technology" # the Directory's, if the feed names none
|
||||
listed = true # shared with the directory with no subscribers
|
||||
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).
|
||||
|
||||
`username` and its password go to the feed's own host alone: not to episodes on another host, nor to
|
||||
the feeds an OPML lists on other hosts, which would hand the password to whoever runs them.
|
||||
|
||||
**The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
|
||||
up in Admin, Directory: its address, this server's token and whether to share. They are server
|
||||
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
|
||||
With sharing on, this server sends it every feed in the catalogue that is not private, with how
|
||||
many people here subscribe, and the `category` above for a feed that names none. To offer one with
|
||||
nobody subscribed, `ipx add --list --category News <url>`: it subscribes no one, and a listed feed
|
||||
stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another
|
||||
is dropped. A feed nobody
|
||||
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
|
||||
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
|
||||
|
||||
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. So
|
||||
does Keep, the plus on a show's page, for whoever turns it on (`kept` on their subscription, #280): when
|
||||
the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue
|
||||
entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who
|
||||
had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `IPX_CONFIG` | Config file path |
|
||||
| `IPX_ADMIN_PASSWORD` | The first admin's password, for a database with no accounts; random, in `admin-password` beside config.toml, without it |
|
||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
||||
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
||||
| `IPX_LOG_FORMAT` | `json` for one JSON object a line, with each request's and event's fields as its own (for Loki and the like); text otherwise |
|
||||
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
|
||||
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||
|
||||
## ipx-web
|
||||
|
||||
The page's container reads only its environment (`web/Caddyfile`):
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `IPX_UPSTREAM` | Where ipx is, `host:port`; `ipx:8099` by default |
|
||||
| `WEB_BIND` | Where to listen; `:8080` by default |
|
||||
| `WEB_ROOT` | The built page; `/srv` in the image |
|
||||
| `TUNNEL_PEER` | The one address whose `Cf-Access-*` headers are passed on to ipx: where cloudflared's requests come from. Unset passes none. See [sso.md](sso.md) |
|
||||
| `TRUSTED_HEADER` | ipx's `[web] trusted_header`, when it is not Cloudflare's: it is passed on from `TUNNEL_PEER` alone too, and dropped from anyone else. Set it to the same name, or anyone could sign in as anyone |
|
||||
| `WEB_TRACING` | `on` sends a span for each request to the collector the `OTEL_*` variables name (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, `OTEL_SERVICE_NAME`), continuing the page's trace into ipx. Off by default |
|
||||
|
||||
ipx-directory's settings are in [directory.md](directory.md).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user