Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027917c8cf | ||
|
|
26beb2444b | ||
|
|
a39f728b41 | ||
|
|
cd426de1e8 | ||
|
|
1c9d415a1e | ||
|
|
60c4e855a5 | ||
|
|
03aa977e58 | ||
|
|
fd9da12d93 | ||
|
|
4baf81e2bf |
+45
-13
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "meta-feeder-sdk"
|
name = "meta-feeder-sdk"
|
||||||
version = "0.1.0"
|
version = "1.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -31,33 +31,65 @@ publish = false
|
|||||||
# and the libp2p wire. Keep heavy deps (librqbit, beetswap, cid, libp2p) OUT
|
# and the libp2p wire. Keep heavy deps (librqbit, beetswap, cid, libp2p) OUT
|
||||||
# of here so feeders that don't need them don't pay for them.
|
# of here so feeders that don't need them don't pay for them.
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["feeder"]
|
||||||
|
# Everything a feeder sidecar (and the transport contract) builds on: the
|
||||||
|
# FeederPlugin trait, hashing, the redb cache, the axum harness. On by default
|
||||||
|
# so existing consumers build exactly what they always did.
|
||||||
|
feeder = [
|
||||||
|
"beacon",
|
||||||
|
"dep:axum", "dep:hyper", "dep:reqwest", "dep:base64", "dep:futures",
|
||||||
|
"dep:async-trait", "dep:anyhow", "dep:thiserror", "dep:sha2", "dep:bytes",
|
||||||
|
"dep:regex", "dep:redb",
|
||||||
|
]
|
||||||
|
# Beacon v2 local discovery (`src/beacon/`). Needs only tokio/serde/tracing/libc,
|
||||||
|
# so a meta-* service takes the SDK with `default-features = false,
|
||||||
|
# features = ["beacon"]` and pulls in none of the feeder machinery.
|
||||||
|
beacon = ["dep:libc"]
|
||||||
|
# The meta-share transport-plugin contract (`src/transport/`): the hull's
|
||||||
|
# client, the plugins' axum harness, the focus replica, shared error/range
|
||||||
|
# types. Off by default so feeders build exactly what they always did.
|
||||||
|
transport = ["feeder", "dep:cid", "dep:tokio-util"]
|
||||||
|
# Conformance checks any transport implementation must pass.
|
||||||
|
testkit = ["transport"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Async runtime + HTTP server (the serve_feeders harness)
|
# Async runtime + HTTP server (the serve_feeders harness)
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
axum = { version = "0.7", features = ["json"] }
|
axum = { version = "0.7", features = ["json"], optional = true }
|
||||||
hyper = { version = "1", features = ["full"] }
|
hyper = { version = "1", features = ["full"], optional = true }
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false, optional = true }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = { version = "0.22", optional = true }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
futures = "0.3"
|
futures = { version = "0.3", optional = true }
|
||||||
async-trait = "0.1"
|
async-trait = { version = "0.1", optional = true }
|
||||||
anyhow = "1"
|
anyhow = { version = "1", optional = true }
|
||||||
thiserror = "1"
|
thiserror = { version = "1", optional = true }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = { version = "0.10", optional = true }
|
||||||
bytes = "1"
|
bytes = { version = "1", optional = true }
|
||||||
# Title-tag parsing (shared filename_meta — season/episode/quality/codec/languages).
|
# Title-tag parsing (shared filename_meta — season/episode/quality/codec/languages).
|
||||||
regex = "1"
|
regex = { version = "1", optional = true }
|
||||||
|
|
||||||
# Per-plugin midhash/blob cache. Schema is identical to meta-share v1 — see
|
# Per-plugin midhash/blob cache. Schema is identical to meta-share v1 — see
|
||||||
# cache.rs. Pure-Rust single-process embedded store.
|
# cache.rs. Pure-Rust single-process embedded store.
|
||||||
redb = "2"
|
redb = { version = "2", optional = true }
|
||||||
|
|
||||||
|
# CID parsing for the transport contract only (`transport::cid`). Optional so
|
||||||
|
# feeders don't pull it.
|
||||||
|
cid = { version = "0.11", optional = true }
|
||||||
|
# Raw sockets for `beacon` (per-interface multicast) and statvfs(2) for
|
||||||
|
# `transport::fsutil::available_bytes`.
|
||||||
|
libc = { version = "0.2", optional = true }
|
||||||
|
# `ReaderStream` for `transport::file`.
|
||||||
|
tokio-util = { version = "0.7", features = ["io"], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
tokio = { version = "1.40", features = ["full", "test-util"] }
|
tokio = { version = "1.40", features = ["full", "test-util"] }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ bytes; it never joins the swarm.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v0.1.0" }
|
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.3.0" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Not on crates.io yet — the contract is still moving, so consumers pin a tag.
|
Not on crates.io yet — the contract is still moving, so consumers pin a tag.
|
||||||
@@ -35,6 +35,42 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
A **service is a `Vec`** — grouping plugins into one binary is a deployment
|
A **service is a `Vec`** — grouping plugins into one binary is a deployment
|
||||||
choice, not an architectural one.
|
choice, not an architectural one.
|
||||||
|
|
||||||
|
### Discovery (1.6.0, scan 1.7.0)
|
||||||
|
|
||||||
|
`serve_feeders` and `serve_transport` advertise the process over **beacon v2**
|
||||||
|
(UDP multicast `239.255.99.1:9099`, spec: meta-root
|
||||||
|
`docs/project-architecture/beacon-v2.md`) as soon as the port is bound — a
|
||||||
|
feeder as `metamesh.feeder/<upstream_id>` (one cap per hosted plugin), a
|
||||||
|
transport as `metamesh.transport/<id>@<contract>` — with `endpoints.http`,
|
||||||
|
`endpoints.manifest = /manifest` and `rev` = a hash of the served manifest
|
||||||
|
(re-advertised when it changes). Nothing to call. Env:
|
||||||
|
|
||||||
|
| Env | Default | |
|
||||||
|
|---|---|---|
|
||||||
|
| `ENABLE_UDP_DISCOVERY` | `true` | `false` turns advertising off |
|
||||||
|
| `BEACON_ADVERTISE_URL` | `http://$HOSTNAME:<port>` | `endpoints.http` |
|
||||||
|
| `BEACON_BINDS` | unset | the one consumer instance allowed to list this plugin |
|
||||||
|
| `BEACON_GROUP` / `BEACON_PORT` / `BEACON_INTERVAL_MS` | `239.255.99.1` / `9099` / `10000` | |
|
||||||
|
|
||||||
|
`beacon::scan(node, pattern, configured, wait)` (1.7.0) is what a consumer's
|
||||||
|
**Scan** button runs: probe everyone, wait, then classify every resource
|
||||||
|
matching `pattern` as `configured` / `addable` / `bound-elsewhere` against the
|
||||||
|
consumer's `(name, url)` list — the `ScanReport` JSON the shared
|
||||||
|
`<meta-beacon-scan>` element renders. Transports advertise as
|
||||||
|
`meta-transport-<id>` (`SERVICE_NAME` overrides), feeders as
|
||||||
|
`meta-feeder-<first upstream>`.
|
||||||
|
|
||||||
|
The `beacon` module is also usable on its own — the meta-* services depend on
|
||||||
|
the SDK for nothing else:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.7.0", default-features = false, features = ["beacon"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Features: `feeder` (default — the whole feeder harness; implies `beacon`),
|
||||||
|
`beacon` (tokio/serde/tracing/libc only), `transport` (implies `feeder`),
|
||||||
|
`testkit`.
|
||||||
|
|
||||||
## Writing a plugin
|
## Writing a plugin
|
||||||
|
|
||||||
Four required methods; everything else has a default:
|
Four required methods; everything else has a default:
|
||||||
@@ -47,8 +83,32 @@ Four required methods; everything else has a default:
|
|||||||
| `compute_outcomes` | resolve a `record_id` to `HashOutcome`s | `bytes: None` for metadata-only sources |
|
| `compute_outcomes` | resolve a `record_id` to `HashOutcome`s | `bytes: None` for metadata-only sources |
|
||||||
|
|
||||||
Defaults you can override: `handle_query_stream`, `handle_fetch`, `health`,
|
Defaults you can override: `handle_query_stream`, `handle_fetch`, `health`,
|
||||||
`served_file_types`, `served_content_kinds`, `config_schema`, `config_values`,
|
`served_file_types`, `served_content_kinds`, `package`, `redeems`,
|
||||||
`get_blob`.
|
`config_schema`, `config_values`, `get_blob`.
|
||||||
|
|
||||||
|
### Redeeming locators (1.3.0)
|
||||||
|
|
||||||
|
A plugin can **redeem** a locator family — turn a locator cid into the bytes it
|
||||||
|
names, usually spending a metered quota (a Newznab `.nzb` grab, an OpenSubtitles
|
||||||
|
download):
|
||||||
|
|
||||||
|
1. Return a `RedeemClaim` from `redeems()` — `RedeemClaim::nzb_release(hosts)` or
|
||||||
|
`RedeemClaim::provider_file(sources)` — reflecting the *current* config (no
|
||||||
|
credential → no claim). Served per plugin in `GET /manifest` and, for polling,
|
||||||
|
at `GET /redeems` → `{"redeems": {"<upstream_id>": [claim…]}}`.
|
||||||
|
2. Name a stable `package()` — the `/files/plugin/<package>/` folder the gateway
|
||||||
|
stores the bytes under.
|
||||||
|
3. In `compute_outcomes`, accept the locator cid as `record_id` and return ONE
|
||||||
|
`Sha2_256` outcome: `hash = hash::compute_ipfs_cid(&bytes)`, `bytes`,
|
||||||
|
`file_extension`, and optionally a `record` whose fields the gateway merges
|
||||||
|
onto the locator record. Return `NotFound` when the cid is not yours (e.g. no
|
||||||
|
key for that host) so the gateway can try the next claimer; `RateLimited` when
|
||||||
|
the quota is spent.
|
||||||
|
|
||||||
|
The gateway calls this only on a real play, one claimer at a time, and writes
|
||||||
|
the bytes' cid onto the locator record under the claim's `field` (`manifest` /
|
||||||
|
`file`), so each file's quota is spent once. Decode helpers:
|
||||||
|
`hash::decode_nzb_release_cid`, `hash::decode_provider_file_cid`, `hash::codec_of`.
|
||||||
|
|
||||||
### The routing gate
|
### The routing gate
|
||||||
|
|
||||||
@@ -101,6 +161,9 @@ into the bitswap blockstore.
|
|||||||
The last two carry an identity multihash. Anything that dispatches on the
|
The last two carry an identity multihash. Anything that dispatches on the
|
||||||
multihash instead of the **codec** will silently mis-rank them.
|
multihash instead of the **codec** will silently mis-rank them.
|
||||||
|
|
||||||
|
`hash` also encodes/decodes the redeemable locators: `nzb-release` (`0x1005`)
|
||||||
|
and `provider-file` (`0x100A`, `(source, id)` — `docs/cid-formats.md` §8).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| module | what |
|
| module | what |
|
||||||
@@ -114,6 +177,7 @@ multihash instead of the **codec** will silently mis-rank them.
|
|||||||
| `hash` | the content-addressing families above |
|
| `hash` | the content-addressing families above |
|
||||||
| `filename_meta` / `lang` | release-tag parsing helpers |
|
| `filename_meta` / `lang` | release-tag parsing helpers |
|
||||||
| `enrich`, `meta_core`, `common` | MetaMesh-internal; not part of the plugin contract |
|
| `enrich`, `meta_core`, `common` | MetaMesh-internal; not part of the plugin contract |
|
||||||
|
| `beacon` | beacon v2 local discovery: `BeaconNode`, `Resource`, `cap_matches`, the MetaMesh `caps` vocabulary |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -121,13 +185,19 @@ multihash instead of the **codec** will silently mis-rank them.
|
|||||||
cargo build && cargo test
|
cargo build && cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
To work on the SDK from inside a feeder checkout without pushing a tag, add a
|
To test a feeder against an SDK change **before its tag is pushed**: cargo will
|
||||||
gitignored `.cargo/config.toml` in the feeder:
|
not resolve an unpushed tag, not even under a `[patch]` override ("failed to
|
||||||
|
find tag"). Build a scratch copy of the feeder (outside its git tree) with the
|
||||||
|
dependency swapped for a path, and run `cargo test` there:
|
||||||
|
|
||||||
```toml
|
```bash
|
||||||
paths = ["../meta-feeder-sdk"]
|
mkdir -p /tmp/feeder && (cd meta-feeder-x && tar cf - --exclude=./target --exclude=./.git .) | (cd /tmp/feeder && tar xf -)
|
||||||
|
sed -i 's#meta-feeder-sdk = { git = .*#meta-feeder-sdk = { path = "/abs/path/to/meta-feeder-sdk" }#' /tmp/feeder/feeder-plugin/*/Cargo.toml
|
||||||
|
(cd /tmp/feeder && cargo test)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Never commit the path form: each feeder repo is its own Docker build context.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Design rationale, the feeder roster, and the naming rules live in the meta-root:
|
Design rationale, the feeder roster, and the naming rules live in the meta-root:
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! Beacon v2 — local resource advertise / discover over UDP multicast
|
||||||
|
//! (`239.255.99.1:9099`).
|
||||||
|
//!
|
||||||
|
//! Every meta-* service and every SDK-hosted plugin runs one [`BeaconNode`]: it
|
||||||
|
//! advertises this process's [`Resource`]s, each tagged with capability strings
|
||||||
|
//! (`metamesh.service/meta-sort`, `metamesh.transport/nzb@1`, …), and keeps a
|
||||||
|
//! live view of everyone else's, which consumers filter by capability
|
||||||
|
//! ([`cap_matches`]).
|
||||||
|
//!
|
||||||
|
//! The normative spec is meta-root's `docs/project-architecture/beacon-v2.md`
|
||||||
|
//! (the Go / TypeScript / Python ports are written against it too). This module
|
||||||
|
//! needs only the `beacon` feature, so a service can depend on the SDK with
|
||||||
|
//! `default-features = false, features = ["beacon"]` and pull in none of the
|
||||||
|
//! feeder machinery.
|
||||||
|
|
||||||
|
mod node;
|
||||||
|
mod proto;
|
||||||
|
mod scan;
|
||||||
|
|
||||||
|
pub use node::{enabled_from_env, hostname, BeaconNode, Config, SeenNode, SeenResource};
|
||||||
|
pub use scan::{classify, scan, suggested_name, Candidate, CandidateState, ScanReport, ScanSummary};
|
||||||
|
pub use proto::{
|
||||||
|
cap_matches, rev_of, Message, NodeInfo, Resource, DEFAULT_GROUP, DEFAULT_INTERVAL, DEFAULT_PORT,
|
||||||
|
LIVENESS_FACTOR, MAX_DATAGRAM, PROTO, VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Capability vocabulary of the MetaMesh profile (spec Part 2).
|
||||||
|
pub mod caps {
|
||||||
|
/// meta-core; its resource carries `data.urls`.
|
||||||
|
pub const CORE: &str = "metamesh.core";
|
||||||
|
/// Prefix of `metamesh.service/<name>`.
|
||||||
|
pub const SERVICE_PREFIX: &str = "metamesh.service/";
|
||||||
|
/// Every service.
|
||||||
|
pub const ANY_SERVICE: &str = "metamesh.service/*";
|
||||||
|
/// Prefix of `metamesh.transport/<id>@<contract>`.
|
||||||
|
pub const TRANSPORT_PREFIX: &str = "metamesh.transport/";
|
||||||
|
/// Every transport plugin.
|
||||||
|
pub const ANY_TRANSPORT: &str = "metamesh.transport/*";
|
||||||
|
/// Prefix of `metamesh.feeder/<upstream_id>`.
|
||||||
|
pub const FEEDER_PREFIX: &str = "metamesh.feeder/";
|
||||||
|
/// Every feeder upstream.
|
||||||
|
pub const ANY_FEEDER: &str = "metamesh.feeder/*";
|
||||||
|
/// Prefix of `metamesh.enrich/<plugin_id>` (meta-sort enrichment plugins).
|
||||||
|
pub const ENRICH_PREFIX: &str = "metamesh.enrich/";
|
||||||
|
/// Every meta-sort enrichment plugin.
|
||||||
|
pub const ANY_ENRICH: &str = "metamesh.enrich/*";
|
||||||
|
|
||||||
|
pub fn service(name: &str) -> String {
|
||||||
|
format!("{SERVICE_PREFIX}{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transport(id: &str, contract: u32) -> String {
|
||||||
|
format!("{TRANSPORT_PREFIX}{id}@{contract}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn feeder(upstream_id: &str) -> String {
|
||||||
|
format!("{FEEDER_PREFIX}{upstream_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enrich(plugin_id: &str) -> String {
|
||||||
|
format!("{ENRICH_PREFIX}{plugin_id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resource every meta-* service advertises: id `service`, cap
|
||||||
|
/// `metamesh.service/<name>`, `endpoints.ui` = its browser-facing URL.
|
||||||
|
pub fn service_resource(name: &str, ui_url: Option<&str>) -> Resource {
|
||||||
|
let mut r = Resource::new("service", [caps::service(name)]);
|
||||||
|
if let Some(u) = ui_url {
|
||||||
|
r = r.with_endpoint("ui", u);
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BEACON_ADVERTISE_URL`, else `http://$HOSTNAME:<port>` — what a plugin
|
||||||
|
/// advertises as `endpoints.http`.
|
||||||
|
pub fn advertise_url(listen_port: u16) -> String {
|
||||||
|
std::env::var("BEACON_ADVERTISE_URL")
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().trim_end_matches('/').to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| format!("http://{}:{}", hostname(), listen_port))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BEACON_BINDS`, when set.
|
||||||
|
pub fn binds_from_env() -> Option<String> {
|
||||||
|
std::env::var("BEACON_BINDS").ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advertise a plugin process: spawn a [`BeaconNode`] named `name` whose single
|
||||||
|
/// resource is whatever `build` returns, and re-run `build` every interval so a
|
||||||
|
/// changed manifest (`rev`) is re-advertised at once. Used by both SDK
|
||||||
|
/// harnesses; returns `None` (and logs) when discovery is disabled or the
|
||||||
|
/// socket can't be set up — a plugin keeps serving either way.
|
||||||
|
pub fn advertise_plugin<F>(name: &str, version: &str, build: F) -> Option<BeaconNode>
|
||||||
|
where
|
||||||
|
F: Fn() -> Resource + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
if !enabled_from_env() {
|
||||||
|
tracing::info!("beacon: disabled (ENABLE_UDP_DISCOVERY)");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cfg = Config::from_env(name).version(version).resource(build());
|
||||||
|
let interval = cfg.interval;
|
||||||
|
let node = match BeaconNode::spawn(cfg) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "beacon: could not start; plugin will not be discoverable");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let refresher = node.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(interval);
|
||||||
|
tick.tick().await;
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
refresher.set_resources(vec![build()]).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `/api/neighbors` JSON every service serves (spec Part 2, "HTTP
|
||||||
|
/// surface"). Each row is a [`SeenNode`] plus `caps` and a v1-compatible
|
||||||
|
/// `baseUrl` (the `metamesh.service/*` resource's `endpoints.ui`).
|
||||||
|
///
|
||||||
|
/// `all` returns every instance instead of one row per name; `cap` keeps only
|
||||||
|
/// nodes owning a resource that matches it.
|
||||||
|
pub fn neighbors_json(
|
||||||
|
node: Option<&BeaconNode>,
|
||||||
|
current: &str,
|
||||||
|
all: bool,
|
||||||
|
cap: Option<&str>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let Some(node) = node else {
|
||||||
|
return serde_json::json!({
|
||||||
|
"current": current, "enabled": false, "count": 0, "neighbors": [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let rows: Vec<serde_json::Value> = if all { node.nodes() } else { node.nodes_by_name() }
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| cap.map_or(true, |c| n.matches(c)))
|
||||||
|
.map(|n| neighbor_row(&n))
|
||||||
|
.collect();
|
||||||
|
serde_json::json!({
|
||||||
|
"current": current,
|
||||||
|
"enabled": true,
|
||||||
|
"count": rows.len(),
|
||||||
|
"self": neighbor_row(&node.self_node()),
|
||||||
|
"neighbors": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neighbor_row(n: &SeenNode) -> serde_json::Value {
|
||||||
|
let mut v = serde_json::to_value(n).unwrap_or_default();
|
||||||
|
if let Some(obj) = v.as_object_mut() {
|
||||||
|
obj.insert("caps".into(), serde_json::json!(n.caps()));
|
||||||
|
let base = n.resource(caps::ANY_SERVICE).and_then(|r| r.endpoint("ui"));
|
||||||
|
if let Some(b) = base {
|
||||||
|
obj.insert("baseUrl".into(), serde_json::Value::String(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
//! `BeaconNode` — advertise this process's resources and keep a live view of
|
||||||
|
//! everyone else's.
|
||||||
|
//!
|
||||||
|
//! Ported from meta-discovery v1's `meshdisco.rs`, keeping the two details that
|
||||||
|
//! fail *silently* when wrong:
|
||||||
|
//!
|
||||||
|
//! 1. **Per-interface fan-out.** A socket that joins with `INADDR_ANY` and sends
|
||||||
|
//! via the default route covers exactly one interface. `metashare-app` sits
|
||||||
|
//! on `pcs` *and* `metamesh-mesh`; `metawatch-*` on `metawatch` *and*
|
||||||
|
//! `metamesh-mesh`. We enumerate interfaces and join/send on each, using
|
||||||
|
//! `ip_mreqn`'s `imr_ifindex`.
|
||||||
|
//! 2. **Expiry at read time.** No reaper owns correctness; the announce loop's
|
||||||
|
//! prune only exists to fire change notifications for nodes that went quiet.
|
||||||
|
//!
|
||||||
|
//! Raw `libc` rather than `socket2` / `if-addrs`: every consumer already links
|
||||||
|
//! libc, and this is the whole of what we need from either crate.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io;
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||||
|
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::proto::{
|
||||||
|
Message, NodeInfo, Resource, DEFAULT_GROUP, DEFAULT_INTERVAL, DEFAULT_PORT, LIVENESS_FACTOR,
|
||||||
|
MAX_DATAGRAM, READ_BUFFER, TYPE_ADVERTISE, TYPE_BYE, TYPE_PROBE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// How a node is set up. Build with [`Config::new`] / [`Config::from_env`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub node: NodeInfo,
|
||||||
|
pub resources: Vec<Resource>,
|
||||||
|
pub group: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub interval: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Defaults: this host's name as the instance, the standard group/port,
|
||||||
|
/// a 10 s interval, no resources.
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
node: NodeInfo {
|
||||||
|
name: name.into(),
|
||||||
|
instance: hostname(),
|
||||||
|
version: None,
|
||||||
|
status: None,
|
||||||
|
},
|
||||||
|
resources: Vec::new(),
|
||||||
|
group: DEFAULT_GROUP.into(),
|
||||||
|
port: DEFAULT_PORT,
|
||||||
|
interval: DEFAULT_INTERVAL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Config::new`] with `BEACON_GROUP` / `BEACON_PORT` / `BEACON_INTERVAL_MS`
|
||||||
|
/// applied.
|
||||||
|
pub fn from_env(name: impl Into<String>) -> Self {
|
||||||
|
let mut c = Self::new(name);
|
||||||
|
if let Some(g) = env_nonempty("BEACON_GROUP") {
|
||||||
|
c.group = g;
|
||||||
|
}
|
||||||
|
if let Some(p) = env_nonempty("BEACON_PORT").and_then(|v| v.parse().ok()) {
|
||||||
|
c.port = p;
|
||||||
|
}
|
||||||
|
if let Some(ms) = env_nonempty("BEACON_INTERVAL_MS").and_then(|v| v.parse::<u64>().ok()) {
|
||||||
|
if ms > 0 {
|
||||||
|
c.interval = Duration::from_millis(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn version(mut self, v: impl Into<String>) -> Self {
|
||||||
|
self.node.version = Some(v.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn instance(mut self, i: impl Into<String>) -> Self {
|
||||||
|
self.node.instance = i.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resource(mut self, r: Resource) -> Self {
|
||||||
|
self.resources.push(r);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node heard on the wire (or this node itself, via [`BeaconNode::self_node`]).
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct SeenNode {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub node: NodeInfo,
|
||||||
|
pub resources: Vec<Resource>,
|
||||||
|
/// Source address taken from the packet, never from the payload.
|
||||||
|
pub addr: String,
|
||||||
|
/// Unix seconds.
|
||||||
|
#[serde(rename = "lastSeen")]
|
||||||
|
pub last_seen: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SeenNode {
|
||||||
|
/// Every cap of every resource, in order, deduplicated.
|
||||||
|
pub fn caps(&self) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for c in self.resources.iter().flat_map(|r| r.caps.iter()) {
|
||||||
|
if !out.contains(c) {
|
||||||
|
out.push(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if any resource matches `pattern`.
|
||||||
|
pub fn matches(&self, pattern: &str) -> bool {
|
||||||
|
self.resources.iter().any(|r| r.matches(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first resource matching `pattern`.
|
||||||
|
pub fn resource(&self, pattern: &str) -> Option<&Resource> {
|
||||||
|
self.resources.iter().find(|r| r.matches(pattern))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resource together with the node advertising it.
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct SeenResource {
|
||||||
|
pub node: NodeInfo,
|
||||||
|
pub resource: Resource,
|
||||||
|
pub addr: String,
|
||||||
|
#[serde(rename = "lastSeen")]
|
||||||
|
pub last_seen: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
group: Ipv4Addr,
|
||||||
|
port: u16,
|
||||||
|
interval: Duration,
|
||||||
|
node: RwLock<NodeInfo>,
|
||||||
|
resources: RwLock<Vec<Resource>>,
|
||||||
|
socket: UdpSocket,
|
||||||
|
ifindexes: Vec<(String, u32)>,
|
||||||
|
seen: Mutex<HashMap<String, SeenNode>>,
|
||||||
|
changes: watch::Sender<u64>,
|
||||||
|
stopped: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running beacon v2 participant. Cheap to clone.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BeaconNode {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BeaconNode {
|
||||||
|
/// Bind, join the group on every eligible interface, and start the read and
|
||||||
|
/// advertise loops. Must be called inside a tokio runtime.
|
||||||
|
///
|
||||||
|
/// A join failure on one interface is logged and skipped rather than fatal —
|
||||||
|
/// a host that blocks multicast must degrade, not fail to boot.
|
||||||
|
pub fn spawn(cfg: Config) -> io::Result<BeaconNode> {
|
||||||
|
let group: Ipv4Addr = cfg.group.parse().map_err(|_| {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidInput, format!("bad group {}", cfg.group))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let std_sock = bind_reuse(cfg.port)?;
|
||||||
|
set_multicast_ttl(&std_sock, 1)?;
|
||||||
|
|
||||||
|
let ifindexes = eligible_interfaces();
|
||||||
|
let mut joined = 0usize;
|
||||||
|
for (name, idx) in &ifindexes {
|
||||||
|
match join_group(&std_sock, group, *idx) {
|
||||||
|
Ok(()) => joined += 1,
|
||||||
|
Err(e) => warn!(iface = %name, error = %e, "beacon: join failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
group = %cfg.group, port = cfg.port, name = %cfg.node.name,
|
||||||
|
instance = %cfg.node.instance, resources = cfg.resources.len(),
|
||||||
|
joined, total = ifindexes.len(),
|
||||||
|
"beacon: listening"
|
||||||
|
);
|
||||||
|
|
||||||
|
std_sock.set_nonblocking(true)?;
|
||||||
|
let socket = UdpSocket::from_std(std_sock)?;
|
||||||
|
let (changes, _) = watch::channel(0u64);
|
||||||
|
|
||||||
|
let node = BeaconNode {
|
||||||
|
inner: Arc::new(Inner {
|
||||||
|
group,
|
||||||
|
port: cfg.port,
|
||||||
|
interval: cfg.interval,
|
||||||
|
node: RwLock::new(cfg.node),
|
||||||
|
resources: RwLock::new(cfg.resources),
|
||||||
|
socket,
|
||||||
|
ifindexes,
|
||||||
|
seen: Mutex::new(HashMap::new()),
|
||||||
|
changes,
|
||||||
|
stopped: AtomicBool::new(false),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let reader = node.clone();
|
||||||
|
tokio::spawn(async move { reader.read_loop().await });
|
||||||
|
|
||||||
|
let advertiser = node.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
advertiser.advertise().await;
|
||||||
|
advertiser.probe(&[]).await;
|
||||||
|
let mut tick = tokio::time::interval(advertiser.inner.interval);
|
||||||
|
tick.tick().await; // the immediate first tick
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
if advertiser.inner.stopped.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
advertiser.advertise().await;
|
||||||
|
advertiser.prune();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instance name this node advertises as.
|
||||||
|
pub fn instance(&self) -> String {
|
||||||
|
self.inner.node.read().unwrap().instance.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace this node's resources and re-advertise at once (e.g. a manifest
|
||||||
|
/// `rev` changed). A no-op if nothing changed.
|
||||||
|
pub async fn set_resources(&self, resources: Vec<Resource>) {
|
||||||
|
{
|
||||||
|
let mut cur = self.inner.resources.write().unwrap();
|
||||||
|
if *cur == resources {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*cur = resources;
|
||||||
|
}
|
||||||
|
self.advertise().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set `node.status` (`starting` / `running`) and re-advertise.
|
||||||
|
pub async fn set_status(&self, status: &str) {
|
||||||
|
{
|
||||||
|
let mut n = self.inner.node.write().unwrap();
|
||||||
|
if n.status.as_deref() == Some(status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
n.status = Some(status.to_string());
|
||||||
|
}
|
||||||
|
self.advertise().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multicast a probe. Every node owning a resource matching one of `want`
|
||||||
|
/// (every node, when `want` is empty) replies at once — what makes the
|
||||||
|
/// protocol usable on a boot path instead of waiting out an interval.
|
||||||
|
pub async fn probe(&self, want: &[String]) {
|
||||||
|
let msg = Message::probe(&self.instance(), want);
|
||||||
|
self.multicast(&msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Say goodbye: multicast `bye` and stop advertising. Call on clean
|
||||||
|
/// shutdown so neighbours drop us at once instead of after the TTL.
|
||||||
|
pub async fn bye(&self) {
|
||||||
|
if self.inner.stopped.swap(true, Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let node = self.inner.node.read().unwrap().clone();
|
||||||
|
self.multicast(&Message::bye(node)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires (the counter increments) whenever the set of live nodes or any
|
||||||
|
/// node's resources change — new node, changed advertise, bye, expiry.
|
||||||
|
/// Consumers re-read [`BeaconNode::resources`] on each change.
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<u64> {
|
||||||
|
self.inner.changes.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every live node, sorted by (name, instance).
|
||||||
|
pub fn nodes(&self) -> Vec<SeenNode> {
|
||||||
|
let cutoff = self.cutoff();
|
||||||
|
let guard = self.inner.seen.lock().unwrap();
|
||||||
|
let mut out: Vec<SeenNode> = guard.values().filter(|n| n.last_seen >= cutoff).cloned().collect();
|
||||||
|
drop(guard);
|
||||||
|
out.sort_by(|a, b| a.node.name.cmp(&b.node.name).then(a.node.instance.cmp(&b.node.instance)));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row per node name, most recently seen instance wins — the nav
|
||||||
|
/// menu's shape.
|
||||||
|
pub fn nodes_by_name(&self) -> Vec<SeenNode> {
|
||||||
|
let mut best: HashMap<String, SeenNode> = HashMap::new();
|
||||||
|
for n in self.nodes() {
|
||||||
|
match best.get(&n.node.name) {
|
||||||
|
Some(cur) if cur.last_seen >= n.last_seen => {}
|
||||||
|
_ => {
|
||||||
|
best.insert(n.node.name.clone(), n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out: Vec<SeenNode> = best.into_values().collect();
|
||||||
|
out.sort_by(|a, b| a.node.name.cmp(&b.node.name));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every live resource matching `pattern`, with its node.
|
||||||
|
pub fn resources(&self, pattern: &str) -> Vec<SeenResource> {
|
||||||
|
self.nodes()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|n| {
|
||||||
|
let SeenNode { node, resources, addr, last_seen } = n;
|
||||||
|
resources.into_iter().filter(|r| r.matches(pattern)).map(move |resource| SeenResource {
|
||||||
|
node: node.clone(),
|
||||||
|
resource,
|
||||||
|
addr: addr.clone(),
|
||||||
|
last_seen,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This node as a row, so a UI can render itself without waiting for its
|
||||||
|
/// own echo.
|
||||||
|
pub fn self_node(&self) -> SeenNode {
|
||||||
|
SeenNode {
|
||||||
|
node: self.inner.node.read().unwrap().clone(),
|
||||||
|
resources: self.inner.resources.read().unwrap().clone(),
|
||||||
|
addr: String::new(),
|
||||||
|
last_seen: now_secs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- internals ----------------------------------------------------------
|
||||||
|
|
||||||
|
fn cutoff(&self) -> f64 {
|
||||||
|
now_secs() - self.inner.interval.as_secs_f64() * LIVENESS_FACTOR as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bump(&self) {
|
||||||
|
self.inner.changes.send_modify(|g| *g = g.wrapping_add(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop expired nodes; notify if any went.
|
||||||
|
fn prune(&self) {
|
||||||
|
let cutoff = self.cutoff();
|
||||||
|
let removed = {
|
||||||
|
let mut guard = self.inner.seen.lock().unwrap();
|
||||||
|
let before = guard.len();
|
||||||
|
guard.retain(|_, n| n.last_seen >= cutoff);
|
||||||
|
before != guard.len()
|
||||||
|
};
|
||||||
|
if removed {
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advertise_msg(&self) -> Message {
|
||||||
|
Message::advertise(
|
||||||
|
self.inner.node.read().unwrap().clone(),
|
||||||
|
self.inner.resources.read().unwrap().clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn advertise(&self) {
|
||||||
|
if self.inner.stopped.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let msg = self.advertise_msg();
|
||||||
|
self.multicast(&msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(msg: &Message) -> Option<Vec<u8>> {
|
||||||
|
match serde_json::to_vec(msg) {
|
||||||
|
Ok(b) => {
|
||||||
|
if b.len() > MAX_DATAGRAM {
|
||||||
|
warn!(bytes = b.len(), max = MAX_DATAGRAM, "beacon: datagram over the fragmentation-safe size");
|
||||||
|
}
|
||||||
|
Some(b)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "beacon: encode failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes once per interface. Letting the routing table choose would reach
|
||||||
|
/// exactly one network — the failure this protocol exists to avoid.
|
||||||
|
async fn multicast(&self, msg: &Message) {
|
||||||
|
let Some(body) = Self::encode(msg) else { return };
|
||||||
|
let dst = SocketAddr::V4(SocketAddrV4::new(self.inner.group, self.inner.port));
|
||||||
|
for (name, idx) in &self.inner.ifindexes {
|
||||||
|
if let Err(e) = set_multicast_if(&self.inner.socket, *idx) {
|
||||||
|
warn!(iface = %name, error = %e, "beacon: select interface failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = self.inner.socket.send_to(&body, dst).await {
|
||||||
|
warn!(iface = %name, error = %e, "beacon: send failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reply_to(&self, dst: SocketAddr) {
|
||||||
|
let Some(body) = Self::encode(&self.advertise_msg()) else { return };
|
||||||
|
if let Err(e) = self.inner.socket.send_to(&body, dst).await {
|
||||||
|
debug!(error = %e, "beacon: reply failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_loop(self) {
|
||||||
|
let mut buf = vec![0u8; READ_BUFFER];
|
||||||
|
loop {
|
||||||
|
let (n, src) = match self.inner.socket.recv_from(&mut buf).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
debug!(error = %e, "beacon: read error");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(msg) = Message::parse(&buf[..n]) else { continue };
|
||||||
|
let me = self.instance();
|
||||||
|
|
||||||
|
match msg.msg_type.as_str() {
|
||||||
|
TYPE_PROBE => {
|
||||||
|
if msg.from.as_deref() == Some(me.as_str()) || self.inner.stopped.load(Ordering::Relaxed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let wanted = msg.want.is_empty()
|
||||||
|
|| self.inner.resources.read().unwrap().iter().any(|r| r.matches_any(&msg.want));
|
||||||
|
if wanted {
|
||||||
|
self.reply_to(src).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TYPE_ADVERTISE | TYPE_BYE => {
|
||||||
|
let Some(node) = msg.node else { continue };
|
||||||
|
if node.instance == me {
|
||||||
|
continue; // our own multicast echo
|
||||||
|
}
|
||||||
|
let changed = if msg.msg_type == TYPE_BYE {
|
||||||
|
self.inner.seen.lock().unwrap().remove(&node.instance).is_some()
|
||||||
|
} else {
|
||||||
|
let cutoff = self.cutoff();
|
||||||
|
let mut guard = self.inner.seen.lock().unwrap();
|
||||||
|
let changed = match guard.get(&node.instance) {
|
||||||
|
Some(prev) => {
|
||||||
|
prev.last_seen < cutoff || prev.node != node || prev.resources != msg.resources
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
guard.insert(
|
||||||
|
node.instance.clone(),
|
||||||
|
SeenNode {
|
||||||
|
node,
|
||||||
|
resources: msg.resources,
|
||||||
|
addr: src.ip().to_string(),
|
||||||
|
last_seen: now_secs(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
changed
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Socket plumbing.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bind_reuse(port: u16) -> io::Result<std::net::UdpSocket> {
|
||||||
|
unsafe {
|
||||||
|
let fd: RawFd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// Must be set before bind. Lets several listeners share the port on one
|
||||||
|
// host (a test, a sidecar, a beacon v1 responder in the same netns).
|
||||||
|
let one: libc::c_int = 1;
|
||||||
|
for opt in [libc::SO_REUSEADDR, libc::SO_REUSEPORT] {
|
||||||
|
libc::setsockopt(
|
||||||
|
fd,
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
opt,
|
||||||
|
&one as *const _ as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let addr = libc::sockaddr_in {
|
||||||
|
sin_family: libc::AF_INET as libc::sa_family_t,
|
||||||
|
sin_port: port.to_be(),
|
||||||
|
sin_addr: libc::in_addr { s_addr: libc::INADDR_ANY.to_be() },
|
||||||
|
sin_zero: [0; 8],
|
||||||
|
};
|
||||||
|
if libc::bind(
|
||||||
|
fd,
|
||||||
|
&addr as *const _ as *const libc::sockaddr,
|
||||||
|
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||||
|
) < 0
|
||||||
|
{
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
libc::close(fd);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(std::net::UdpSocket::from_raw_fd(fd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_multicast_ttl(sock: &std::net::UdpSocket, ttl: libc::c_int) -> io::Result<()> {
|
||||||
|
// TTL 1: link-local only, never routed off the segment.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
sock.as_raw_fd(),
|
||||||
|
libc::IPPROTO_IP,
|
||||||
|
libc::IP_MULTICAST_TTL,
|
||||||
|
&ttl as *const _ as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ip_mreqn` — `imr_ifindex` is what makes this a per-interface join instead
|
||||||
|
/// of "whatever the routing table picks".
|
||||||
|
fn mreqn(group: Ipv4Addr, ifindex: u32) -> libc::ip_mreqn {
|
||||||
|
libc::ip_mreqn {
|
||||||
|
imr_multiaddr: libc::in_addr { s_addr: u32::from(group).to_be() },
|
||||||
|
imr_address: libc::in_addr { s_addr: libc::INADDR_ANY.to_be() },
|
||||||
|
imr_ifindex: ifindex as libc::c_int,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_group(sock: &std::net::UdpSocket, group: Ipv4Addr, ifindex: u32) -> io::Result<()> {
|
||||||
|
let req = mreqn(group, ifindex);
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
sock.as_raw_fd(),
|
||||||
|
libc::IPPROTO_IP,
|
||||||
|
libc::IP_ADD_MEMBERSHIP,
|
||||||
|
&req as *const _ as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::ip_mreqn>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_multicast_if(sock: &UdpSocket, ifindex: u32) -> io::Result<()> {
|
||||||
|
let req = mreqn(Ipv4Addr::UNSPECIFIED, ifindex);
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
sock.as_raw_fd(),
|
||||||
|
libc::IPPROTO_IP,
|
||||||
|
libc::IP_MULTICAST_IF,
|
||||||
|
&req as *const _ as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::ip_mreqn>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every interface except loopback, as (name, ifindex).
|
||||||
|
fn eligible_interfaces() -> Vec<(String, u32)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
unsafe {
|
||||||
|
let list = libc::if_nameindex();
|
||||||
|
if list.is_null() {
|
||||||
|
warn!("beacon: could not enumerate interfaces");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut p = list;
|
||||||
|
while (*p).if_index != 0 && !(*p).if_name.is_null() {
|
||||||
|
let name = std::ffi::CStr::from_ptr((*p).if_name).to_string_lossy().into_owned();
|
||||||
|
if name != "lo" {
|
||||||
|
out.push((name, (*p).if_index));
|
||||||
|
}
|
||||||
|
p = p.add(1);
|
||||||
|
}
|
||||||
|
libc::if_freenameindex(list);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_nonempty(key: &str) -> Option<String> {
|
||||||
|
std::env::var(key).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$HOSTNAME`, else `/etc/hostname`, else `unknown`.
|
||||||
|
pub fn hostname() -> String {
|
||||||
|
env_nonempty("HOSTNAME")
|
||||||
|
.or_else(|| {
|
||||||
|
std::fs::read_to_string("/etc/hostname")
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn now_secs() -> f64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ENABLE_UDP_DISCOVERY`, permissive: only `false`/`0`/`no`/`off` disable.
|
||||||
|
pub fn enabled_from_env() -> bool {
|
||||||
|
match std::env::var("ENABLE_UDP_DISCOVERY") {
|
||||||
|
Ok(v) => !matches!(v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off"),
|
||||||
|
Err(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Two nodes on loopback-only hosts can't multicast to each other, so
|
||||||
|
/// exercise the receive path directly by unicasting into a node's port.
|
||||||
|
async fn node_on(port: u16, instance: &str, resources: Vec<Resource>) -> BeaconNode {
|
||||||
|
let mut cfg = Config::new("test").instance(instance);
|
||||||
|
cfg.port = port;
|
||||||
|
cfg.interval = Duration::from_millis(200);
|
||||||
|
cfg.resources = resources;
|
||||||
|
BeaconNode::spawn(cfg).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(port: u16, msg: &Message) {
|
||||||
|
let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
s.send_to(&serde_json::to_vec(msg).unwrap(), ("127.0.0.1", port)).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn free_port() -> u16 {
|
||||||
|
std::net::UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(instance: &str) -> NodeInfo {
|
||||||
|
NodeInfo { name: "peer".into(), instance: instance.into(), ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn advertise_bye_and_expiry() {
|
||||||
|
let port = free_port();
|
||||||
|
let node = node_on(port, "me", vec![]).await;
|
||||||
|
let mut rx = node.subscribe();
|
||||||
|
|
||||||
|
let r = Resource::new("nzb", ["metamesh.transport/nzb@1"]);
|
||||||
|
send(port, &Message::advertise(info("peer-1"), vec![r])).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), rx.changed()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(node.resources("metamesh.transport/*").len(), 1);
|
||||||
|
assert!(node.resources("metamesh.core").is_empty());
|
||||||
|
|
||||||
|
send(port, &Message::bye(info("peer-1"))).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), rx.changed()).await.unwrap().unwrap();
|
||||||
|
assert!(node.nodes().is_empty());
|
||||||
|
|
||||||
|
send(port, &Message::advertise(info("peer-2"), vec![])).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), rx.changed()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(node.nodes().len(), 1);
|
||||||
|
// 3 × 200 ms liveness, then the prune on the next tick notifies.
|
||||||
|
tokio::time::timeout(Duration::from_secs(3), rx.changed()).await.unwrap().unwrap();
|
||||||
|
assert!(node.nodes().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn own_echo_and_v1_ignored() {
|
||||||
|
let port = free_port();
|
||||||
|
let node = node_on(port, "me", vec![]).await;
|
||||||
|
send(port, &Message::advertise(info("me"), vec![])).await;
|
||||||
|
let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
s.send_to(br#"{"type":"announce","name":"x","tools":[]}"#, ("127.0.0.1", port)).await.unwrap();
|
||||||
|
s.send_to(br#"{"v":1,"type":"announce","name":"meta-sort","instance":"a"}"#, ("127.0.0.1", port))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
assert!(node.nodes().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn probe_reply_honours_want() {
|
||||||
|
let port = free_port();
|
||||||
|
let _node = node_on(port, "me", vec![Resource::new("service", ["metamesh.service/meta-sort"])]).await;
|
||||||
|
let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let mut buf = vec![0u8; READ_BUFFER];
|
||||||
|
|
||||||
|
let miss = Message::probe("cli", &["metamesh.core".to_string()]);
|
||||||
|
s.send_to(&serde_json::to_vec(&miss).unwrap(), ("127.0.0.1", port)).await.unwrap();
|
||||||
|
assert!(tokio::time::timeout(Duration::from_millis(300), s.recv_from(&mut buf)).await.is_err());
|
||||||
|
|
||||||
|
let hit = Message::probe("cli", &["metamesh.service/*".to_string()]);
|
||||||
|
s.send_to(&serde_json::to_vec(&hit).unwrap(), ("127.0.0.1", port)).await.unwrap();
|
||||||
|
let (n, _) = tokio::time::timeout(Duration::from_secs(2), s.recv_from(&mut buf)).await.unwrap().unwrap();
|
||||||
|
let m = Message::parse(&buf[..n]).unwrap();
|
||||||
|
assert_eq!(m.msg_type, TYPE_ADVERTISE);
|
||||||
|
assert_eq!(m.node.unwrap().instance, "me");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//! Beacon v2 wire types and capability matching.
|
||||||
|
//!
|
||||||
|
//! The normative spec is meta-root's `docs/project-architecture/beacon-v2.md`.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const PROTO: &str = "beacon";
|
||||||
|
pub const VERSION: u8 = 2;
|
||||||
|
/// Beacon v1's group and port, on purpose — one well-known endpoint for every
|
||||||
|
/// kind of local resource. v1 and v2 coexist because v2 never sends v1's
|
||||||
|
/// `discovery` / `announce` types.
|
||||||
|
pub const DEFAULT_GROUP: &str = "239.255.99.1";
|
||||||
|
pub const DEFAULT_PORT: u16 = 9099;
|
||||||
|
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
/// A node unheard for `interval * this` is dropped (evaluated at read time).
|
||||||
|
pub const LIVENESS_FACTOR: u32 = 3;
|
||||||
|
/// Receive buffer. Senders stay under [`MAX_DATAGRAM`].
|
||||||
|
pub const READ_BUFFER: usize = 8192;
|
||||||
|
/// Above this a datagram risks IP fragmentation; the sender logs a warning.
|
||||||
|
pub const MAX_DATAGRAM: usize = 1400;
|
||||||
|
|
||||||
|
pub const TYPE_PROBE: &str = "probe";
|
||||||
|
pub const TYPE_ADVERTISE: &str = "advertise";
|
||||||
|
pub const TYPE_BYE: &str = "bye";
|
||||||
|
|
||||||
|
/// Who is advertising.
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NodeInfo {
|
||||||
|
/// What the software is (`meta-sort`, `meta-transport-nzb`).
|
||||||
|
pub name: String,
|
||||||
|
/// Unique per running copy — the container hostname.
|
||||||
|
pub instance: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// `starting` | `running`. Absent reads as `running`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One advertised resource.
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Resource {
|
||||||
|
/// Unique within the node.
|
||||||
|
pub id: String,
|
||||||
|
pub caps: Vec<String>,
|
||||||
|
/// Well-known names: `http`, `ui`, `manifest`, `mcp`. A value starting with
|
||||||
|
/// `/` is relative to `http` — see [`Resource::endpoint`].
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub endpoints: BTreeMap<String, String>,
|
||||||
|
/// Opaque revision of the resource's details (e.g. its manifest).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rev: Option<String>,
|
||||||
|
/// The one consumer instance this resource belongs to. Absent = anyone.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub binds: Option<String>,
|
||||||
|
/// Profile-defined, per capability.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resource {
|
||||||
|
pub fn new(id: impl Into<String>, caps: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
caps: caps.into_iter().map(Into::into).collect(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_endpoint(mut self, name: impl Into<String>, url: impl Into<String>) -> Self {
|
||||||
|
let url = url.into();
|
||||||
|
if !url.is_empty() {
|
||||||
|
self.endpoints.insert(name.into(), url);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if any of this resource's caps matches `pattern`.
|
||||||
|
pub fn matches(&self, pattern: &str) -> bool {
|
||||||
|
self.caps.iter().any(|c| cap_matches(pattern, c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if any cap matches any of `patterns`. An empty list matches.
|
||||||
|
pub fn matches_any(&self, patterns: &[String]) -> bool {
|
||||||
|
patterns.is_empty() || patterns.iter().any(|p| self.matches(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `binds` is absent, or names `instance`.
|
||||||
|
pub fn usable_by(&self, instance: &str) -> bool {
|
||||||
|
self.binds.as_deref().map_or(true, |b| b == instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The named endpoint as an absolute URL: a value starting with `/` is
|
||||||
|
/// joined onto `endpoints.http`.
|
||||||
|
pub fn endpoint(&self, name: &str) -> Option<String> {
|
||||||
|
let v = self.endpoints.get(name)?;
|
||||||
|
if v.starts_with('/') {
|
||||||
|
let base = self.endpoints.get("http")?;
|
||||||
|
Some(format!("{}{}", base.trim_end_matches('/'), v))
|
||||||
|
} else {
|
||||||
|
Some(v.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every v2 datagram.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub proto: String,
|
||||||
|
pub v: u8,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub msg_type: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub node: Option<NodeInfo>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub resources: Vec<Resource>,
|
||||||
|
/// Probe only: informational sender instance.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Probe only: reply only if a resource matches one of these.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub want: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
fn base(msg_type: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
proto: PROTO.into(),
|
||||||
|
v: VERSION,
|
||||||
|
msg_type: msg_type.into(),
|
||||||
|
node: None,
|
||||||
|
resources: Vec::new(),
|
||||||
|
from: None,
|
||||||
|
want: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn probe(from: &str, want: &[String]) -> Self {
|
||||||
|
Self {
|
||||||
|
from: Some(from.to_string()),
|
||||||
|
want: want.to_vec(),
|
||||||
|
..Self::base(TYPE_PROBE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advertise(node: NodeInfo, resources: Vec<Resource>) -> Self {
|
||||||
|
Self {
|
||||||
|
node: Some(node),
|
||||||
|
resources,
|
||||||
|
..Self::base(TYPE_ADVERTISE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bye(node: NodeInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
node: Some(node),
|
||||||
|
..Self::base(TYPE_BYE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a datagram, returning `None` for anything that is not a
|
||||||
|
/// well-formed beacon v2 message — beacon v1 traffic (no envelope), other
|
||||||
|
/// versions, unknown types, or an advertise/bye without a node. The group
|
||||||
|
/// is shared, so all of that is silently ignored.
|
||||||
|
pub fn parse(bytes: &[u8]) -> Option<Self> {
|
||||||
|
let m: Message = serde_json::from_slice(bytes).ok()?;
|
||||||
|
if m.proto != PROTO || m.v != VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match m.msg_type.as_str() {
|
||||||
|
TYPE_PROBE => Some(m),
|
||||||
|
TYPE_ADVERTISE | TYPE_BYE => {
|
||||||
|
let n = m.node.as_ref()?;
|
||||||
|
if n.instance.is_empty() || n.name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(m)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `base@contract`. A non-numeric or missing contract is `None`.
|
||||||
|
fn split_contract(s: &str) -> (&str, Option<&str>) {
|
||||||
|
match s.rsplit_once('@') {
|
||||||
|
Some((base, c)) => (base, Some(c)),
|
||||||
|
None => (s, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does capability `cap` satisfy `pattern`?
|
||||||
|
///
|
||||||
|
/// - `*` matches everything.
|
||||||
|
/// - `@N` on the pattern must equal the cap's contract; without one the cap's
|
||||||
|
/// contract is ignored.
|
||||||
|
/// - `x/*` matches any variant of `x`; otherwise the bases must be equal.
|
||||||
|
pub fn cap_matches(pattern: &str, cap: &str) -> bool {
|
||||||
|
if pattern == "*" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let (pbase, pcontract) = split_contract(pattern);
|
||||||
|
let (cbase, ccontract) = split_contract(cap);
|
||||||
|
if let Some(pc) = pcontract {
|
||||||
|
if ccontract != Some(pc) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match pbase.strip_suffix("/*") {
|
||||||
|
Some(prefix) => cbase
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/') && rest.len() > 1),
|
||||||
|
None => pbase == cbase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short opaque revision for any serialisable value — what a harness puts in
|
||||||
|
/// [`Resource::rev`] so consumers re-fetch a manifest only when it changed.
|
||||||
|
/// Stable for a given build; not cryptographic and not meant to be.
|
||||||
|
pub fn rev_of<T: Serialize>(value: &T) -> String {
|
||||||
|
let bytes = serde_json::to_vec(value).unwrap_or_default();
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
bytes.hash(&mut h);
|
||||||
|
format!("{:016x}", h.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wildcard_matches_any_variant_only() {
|
||||||
|
assert!(cap_matches("metamesh.transport/*", "metamesh.transport/nzb@1"));
|
||||||
|
assert!(cap_matches("metamesh.transport/*", "metamesh.transport/ipfs"));
|
||||||
|
assert!(!cap_matches("metamesh.transport/*", "metamesh.transport"));
|
||||||
|
assert!(!cap_matches("metamesh.transport/*", "metamesh.transportx/a"));
|
||||||
|
assert!(!cap_matches("metamesh.transport/*", "metamesh.transport/"));
|
||||||
|
assert!(cap_matches("*", "anything@3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contract_rules() {
|
||||||
|
assert!(cap_matches("metamesh.transport/nzb", "metamesh.transport/nzb@1"));
|
||||||
|
assert!(cap_matches("metamesh.transport/nzb", "metamesh.transport/nzb@2"));
|
||||||
|
assert!(cap_matches("metamesh.transport/nzb@2", "metamesh.transport/nzb@2"));
|
||||||
|
assert!(!cap_matches("metamesh.transport/nzb@2", "metamesh.transport/nzb@1"));
|
||||||
|
assert!(!cap_matches("metamesh.transport/nzb@2", "metamesh.transport/nzb"));
|
||||||
|
assert!(cap_matches("metamesh.transport/*@1", "metamesh.transport/nzb@1"));
|
||||||
|
assert!(!cap_matches("metamesh.transport/*@1", "metamesh.transport/nzb@2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_match() {
|
||||||
|
assert!(cap_matches("mcp", "mcp"));
|
||||||
|
assert!(!cap_matches("mcp", "mcp/x"));
|
||||||
|
assert!(!cap_matches("metamesh.core", "metamesh.core2"));
|
||||||
|
assert!(!cap_matches("metamesh.transport/nzb", "metamesh.transport/ipfs@1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_v1_and_foreign_traffic() {
|
||||||
|
// beacon v1 aggregator probe / server announce
|
||||||
|
assert!(Message::parse(br#"{"type":"discovery"}"#).is_none());
|
||||||
|
assert!(Message::parse(br#"{"type":"announce","name":"x","tools":[]}"#).is_none());
|
||||||
|
// meta-discovery v1
|
||||||
|
assert!(Message::parse(br#"{"v":1,"type":"announce","name":"meta-sort"}"#).is_none());
|
||||||
|
// wrong version / unknown type / not json
|
||||||
|
assert!(Message::parse(br#"{"proto":"beacon","v":3,"type":"probe"}"#).is_none());
|
||||||
|
assert!(Message::parse(br#"{"proto":"beacon","v":2,"type":"announce"}"#).is_none());
|
||||||
|
assert!(Message::parse(b"\x00\x01garbage").is_none());
|
||||||
|
// advertise without a node
|
||||||
|
assert!(Message::parse(br#"{"proto":"beacon","v":2,"type":"advertise"}"#).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_advertise() {
|
||||||
|
let r = Resource::new("nzb", ["metamesh.transport/nzb@1"])
|
||||||
|
.with_endpoint("http", "http://metashare-nzb:3000")
|
||||||
|
.with_endpoint("manifest", "/manifest");
|
||||||
|
let m = Message::advertise(
|
||||||
|
NodeInfo { name: "meta-transport-nzb".into(), instance: "metashare-nzb".into(), ..Default::default() },
|
||||||
|
vec![r],
|
||||||
|
);
|
||||||
|
let bytes = serde_json::to_vec(&m).unwrap();
|
||||||
|
let back = Message::parse(&bytes).unwrap();
|
||||||
|
assert_eq!(back.msg_type, TYPE_ADVERTISE);
|
||||||
|
let r = &back.resources[0];
|
||||||
|
assert_eq!(r.endpoint("manifest").as_deref(), Some("http://metashare-nzb:3000/manifest"));
|
||||||
|
assert!(r.matches("metamesh.transport/*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binds() {
|
||||||
|
let mut r = Resource::new("nzb", ["metamesh.transport/nzb@1"]);
|
||||||
|
assert!(r.usable_by("anyone"));
|
||||||
|
r.binds = Some("metashare-app".into());
|
||||||
|
assert!(r.usable_by("metashare-app"));
|
||||||
|
assert!(!r.usable_by("metawatch-share"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_filter() {
|
||||||
|
let r = Resource::new("service", ["metamesh.service/meta-sort"]);
|
||||||
|
assert!(r.matches_any(&[]));
|
||||||
|
assert!(r.matches_any(&["metamesh.service/*".into()]));
|
||||||
|
assert!(!r.matches_any(&["metamesh.core".into()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! On-demand discovery scan — what a consumer's "Scan" button runs.
|
||||||
|
//!
|
||||||
|
//! Probe everyone, wait a moment for the replies, then classify every resource
|
||||||
|
//! matching the consumer's capability pattern against what the consumer has
|
||||||
|
//! already configured. The report is the same JSON in every consumer
|
||||||
|
//! (meta-share, meta-gateway, meta-sort), rendered by the shared
|
||||||
|
//! `<meta-beacon-scan>` element.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::node::{BeaconNode, SeenResource};
|
||||||
|
|
||||||
|
/// How a discovered resource relates to this consumer.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum CandidateState {
|
||||||
|
/// Its URL is already in the consumer's list.
|
||||||
|
Configured,
|
||||||
|
/// Can be added.
|
||||||
|
Addable,
|
||||||
|
/// `binds` names another consumer; shown but not addable.
|
||||||
|
BoundElsewhere,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Candidate {
|
||||||
|
pub instance: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub resource_id: String,
|
||||||
|
pub caps: Vec<String>,
|
||||||
|
/// `endpoints.http` — what the consumer would register.
|
||||||
|
pub url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub binds: Option<String>,
|
||||||
|
pub state: CandidateState,
|
||||||
|
/// The list entry that already holds this URL (state `configured`).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub configured_as: Option<String>,
|
||||||
|
/// A list-safe name to register it under.
|
||||||
|
pub suggested_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScanSummary {
|
||||||
|
/// Every node heard on the segment, whatever it advertises.
|
||||||
|
pub nodes: usize,
|
||||||
|
/// Resources matching the consumer's pattern.
|
||||||
|
pub capable: usize,
|
||||||
|
pub configured: usize,
|
||||||
|
pub bound_elsewhere: usize,
|
||||||
|
pub addable: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScanReport {
|
||||||
|
pub cap: String,
|
||||||
|
/// This consumer's instance (what `binds` is compared against).
|
||||||
|
#[serde(rename = "self")]
|
||||||
|
pub self_instance: String,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub summary: ScanSummary,
|
||||||
|
pub candidates: Vec<Candidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn norm(u: &str) -> String {
|
||||||
|
u.trim().trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower-case, `[a-z0-9._-]` only — accepted by every consumer's name rules.
|
||||||
|
pub fn suggested_name(instance: &str) -> String {
|
||||||
|
let s: String = instance
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' })
|
||||||
|
.collect();
|
||||||
|
if s.is_empty() { "plugin".into() } else { s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure classification, for tests and for callers that already hold a view.
|
||||||
|
/// `configured` is `(name, url)` of every entry the consumer has.
|
||||||
|
pub fn classify(
|
||||||
|
pattern: &str,
|
||||||
|
self_instance: &str,
|
||||||
|
nodes: usize,
|
||||||
|
resources: Vec<SeenResource>,
|
||||||
|
configured: &[(String, String)],
|
||||||
|
) -> ScanReport {
|
||||||
|
let mut summary = ScanSummary { nodes, ..Default::default() };
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for r in resources {
|
||||||
|
// A resource without an http endpoint cannot be registered anywhere.
|
||||||
|
let Some(url) = r.resource.endpoint("http") else { continue };
|
||||||
|
let url = norm(&url);
|
||||||
|
summary.capable += 1;
|
||||||
|
let configured_as = configured.iter().find(|(_, u)| norm(u) == url).map(|(n, _)| n.clone());
|
||||||
|
let state = if configured_as.is_some() {
|
||||||
|
summary.configured += 1;
|
||||||
|
CandidateState::Configured
|
||||||
|
} else if !r.resource.usable_by(self_instance) {
|
||||||
|
summary.bound_elsewhere += 1;
|
||||||
|
CandidateState::BoundElsewhere
|
||||||
|
} else {
|
||||||
|
summary.addable += 1;
|
||||||
|
CandidateState::Addable
|
||||||
|
};
|
||||||
|
candidates.push(Candidate {
|
||||||
|
suggested_name: suggested_name(&r.node.instance),
|
||||||
|
instance: r.node.instance,
|
||||||
|
name: r.node.name,
|
||||||
|
version: r.node.version,
|
||||||
|
resource_id: r.resource.id,
|
||||||
|
caps: r.resource.caps,
|
||||||
|
url,
|
||||||
|
binds: r.resource.binds,
|
||||||
|
state,
|
||||||
|
configured_as,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Addable first (what the operator came for), then configured, then the rest.
|
||||||
|
let rank = |s: CandidateState| match s {
|
||||||
|
CandidateState::Addable => 0,
|
||||||
|
CandidateState::Configured => 1,
|
||||||
|
CandidateState::BoundElsewhere => 2,
|
||||||
|
};
|
||||||
|
candidates.sort_by(|a, b| rank(a.state).cmp(&rank(b.state)).then(a.instance.cmp(&b.instance)));
|
||||||
|
ScanReport {
|
||||||
|
cap: pattern.to_string(),
|
||||||
|
self_instance: self_instance.to_string(),
|
||||||
|
duration_ms: 0,
|
||||||
|
summary,
|
||||||
|
candidates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe every node, wait `wait` for the replies, then classify what matches
|
||||||
|
/// `pattern`. A node that stays silent through the wait but is still inside
|
||||||
|
/// its liveness window is counted too — the view is "who is here", not "who
|
||||||
|
/// answered this probe".
|
||||||
|
pub async fn scan(
|
||||||
|
node: &BeaconNode,
|
||||||
|
pattern: &str,
|
||||||
|
configured: &[(String, String)],
|
||||||
|
wait: Duration,
|
||||||
|
) -> ScanReport {
|
||||||
|
let started = Instant::now();
|
||||||
|
node.probe(&[]).await;
|
||||||
|
tokio::time::sleep(wait).await;
|
||||||
|
let mut report = classify(
|
||||||
|
pattern,
|
||||||
|
&node.instance(),
|
||||||
|
node.nodes().len(),
|
||||||
|
node.resources(pattern),
|
||||||
|
configured,
|
||||||
|
);
|
||||||
|
report.duration_ms = started.elapsed().as_millis() as u64;
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::beacon::{NodeInfo, Resource};
|
||||||
|
|
||||||
|
fn seen(instance: &str, cap: &str, url: Option<&str>, binds: Option<&str>) -> SeenResource {
|
||||||
|
let mut r = Resource::new("x", [cap]);
|
||||||
|
if let Some(u) = url {
|
||||||
|
r = r.with_endpoint("http", u);
|
||||||
|
}
|
||||||
|
r.binds = binds.map(Into::into);
|
||||||
|
SeenResource {
|
||||||
|
node: NodeInfo { name: "plugin".into(), instance: instance.into(), ..Default::default() },
|
||||||
|
resource: r,
|
||||||
|
addr: "10.0.0.1".into(),
|
||||||
|
last_seen: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_configured_addable_and_bound_elsewhere() {
|
||||||
|
let resources = vec![
|
||||||
|
seen("metashare-nzb", "metamesh.transport/nzb@1", Some("http://metashare-nzb:3000/"), Some("metashare-app")),
|
||||||
|
seen("qbit", "metamesh.transport/torrent@1", Some("http://qbit:3000"), None),
|
||||||
|
seen("other-ipfs", "metamesh.transport/ipfs@1", Some("http://other-ipfs:3000"), Some("metawatch-share")),
|
||||||
|
seen("no-endpoint", "metamesh.transport/ipfs@1", None, None),
|
||||||
|
];
|
||||||
|
let configured = vec![("nzb".to_string(), "http://metashare-nzb:3000".to_string())];
|
||||||
|
let r = classify("metamesh.transport/*", "metashare-app", 10, resources, &configured);
|
||||||
|
assert_eq!(r.summary.nodes, 10);
|
||||||
|
assert_eq!(r.summary.capable, 3, "a resource without an http endpoint is not a candidate");
|
||||||
|
assert_eq!((r.summary.configured, r.summary.addable, r.summary.bound_elsewhere), (1, 1, 1));
|
||||||
|
assert_eq!(r.candidates[0].instance, "qbit", "addable rows sort first");
|
||||||
|
assert_eq!(r.candidates[1].configured_as.as_deref(), Some("nzb"));
|
||||||
|
assert_eq!(r.candidates[2].state, CandidateState::BoundElsewhere);
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json["self"], "metashare-app");
|
||||||
|
assert_eq!(json["summary"]["boundElsewhere"], 1);
|
||||||
|
assert_eq!(json["candidates"][2]["state"], "bound-elsewhere");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggested_names_are_list_safe() {
|
||||||
|
assert_eq!(suggested_name("MetaShare NZB"), "metashare-nzb");
|
||||||
|
assert_eq!(suggested_name(""), "plugin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_reports_what_the_node_heard() {
|
||||||
|
use crate::beacon::{BeaconNode, Config, Message};
|
||||||
|
let port = std::net::UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||||
|
let mut cfg = Config::new("consumer").instance("me");
|
||||||
|
cfg.port = port;
|
||||||
|
let node = BeaconNode::spawn(cfg).unwrap();
|
||||||
|
let ad = Message::advertise(
|
||||||
|
NodeInfo { name: "meta-feeder-tmdb".into(), instance: "tmdb-feeder".into(), ..Default::default() },
|
||||||
|
vec![Resource::new("feeder", ["metamesh.feeder/tmdb"]).with_endpoint("http", "http://tmdb-feeder:8080")],
|
||||||
|
);
|
||||||
|
let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
s.send_to(&serde_json::to_vec(&ad).unwrap(), ("127.0.0.1", port)).await.unwrap();
|
||||||
|
let r = scan(&node, "metamesh.feeder/*", &[], Duration::from_millis(200)).await;
|
||||||
|
assert_eq!(r.summary.nodes, 1);
|
||||||
|
assert_eq!(r.summary.addable, 1);
|
||||||
|
assert_eq!(r.candidates[0].url, "http://tmdb-feeder:8080");
|
||||||
|
assert_eq!(r.candidates[0].suggested_name, "tmdb-feeder");
|
||||||
|
assert!(r.duration_ms >= 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
//! Per-upstream request budget — a shared token bucket.
|
||||||
|
//!
|
||||||
|
//! Lifted from the card feeder's `tmdb_budget.rs` (its `TmdbBudget` half; the
|
||||||
|
//! per-indexer registry has no counterpart here) and renamed, because the
|
||||||
|
//! mechanism is upstream-agnostic and the music feeder needs three independent
|
||||||
|
//! instances rather than one global.
|
||||||
|
//!
|
||||||
|
//! **Why three, not one.** MusicBrainz's 1 req/s is a hard published ceiling
|
||||||
|
//! enforced per source IP; ListenBrainz and the Internet Archive are far more
|
||||||
|
//! generous. A single shared bucket would either throttle all three to 1/s or
|
||||||
|
//! let MusicBrainz calls burst past a limit that gets the whole box blocked.
|
||||||
|
//!
|
||||||
|
//! Design notes, unchanged from the original:
|
||||||
|
//! - **Lazy refill.** Tokens accrue on each `acquire` from elapsed wall-clock,
|
||||||
|
//! so there is no background timer task and no idle cost.
|
||||||
|
//! - **Cancel-safe.** The token is decremented synchronously under the lock
|
||||||
|
//! right before `acquire` returns [`Lease::Granted`]; there is no `.await`
|
||||||
|
//! between the decision to grant and the return, so a dropped `acquire`
|
||||||
|
//! future (consumer disconnected) never spends a token.
|
||||||
|
//! - **Global pause on throttle.** One upstream 429/503 freezes *all* grants
|
||||||
|
//! on that bucket until the Retry-After window elapses, via
|
||||||
|
//! [`RateBudget::note_throttled`].
|
||||||
|
//!
|
||||||
|
//! The critical sections hold a plain `std::sync::Mutex` (no awaits inside),
|
||||||
|
//! while the waiting happens outside the lock on a `tokio::time::sleep` raced
|
||||||
|
//! against a `tokio::sync::Notify`.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
/// Outcome of a [`RateBudget::acquire`] call.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Lease {
|
||||||
|
/// A token was granted; the caller may make one upstream call.
|
||||||
|
Granted,
|
||||||
|
/// The wait deadline elapsed before a token became available. The caller
|
||||||
|
/// degrades best-effort — it does **not** call the upstream anyway.
|
||||||
|
DeadlineExceeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BucketState {
|
||||||
|
tokens: f64,
|
||||||
|
capacity: f64,
|
||||||
|
refill_per_sec: f64,
|
||||||
|
last_refill: Instant,
|
||||||
|
/// When set and in the future, all grants are frozen.
|
||||||
|
paused_until: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BucketState {
|
||||||
|
/// Accrue tokens for elapsed time since the last refill. Called under the
|
||||||
|
/// lock before inspecting `tokens`.
|
||||||
|
fn refill(&mut self, now: Instant) {
|
||||||
|
let elapsed = now.saturating_duration_since(self.last_refill).as_secs_f64();
|
||||||
|
if elapsed > 0.0 {
|
||||||
|
self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
|
||||||
|
self.last_refill = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared token bucket gating one upstream's API calls across the process.
|
||||||
|
pub struct RateBudget {
|
||||||
|
state: Mutex<BucketState>,
|
||||||
|
/// Woken whenever a grant *might* now be possible (a token accrued, or a
|
||||||
|
/// pause was cleared). Waiters re-check under the lock after waking; a
|
||||||
|
/// missed wake only costs an extra bounded sleep.
|
||||||
|
wake: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateBudget {
|
||||||
|
/// Build a budget sustaining `refill_per_sec` grants/second with a burst
|
||||||
|
/// ceiling of `capacity` tokens. The bucket starts full, so the first
|
||||||
|
/// burst is served immediately.
|
||||||
|
pub fn new(refill_per_sec: f64, capacity: f64) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
state: Mutex::new(BucketState {
|
||||||
|
tokens: capacity,
|
||||||
|
capacity,
|
||||||
|
refill_per_sec,
|
||||||
|
last_refill: Instant::now(),
|
||||||
|
paused_until: None,
|
||||||
|
}),
|
||||||
|
wake: Notify::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for one token, giving up after `deadline`. Cancel-safe: dropping
|
||||||
|
/// the returned future before it resolves [`Lease::Granted`] spends no
|
||||||
|
/// token.
|
||||||
|
pub async fn acquire(&self, deadline: Duration) -> Lease {
|
||||||
|
let give_up_at = Instant::now() + deadline;
|
||||||
|
loop {
|
||||||
|
// Decide-or-compute-wait, all under the lock (no awaits inside).
|
||||||
|
let sleep_for = {
|
||||||
|
let mut st = self.state.lock().expect("rate budget mutex poisoned");
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some(until) = st.paused_until {
|
||||||
|
if now >= until {
|
||||||
|
st.paused_until = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let paused = st.paused_until;
|
||||||
|
if paused.is_none() {
|
||||||
|
st.refill(now);
|
||||||
|
if st.tokens >= 1.0 {
|
||||||
|
// Atomic grant: decrement and return with no await between.
|
||||||
|
st.tokens -= 1.0;
|
||||||
|
return Lease::Granted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if now >= give_up_at {
|
||||||
|
return Lease::DeadlineExceeded;
|
||||||
|
}
|
||||||
|
let until_deadline = give_up_at.saturating_duration_since(now);
|
||||||
|
let wait = match paused {
|
||||||
|
// Frozen: wake when the pause is scheduled to lift.
|
||||||
|
Some(until) => until.saturating_duration_since(now),
|
||||||
|
// Throttled: wake when the next whole token should accrue.
|
||||||
|
None => {
|
||||||
|
let needed = 1.0 - st.tokens;
|
||||||
|
let secs = if st.refill_per_sec > 0.0 {
|
||||||
|
needed / st.refill_per_sec
|
||||||
|
} else {
|
||||||
|
until_deadline.as_secs_f64()
|
||||||
|
};
|
||||||
|
Duration::from_secs_f64(secs.max(0.0))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wait.min(until_deadline)
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(sleep_for) => {}
|
||||||
|
_ = self.wake.notified() => {}
|
||||||
|
}
|
||||||
|
if Instant::now() >= give_up_at {
|
||||||
|
return Lease::DeadlineExceeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed back an upstream throttle response (429, or MusicBrainz's 503):
|
||||||
|
/// pause all grants on this bucket until `now + retry_after`. Extends an
|
||||||
|
/// existing pause but never shortens it.
|
||||||
|
pub fn note_throttled(&self, retry_after: Duration) {
|
||||||
|
let until = Instant::now() + retry_after;
|
||||||
|
{
|
||||||
|
let mut st = self.state.lock().expect("rate budget mutex poisoned");
|
||||||
|
st.paused_until = Some(match st.paused_until {
|
||||||
|
Some(existing) if existing > until => existing,
|
||||||
|
_ => until,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Wake waiters so they recompute their sleep against the new pause.
|
||||||
|
self.wake.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn grants_up_to_capacity_immediately() {
|
||||||
|
let b = RateBudget::new(3.0, 3.0);
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn next_token_waits_for_refill() {
|
||||||
|
let b = RateBudget::new(2.0, 2.0); // 2/s sustained
|
||||||
|
for _ in 0..2 {
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
|
||||||
|
}
|
||||||
|
// Bucket empty; next grant must wait ~0.5 s for one token at 2/s.
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
|
||||||
|
assert!(
|
||||||
|
start.elapsed() >= Duration::from_millis(450),
|
||||||
|
"expected to wait ~0.5s for refill, waited {:?}",
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MusicBrainz shape specifically: 1/s with a burst of 2 must not let
|
||||||
|
/// three calls through inside one second. This is the property that keeps
|
||||||
|
/// the box off their block-list.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn musicbrainz_shape_holds_one_per_second() {
|
||||||
|
let b = RateBudget::new(1.0, 1.0);
|
||||||
|
let start = Instant::now();
|
||||||
|
for _ in 0..4 {
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(30)).await, Lease::Granted);
|
||||||
|
}
|
||||||
|
// 4 grants from a 2-token bucket refilling at 1/s ⇒ at least 2 s.
|
||||||
|
assert!(
|
||||||
|
start.elapsed() >= Duration::from_millis(1_900),
|
||||||
|
"4 calls at 1/s (burst 2) must take ~2s, took {:?}",
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn deadline_exceeded_when_drained() {
|
||||||
|
let b = RateBudget::new(0.001, 1.0); // effectively no refill within the test
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(1)).await, Lease::Granted);
|
||||||
|
assert_eq!(
|
||||||
|
b.acquire(Duration::from_millis(200)).await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn note_throttled_pauses_grants_even_with_tokens_available() {
|
||||||
|
let b = RateBudget::new(100.0, 100.0);
|
||||||
|
b.note_throttled(Duration::from_secs(2));
|
||||||
|
assert_eq!(
|
||||||
|
b.acquire(Duration::from_millis(500)).await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
);
|
||||||
|
// After the pause window, grants resume.
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(5)).await, Lease::Granted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pause is extended, never shortened — otherwise a late short retry
|
||||||
|
/// would cancel an earlier long one and walk straight back into the block.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_shorter_retry_after_does_not_shorten_an_existing_pause() {
|
||||||
|
let b = RateBudget::new(100.0, 100.0);
|
||||||
|
b.note_throttled(Duration::from_secs(10));
|
||||||
|
b.note_throttled(Duration::from_secs(1));
|
||||||
|
assert_eq!(
|
||||||
|
b.acquire(Duration::from_secs(3)).await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
-1
@@ -13,7 +13,7 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use redb::{Database, ReadableTableMetadata, TableDefinition};
|
use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition};
|
||||||
|
|
||||||
pub const CACHE_FILENAME: &str = "cache.redb";
|
pub const CACHE_FILENAME: &str = "cache.redb";
|
||||||
|
|
||||||
@@ -53,6 +53,44 @@ const SUBTITLES_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("s
|
|||||||
/// OpenSubtitles search results keyed by `"<tmdb_id>\x01<lang3>"`. Value is the
|
/// OpenSubtitles search results keyed by `"<tmdb_id>\x01<lang3>"`. Value is the
|
||||||
/// resolved subtitle's cid, or the literal `"null"` negative-cache sentinel.
|
/// resolved subtitle's cid, or the literal `"null"` negative-cache sentinel.
|
||||||
const OPENSUBTITLES_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("opensubtitles");
|
const OPENSUBTITLES_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("opensubtitles");
|
||||||
|
|
||||||
|
|
||||||
|
// ── music-family tables (MusicBrainz / Internet Archive / YouTube / Jamendo) ──
|
||||||
|
|
||||||
|
/// Ranked MusicBrainz search results keyed by `"<entity>\x01<normalised text>"`.
|
||||||
|
const MB_SEARCH_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("mb_search");
|
||||||
|
/// Release-group detail keyed by its MBID.
|
||||||
|
const MB_RELEASE_GROUP_TABLE: TableDefinition<'_, &str, &str> =
|
||||||
|
TableDefinition::new("mb_release_group");
|
||||||
|
/// Artist detail keyed by its MBID.
|
||||||
|
const MB_ARTIST_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("mb_artist");
|
||||||
|
/// A release-group's canonical track list keyed by the release-group MBID.
|
||||||
|
const MB_TRACKLIST_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("mb_tracklist");
|
||||||
|
/// Internet Archive per-item file listing keyed by the item identifier.
|
||||||
|
const IA_ITEM_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("ia_item");
|
||||||
|
/// An artist's YouTube channel keys, keyed by artist MBID. See
|
||||||
|
/// `youtube::ChannelKeys`.
|
||||||
|
///
|
||||||
|
/// ⚠ **Only ever holds a POSITIVE result.** MusicBrainz answers `503` when
|
||||||
|
/// crowded and the client reports every failure as `None`, so caching a
|
||||||
|
/// negative would freeze a transport hiccup into "this artist has no channel"
|
||||||
|
/// — the exact fabrication the match probe hit (study §7.5).
|
||||||
|
const YT_CHANNEL_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("yt_channel");
|
||||||
|
/// Piped `music_songs` results keyed by the normalised search text.
|
||||||
|
const YT_SEARCH_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("yt_search");
|
||||||
|
/// Jamendo per-track detail keyed by the Jamendo track id. Permanent: a track
|
||||||
|
/// id names an immutable recording, and its licence, artist credit and
|
||||||
|
/// download URL do not change under it.
|
||||||
|
const JAMENDO_TRACK_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("jamendo_track");
|
||||||
|
/// Jamendo API calls spent, keyed by UTC `YYYY-MM`.
|
||||||
|
///
|
||||||
|
/// ⚠ **This table is not a cache — it is an accounting ledger, and losing it
|
||||||
|
/// loses money.** Every other table here can be deleted to reclaim disk with
|
||||||
|
/// no consequence beyond a slower next request. Deleting this one resets the
|
||||||
|
/// month's spend to zero while Jamendo's own counter keeps climbing, so the
|
||||||
|
/// feeder cheerfully spends a quota it has already exhausted and every call
|
||||||
|
/// fails for the rest of the month. See `consts::JAMENDO_MONTHLY_QUOTA`.
|
||||||
|
const JAMENDO_QUOTA_TABLE: TableDefinition<'_, &str, u64> = TableDefinition::new("jamendo_quota");
|
||||||
/// Small durable plugin scratch values that must survive a restart but aren't
|
/// Small durable plugin scratch values that must survive a restart but aren't
|
||||||
/// content-derived — today, the torznab plugin's Prowlarr indexer-set
|
/// content-derived — today, the torznab plugin's Prowlarr indexer-set
|
||||||
/// fingerprint, so a set that changed *while the feeder was down* is still
|
/// fingerprint, so a set that changed *while the feeder was down* is still
|
||||||
@@ -113,6 +151,15 @@ impl MidhashCache {
|
|||||||
tx.open_table(SUBTITLES_TABLE)?;
|
tx.open_table(SUBTITLES_TABLE)?;
|
||||||
tx.open_table(OPENSUBTITLES_TABLE)?;
|
tx.open_table(OPENSUBTITLES_TABLE)?;
|
||||||
tx.open_table(MISC_TABLE)?;
|
tx.open_table(MISC_TABLE)?;
|
||||||
|
tx.open_table(MB_SEARCH_TABLE)?;
|
||||||
|
tx.open_table(MB_RELEASE_GROUP_TABLE)?;
|
||||||
|
tx.open_table(MB_ARTIST_TABLE)?;
|
||||||
|
tx.open_table(MB_TRACKLIST_TABLE)?;
|
||||||
|
tx.open_table(IA_ITEM_TABLE)?;
|
||||||
|
tx.open_table(YT_CHANNEL_TABLE)?;
|
||||||
|
tx.open_table(YT_SEARCH_TABLE)?;
|
||||||
|
tx.open_table(JAMENDO_TRACK_TABLE)?;
|
||||||
|
tx.open_table(JAMENDO_QUOTA_TABLE)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
Ok(Self { db: Arc::new(db) })
|
Ok(Self { db: Arc::new(db) })
|
||||||
@@ -120,6 +167,47 @@ impl MidhashCache {
|
|||||||
|
|
||||||
str_table_accessors!(get_midhash, put_midhash, MIDHASH_TABLE);
|
str_table_accessors!(get_midhash, put_midhash, MIDHASH_TABLE);
|
||||||
|
|
||||||
|
// ── music-family accessors ──
|
||||||
|
str_table_accessors!(get_mb_search, put_mb_search, MB_SEARCH_TABLE);
|
||||||
|
str_table_accessors!(get_mb_release_group, put_mb_release_group, MB_RELEASE_GROUP_TABLE);
|
||||||
|
str_table_accessors!(get_mb_artist, put_mb_artist, MB_ARTIST_TABLE);
|
||||||
|
str_table_accessors!(get_mb_tracklist, put_mb_tracklist, MB_TRACKLIST_TABLE);
|
||||||
|
str_table_accessors!(get_ia_item, put_ia_item, IA_ITEM_TABLE);
|
||||||
|
str_table_accessors!(get_yt_channel, put_yt_channel, YT_CHANNEL_TABLE);
|
||||||
|
str_table_accessors!(get_yt_search, put_yt_search, YT_SEARCH_TABLE);
|
||||||
|
str_table_accessors!(get_jamendo_track, put_jamendo_track, JAMENDO_TRACK_TABLE);
|
||||||
|
|
||||||
|
pub fn jamendo_quota_spend(
|
||||||
|
&self,
|
||||||
|
month: &str,
|
||||||
|
budget: u64,
|
||||||
|
) -> Result<Option<u64>, redb::Error> {
|
||||||
|
let tx = self.db.begin_write()?;
|
||||||
|
let spent_after;
|
||||||
|
{
|
||||||
|
let mut table = tx.open_table(JAMENDO_QUOTA_TABLE)?;
|
||||||
|
let spent = table.get(month)?.map(|v| v.value()).unwrap_or(0);
|
||||||
|
if spent >= budget {
|
||||||
|
drop(table);
|
||||||
|
// Nothing was written; abort rather than commit an empty txn.
|
||||||
|
tx.abort()?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
spent_after = spent + 1;
|
||||||
|
table.insert(month, spent_after)?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(Some(spent_after))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls already spent in `month`. Read-only — for health and the config
|
||||||
|
/// surface, never on the request path.
|
||||||
|
pub fn jamendo_quota_used(&self, month: &str) -> Result<u64, redb::Error> {
|
||||||
|
let tx = self.db.begin_read()?;
|
||||||
|
let table = tx.open_table(JAMENDO_QUOTA_TABLE)?;
|
||||||
|
Ok(table.get(month)?.map(|v| v.value()).unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
str_table_accessors!(
|
str_table_accessors!(
|
||||||
/// Read a durable plugin scratch value (see [`MISC_TABLE`]).
|
/// Read a durable plugin scratch value (see [`MISC_TABLE`]).
|
||||||
get_misc,
|
get_misc,
|
||||||
@@ -351,3 +439,68 @@ mod tests {
|
|||||||
assert_eq!(cache.entry_count().unwrap(), 2);
|
assert_eq!(cache.entry_count().unwrap(), 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cache key for a search: entity type plus the normalised query text.
|
||||||
|
///
|
||||||
|
/// Normalisation is lowercase + whitespace-collapse only. Deliberately not
|
||||||
|
/// more aggressive: MusicBrainz treats punctuation as significant (`Re:ZERO`,
|
||||||
|
/// `M!LK`, `!!!` are all real artist names), so stripping it would collapse
|
||||||
|
/// distinct queries onto one cache entry and serve the wrong answer.
|
||||||
|
pub fn search_key(entity: &str, text: &str) -> String {
|
||||||
|
let normalised = text.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase();
|
||||||
|
format!("{entity}\u{1}{normalised}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod music_table_tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_each_table_independently() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let c = MidhashCache::open(dir.path()).expect("open");
|
||||||
|
|
||||||
|
c.put_mb_search("k", "v-search").unwrap();
|
||||||
|
c.put_mb_release_group("k", "v-rg").unwrap();
|
||||||
|
c.put_mb_artist("k", "v-artist").unwrap();
|
||||||
|
c.put_mb_tracklist("k", "v-tracks").unwrap();
|
||||||
|
c.put_ia_item("k", "v-ia").unwrap();
|
||||||
|
|
||||||
|
// Same key in five tables must not collide.
|
||||||
|
assert_eq!(c.get_mb_search("k").unwrap().as_deref(), Some("v-search"));
|
||||||
|
assert_eq!(c.get_mb_release_group("k").unwrap().as_deref(), Some("v-rg"));
|
||||||
|
assert_eq!(c.get_mb_artist("k").unwrap().as_deref(), Some("v-artist"));
|
||||||
|
assert_eq!(c.get_mb_tracklist("k").unwrap().as_deref(), Some("v-tracks"));
|
||||||
|
assert_eq!(c.get_ia_item("k").unwrap().as_deref(), Some("v-ia"));
|
||||||
|
assert!(c.get_mb_search("absent").unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_key_normalises_case_and_whitespace_only() {
|
||||||
|
assert_eq!(
|
||||||
|
search_key("release-group", " Kind OF Blue "),
|
||||||
|
search_key("release-group", "kind of blue")
|
||||||
|
);
|
||||||
|
// Entity namespacing keeps an artist search off a release-group entry.
|
||||||
|
assert_ne!(
|
||||||
|
search_key("artist", "miles davis"),
|
||||||
|
search_key("release-group", "miles davis")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Punctuation is significant to MusicBrainz, so it must survive
|
||||||
|
/// normalisation — otherwise two genuinely different works share a cache
|
||||||
|
/// entry and one of them is served the other's answer.
|
||||||
|
#[test]
|
||||||
|
fn search_key_preserves_punctuation() {
|
||||||
|
assert_ne!(
|
||||||
|
search_key("artist", "m!lk"),
|
||||||
|
search_key("artist", "mlk"),
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
search_key("release-group", "re:zero"),
|
||||||
|
search_key("release-group", "rezero"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -286,3 +286,43 @@ mod tests {
|
|||||||
assert!(decode_infohash("").is_none());
|
assert!(decode_infohash("").is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Percent-encode for a URL **path or query value**, escaping space as `%20`.
|
||||||
|
///
|
||||||
|
/// ⚠ **Not interchangeable with [`urlencode`]**, which encodes space as `+`.
|
||||||
|
/// The `+` form is correct for `application/x-www-form-urlencoded` bodies and
|
||||||
|
/// wrong for a Lucene-backed search API — MusicBrainz's `/ws/2` treats `+` as
|
||||||
|
/// the "required term" metacharacter, so a `+`-encoded space silently changes
|
||||||
|
/// the query's meaning rather than failing loudly. Use this one for anything
|
||||||
|
/// going into a query *value*; use `urlencode` for form bodies.
|
||||||
|
pub fn percent_encode(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() * 2);
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
out.push(b as char)
|
||||||
|
}
|
||||||
|
b' ' => out.push_str("%20"),
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalise a variable-precision date (`YYYY`, `YYYY-MM`, `YYYY-MM-DD`) to a
|
||||||
|
/// full `YYYY-MM-DD`, or `None` if it is not one.
|
||||||
|
///
|
||||||
|
/// ⚠ Validate the shape, never infer it from the length: a first cut matched on
|
||||||
|
/// `len()` alone, which turned the seven-character string "garbage" into the
|
||||||
|
/// `releasedate` "garbage-01" — a malformed value written to the shared hash
|
||||||
|
/// where nothing downstream would question it.
|
||||||
|
pub fn normalise_date(raw: &str) -> Option<String> {
|
||||||
|
let digits = |s: &str, n: usize| s.len() == n && s.chars().all(|c| c.is_ascii_digit());
|
||||||
|
let parts: Vec<&str> = raw.trim().split('-').collect();
|
||||||
|
match parts.as_slice() {
|
||||||
|
[y] if digits(y, 4) => Some(format!("{y}-01-01")),
|
||||||
|
[y, m] if digits(y, 4) && digits(m, 2) => Some(format!("{y}-{m}-01")),
|
||||||
|
[y, m, d] if digits(y, 4) && digits(m, 2) && digits(d, 2) => Some(format!("{y}-{m}-{d}")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>feeder config</title>
|
<title>plugin config</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 id="title">Plugin configuration</h1>
|
<h1 id="title">Plugin configuration</h1>
|
||||||
<p class="sub">Secrets are write-only — set values are shown as “set”, never echoed back. Saving requires a feeder restart to take effect. Fields marked <span class="req">*</span> are required.</p>
|
<p class="sub">Secrets are write-only — set values are shown as “set”, never echoed back. Saving restarts the plugin to apply. Fields marked <span class="req">*</span> are required.</p>
|
||||||
<div id="banner" class="banner"></div>
|
<div id="banner" class="banner"></div>
|
||||||
<form id="form"></form>
|
<form id="form"></form>
|
||||||
<div class="bar" id="bar" style="display:none">
|
<div class="bar" id="bar" style="display:none">
|
||||||
@@ -220,14 +220,14 @@ async function save() {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = (await res.text().catch(() => "")).trim();
|
const detail = (await res.text().catch(() => "")).trim();
|
||||||
throw new Error(detail ? `the feeder rejected it (HTTP ${res.status}): ${detail}`
|
throw new Error(detail ? `the plugin rejected it (HTTP ${res.status}): ${detail}`
|
||||||
: `the feeder returned HTTP ${res.status}`);
|
: `the plugin returned HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const j = await res.json().catch(() => ({}));
|
const j = await res.json().catch(() => ({}));
|
||||||
if (j.reloading) {
|
if (j.reloading) {
|
||||||
// The feeder is restarting itself (~2-3s) to apply the change. Poll it
|
// The feeder is restarting itself (~2-3s) to apply the change. Poll it
|
||||||
// back up, then refresh the form so secret pills reflect stored state.
|
// back up, then refresh the form so secret pills reflect stored state.
|
||||||
banner("warn", "Saved — reloading the feeder to apply (~3 s)…");
|
banner("warn", "Saved — reloading the plugin to apply (~3 s)…");
|
||||||
let tries = 6;
|
let tries = 6;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try { await load(); banner("ok", "Reloaded — new config applied."); }
|
try { await load(); banner("ok", "Reloaded — new config applied."); }
|
||||||
@@ -235,7 +235,7 @@ async function save() {
|
|||||||
};
|
};
|
||||||
setTimeout(poll, 3000);
|
setTimeout(poll, 3000);
|
||||||
} else {
|
} else {
|
||||||
if (j.restart_required) banner("warn", "Saved. Restart the feeder container for changes to take effect.");
|
if (j.restart_required) banner("warn", "Saved. Restart the plugin container for changes to take effect.");
|
||||||
else banner("ok", "Saved.");
|
else banner("ok", "Saved.");
|
||||||
// Re-fetch so secret pills reflect the new stored state.
|
// Re-fetch so secret pills reflect the new stored state.
|
||||||
await load();
|
await load();
|
||||||
@@ -243,7 +243,7 @@ async function save() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = (e && e.message) ? e.message : String(e);
|
const msg = (e && e.message) ? e.message : String(e);
|
||||||
const friendly = /Failed to fetch|NetworkError|load failed/i.test(msg)
|
const friendly = /Failed to fetch|NetworkError|load failed/i.test(msg)
|
||||||
? "couldn't reach the feeder — it may be restarting; wait a moment and try again."
|
? "couldn't reach the plugin — it may be restarting; wait a moment and try again."
|
||||||
: msg;
|
: msg;
|
||||||
banner("err", "Could not save: " + friendly);
|
banner("err", "Could not save: " + friendly);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+549
@@ -0,0 +1,549 @@
|
|||||||
|
//! `contentKind` → `domain` / `workForm` — the routing tables from
|
||||||
|
//! METADATA_KEYS.md §1.
|
||||||
|
//!
|
||||||
|
//! `domain` answers **"which application is this record destined for?"**. It
|
||||||
|
//! is the coarsest facet in the registry and the key every client filters its
|
||||||
|
//! catalogue on — **one value per app**, no alternation:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! meta-watch → domain:screen
|
||||||
|
//! meta-listen → domain:music
|
||||||
|
//! meta-read → domain:literature
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `workForm` answers the *other* question the old `film`/`tv` split was
|
||||||
|
//! secretly encoding: **is this work complete in itself, or one instalment of
|
||||||
|
//! a run?** (`standalone` | `serial`). It is cross-domain — a comic issue is
|
||||||
|
//! `serial` in `literature` exactly as an episode is `serial` in `screen` —
|
||||||
|
//! which is what lets a client ask for it independently of the wall.
|
||||||
|
//!
|
||||||
|
//! # Why they are stored and not derived on read
|
||||||
|
//!
|
||||||
|
//! `contentKind`'s vocabulary is deliberately open — a new feeder may mint a
|
||||||
|
//! kind and ship without a fleet-wide update. That only stays safe because the
|
||||||
|
//! domain travels *with* the record: a peer that has never heard of
|
||||||
|
//! `podcastEpisode` still routes it correctly, because it never has to
|
||||||
|
//! interpret the token. Deriving on read would silently drop every record
|
||||||
|
//! whose kind the reader predates. The same argument applies to `workForm`,
|
||||||
|
//! plus a second one: the two kinds that most need it (`pack` and `card`) are
|
||||||
|
//! exactly the two the kind alone cannot decide.
|
||||||
|
//!
|
||||||
|
//! # ⚠ The binding is permanent and one-way
|
||||||
|
//!
|
||||||
|
//! Add kinds freely; **never re-map an existing kind to a different domain**.
|
||||||
|
//! Because the value is stored, two peers on different versions that disagree
|
||||||
|
//! about where `audiobook` belongs produce a split-brain that redeploying
|
||||||
|
//! cannot heal — the wrong domain is already written into records across the
|
||||||
|
//! mesh. If a kind genuinely has to move, mint a new kind.
|
||||||
|
//!
|
||||||
|
//! The `film`+`tv` → `screen` merge (METADATA_KEYS.md §14.17) is the one
|
||||||
|
//! deliberate exception, taken once and early and paid for with a one-shot
|
||||||
|
//! meta-core corpus sweep. It is not a precedent.
|
||||||
|
//!
|
||||||
|
//! # Why `domain` and not one kind-key per medium
|
||||||
|
//!
|
||||||
|
//! `record_matches` is AND-across-keys / OR-within-key, and a non-matching key
|
||||||
|
//! fails the record outright. A per-medium split (`videoKind` / `audioKind` /
|
||||||
|
//! `documentKind`) would force meta-listen to express "music video **or**
|
||||||
|
//! audio track" as an OR across two *different* keys — which the query
|
||||||
|
//! language cannot represent at all. One domain key, OR-ed within itself,
|
||||||
|
//! always can. `workForm` is one key everywhere for the same reason.
|
||||||
|
|
||||||
|
/// The `contentKind` → `domain` table. A kind absent from this list has no
|
||||||
|
/// domain and is **not independently routable** — correct for sidecars
|
||||||
|
/// (subtitles, artwork) and for un-anchored hits, a producer bug for anything
|
||||||
|
/// else.
|
||||||
|
///
|
||||||
|
/// ⚠ `pack` is deliberately absent. A season pack and an album release are the
|
||||||
|
/// same structural thing, so the kind alone cannot tell `screen` from `music`;
|
||||||
|
/// the writer stamps `pack`'s domain from its own context (the same reason
|
||||||
|
/// `derive_file_type` cannot have a second `pack` arm).
|
||||||
|
///
|
||||||
|
/// ⚠ `podcast` / `podcastEpisode` are absent too: the `spoken` domain was
|
||||||
|
/// retired unused (METADATA_KEYS.md §14.17), and the PR that first *writes*
|
||||||
|
/// those kinds picks their domain.
|
||||||
|
const DOMAIN_BY_CONTENT_KIND: &[(&str, &str)] = &[
|
||||||
|
// screen — everything meta-watch consumes, films and serials alike. The
|
||||||
|
// standalone/serial split lives in `workForm`, not here.
|
||||||
|
("movie", "screen"),
|
||||||
|
("series", "screen"),
|
||||||
|
("episode", "screen"),
|
||||||
|
// music
|
||||||
|
("track", "music"),
|
||||||
|
("album", "music"),
|
||||||
|
("artist", "music"),
|
||||||
|
("musicVideo", "music"),
|
||||||
|
("djMix", "music"),
|
||||||
|
("liveSet", "music"),
|
||||||
|
// literature — an audiobook's identity is the book (same author, same ISBN
|
||||||
|
// family, same series as the ebook), so meta-read groups the two editions
|
||||||
|
// on one page. The player does not decide the domain.
|
||||||
|
("book", "literature"),
|
||||||
|
("audiobook", "literature"),
|
||||||
|
("comic", "literature"),
|
||||||
|
("manga", "literature"),
|
||||||
|
("magazine", "literature"),
|
||||||
|
// science
|
||||||
|
("paper", "science"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The `contentKind` → `workForm` table (METADATA_KEYS.md §1).
|
||||||
|
///
|
||||||
|
/// `standalone` = a work complete in itself. `serial` = an instalment of an
|
||||||
|
/// ongoing run, **or the container or headline for one** — which is why a
|
||||||
|
/// `series` card and an `episode` are both `serial`.
|
||||||
|
///
|
||||||
|
/// ⚠ `pack` is absent for the same reason it is absent above: a season pack is
|
||||||
|
/// `serial`, an album release is `standalone`, and only the writer knows which.
|
||||||
|
const WORK_FORM_BY_CONTENT_KIND: &[(&str, &str)] = &[
|
||||||
|
// screen
|
||||||
|
("movie", "standalone"),
|
||||||
|
("series", "serial"),
|
||||||
|
("episode", "serial"),
|
||||||
|
// music — an album is a closed work, not an ongoing run, so every rung of
|
||||||
|
// the music ladder is standalone.
|
||||||
|
("track", "standalone"),
|
||||||
|
("album", "standalone"),
|
||||||
|
("artist", "standalone"),
|
||||||
|
("musicVideo", "standalone"),
|
||||||
|
("djMix", "standalone"),
|
||||||
|
("liveSet", "standalone"),
|
||||||
|
// literature — the split that proves `workForm` is not a screen-only axis.
|
||||||
|
("book", "standalone"),
|
||||||
|
("audiobook", "standalone"),
|
||||||
|
("comic", "serial"),
|
||||||
|
("manga", "serial"),
|
||||||
|
("magazine", "serial"),
|
||||||
|
// science
|
||||||
|
("paper", "standalone"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Resolve the domain a `contentKind` belongs to.
|
||||||
|
///
|
||||||
|
/// Returns `None` for `pack` (not derivable from the kind — the writer supplies
|
||||||
|
/// it), for format/sidecar kinds that route nowhere, and for any kind this
|
||||||
|
/// build has never heard of. A caller that already knows the domain from its
|
||||||
|
/// own context must prefer that over this table.
|
||||||
|
pub fn domain_for_content_kind(content_kind: &str) -> Option<&'static str> {
|
||||||
|
lookup(DOMAIN_BY_CONTENT_KIND, content_kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the work form a `contentKind` sits on. Same contract as
|
||||||
|
/// [`domain_for_content_kind`]: `None` means "not derivable", never a guess.
|
||||||
|
pub fn work_form_for_content_kind(content_kind: &str) -> Option<&'static str> {
|
||||||
|
lookup(WORK_FORM_BY_CONTENT_KIND, content_kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(table: &'static [(&'static str, &'static str)], key: &str) -> Option<&'static str> {
|
||||||
|
let k = key.trim();
|
||||||
|
table
|
||||||
|
.iter()
|
||||||
|
.find(|(kind, _)| kind.eq_ignore_ascii_case(k))
|
||||||
|
.map(|(_, value)| *value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kinds whose `domain` the kind→domain table deliberately cannot decide, so
|
||||||
|
/// the **writer** supplies it (METADATA_KEYS.md §1). Today that is `pack`
|
||||||
|
/// alone: a season pack and an album release are the same structural thing, and
|
||||||
|
/// only the producer knows which — indexer-feeder's `bt.rs` reads the domain off
|
||||||
|
/// the per-file kind *before* overwriting it with `pack` precisely so the value
|
||||||
|
/// survives.
|
||||||
|
///
|
||||||
|
/// They must ride along in every `domain:` → `contentKind:` expansion. The
|
||||||
|
/// expansion is only ever a **pre-filter** — the real `domain:` term is enforced
|
||||||
|
/// afterwards by [`crate::query_eval::record_matches`] — so widening it is free,
|
||||||
|
/// while omitting these kinds would drop exactly the records whose `domain` is
|
||||||
|
/// the most authoritative one on the wire.
|
||||||
|
pub const WRITER_SUPPLIED_DOMAIN_KINDS: &[&str] = &["pack"];
|
||||||
|
|
||||||
|
/// The `workForm` twin of [`WRITER_SUPPLIED_DOMAIN_KINDS`], and for the same
|
||||||
|
/// reason: a season pack is `serial`, an album release is `standalone`.
|
||||||
|
pub const WRITER_SUPPLIED_WORK_FORM_KINDS: &[&str] = &["pack"];
|
||||||
|
|
||||||
|
/// The inverse: every `contentKind` that belongs to `domain`.
|
||||||
|
///
|
||||||
|
/// Used at the gateway-routing boundary. A gateway declares the *content kinds*
|
||||||
|
/// it serves (`served_content_kinds`), not domains, so a `domain:` filter is
|
||||||
|
/// matched by expanding it here rather than by adding a `served_domains` field
|
||||||
|
/// to the heartbeat — which would be a wire-protocol change requiring every
|
||||||
|
/// feeder manifest and every peer to move at once.
|
||||||
|
///
|
||||||
|
/// ⚠ Since the `film`+`tv` merge this returns **several** kinds for `screen`
|
||||||
|
/// (`movie`, `series`, `episode`) where the two old values were disjoint. A
|
||||||
|
/// caller that reads only the first element silently means "movie" — scan the
|
||||||
|
/// whole list, or narrow with a `workForm:` term first.
|
||||||
|
pub fn content_kinds_for_domain(domain: &str) -> Vec<&'static str> {
|
||||||
|
inverse(DOMAIN_BY_CONTENT_KIND, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse of the `workForm` table. Same routing-boundary role, same
|
||||||
|
/// scan-the-whole-list warning as [`content_kinds_for_domain`].
|
||||||
|
pub fn content_kinds_for_work_form(work_form: &str) -> Vec<&'static str> {
|
||||||
|
inverse(WORK_FORM_BY_CONTENT_KIND, work_form)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inverse(table: &'static [(&'static str, &'static str)], value: &str) -> Vec<&'static str> {
|
||||||
|
let v = value.trim();
|
||||||
|
table
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, val)| val.eq_ignore_ascii_case(v))
|
||||||
|
.map(|(kind, _)| *kind)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite a query so a `domain:` / `workForm:` filter can reach a plugin that
|
||||||
|
/// has never heard of either key.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the query carries neither — the common path, and cheaper
|
||||||
|
/// than cloning.
|
||||||
|
///
|
||||||
|
/// # Why translate instead of teaching every plugin
|
||||||
|
///
|
||||||
|
/// `record_matches` fails a record outright when a filter key is missing from
|
||||||
|
/// its fields, and `domain` is missing from every record a not-yet-updated
|
||||||
|
/// feeder emits (`workForm` from every record written before it existed).
|
||||||
|
/// Most feeders pin the SDK by git tag, so they cannot pick up a new key on our
|
||||||
|
/// schedule. Translating `domain:literature` into the `contentKind:`
|
||||||
|
/// alternation it covers means the wire query a plugin sees never mentions
|
||||||
|
/// either key — old plugins keep working unchanged, while the dispatcher still
|
||||||
|
/// enforces the real filters afterwards, against records it has just stamped.
|
||||||
|
///
|
||||||
|
/// # The two axes intersect
|
||||||
|
///
|
||||||
|
/// `domain:screen workForm:serial` covers `series` and `episode` — the
|
||||||
|
/// **intersection**, not the union. Widening to the union would hand a serial
|
||||||
|
/// query the whole film corpus as a pre-filter; the post-stamp check would then
|
||||||
|
/// throw it away, having already paid for it upstream.
|
||||||
|
///
|
||||||
|
/// An explicit `contentKind:` filter already in the query is left alone: it is
|
||||||
|
/// the narrower of the three, and the `domain`/`workForm` terms are still
|
||||||
|
/// enforced post-stamp, so the AND semantics hold either way.
|
||||||
|
pub fn expand_routing_filters(
|
||||||
|
query: &crate::query::GatewayQuery,
|
||||||
|
) -> Option<crate::query::GatewayQuery> {
|
||||||
|
let domains = query.filters.get("domain");
|
||||||
|
let work_forms = query.filters.get("workForm");
|
||||||
|
if domains.is_none() && work_forms.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let by_domain = domains.map(|vs| expand_values(vs, content_kinds_for_domain));
|
||||||
|
let by_work_form = work_forms.map(|vs| expand_values(vs, content_kinds_for_work_form));
|
||||||
|
|
||||||
|
let mut out = query.clone();
|
||||||
|
out.filters.remove("domain");
|
||||||
|
out.filters.remove("workForm");
|
||||||
|
if out.filters.contains_key("contentKind") {
|
||||||
|
return Some(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut kinds: Vec<&'static str> = match (by_domain, by_work_form) {
|
||||||
|
(Some(d), Some(w)) => d.into_iter().filter(|k| w.contains(k)).collect(),
|
||||||
|
(Some(d), None) => d,
|
||||||
|
(None, Some(w)) => w,
|
||||||
|
(None, None) => unreachable!("guarded above"),
|
||||||
|
};
|
||||||
|
// Domain-ambiguous kinds come along whenever the expansion is non-empty
|
||||||
|
// — see [`WRITER_SUPPLIED_DOMAIN_KINDS`]. Skipped when nothing mapped,
|
||||||
|
// so an unknown value still expands to nothing and leaves the filter
|
||||||
|
// out entirely (below) rather than narrowing to `contentKind:pack`.
|
||||||
|
if !kinds.is_empty() {
|
||||||
|
for k in WRITER_SUPPLIED_DOMAIN_KINDS
|
||||||
|
.iter()
|
||||||
|
.chain(WRITER_SUPPLIED_WORK_FORM_KINDS.iter())
|
||||||
|
{
|
||||||
|
if !kinds.contains(k) {
|
||||||
|
kinds.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An unknown value — or two axes with no kind in common — expands to
|
||||||
|
// nothing. Inserting an empty alternation would drop every record at the
|
||||||
|
// plugin; leaving the filter out lets the plugin answer and the post-stamp
|
||||||
|
// check do the (correct, empty) filtering.
|
||||||
|
if !kinds.is_empty() {
|
||||||
|
out.filters.insert(
|
||||||
|
"contentKind".to_string(),
|
||||||
|
kinds.into_iter().map(|k| k.to_string()).collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deprecated alias for [`expand_routing_filters`], kept so a caller pinned to
|
||||||
|
/// an older SDK keeps compiling. It expands `workForm:` too — the name is the
|
||||||
|
/// only thing that is stale.
|
||||||
|
#[deprecated(note = "renamed to expand_routing_filters; it now expands workForm: as well")]
|
||||||
|
pub fn expand_domain_filter(
|
||||||
|
query: &crate::query::GatewayQuery,
|
||||||
|
) -> Option<crate::query::GatewayQuery> {
|
||||||
|
expand_routing_filters(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_values(
|
||||||
|
values: &[String],
|
||||||
|
expand: fn(&str) -> Vec<&'static str>,
|
||||||
|
) -> Vec<&'static str> {
|
||||||
|
let mut out: Vec<&'static str> = Vec::new();
|
||||||
|
for v in values {
|
||||||
|
for k in expand(v) {
|
||||||
|
if !out.contains(&k) {
|
||||||
|
out.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp `domain` on a field map that already carries a `contentKind`.
|
||||||
|
///
|
||||||
|
/// The convention from METADATA_KEYS.md §1: **whoever writes `contentKind`
|
||||||
|
/// writes `domain` in the same breath**. An existing `domain` is never
|
||||||
|
/// overwritten — a writer that knows better than the table (a `pack`, say) has
|
||||||
|
/// already set it.
|
||||||
|
pub fn stamp_domain(fields: &mut std::collections::BTreeMap<String, String>) {
|
||||||
|
stamp(fields, "domain", domain_for_content_kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp `workForm` the same way, from the same `contentKind`. Call it
|
||||||
|
/// wherever [`stamp_domain`] is called — the registry treats the two as one
|
||||||
|
/// breath.
|
||||||
|
pub fn stamp_work_form(fields: &mut std::collections::BTreeMap<String, String>) {
|
||||||
|
stamp(fields, "workForm", work_form_for_content_kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stamp(
|
||||||
|
fields: &mut std::collections::BTreeMap<String, String>,
|
||||||
|
key: &str,
|
||||||
|
derive: fn(&str) -> Option<&'static str>,
|
||||||
|
) {
|
||||||
|
if fields.contains_key(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(kind) = fields.get("contentKind").cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(value) = derive(&kind) {
|
||||||
|
fields.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kinds_map_to_their_domain() {
|
||||||
|
assert_eq!(domain_for_content_kind("movie"), Some("screen"));
|
||||||
|
assert_eq!(domain_for_content_kind("episode"), Some("screen"));
|
||||||
|
assert_eq!(domain_for_content_kind("track"), Some("music"));
|
||||||
|
// The case that proves medium and domain are different axes.
|
||||||
|
assert_eq!(domain_for_content_kind("musicVideo"), Some("music"));
|
||||||
|
// The case that proves the player does not decide the domain.
|
||||||
|
assert_eq!(domain_for_content_kind("audiobook"), Some("literature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn film_and_tv_are_one_domain_now() {
|
||||||
|
// The merge (METADATA_KEYS.md §14.17): meta-watch's wall is one term.
|
||||||
|
assert_eq!(
|
||||||
|
domain_for_content_kind("movie"),
|
||||||
|
domain_for_content_kind("series")
|
||||||
|
);
|
||||||
|
// ...and the split it used to carry lives here instead.
|
||||||
|
assert_eq!(work_form_for_content_kind("movie"), Some("standalone"));
|
||||||
|
assert_eq!(work_form_for_content_kind("series"), Some("serial"));
|
||||||
|
assert_eq!(work_form_for_content_kind("episode"), Some("serial"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn work_form_is_not_a_screen_only_axis() {
|
||||||
|
// The point of the key: it means the same thing in every domain.
|
||||||
|
assert_eq!(work_form_for_content_kind("comic"), Some("serial"));
|
||||||
|
assert_eq!(work_form_for_content_kind("book"), Some("standalone"));
|
||||||
|
assert_eq!(work_form_for_content_kind("album"), Some("standalone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_spoken_domain_is_gone() {
|
||||||
|
// Retired unused. The kinds stay reserved with no domain until the PR
|
||||||
|
// that writes them picks one.
|
||||||
|
assert_eq!(domain_for_content_kind("podcast"), None);
|
||||||
|
assert_eq!(domain_for_content_kind("podcastEpisode"), None);
|
||||||
|
assert!(content_kinds_for_domain("spoken").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pack_has_no_derivable_domain_or_work_form() {
|
||||||
|
assert_eq!(domain_for_content_kind("pack"), None);
|
||||||
|
assert_eq!(work_form_for_content_kind("pack"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_kind_is_none_not_a_guess() {
|
||||||
|
assert_eq!(domain_for_content_kind("hologram"), None);
|
||||||
|
assert_eq!(work_form_for_content_kind("hologram"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expansion_is_the_inverse() {
|
||||||
|
let music = content_kinds_for_domain("music");
|
||||||
|
assert!(music.contains(&"track"));
|
||||||
|
assert!(music.contains(&"musicVideo"));
|
||||||
|
assert!(!music.contains(&"audiobook"));
|
||||||
|
for kind in content_kinds_for_domain("literature") {
|
||||||
|
assert_eq!(domain_for_content_kind(kind), Some("literature"));
|
||||||
|
}
|
||||||
|
for kind in content_kinds_for_work_form("serial") {
|
||||||
|
assert_eq!(work_form_for_content_kind(kind), Some("serial"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_expands_to_every_rung_not_just_the_first() {
|
||||||
|
// The regression the merge creates for any caller reading `.first()`.
|
||||||
|
let screen = content_kinds_for_domain("screen");
|
||||||
|
assert!(screen.contains(&"movie"));
|
||||||
|
assert!(screen.contains(&"series"));
|
||||||
|
assert!(screen.contains(&"episode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn q(filters: &[(&str, &[&str])]) -> crate::query::GatewayQuery {
|
||||||
|
crate::query::GatewayQuery {
|
||||||
|
raw_text: String::new(),
|
||||||
|
free_text: String::new(),
|
||||||
|
filters: filters
|
||||||
|
.iter()
|
||||||
|
.map(|(k, vs)| {
|
||||||
|
(
|
||||||
|
k.to_string(),
|
||||||
|
vs.iter().map(|v| v.to_string()).collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
ranges: Vec::new(),
|
||||||
|
negations: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_routing_filter_is_left_untouched() {
|
||||||
|
assert!(expand_routing_filters(&q(&[("fileType", &["video"])])).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_becomes_the_content_kinds_it_covers() {
|
||||||
|
let out = expand_routing_filters(&q(&[("domain", &["literature"])])).unwrap();
|
||||||
|
// The plugin never sees `domain:` — that is the whole point.
|
||||||
|
assert!(!out.filters.contains_key("domain"));
|
||||||
|
let kinds = out.filters.get("contentKind").unwrap();
|
||||||
|
assert!(kinds.contains(&"book".to_string()));
|
||||||
|
assert!(kinds.contains(&"audiobook".to_string()));
|
||||||
|
assert!(!kinds.contains(&"track".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn work_form_alone_also_expands() {
|
||||||
|
let out = expand_routing_filters(&q(&[("workForm", &["serial"])])).unwrap();
|
||||||
|
assert!(!out.filters.contains_key("workForm"));
|
||||||
|
let kinds = out.filters.get("contentKind").unwrap();
|
||||||
|
assert!(kinds.contains(&"episode".to_string()));
|
||||||
|
assert!(kinds.contains(&"comic".to_string()));
|
||||||
|
assert!(!kinds.contains(&"movie".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_axes_intersect_they_do_not_union() {
|
||||||
|
let out = expand_routing_filters(&q(&[
|
||||||
|
("domain", &["screen"][..]),
|
||||||
|
("workForm", &["serial"][..]),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let kinds = out.filters.get("contentKind").unwrap();
|
||||||
|
assert!(kinds.contains(&"series".to_string()));
|
||||||
|
assert!(kinds.contains(&"episode".to_string()));
|
||||||
|
// The union would have dragged the whole film corpus in.
|
||||||
|
assert!(!kinds.contains(&"movie".to_string()));
|
||||||
|
// A `serial` comic is in `literature`, not on meta-watch's wall.
|
||||||
|
assert!(!kinds.contains(&"comic".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_content_kind_filter_wins() {
|
||||||
|
let out = expand_routing_filters(&q(&[
|
||||||
|
("domain", &["music"][..]),
|
||||||
|
("contentKind", &["track"][..]),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out.filters.get("contentKind").map(Vec::as_slice),
|
||||||
|
Some(&["track".to_string()][..])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expansion_carries_the_writer_supplied_kinds() {
|
||||||
|
// `pack` has no domain in the table (a season pack and an album are the
|
||||||
|
// same structural thing), so the writer stamps one. Leaving it out of
|
||||||
|
// the alternation would make a `domain:screen` pre-filter drop the whole
|
||||||
|
// torrent-pack corpus, which `record_matches` would then never see.
|
||||||
|
let out = expand_routing_filters(&q(&[("domain", &["screen"])])).unwrap();
|
||||||
|
let kinds = out.filters.get("contentKind").unwrap();
|
||||||
|
assert!(kinds.contains(&"episode".to_string()));
|
||||||
|
assert!(kinds.contains(&"pack".to_string()));
|
||||||
|
// ...and it rides along on the `workForm` axis too, exactly once.
|
||||||
|
let out = expand_routing_filters(&q(&[("workForm", &["serial"])])).unwrap();
|
||||||
|
let kinds = out.filters.get("contentKind").unwrap();
|
||||||
|
assert_eq!(kinds.iter().filter(|k| *k == "pack").count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_value_adds_no_alternation() {
|
||||||
|
// An empty alternation would drop every record at the plugin; the
|
||||||
|
// post-stamp check does the (correct, empty) filtering.
|
||||||
|
let out = expand_routing_filters(&q(&[("domain", &["hologram"])])).unwrap();
|
||||||
|
// Not even `pack`: a lone `contentKind:pack` would be a NARROWING of a
|
||||||
|
// filter that should widen to nothing.
|
||||||
|
assert!(!out.filters.contains_key("contentKind"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_axes_with_nothing_in_common_widen_rather_than_narrow() {
|
||||||
|
// No music kind is `serial`. The pre-filter drops out; the post-stamp
|
||||||
|
// check still enforces both terms.
|
||||||
|
let out = expand_routing_filters(&q(&[
|
||||||
|
("domain", &["music"][..]),
|
||||||
|
("workForm", &["serial"][..]),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.filters.contains_key("contentKind"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stamp_fills_in_and_never_overwrites() {
|
||||||
|
let mut f: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
f.insert("contentKind".into(), "episode".into());
|
||||||
|
stamp_domain(&mut f);
|
||||||
|
stamp_work_form(&mut f);
|
||||||
|
assert_eq!(f.get("domain").map(String::as_str), Some("screen"));
|
||||||
|
assert_eq!(f.get("workForm").map(String::as_str), Some("serial"));
|
||||||
|
|
||||||
|
// A pack writer that already knows its context keeps it.
|
||||||
|
let mut p: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
p.insert("contentKind".into(), "pack".into());
|
||||||
|
p.insert("domain".into(), "music".into());
|
||||||
|
p.insert("workForm".into(), "standalone".into());
|
||||||
|
stamp_domain(&mut p);
|
||||||
|
stamp_work_form(&mut p);
|
||||||
|
assert_eq!(p.get("domain").map(String::as_str), Some("music"));
|
||||||
|
assert_eq!(p.get("workForm").map(String::as_str), Some("standalone"));
|
||||||
|
|
||||||
|
// No contentKind → nothing invented.
|
||||||
|
let mut n: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
stamp_domain(&mut n);
|
||||||
|
stamp_work_form(&mut n);
|
||||||
|
assert!(n.get("domain").is_none());
|
||||||
|
assert!(n.get("workForm").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+515
-18
@@ -206,6 +206,76 @@ pub fn compute_nzb_release_cid(api_base: &str, release_id: &str) -> Option<Strin
|
|||||||
Some(format!("b{}", base32_lower_no_padding(&wire)))
|
Some(format!("b{}", base32_lower_no_padding(&wire)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode a self-describing `nzb-release` cid into `(api_base, id)` — the exact
|
||||||
|
/// inverse of [`compute_nzb_release_cid`], string-level (no `cid` crate).
|
||||||
|
///
|
||||||
|
/// `api_base` is scheme-less authority + optional path (`api.nzbgeek.info/api`);
|
||||||
|
/// match credentials on its authority (split at the first `/`), grab against
|
||||||
|
/// `https://{api_base}`. `id` is lowercase hex, re-encoded from the digest — so
|
||||||
|
/// an **odd-length** id comes back left-padded with `0` (the encoder pads it),
|
||||||
|
/// which Newznab `t=get` accepts as the same release.
|
||||||
|
///
|
||||||
|
/// `None` on a wrong codec, a non-identity multihash, or a malformed digest.
|
||||||
|
pub fn decode_nzb_release_cid(cid: &str) -> Option<(String, String)> {
|
||||||
|
let digest = identity_locator_digest(cid, NZB_RELEASE_CODEC)?;
|
||||||
|
let mut pos = 0usize;
|
||||||
|
let base_len = read_varint(&digest, &mut pos)? as usize;
|
||||||
|
let rest = digest.get(pos..)?;
|
||||||
|
if rest.len() <= base_len {
|
||||||
|
return None; // need the base AND at least one id byte
|
||||||
|
}
|
||||||
|
let (base, id) = rest.split_at(base_len);
|
||||||
|
let api_base = std::str::from_utf8(base).ok()?.to_string();
|
||||||
|
let id_hex: String = id.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
Some((api_base, id_hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The multicodec of a base32 (`b…`) CIDv1, or `None` for anything else
|
||||||
|
/// (CIDv0 `Qm…`, other multibases, garbage). Dispatch on this — never on the
|
||||||
|
/// multihash, which is identity (`0x00`) for every locator family.
|
||||||
|
pub fn codec_of(cid: &str) -> Option<u64> {
|
||||||
|
let wire = base32_lower_decode(cid.strip_prefix('b')?)?;
|
||||||
|
let mut pos = 0usize;
|
||||||
|
if read_varint(&wire, &mut pos)? != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
read_varint(&wire, &mut pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digest of a well-formed identity-multihash CIDv1 carrying `codec`, or
|
||||||
|
/// `None`. Shared by the locator decoders.
|
||||||
|
fn identity_locator_digest(cid: &str, codec: u64) -> Option<Vec<u8>> {
|
||||||
|
let wire = base32_lower_decode(cid.strip_prefix('b')?)?;
|
||||||
|
let mut pos = 0usize;
|
||||||
|
if read_varint(&wire, &mut pos)? != 1
|
||||||
|
|| read_varint(&wire, &mut pos)? != codec
|
||||||
|
|| read_varint(&wire, &mut pos)? != 0x00
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let len = read_varint(&wire, &mut pos)? as usize;
|
||||||
|
let digest = wire.get(pos..)?;
|
||||||
|
(digest.len() == len).then(|| digest.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsigned LEB128 varint at `*pos`, advancing it.
|
||||||
|
fn read_varint(bytes: &[u8], pos: &mut usize) -> Option<u64> {
|
||||||
|
let mut value = 0u64;
|
||||||
|
let mut shift = 0u32;
|
||||||
|
loop {
|
||||||
|
let byte = *bytes.get(*pos)?;
|
||||||
|
*pos += 1;
|
||||||
|
value |= u64::from(byte & 0x7f) << shift;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
|
if shift >= 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hex-decode a Newznab release id. The guid parser only keeps hex runs, so the
|
/// Hex-decode a Newznab release id. The guid parser only keeps hex runs, so the
|
||||||
/// id is all-hex by construction; odd-length ids are left-padded with a `0`
|
/// id is all-hex by construction; odd-length ids are left-padded with a `0`
|
||||||
/// nibble. Returns `None` on any non-hex byte (defensive).
|
/// nibble. Returns `None` on any non-hex byte (defensive).
|
||||||
@@ -248,8 +318,12 @@ fn decode_hex_id(id: &str) -> Option<Vec<u8>> {
|
|||||||
/// merge (a TMDB and a MyAnimeList locator on one meta-core record) safe.
|
/// merge (a TMDB and a MyAnimeList locator on one meta-core record) safe.
|
||||||
pub const CARD_LOCATOR_CODEC: u64 = 0x1007;
|
pub const CARD_LOCATOR_CODEC: u64 = 0x1007;
|
||||||
|
|
||||||
/// Digest byte ceiling for a card locator, matching meta-share's
|
/// Digest byte ceiling for the locators that still carry one — `yt-video`
|
||||||
/// `MAX_MULTIHASH_SIZE` — its `CidGeneric<64>` rejects a longer multihash.
|
/// (via [`kinded_locator_cid`]) and `ext-play`. Matches meta-share's
|
||||||
|
/// `MAX_MULTIHASH_SIZE`, whose `CidGeneric<64>` rejects a longer multihash.
|
||||||
|
///
|
||||||
|
/// ⚠ **[`compute_card_cid`] is deliberately NOT subject to this** — see its
|
||||||
|
/// doc. The name is kept because both remaining users were written against it.
|
||||||
const CARD_LOCATOR_MAX_DIGEST: usize = 64;
|
const CARD_LOCATOR_MAX_DIGEST: usize = 64;
|
||||||
|
|
||||||
/// Encode a self-describing **card locator** CID from a metadata source and
|
/// Encode a self-describing **card locator** CID from a metadata source and
|
||||||
@@ -270,8 +344,28 @@ const CARD_LOCATOR_MAX_DIGEST: usize = 64;
|
|||||||
/// operation driven by an explicit cross-source id mapping — never inferred
|
/// operation driven by an explicit cross-source id mapping — never inferred
|
||||||
/// here (see `meta-gateway/docs/others/card-tier-search.md` §9).
|
/// here (see `meta-gateway/docs/others/card-tier-search.md` §9).
|
||||||
///
|
///
|
||||||
/// Returns `None` when `source` is empty or the digest would overflow the
|
/// # No length ceiling, deliberately
|
||||||
/// multihash budget — the caller drops that card.
|
///
|
||||||
|
/// A CIDv1 digest length is a varint, so the wire format imposes no limit; the
|
||||||
|
/// 64-byte figure elsewhere in this file is Rust's fixed-size `CidGeneric<64>`,
|
||||||
|
/// which meta-share needs only because beetswap is
|
||||||
|
/// `Behaviour<const MAX_MULTIHASH_SIZE, B>`. A card is never bitswap content
|
||||||
|
/// (seeding is gated on sha2-256), so that ceiling does not apply — exactly the
|
||||||
|
/// reasoning `METADATA_KEYS.md` §2.1 already records for the `url` locator,
|
||||||
|
/// which ships URLs past it today. meta-core's Go `Decode` hand-parses varints
|
||||||
|
/// with no cap and reads these correctly.
|
||||||
|
///
|
||||||
|
/// This is load-bearing for URL-shaped ids: a card keyed on a work's own
|
||||||
|
/// address (`("weebcentral.com", "/series/…/some-long-slug")`) runs to ~88
|
||||||
|
/// bytes, and capping would silently drop the card rather than mint it.
|
||||||
|
///
|
||||||
|
/// ⚠ The one consumer that still cares is meta-share's `cid_rank`, which parses
|
||||||
|
/// through `MsCid::from_str`. A locator it cannot parse scores `RANK_UNKNOWN`
|
||||||
|
/// instead of `RANK_LOCATOR`; harmless for a card (its record carries one CID,
|
||||||
|
/// so it wins the canonical election at any rank) but pinned by a golden vector
|
||||||
|
/// in `cid-rank-vectors.json` so the two implementations cannot drift unseen.
|
||||||
|
///
|
||||||
|
/// Returns `None` only when `source` or `id` is empty.
|
||||||
pub fn compute_card_cid(source: &str, id: &str) -> Option<String> {
|
pub fn compute_card_cid(source: &str, id: &str) -> Option<String> {
|
||||||
if source.is_empty() || id.is_empty() {
|
if source.is_empty() || id.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -284,9 +378,6 @@ pub fn compute_card_cid(source: &str, id: &str) -> Option<String> {
|
|||||||
write_pb_varint(source_b.len() as u64, &mut digest);
|
write_pb_varint(source_b.len() as u64, &mut digest);
|
||||||
digest.extend_from_slice(source_b);
|
digest.extend_from_slice(source_b);
|
||||||
digest.extend_from_slice(id_b);
|
digest.extend_from_slice(id_b);
|
||||||
if digest.len() > CARD_LOCATOR_MAX_DIGEST {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CIDv1: [version=0x01][codec varint][mh code=0x00 identity][len varint][digest]
|
// CIDv1: [version=0x01][codec varint][mh code=0x00 identity][len varint][digest]
|
||||||
let mut wire = Vec::with_capacity(1 + 3 + 2 + digest.len());
|
let mut wire = Vec::with_capacity(1 + 3 + 2 + digest.len());
|
||||||
@@ -378,8 +469,39 @@ pub fn compute_ext_play_cid(url: &str) -> Option<String> {
|
|||||||
Some(identity_locator_cid(EXT_PLAY_CODEC, digest))
|
Some(identity_locator_cid(EXT_PLAY_CODEC, digest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom multicodec for a fetchable **`provider-file`** locator —
|
||||||
|
/// `docs/cid-formats.md` §8. One codec for every provider whose bytes are
|
||||||
|
/// fetched the same way; the provider lives inside the digest:
|
||||||
|
/// `varint(len(source)) ‖ source ‖ id`. Framing byte-identical to `card`
|
||||||
|
/// (`0x1007`) — only the codec differs — but the byte path *redeems* it (through
|
||||||
|
/// the claiming feeder's `/compute`) instead of 404ing.
|
||||||
|
pub const PROVIDER_FILE_CODEC: u64 = 0x100A;
|
||||||
|
|
||||||
|
/// Encode a `provider-file` CID from a registered source token
|
||||||
|
/// (`opensubtitles`) and that source's id (`file:7061834`). `None` on an empty
|
||||||
|
/// part or a digest past 64 bytes — the caller drops that row rather than emit
|
||||||
|
/// an address no peer can parse.
|
||||||
|
pub fn compute_provider_file_cid(source: &str, id: &str) -> Option<String> {
|
||||||
|
kinded_locator_cid(PROVIDER_FILE_CODEC, source, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`compute_provider_file_cid`]: `(source, id)`. Matches the
|
||||||
|
/// **codec slot** — a `card` with identical framing does not decode here.
|
||||||
|
pub fn decode_provider_file_cid(cid: &str) -> Option<(String, String)> {
|
||||||
|
let digest = identity_locator_digest(cid, PROVIDER_FILE_CODEC)?;
|
||||||
|
let mut pos = 0usize;
|
||||||
|
let source_len = read_varint(&digest, &mut pos)? as usize;
|
||||||
|
let source = digest.get(pos..pos.checked_add(source_len)?)?;
|
||||||
|
let id = digest.get(pos + source_len..)?;
|
||||||
|
Some((
|
||||||
|
String::from_utf8(source.to_vec()).ok()?,
|
||||||
|
String::from_utf8(id.to_vec()).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared encoder for the `varint(len(kind)) ‖ kind ‖ id` locator framing used
|
/// Shared encoder for the `varint(len(kind)) ‖ kind ‖ id` locator framing used
|
||||||
/// by [`compute_card_cid`] and [`compute_yt_video_cid`].
|
/// by [`compute_card_cid`], [`compute_yt_video_cid`] and
|
||||||
|
/// [`compute_provider_file_cid`].
|
||||||
fn kinded_locator_cid(codec: u64, kind: &str, id: &str) -> Option<String> {
|
fn kinded_locator_cid(codec: u64, kind: &str, id: &str) -> Option<String> {
|
||||||
if kind.is_empty() || id.is_empty() {
|
if kind.is_empty() || id.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -423,6 +545,86 @@ fn identity_locator_cid(codec: u64, digest: &[u8]) -> String {
|
|||||||
/// `beetswap::incoming_stream`). The `.nzb` manifest travels separately, as an
|
/// `beetswap::incoming_stream`). The `.nzb` manifest travels separately, as an
|
||||||
/// ordinary sha2-256 IPFS cid in the record's `manifest` field. See
|
/// ordinary sha2-256 IPFS cid in the record's `manifest` field. See
|
||||||
/// `meta-gateway/docs/others/self-hosted-usenet-indexer-study.md` §5.
|
/// `meta-gateway/docs/others/self-hosted-usenet-indexer-study.md` §5.
|
||||||
|
/// Custom multicodec for the **url-locator** family.
|
||||||
|
///
|
||||||
|
/// A CID whose *bytes are a URL string* rather than a hash of the file. It lets
|
||||||
|
/// a record reference externally-hosted bytes without storing a raw URL field:
|
||||||
|
/// the URL is wrapped in a CID, so it flows through every cid-shaped code path
|
||||||
|
/// (`cids/*` membership, `/api/file/{cid}/raw`, the reverse index) exactly like
|
||||||
|
/// a content hash — honouring `METADATA_KEYS.md` rule #5, "every byte reference
|
||||||
|
/// is a `cid`, never a URL".
|
||||||
|
///
|
||||||
|
/// Contrast `ext-play` (`0x1009`), which is wire-identical apart from the
|
||||||
|
/// codec: `0x1006` means **"fetch these bytes once and seed them"**, while
|
||||||
|
/// `0x1009` means "open this; there are no bytes for us, ever".
|
||||||
|
///
|
||||||
|
/// ⚠ **Never route a url-CID through a fixed-64-byte multihash type.** An
|
||||||
|
/// archive download URL runs to ~75 bytes, past the 62-ish ceiling of
|
||||||
|
/// meta-share's `CidGeneric<64>` bitswap alias. That is fine — it is never
|
||||||
|
/// bitswap *content*, and the `/raw` dispatch decodes it off the raw string
|
||||||
|
/// before the fixed-size codec dispatch runs — but a consumer that parses it as
|
||||||
|
/// an `MsCid` will reject a perfectly good locator.
|
||||||
|
pub const URL_LOCATOR_CODEC: u64 = 0x1006;
|
||||||
|
|
||||||
|
/// Encode an `http(s)` URL as a `0x1006` identity-multihash CIDv1.
|
||||||
|
///
|
||||||
|
/// The identity multihash (`0x00`) means "the digest *is* the payload", so
|
||||||
|
/// [`decode_url_cid`] recovers the URL verbatim with zero I/O.
|
||||||
|
///
|
||||||
|
/// `None` for anything that is not an absolute http(s) URL — a relative or
|
||||||
|
/// malformed value would encode fine and then fail unresolvably at play time,
|
||||||
|
/// which is a far more expensive way to find out.
|
||||||
|
pub fn compute_url_cid(url: &str) -> Option<String> {
|
||||||
|
let url = url.trim();
|
||||||
|
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(identity_locator_cid(URL_LOCATOR_CODEC, url.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The artwork locator for a `cover` / `photo` field.
|
||||||
|
///
|
||||||
|
/// A thin alias over [`compute_url_cid`] that names the *intent* at the call
|
||||||
|
/// sites: artwork is referenced by a locator cid, never by a raw `*_url` field
|
||||||
|
/// (METADATA_KEYS rule #5). `None` for a non-http(s) URL, in which case the
|
||||||
|
/// caller writes no artwork field at all — better a card the gate hides than
|
||||||
|
/// one carrying an unresolvable reference.
|
||||||
|
pub fn artwork_locator(url: &str) -> Option<String> {
|
||||||
|
compute_url_cid(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the URL from a `url` locator cid — the exact inverse of
|
||||||
|
/// [`compute_url_cid`], with no I/O.
|
||||||
|
///
|
||||||
|
/// `None` if `cid` is not a well-formed `0x1006` identity-multihash CIDv1 over
|
||||||
|
/// an http(s) URL.
|
||||||
|
pub fn decode_url_cid(cid: &str) -> Option<String> {
|
||||||
|
let bytes = base32_lower_decode(cid.strip_prefix('b')?)?;
|
||||||
|
let mut i = 0usize;
|
||||||
|
let mut next = || -> Option<u64> {
|
||||||
|
let mut n = 0u64;
|
||||||
|
let mut shift = 0u32;
|
||||||
|
loop {
|
||||||
|
let b = *bytes.get(i)?;
|
||||||
|
i += 1;
|
||||||
|
n |= u64::from(b & 0x7f) << shift;
|
||||||
|
if b & 0x80 == 0 {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
|
if shift > 63 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if next()? != 1 || next()? != URL_LOCATOR_CODEC || next()? != 0x00 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let len = next()? as usize;
|
||||||
|
let url = std::str::from_utf8(bytes.get(i..i + len)?).ok()?;
|
||||||
|
(url.starts_with("http://") || url.starts_with("https://")).then(|| url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub const NZB_POSTING_CODEC: u64 = 0x1003;
|
pub const NZB_POSTING_CODEC: u64 = 0x1003;
|
||||||
|
|
||||||
/// Mint an `nzb-posting` cid (`0x1003`) from a release's article Message-IDs.
|
/// Mint an `nzb-posting` cid (`0x1003`) from a release's article Message-IDs.
|
||||||
@@ -987,19 +1189,88 @@ mod tests {
|
|||||||
assert_eq!(&digest[5..], b"tv:95479");
|
assert_eq!(&digest[5..], b"tv:95479");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Empty inputs are rejected rather than encoded as an ambiguous locator,
|
/// Empty inputs are rejected rather than encoded as an ambiguous locator.
|
||||||
/// and an id past the 64-byte multihash budget (meta-share's
|
|
||||||
/// `CidGeneric<64>`) returns `None` so the caller drops the card instead of
|
|
||||||
/// emitting a cid that peer would refuse to parse.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_cid_rejects_empty_and_oversized() {
|
fn card_cid_rejects_empty_halves() {
|
||||||
assert!(compute_card_cid("", "tv:1").is_none());
|
assert!(compute_card_cid("", "tv:1").is_none());
|
||||||
assert!(compute_card_cid("tmdb", "").is_none());
|
assert!(compute_card_cid("tmdb", "").is_none());
|
||||||
let huge = "x".repeat(CARD_LOCATOR_MAX_DIGEST);
|
}
|
||||||
assert!(compute_card_cid("tmdb", &huge).is_none());
|
|
||||||
// Exactly at the ceiling still encodes.
|
/// ⚠ A card locator has **no length ceiling**, and this is the test that
|
||||||
let fits = "x".repeat(CARD_LOCATOR_MAX_DIGEST - 5); // varint(4) + "tmdb"
|
/// says so on purpose — it used to assert the opposite.
|
||||||
assert!(compute_card_cid("tmdb", &fits).is_some());
|
///
|
||||||
|
/// The 64-byte figure is meta-share's `CidGeneric<64>` bitswap alias, and a
|
||||||
|
/// card is never bitswap content. Capping here would silently drop exactly
|
||||||
|
/// the cards a URL-shaped identity exists to mint: a `weebcentral.com` work
|
||||||
|
/// path digests to ~88 bytes. Same reasoning `METADATA_KEYS.md` §2.1
|
||||||
|
/// already records for the `url` locator.
|
||||||
|
#[test]
|
||||||
|
fn card_cid_has_no_length_ceiling() {
|
||||||
|
let long_id = "/series/01J76XYGF1RKSPWA4TQ6GGSVFV/\
|
||||||
|
Ive-Been-Killing-Slimes-for-300-Years-The-Red-Dragon-Academy-for-Girls";
|
||||||
|
let cid = compute_card_cid("weebcentral.com", long_id).expect("no ceiling");
|
||||||
|
let (source, id) = split_card_digest(&cid);
|
||||||
|
assert!(
|
||||||
|
source.len() + id.len() > CARD_LOCATOR_MAX_DIGEST,
|
||||||
|
"fixture must actually exceed the old cap, got {}",
|
||||||
|
source.len() + id.len()
|
||||||
|
);
|
||||||
|
assert_eq!(source, "weebcentral.com");
|
||||||
|
assert_eq!(id, long_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host-form identity: the source slot names the site that *owns* the
|
||||||
|
/// work, never the tool that reached it. Pinned as a literal because
|
||||||
|
/// `meta-read`'s `card_cid::encode` carries the mirrored assertion — the two
|
||||||
|
/// addressing one work differently is the failure the whole scheme cannot
|
||||||
|
/// survive.
|
||||||
|
#[test]
|
||||||
|
fn host_form_card_cid_golden_vector() {
|
||||||
|
let cid = compute_card_cid("mangadex.org", "/manga/b0b721ff-c388-4486-aa0f-c2b0bb321512")
|
||||||
|
.expect("card cid");
|
||||||
|
assert_eq!(
|
||||||
|
cid,
|
||||||
|
"bagdsaabybrwwc3thmfsgk6bon5zgol3nmfxgoyjpmiygenzsgftgmlldgm4dqljugq4dmllbmeygmlldgjrdaytcgmzdcnjrgi"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `varint(source_len) ‖ source ‖ id` back out of a card locator, so the
|
||||||
|
/// tests above can assert on the halves without a decoder in this crate.
|
||||||
|
fn split_card_digest(cid: &str) -> (String, String) {
|
||||||
|
let raw = base32_lower_decode(&cid[1..]);
|
||||||
|
let mut i = 0usize;
|
||||||
|
let mut rd = || {
|
||||||
|
let (mut n, mut sh) = (0u64, 0u32);
|
||||||
|
loop {
|
||||||
|
let b = raw[i];
|
||||||
|
i += 1;
|
||||||
|
n |= u64::from(b & 0x7f) << sh;
|
||||||
|
if b & 0x80 == 0 {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
sh += 7;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(rd(), 1, "CIDv1");
|
||||||
|
assert_eq!(rd(), CARD_LOCATOR_CODEC);
|
||||||
|
assert_eq!(rd(), 0, "identity multihash");
|
||||||
|
let len = rd() as usize;
|
||||||
|
let digest = &raw[i..i + len];
|
||||||
|
let mut j = 0usize;
|
||||||
|
let (mut sl, mut sh) = (0usize, 0u32);
|
||||||
|
loop {
|
||||||
|
let b = digest[j];
|
||||||
|
j += 1;
|
||||||
|
sl |= usize::from(b & 0x7f) << sh;
|
||||||
|
if b & 0x80 == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sh += 7;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
String::from_utf8(digest[j..j + sl].to_vec()).unwrap(),
|
||||||
|
String::from_utf8(digest[j + sl..].to_vec()).unwrap(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- nzb-posting (0x1003) — the dedup contract -----------------------
|
// ---- nzb-posting (0x1003) — the dedup contract -----------------------
|
||||||
@@ -1074,3 +1345,229 @@ mod tests {
|
|||||||
assert_eq!(&decoded[5..], &expect);
|
assert_eq!(&decoded[5..], &expect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RFC 4648 base32 (lowercase, no padding) → bytes. Inverse of
|
||||||
|
/// [`base32_lower_no_padding`]; used by the identity-multihash decoders.
|
||||||
|
fn base32_lower_decode(s: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut acc = 0u16;
|
||||||
|
let mut bits = 0u32;
|
||||||
|
let mut out = Vec::with_capacity(s.len() * 5 / 8);
|
||||||
|
for c in s.bytes() {
|
||||||
|
let v = match c {
|
||||||
|
b'a'..=b'z' => c - b'a',
|
||||||
|
b'2'..=b'7' => c - b'2' + 26,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
acc = (acc << 5) | u16::from(v);
|
||||||
|
bits += 5;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push((acc >> bits) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod url_locator_wire_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Decode a base32-lower multibase string back to bytes, so the tests can
|
||||||
|
/// assert the wire layout rather than an opaque string.
|
||||||
|
fn decode(cid: &str) -> Vec<u8> {
|
||||||
|
const A: &str = "abcdefghijklmnopqrstuvwxyz234567";
|
||||||
|
let mut bits = String::new();
|
||||||
|
for c in cid.strip_prefix('b').expect("multibase b").chars() {
|
||||||
|
let i = A.find(c).expect("base32 alphabet");
|
||||||
|
bits.push_str(&format!("{i:05b}"));
|
||||||
|
}
|
||||||
|
bits.as_bytes()
|
||||||
|
.chunks(8)
|
||||||
|
.filter(|c| c.len() == 8)
|
||||||
|
.map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap(), 2).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_varint(data: &[u8], at: &mut usize) -> u64 {
|
||||||
|
let (mut v, mut shift) = (0u64, 0);
|
||||||
|
loop {
|
||||||
|
let b = data[*at];
|
||||||
|
*at += 1;
|
||||||
|
v |= ((b & 0x7f) as u64) << shift;
|
||||||
|
if b & 0x80 == 0 {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pins the on-the-wire layout `METADATA_KEYS.md` §2.1 specifies. meta-share
|
||||||
|
/// decodes exactly this; a change here is a change to a cross-binary
|
||||||
|
/// contract, not an implementation detail.
|
||||||
|
#[test]
|
||||||
|
fn the_wire_layout_matches_the_specified_encoding() {
|
||||||
|
let url = "https://archive.org/download/item/track.flac";
|
||||||
|
let cid = compute_url_cid(url).expect("cid");
|
||||||
|
let raw = decode(&cid);
|
||||||
|
let mut i = 0;
|
||||||
|
assert_eq!(raw[i], 0x01, "CIDv1");
|
||||||
|
i += 1;
|
||||||
|
assert_eq!(read_varint(&raw, &mut i), URL_LOCATOR_CODEC);
|
||||||
|
assert_eq!(raw[i], 0x00, "identity multihash");
|
||||||
|
i += 1;
|
||||||
|
let len = read_varint(&raw, &mut i) as usize;
|
||||||
|
assert_eq!(len, url.len());
|
||||||
|
assert_eq!(
|
||||||
|
std::str::from_utf8(&raw[i..i + len]).unwrap(),
|
||||||
|
url,
|
||||||
|
"the digest IS the payload — decoding must recover the URL verbatim"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⚠ A real archive URL is ~75 bytes, past the ~62-byte ceiling of a
|
||||||
|
/// fixed-64 multihash type. That is expected and fine (the locator is never
|
||||||
|
/// bitswap content), but it is the thing that trips a consumer parsing it
|
||||||
|
/// as an `MsCid`, so the case is pinned deliberately rather than avoided.
|
||||||
|
#[test]
|
||||||
|
fn a_long_archive_url_still_encodes() {
|
||||||
|
let url = "https://archive.org/download/gd1977-05-08eaton/gd77-05-08eaton-d1t01.ogg";
|
||||||
|
assert!(url.len() > 62, "fixture must exercise the over-64 case");
|
||||||
|
let cid = compute_url_cid(url).expect("cid");
|
||||||
|
let raw = decode(&cid);
|
||||||
|
let mut i = 1;
|
||||||
|
read_varint(&raw, &mut i);
|
||||||
|
i += 1;
|
||||||
|
let len = read_varint(&raw, &mut i) as usize;
|
||||||
|
assert_eq!(std::str::from_utf8(&raw[i..i + len]).unwrap(), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two URLs are two locators — a url-CID is not byte-derivable, so it is an
|
||||||
|
/// address this one record advertises, never a content identity.
|
||||||
|
#[test]
|
||||||
|
fn distinct_urls_yield_distinct_locators() {
|
||||||
|
assert_ne!(
|
||||||
|
compute_url_cid("https://example.com/a.flac"),
|
||||||
|
compute_url_cid("https://example.com/b.flac")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relative or malformed value would encode fine and then fail
|
||||||
|
/// unresolvably at play time — the expensive place to find out.
|
||||||
|
#[test]
|
||||||
|
fn only_absolute_http_urls_are_accepted() {
|
||||||
|
assert!(compute_url_cid("/download/item/track.flac").is_none());
|
||||||
|
assert!(compute_url_cid("ftp://example.com/x").is_none());
|
||||||
|
assert!(compute_url_cid("").is_none());
|
||||||
|
assert!(compute_url_cid("archive.org/download/x").is_none());
|
||||||
|
assert!(compute_url_cid("http://example.com/x").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod roundtrip_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Long URLs are the whole reason this locator exists (they overflow the
|
||||||
|
/// fixed-size multihash buffer the `cid` crate uses), so the round trip is
|
||||||
|
/// pinned at a realistic Cover Art Archive length, not a toy one.
|
||||||
|
#[test]
|
||||||
|
fn encode_then_decode_is_identity() {
|
||||||
|
for url in [
|
||||||
|
"https://coverartarchive.org/release/aa997ea0-2936-40bd-884d-3af8a0e064dc/12345-500.jpg",
|
||||||
|
"https://archive.org/services/img/gd1977-05-08",
|
||||||
|
"http://example.test/a.png",
|
||||||
|
] {
|
||||||
|
let cid = compute_url_cid(url).expect("encodes");
|
||||||
|
assert!(cid.starts_with('b'));
|
||||||
|
assert_eq!(decode_url_cid(&cid).as_deref(), Some(url), "round trip: {url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_locator_cid_does_not_decode() {
|
||||||
|
// A card locator (0x1007) must not be mistaken for a url locator.
|
||||||
|
let card = crate::hash::compute_card_cid("musicbrainz", "abc").unwrap();
|
||||||
|
assert_eq!(decode_url_cid(&card), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod redeem_locator_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `docs/cid-formats.md` §8.2 golden vector — moved here from
|
||||||
|
/// meta-feeder-opensubtitles' local `locator.rs`; the vector proves the
|
||||||
|
/// move changed no bytes.
|
||||||
|
#[test]
|
||||||
|
fn provider_file_matches_the_documented_vector() {
|
||||||
|
assert_eq!(
|
||||||
|
compute_provider_file_cid("opensubtitles", "file:7061834").as_deref(),
|
||||||
|
Some("bagfcaaa2bvxxazloon2we5djorwgk43gnfwgkorxga3dcobtgq")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_file_round_trips_and_refuses_other_codecs() {
|
||||||
|
let cid = compute_provider_file_cid("opensubtitles", "file:12658754").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_provider_file_cid(&cid),
|
||||||
|
Some(("opensubtitles".to_string(), "file:12658754".to_string()))
|
||||||
|
);
|
||||||
|
// Identical framing, different codec: a card must not decode as a file.
|
||||||
|
let card = compute_card_cid("tmdb", "tv:95479").unwrap();
|
||||||
|
assert_eq!(card, "bagdsaaanar2g2zdcor3duojvgq3ts");
|
||||||
|
assert_eq!(decode_provider_file_cid(&card), None);
|
||||||
|
assert_eq!(decode_provider_file_cid("not-a-cid"), None);
|
||||||
|
assert_eq!(decode_provider_file_cid(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_file_digest_ceiling_is_64_bytes() {
|
||||||
|
// varint(13) + "opensubtitles" = 14 bytes, so the id may use 50.
|
||||||
|
assert!(compute_provider_file_cid("opensubtitles", &"x".repeat(50)).is_some());
|
||||||
|
assert!(compute_provider_file_cid("opensubtitles", &"x".repeat(51)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nzb_release_round_trips() {
|
||||||
|
let cid = compute_nzb_release_cid("api.nzbgeek.info/api", "0123abcdef").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_nzb_release_cid(&cid),
|
||||||
|
Some(("api.nzbgeek.info/api".to_string(), "0123abcdef".to_string()))
|
||||||
|
);
|
||||||
|
// A host:port authority survives too.
|
||||||
|
let cid = compute_nzb_release_cid("127.0.0.1:8080", "ff00").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_nzb_release_cid(&cid),
|
||||||
|
Some(("127.0.0.1:8080".to_string(), "ff00".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The encoder left-pads an odd-length id, so the decode is the padded form.
|
||||||
|
#[test]
|
||||||
|
fn nzb_release_odd_length_id_comes_back_padded() {
|
||||||
|
let cid = compute_nzb_release_cid("api.nzb.life", "abc").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_nzb_release_cid(&cid),
|
||||||
|
Some(("api.nzb.life".to_string(), "0abc".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nzb_release_refuses_other_codecs() {
|
||||||
|
let card = compute_card_cid("tmdb", "tv:95479").unwrap();
|
||||||
|
assert_eq!(decode_nzb_release_cid(&card), None);
|
||||||
|
assert_eq!(decode_nzb_release_cid("bafkreigarbage"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_of_reads_the_codec_slot() {
|
||||||
|
let nzb = compute_nzb_release_cid("api.nzb.life", "ab").unwrap();
|
||||||
|
assert_eq!(codec_of(&nzb), Some(NZB_RELEASE_CODEC));
|
||||||
|
let file = compute_provider_file_cid("opensubtitles", "file:1").unwrap();
|
||||||
|
assert_eq!(codec_of(&file), Some(PROVIDER_FILE_CODEC));
|
||||||
|
assert_eq!(codec_of(&compute_ipfs_cid(b"hello")), Some(0x55));
|
||||||
|
assert_eq!(codec_of("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), None);
|
||||||
|
assert_eq!(codec_of(""), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+45
@@ -8,6 +8,14 @@
|
|||||||
//! hashing-into-the-blockstore, and the meta-core store-back to itself.
|
//! hashing-into-the-blockstore, and the meta-core store-back to itself.
|
||||||
//!
|
//!
|
||||||
//! Deliberately libp2p-free and blockstore-free — see this crate's `Cargo.toml`.
|
//! Deliberately libp2p-free and blockstore-free — see this crate's `Cargo.toml`.
|
||||||
|
//!
|
||||||
|
//! With the `transport` feature it also carries the **transport-plugin**
|
||||||
|
//! contract ([`transport`]): the same split, applied to meta-share — a
|
||||||
|
//! protocol-agnostic hull and one sidecar per byte-moving protocol.
|
||||||
|
//!
|
||||||
|
//! [`beacon`] is beacon v2 local discovery. Both harnesses advertise through it;
|
||||||
|
//! with `default-features = false, features = ["beacon"]` it is all a meta-*
|
||||||
|
//! service pulls in.
|
||||||
|
|
||||||
// `redb::Error` is a large enum; the cache wrappers (`cache.rs`) return it
|
// `redb::Error` is a large enum; the cache wrappers (`cache.rs`) return it
|
||||||
// directly rather than boxing on every embedded-DB call. Boxing each would be
|
// directly rather than boxing on every embedded-DB call. Boxing each would be
|
||||||
@@ -21,29 +29,66 @@
|
|||||||
/// `metacore-app` service the AppStore feeder apps ship on the shared network.
|
/// `metacore-app` service the AppStore feeder apps ship on the shared network.
|
||||||
pub const DEFAULT_META_CORE_URL: &str = "http://metacore-app:9000";
|
pub const DEFAULT_META_CORE_URL: &str = "http://metacore-app:9000";
|
||||||
|
|
||||||
|
#[cfg(feature = "beacon")]
|
||||||
|
pub mod beacon;
|
||||||
|
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
|
pub mod budget;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
|
pub mod domain;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod enrich;
|
pub mod enrich;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod filename_meta;
|
pub mod filename_meta;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod lang;
|
pub mod lang;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
|
pub mod licence;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod meta_core;
|
pub mod meta_core;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod query;
|
pub mod query;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod query_eval;
|
pub mod query_eval;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
|
#[cfg(feature = "transport")]
|
||||||
|
pub mod transport;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
|
pub use budget::RateBudget;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub use config::{ConfigField, ConfigSchema, FieldKind};
|
pub use config::{ConfigField, ConfigSchema, FieldKind};
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
|
pub use licence::licence_from_url;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub use enrich::{EnrichTarget, EnrichmentConfig, Enricher};
|
pub use enrich::{EnrichTarget, EnrichmentConfig, Enricher};
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub use meta_core::FeederStore;
|
pub use meta_core::FeederStore;
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub use plugin::{
|
pub use plugin::{
|
||||||
upstream_id_field, ConfigError, FeederPlugin, HashKind, HashOutcome, PluginRegistry,
|
upstream_id_field, ConfigError, FeederPlugin, HashKind, HashOutcome, PluginRegistry,
|
||||||
|
RedeemClaim,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub use query::{GatewayQuery, GatewaySearchEvent, GatewayWireError, Negation, RangeFilter};
|
pub use query::{GatewayQuery, GatewaySearchEvent, GatewayWireError, Negation, RangeFilter};
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub use serve::{
|
pub use serve::{
|
||||||
configure_plugins, router, serve_feeders, ComputeRequest, ComputeResponse, HashKindDto,
|
configure_plugins, router, serve_feeders, ComputeRequest, ComputeResponse, HashKindDto,
|
||||||
HealthResponse, ManifestResponse, OutcomeDto, PluginManifest, QueryRequest, QueryResponse,
|
HealthResponse, ManifestResponse, OutcomeDto, PluginManifest, QueryRequest, QueryResponse,
|
||||||
|
RedeemsResponse,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "feeder")]
|
||||||
pub use types::{ByteStream, DiscoveryId, DiscoveryRecord, GatewayError, Hash, PluginHealth};
|
pub use types::{ByteStream, DiscoveryId, DiscoveryRecord, GatewayError, Hash, PluginHealth};
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Licence-URL → SPDX-style identifier.
|
||||||
|
//!
|
||||||
|
//! Generic across upstreams: Internet Archive, Jamendo and Wikimedia Commons
|
||||||
|
//! all publish a Creative Commons *URL*, while `METADATA_KEYS.md` requires an
|
||||||
|
//! SPDX-style identifier — a consumer comparing licences should not be doing
|
||||||
|
//! string surgery on `http://` vs `https://` and a trailing slash.
|
||||||
|
|
||||||
|
/// `METADATA_KEYS.md` requires an SPDX-style identifier rather than a URL,
|
||||||
|
/// because a consumer comparing licences should not be doing string surgery on
|
||||||
|
/// `http://` vs `https://` and a trailing slash.
|
||||||
|
pub fn licence_from_url(url: &str) -> Option<String> {
|
||||||
|
let u = url.trim().trim_end_matches('/').to_ascii_lowercase();
|
||||||
|
if u.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if u.contains("publicdomain/zero") {
|
||||||
|
return Some("CC0-1.0".to_string());
|
||||||
|
}
|
||||||
|
if u.contains("publicdomain/mark") {
|
||||||
|
return Some("PublicDomain".to_string());
|
||||||
|
}
|
||||||
|
// .../licenses/<variant>/<version>[/<jurisdiction>]
|
||||||
|
let rest = u.split("/licenses/").nth(1)?;
|
||||||
|
let mut parts = rest.split('/');
|
||||||
|
let variant = parts.next()?.to_ascii_uppercase();
|
||||||
|
let version = parts.next().unwrap_or("4.0");
|
||||||
|
if variant.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!("CC-{variant}-{version}"))
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// Re-exported so plugins can `use meta_feeder_sdk::plugin::GatewayQuery` (the
|
// Re-exported so plugins can `use meta_feeder_sdk::plugin::GatewayQuery` (the
|
||||||
// gateway's original `plugin.rs` re-exported it the same way).
|
// gateway's original `plugin.rs` re-exported it the same way).
|
||||||
@@ -95,6 +96,66 @@ pub enum HashKind {
|
|||||||
YtVideo,
|
YtVideo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `RedeemClaim::codec` for a Newznab release locator (`0x1005`).
|
||||||
|
pub const REDEEM_CODEC_NZB_RELEASE: &str = "nzb-release";
|
||||||
|
/// `RedeemClaim::codec` for a `provider-file` locator (`0x100A`).
|
||||||
|
pub const REDEEM_CODEC_PROVIDER_FILE: &str = "provider-file";
|
||||||
|
/// `RedeemClaim::field` naming the `.nzb` manifest pointer on a release record.
|
||||||
|
pub const POINTER_FIELD_MANIFEST: &str = "manifest";
|
||||||
|
/// `RedeemClaim::field` naming the fetched-file pointer on a locator record.
|
||||||
|
pub const POINTER_FIELD_FILE: &str = "file";
|
||||||
|
|
||||||
|
/// A plugin's claim that it can **redeem** a locator family: turn a locator cid
|
||||||
|
/// into the bytes it names, through `POST /compute` with `record_id` = the
|
||||||
|
/// locator cid.
|
||||||
|
///
|
||||||
|
/// Redeeming usually spends a metered upstream quota (a Newznab `t=get` grab,
|
||||||
|
/// an OpenSubtitles `/download`), so the gateway routes a redeem only to the
|
||||||
|
/// plugins claiming the cid's codec, one at a time, and only on a real play. It
|
||||||
|
/// stores the returned bytes and writes their cid onto the locator record under
|
||||||
|
/// [`field`](Self::field), so the quota is spent once per file.
|
||||||
|
///
|
||||||
|
/// Served at `GET /manifest` (per plugin) and `GET /redeems`. Reflects the
|
||||||
|
/// plugin's *current* config: a plugin with no credential for a family claims
|
||||||
|
/// nothing for it.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RedeemClaim {
|
||||||
|
/// Locator family: [`REDEEM_CODEC_NZB_RELEASE`] or [`REDEEM_CODEC_PROVIDER_FILE`].
|
||||||
|
pub codec: String,
|
||||||
|
/// Pointer field the gateway writes the redeemed bytes' cid under:
|
||||||
|
/// [`POINTER_FIELD_MANIFEST`] or [`POINTER_FIELD_FILE`].
|
||||||
|
pub field: String,
|
||||||
|
/// Indexer hosts this plugin holds a key for (`nzb-release`) — bare
|
||||||
|
/// authority, lowercase, never the key itself.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hosts: Vec<String>,
|
||||||
|
/// Provider sources this plugin redeems (`provider-file`), e.g. `opensubtitles`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedeemClaim {
|
||||||
|
/// Claim `nzb-release` locators for these indexer hosts.
|
||||||
|
pub fn nzb_release(hosts: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
codec: REDEEM_CODEC_NZB_RELEASE.to_string(),
|
||||||
|
field: POINTER_FIELD_MANIFEST.to_string(),
|
||||||
|
hosts,
|
||||||
|
sources: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim `provider-file` locators for these sources.
|
||||||
|
pub fn provider_file(sources: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
codec: REDEEM_CODEC_PROVIDER_FILE.to_string(),
|
||||||
|
field: POINTER_FIELD_FILE.to_string(),
|
||||||
|
hosts: Vec::new(),
|
||||||
|
sources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Startup-only configuration error returned by [`FeederPlugin::configure`].
|
/// Startup-only configuration error returned by [`FeederPlugin::configure`].
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
@@ -215,6 +276,20 @@ pub trait FeederPlugin: Send + Sync + 'static {
|
|||||||
&[]
|
&[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Folder under meta-core's `/files/plugin/` where the gateway stores bytes
|
||||||
|
/// this plugin redeems (e.g. `meta-feeder-usenet`). Stable across
|
||||||
|
/// deployments — unlike the feeder's compose service name. Default: none.
|
||||||
|
fn package(&self) -> Option<&'static str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locator families this plugin can redeem through
|
||||||
|
/// [`compute_outcomes`](FeederPlugin::compute_outcomes) — see
|
||||||
|
/// [`RedeemClaim`]. Computed from the current config. Default: none.
|
||||||
|
fn redeems(&self) -> Vec<RedeemClaim> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// Self-describing config schema. The feeder SDK serves this at
|
/// Self-describing config schema. The feeder SDK serves this at
|
||||||
/// `GET /config/schema` and renders a generic form from it — the gateway and
|
/// `GET /config/schema` and renders a generic form from it — the gateway and
|
||||||
/// UI carry no per-plugin field knowledge. Default: no configuration.
|
/// UI carry no per-plugin field knowledge. Default: no configuration.
|
||||||
@@ -255,4 +330,23 @@ mod tests {
|
|||||||
assert!(msg.contains("scihub"));
|
assert!(msg.contains("scihub"));
|
||||||
assert!(msg.contains("mirrors"));
|
assert!(msg.contains("mirrors"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The JSON keys are the gateway/meta-share contract — pin them.
|
||||||
|
#[test]
|
||||||
|
fn redeem_claim_wire_shape() {
|
||||||
|
let claim = RedeemClaim::nzb_release(vec!["api.nzb.life".into()]);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&claim).unwrap(),
|
||||||
|
serde_json::json!({
|
||||||
|
"codec": "nzb-release",
|
||||||
|
"field": "manifest",
|
||||||
|
"hosts": ["api.nzb.life"],
|
||||||
|
"sources": []
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// hosts/sources may be omitted on the wire.
|
||||||
|
let parsed: RedeemClaim =
|
||||||
|
serde_json::from_str(r#"{"codec":"provider-file","field":"file"}"#).unwrap();
|
||||||
|
assert_eq!(parsed, RedeemClaim::provider_file(vec![]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+218
-7
@@ -10,6 +10,17 @@
|
|||||||
//! - `POST /compute` → [`ComputeRequest`] → [`ComputeResponse`]
|
//! - `POST /compute` → [`ComputeRequest`] → [`ComputeResponse`]
|
||||||
//! - `GET /fetch/{upstream_id}/{record_id}` → streamed bytes (or 404)
|
//! - `GET /fetch/{upstream_id}/{record_id}` → streamed bytes (or 404)
|
||||||
//! - `GET /blob/{upstream_id}/{cid}` → bytes (or 404)
|
//! - `GET /blob/{upstream_id}/{cid}` → bytes (or 404)
|
||||||
|
//! - `GET /redeems` → [`RedeemsResponse`] (current redeem claims)
|
||||||
|
//!
|
||||||
|
//! ## Redeeming a locator
|
||||||
|
//!
|
||||||
|
//! A plugin that advertises a [`RedeemClaim`] answers `POST /compute` with
|
||||||
|
//! `record_id` = a locator cid of that family (a Newznab `nzb-release`, an
|
||||||
|
//! OpenSubtitles `provider-file`) by fetching the bytes it names — usually
|
||||||
|
//! spending a metered quota — and returning ONE `Sha2_256` outcome carrying
|
||||||
|
//! them. `NotFound` means "not mine" (e.g. no key for that indexer host), so the
|
||||||
|
//! gateway tries the next claimer. The claims are re-read from `GET /redeems`
|
||||||
|
//! so a config change shows up without restarting the gateway.
|
||||||
//!
|
//!
|
||||||
//! ## Bytes & the v1 simplification
|
//! ## Bytes & the v1 simplification
|
||||||
//!
|
//!
|
||||||
@@ -21,7 +32,7 @@
|
|||||||
//! documented follow-up — those outcomes are metadata-only anyway
|
//! documented follow-up — those outcomes are metadata-only anyway
|
||||||
//! (`bytes: None`), so they never ride this path.
|
//! (`bytes: None`), so they never ride this path.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path as FsPath, PathBuf};
|
use std::path::{Path as FsPath, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -41,7 +52,7 @@ use serde_json::Value;
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::config::{self, CONFIG_PAGE_HTML};
|
use crate::config::{self, CONFIG_PAGE_HTML};
|
||||||
use crate::plugin::{ConfigError, FeederPlugin, HashKind, HashOutcome};
|
use crate::plugin::{ConfigError, FeederPlugin, HashKind, HashOutcome, RedeemClaim};
|
||||||
use crate::query::{GatewayQuery, GatewaySearchEvent, GatewayWireError};
|
use crate::query::{GatewayQuery, GatewaySearchEvent, GatewayWireError};
|
||||||
use crate::types::{DiscoveryRecord, GatewayError, PluginHealth};
|
use crate::types::{DiscoveryRecord, GatewayError, PluginHealth};
|
||||||
|
|
||||||
@@ -94,6 +105,22 @@ pub struct PluginManifest {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub served_file_types: Vec<String>,
|
pub served_file_types: Vec<String>,
|
||||||
pub served_content_kinds: Vec<String>,
|
pub served_content_kinds: Vec<String>,
|
||||||
|
/// [`FeederPlugin::package`] — the `/files/plugin/<package>/` folder redeemed
|
||||||
|
/// bytes are stored under. `null` when the plugin redeems nothing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub package: Option<String>,
|
||||||
|
/// [`FeederPlugin::redeems`] at the time the manifest was served.
|
||||||
|
#[serde(default)]
|
||||||
|
pub redeems: Vec<RedeemClaim>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /redeems` — every hosted plugin's current [`RedeemClaim`]s, keyed by
|
||||||
|
/// `upstream_id` (an empty list for a plugin that claims nothing). Polled by
|
||||||
|
/// the gateway, so a credential added on the config page is advertised without
|
||||||
|
/// a gateway restart.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
pub struct RedeemsResponse {
|
||||||
|
pub redeems: BTreeMap<String, Vec<RedeemClaim>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /manifest` — the feeder's hosted plugins + their served types. The
|
/// `GET /manifest` — the feeder's hosted plugins + their served types. The
|
||||||
@@ -224,6 +251,17 @@ impl AppState {
|
|||||||
/// status semantics the gateway's `RemoteFeederPlugin` will translate back
|
/// status semantics the gateway's `RemoteFeederPlugin` will translate back
|
||||||
/// into a `GatewayError` on the core side.
|
/// into a `GatewayError` on the core side.
|
||||||
fn gateway_error_response(e: GatewayError) -> Response {
|
fn gateway_error_response(e: GatewayError) -> Response {
|
||||||
|
// A 429 carries the wait as a `Retry-After` header too, so the gateway can
|
||||||
|
// forward it to the client (a redeem blocked on a spent quota) instead of
|
||||||
|
// parsing the message.
|
||||||
|
if let GatewayError::RateLimited { retry_after_s } = &e {
|
||||||
|
return (
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
[(axum::http::header::RETRY_AFTER, retry_after_s.to_string())],
|
||||||
|
format!("rate limited; retry in {retry_after_s}s"),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
let (status, msg) = match e {
|
let (status, msg) = match e {
|
||||||
GatewayError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
|
GatewayError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
|
||||||
GatewayError::RateLimited { retry_after_s } => (
|
GatewayError::RateLimited { retry_after_s } => (
|
||||||
@@ -240,8 +278,15 @@ fn gateway_error_response(e: GatewayError) -> Response {
|
|||||||
// -------- handlers ---------------------------------------------------------
|
// -------- handlers ---------------------------------------------------------
|
||||||
|
|
||||||
async fn manifest(State(state): State<AppState>) -> Json<ManifestResponse> {
|
async fn manifest(State(state): State<AppState>) -> Json<ManifestResponse> {
|
||||||
let mut plugins: Vec<PluginManifest> = state
|
Json(build_manifest(&state.plugins, &state.version))
|
||||||
.plugins
|
}
|
||||||
|
|
||||||
|
/// The `/manifest` body — also what the beacon advertise's `rev` is a hash of.
|
||||||
|
fn build_manifest(
|
||||||
|
plugins: &HashMap<String, Arc<dyn FeederPlugin>>,
|
||||||
|
version: &str,
|
||||||
|
) -> ManifestResponse {
|
||||||
|
let mut plugins: Vec<PluginManifest> = plugins
|
||||||
.values()
|
.values()
|
||||||
.map(|p| PluginManifest {
|
.map(|p| PluginManifest {
|
||||||
id: p.upstream_id().to_string(),
|
id: p.upstream_id().to_string(),
|
||||||
@@ -251,12 +296,41 @@ async fn manifest(State(state): State<AppState>) -> Json<ManifestResponse> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
package: p.package().map(str::to_string),
|
||||||
|
redeems: p.redeems(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
plugins.sort_by(|a, b| a.id.cmp(&b.id));
|
plugins.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
Json(ManifestResponse {
|
ManifestResponse {
|
||||||
feeder_version: state.version.clone(),
|
feeder_version: version.to_string(),
|
||||||
plugins,
|
plugins,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feeder's beacon v2 resource: one `metamesh.feeder/<upstream_id>` cap per
|
||||||
|
/// hosted plugin, the manifest endpoint, and `rev` = hash of the manifest.
|
||||||
|
fn beacon_resource(
|
||||||
|
plugins: &HashMap<String, Arc<dyn FeederPlugin>>,
|
||||||
|
version: &str,
|
||||||
|
listen_port: u16,
|
||||||
|
) -> crate::beacon::Resource {
|
||||||
|
use crate::beacon::{advertise_url, binds_from_env, caps, rev_of, Resource};
|
||||||
|
let m = build_manifest(plugins, version);
|
||||||
|
let mut r = Resource::new("feeder", m.plugins.iter().map(|p| caps::feeder(&p.id)))
|
||||||
|
.with_endpoint("http", advertise_url(listen_port))
|
||||||
|
.with_endpoint("manifest", "/manifest");
|
||||||
|
r.rev = Some(rev_of(&m));
|
||||||
|
r.binds = binds_from_env();
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn redeems(State(state): State<AppState>) -> Json<RedeemsResponse> {
|
||||||
|
Json(RedeemsResponse {
|
||||||
|
redeems: state
|
||||||
|
.plugins
|
||||||
|
.values()
|
||||||
|
.map(|p| (p.upstream_id().to_string(), p.redeems()))
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +566,7 @@ pub fn router(
|
|||||||
.route("/compute", post(compute))
|
.route("/compute", post(compute))
|
||||||
.route("/fetch/:upstream_id/:record_id", get(fetch))
|
.route("/fetch/:upstream_id/:record_id", get(fetch))
|
||||||
.route("/blob/:upstream_id/:cid", get(blob))
|
.route("/blob/:upstream_id/:cid", get(blob))
|
||||||
|
.route("/redeems", get(redeems))
|
||||||
.route("/config", get(config_page))
|
.route("/config", get(config_page))
|
||||||
.route("/config/schema", get(config_schema))
|
.route("/config/schema", get(config_schema))
|
||||||
.route("/config/values", get(config_values_get).put(config_values_put))
|
.route("/config/values", get(config_values_get).put(config_values_put))
|
||||||
@@ -517,7 +592,8 @@ pub async fn serve_feeders(
|
|||||||
let state_dir = state_dir.into();
|
let state_dir = state_dir.into();
|
||||||
let configured = configure_plugins(plugins, &state_dir)?;
|
let configured = configure_plugins(plugins, &state_dir)?;
|
||||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||||
let app = router(configured, version, state_dir.clone());
|
let advertised = configured.clone();
|
||||||
|
let app = router(configured, version.clone(), state_dir.clone());
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(listen).await?;
|
let listener = tokio::net::TcpListener::bind(listen).await?;
|
||||||
info!(
|
info!(
|
||||||
@@ -525,6 +601,18 @@ pub async fn serve_feeders(
|
|||||||
%listen,
|
%listen,
|
||||||
"feeder listening"
|
"feeder listening"
|
||||||
);
|
);
|
||||||
|
// Beacon v2: advertise once the port is bound, so a consumer that hears us
|
||||||
|
// can connect. The node lives for the process.
|
||||||
|
let port = listen.port();
|
||||||
|
let name = std::env::var("SERVICE_NAME").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| {
|
||||||
|
let mut ids: Vec<&String> = advertised.keys().collect();
|
||||||
|
ids.sort();
|
||||||
|
ids.first().map_or_else(|| "meta-feeder".to_string(), |id| format!("meta-feeder-{id}"))
|
||||||
|
});
|
||||||
|
let beacon_version = version.clone();
|
||||||
|
let _beacon = crate::beacon::advertise_plugin(&name, &version, move || {
|
||||||
|
beacon_resource(&advertised, &beacon_version, port)
|
||||||
|
});
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(anyhow::Error::from)
|
||||||
@@ -541,6 +629,9 @@ struct DegradedPlugin {
|
|||||||
reason: String,
|
reason: String,
|
||||||
file_types: &'static [&'static str],
|
file_types: &'static [&'static str],
|
||||||
content_kinds: &'static [&'static str],
|
content_kinds: &'static [&'static str],
|
||||||
|
/// Preserved so the manifest keeps naming the plugin's folder. A degraded
|
||||||
|
/// plugin claims no redeems (it cannot redeem anything).
|
||||||
|
package: Option<&'static str>,
|
||||||
/// Preserved from the wrapped plugin so a degraded/unconfigured feeder still
|
/// Preserved from the wrapped plugin so a degraded/unconfigured feeder still
|
||||||
/// serves its config form — otherwise the dashboard shows "no configuration
|
/// serves its config form — otherwise the dashboard shows "no configuration
|
||||||
/// required" and the operator can never fix the very config that's missing.
|
/// required" and the operator can never fix the very config that's missing.
|
||||||
@@ -586,6 +677,10 @@ impl FeederPlugin for DegradedPlugin {
|
|||||||
self.content_kinds
|
self.content_kinds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn package(&self) -> Option<&'static str> {
|
||||||
|
self.package
|
||||||
|
}
|
||||||
|
|
||||||
fn config_schema(&self) -> crate::config::ConfigSchema {
|
fn config_schema(&self) -> crate::config::ConfigSchema {
|
||||||
self.schema.clone()
|
self.schema.clone()
|
||||||
}
|
}
|
||||||
@@ -608,6 +703,7 @@ pub fn configure_plugins(
|
|||||||
// borrow across `configure(&mut self)` / the move into `out`.
|
// borrow across `configure(&mut self)` / the move into `out`.
|
||||||
let file_types = plugin.served_file_types();
|
let file_types = plugin.served_file_types();
|
||||||
let content_kinds = plugin.served_content_kinds();
|
let content_kinds = plugin.served_content_kinds();
|
||||||
|
let package = plugin.package();
|
||||||
// Preserve the config surface so a degraded stand-in still serves the
|
// Preserve the config surface so a degraded stand-in still serves the
|
||||||
// real config form (see DegradedPlugin.schema).
|
// real config form (see DegradedPlugin.schema).
|
||||||
let schema = plugin.config_schema();
|
let schema = plugin.config_schema();
|
||||||
@@ -627,6 +723,7 @@ pub fn configure_plugins(
|
|||||||
reason,
|
reason,
|
||||||
file_types,
|
file_types,
|
||||||
content_kinds,
|
content_kinds,
|
||||||
|
package,
|
||||||
schema: schema.clone(),
|
schema: schema.clone(),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -759,4 +856,118 @@ mod tests {
|
|||||||
assert!(matches!(events[1], GatewaySearchEvent::EnrichPatch { .. }));
|
assert!(matches!(events[1], GatewaySearchEvent::EnrichPatch { .. }));
|
||||||
assert!(matches!(events[2], GatewaySearchEvent::Done));
|
assert!(matches!(events[2], GatewaySearchEvent::Done));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A plugin that redeems `nzb-release` locators for one host.
|
||||||
|
struct StubRedeemer;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FeederPlugin for StubRedeemer {
|
||||||
|
fn upstream_id(&self) -> &'static str {
|
||||||
|
"redeemer"
|
||||||
|
}
|
||||||
|
fn configure(&mut self, _cache_dir: &FsPath) -> Result<(), ConfigError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn handle_query(
|
||||||
|
&self,
|
||||||
|
_q: &GatewayQuery,
|
||||||
|
_n: usize,
|
||||||
|
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn compute_outcomes(
|
||||||
|
&self,
|
||||||
|
_record_id: &str,
|
||||||
|
) -> Result<Vec<HashOutcome>, GatewayError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
fn package(&self) -> Option<&'static str> {
|
||||||
|
Some("meta-feeder-stub")
|
||||||
|
}
|
||||||
|
fn redeems(&self) -> Vec<RedeemClaim> {
|
||||||
|
vec![RedeemClaim::nzb_release(vec!["api.example".into()])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_stubs() -> String {
|
||||||
|
let mut plugins: HashMap<String, Arc<dyn FeederPlugin>> = HashMap::new();
|
||||||
|
plugins.insert("redeemer".to_string(), Arc::new(StubRedeemer));
|
||||||
|
plugins.insert("stub".to_string(), Arc::new(StubStreaming));
|
||||||
|
let app = router(plugins, "test".to_string(), std::env::temp_dir());
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manifest carries `package` + `redeems` per plugin; a plugin that
|
||||||
|
/// claims nothing serializes `null` + `[]`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manifest_carries_package_and_redeems() {
|
||||||
|
let base = serve_stubs().await;
|
||||||
|
let m: serde_json::Value = reqwest::get(format!("{base}/manifest"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let redeemer = &m["plugins"][0];
|
||||||
|
assert_eq!(redeemer["id"], "redeemer");
|
||||||
|
assert_eq!(redeemer["package"], "meta-feeder-stub");
|
||||||
|
assert_eq!(
|
||||||
|
redeemer["redeems"],
|
||||||
|
serde_json::json!([{
|
||||||
|
"codec": "nzb-release", "field": "manifest",
|
||||||
|
"hosts": ["api.example"], "sources": []
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
let stub = &m["plugins"][1];
|
||||||
|
assert_eq!(stub["package"], serde_json::Value::Null);
|
||||||
|
assert_eq!(stub["redeems"], serde_json::json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redeems_route_keys_claims_by_upstream_id() {
|
||||||
|
let base = serve_stubs().await;
|
||||||
|
let r: RedeemsResponse = reqwest::get(format!("{base}/redeems"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.redeems.get("redeemer"),
|
||||||
|
Some(&vec![RedeemClaim::nzb_release(vec!["api.example".into()])])
|
||||||
|
);
|
||||||
|
assert_eq!(r.redeems.get("stub"), Some(&vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gateway forwards a feeder 429's `Retry-After` — pin that it is set.
|
||||||
|
#[test]
|
||||||
|
fn rate_limited_sets_retry_after() {
|
||||||
|
let resp = gateway_error_response(GatewayError::RateLimited { retry_after_s: 3600 });
|
||||||
|
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(axum::http::header::RETRY_AFTER)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("3600")
|
||||||
|
);
|
||||||
|
// Other errors carry no Retry-After.
|
||||||
|
let resp = gateway_error_response(GatewayError::NotFound);
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
assert!(resp.headers().get(axum::http::header::RETRY_AFTER).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A manifest from a pre-1.3 feeder (no `package` / `redeems`) still parses.
|
||||||
|
#[test]
|
||||||
|
fn old_manifest_without_redeem_fields_parses() {
|
||||||
|
let m: PluginManifest = serde_json::from_str(
|
||||||
|
r#"{"id":"x","served_file_types":[],"served_content_kinds":[]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(m.package.is_none() && m.redeems.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! CID parsing and the CID-*shape* knowledge every transport process shares.
|
||||||
|
//!
|
||||||
|
//! Not protocol logic — these are pure decoders over the multihash standard,
|
||||||
|
//! which is the one thing meta-share's hull stays tied to. Moved verbatim from
|
||||||
|
//! meta-share (`blockstore.rs` for the parse, `bt.rs` for the torrent shapes) so
|
||||||
|
//! the hull and the plugins classify a cid identically: a drift here would route
|
||||||
|
//! a torrent cid to the IPFS walker or vice versa.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use cid::CidGeneric;
|
||||||
|
|
||||||
|
/// Multihash byte ceiling. Must match the gateway and meta-search side — it
|
||||||
|
/// feeds beetswap's `Behaviour<const MAX_MULTIHASH_SIZE, B>` in the ipfs plugin.
|
||||||
|
pub const MAX_MULTIHASH_SIZE: usize = 64;
|
||||||
|
|
||||||
|
/// The CID type every MetaMesh transport speaks.
|
||||||
|
pub type MsCid = CidGeneric<MAX_MULTIHASH_SIZE>;
|
||||||
|
|
||||||
|
/// Parse a `Record.cid` into an [`MsCid`].
|
||||||
|
///
|
||||||
|
/// **Strict**: a bare CIDv1 multibase string and nothing else, matching
|
||||||
|
/// `cid.Decode` in meta-core's `internal/cid/rank.go`. The `<algo>:<cid>` token
|
||||||
|
/// form and CIDv0 (`Qm…`) are both rejected — see meta-share's
|
||||||
|
/// `blockstore::parse_record_cid` history for why each one bit.
|
||||||
|
pub fn parse_record_cid(cid_str: &str) -> Result<MsCid> {
|
||||||
|
if cid_str.contains(':') {
|
||||||
|
anyhow::bail!(
|
||||||
|
"parse record cid `{cid_str}`: the `<algo>:<cid>` token form was removed; \
|
||||||
|
pass the bare CIDv1 (the multicodec already names the algorithm)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let parsed =
|
||||||
|
MsCid::from_str(cid_str).with_context(|| format!("parse record cid `{cid_str}`"))?;
|
||||||
|
if parsed.version() != cid::Version::V1 {
|
||||||
|
anyhow::bail!(
|
||||||
|
"parse record cid `{cid_str}`: only CIDv1 is supported, got {:?}",
|
||||||
|
parsed.version()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BitTorrent cid shapes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Multihash code for sha1 — a v1 infohash is the sha1 of the info dict.
|
||||||
|
pub const MH_SHA1: u64 = 0x11;
|
||||||
|
const BT_V1_INFOHASH_LEN: usize = 20;
|
||||||
|
const BT_V2_INFOHASH_LEN: usize = 32;
|
||||||
|
/// `btih-v1-file`: one file inside a v1 torrent; digest `<infohash> ‖ varint(index)`.
|
||||||
|
pub const BTIH_V1_FILE_CODEC: u64 = 0x1001;
|
||||||
|
/// `btih-v2-file`: recognised so the error is precise, not yet fetchable.
|
||||||
|
pub const BTIH_V2_FILE_CODEC: u64 = 0x1002;
|
||||||
|
|
||||||
|
/// Is this a whole-torrent v1 infohash cid (sha1 multihash, 20 bytes)? The raw
|
||||||
|
/// codec `0x55` is shared with IPFS raw leaves, so key off the multihash.
|
||||||
|
pub fn is_btih(mscid: &MsCid) -> bool {
|
||||||
|
let mh = mscid.hash();
|
||||||
|
mh.code() == MH_SHA1 && mh.digest().len() == BT_V1_INFOHASH_LEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw 20-byte v1 infohash of a `btih:` cid. Errors (a `400` upstream) for
|
||||||
|
/// anything that isn't sha1/20.
|
||||||
|
pub fn infohash20_from_mscid(mscid: &MsCid) -> Result<[u8; 20]> {
|
||||||
|
let mh = mscid.hash();
|
||||||
|
anyhow::ensure!(
|
||||||
|
mh.code() == MH_SHA1,
|
||||||
|
"not a v1 BitTorrent infohash CID (multihash code 0x{:x}, want 0x11/sha1)",
|
||||||
|
mh.code()
|
||||||
|
);
|
||||||
|
<[u8; 20]>::try_from(mh.digest())
|
||||||
|
.map_err(|_| anyhow::anyhow!("infohash digest is {} bytes, want 20", mh.digest().len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded per-file torrent cid.
|
||||||
|
pub struct BtFileRef {
|
||||||
|
pub infohash20: [u8; 20],
|
||||||
|
pub file_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this a per-file torrent cid (`0x1001` / `0x1002`)?
|
||||||
|
pub fn is_btih_file(mscid: &MsCid) -> bool {
|
||||||
|
matches!(mscid.codec(), BTIH_V1_FILE_CODEC | BTIH_V2_FILE_CODEC)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a `btih-v1-file` cid into `(infohash, file_index)`; v2 is rejected
|
||||||
|
/// pending swarm support.
|
||||||
|
pub fn decode_btih_file(mscid: &MsCid) -> Result<BtFileRef> {
|
||||||
|
let infohash_len = match mscid.codec() {
|
||||||
|
BTIH_V1_FILE_CODEC => BT_V1_INFOHASH_LEN,
|
||||||
|
BTIH_V2_FILE_CODEC => {
|
||||||
|
let _ = BT_V2_INFOHASH_LEN; // documents the v2 digest shape
|
||||||
|
anyhow::bail!("btih-v2-file CIDs are not yet supported by the fetch tier");
|
||||||
|
}
|
||||||
|
other => anyhow::bail!("not a btih-file CID (codec 0x{other:x})"),
|
||||||
|
};
|
||||||
|
let digest = mscid.hash().digest();
|
||||||
|
anyhow::ensure!(
|
||||||
|
digest.len() > infohash_len,
|
||||||
|
"btih-file digest is {} bytes, need > {} (infohash + file-index varint)",
|
||||||
|
digest.len(),
|
||||||
|
infohash_len
|
||||||
|
);
|
||||||
|
let (ih, idx_bytes) = digest.split_at(infohash_len);
|
||||||
|
let infohash20 = <[u8; 20]>::try_from(ih)
|
||||||
|
.map_err(|_| anyhow::anyhow!("btih-file infohash slice is not 20 bytes"))?;
|
||||||
|
let (file_index, consumed) = read_uvarint(idx_bytes)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
consumed == idx_bytes.len(),
|
||||||
|
"btih-file digest has {} trailing byte(s) after the file-index varint",
|
||||||
|
idx_bytes.len() - consumed
|
||||||
|
);
|
||||||
|
let file_index = usize::try_from(file_index)
|
||||||
|
.map_err(|_| anyhow::anyhow!("btih-file index {file_index} overflows usize"))?;
|
||||||
|
Ok(BtFileRef { infohash20, file_index })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal unsigned-LEB128 reader → `(value, bytes_consumed)`. Rejects truncated
|
||||||
|
/// and overflowing encodings.
|
||||||
|
pub fn read_uvarint(bytes: &[u8]) -> Result<(u64, usize)> {
|
||||||
|
let mut result: u64 = 0;
|
||||||
|
let mut shift: u32 = 0;
|
||||||
|
for (i, &b) in bytes.iter().enumerate() {
|
||||||
|
anyhow::ensure!(shift < 64, "varint overflows u64");
|
||||||
|
result |= u64::from(b & 0x7f) << shift;
|
||||||
|
if b & 0x80 == 0 {
|
||||||
|
return Ok((result, i + 1));
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
anyhow::bail!("varint is truncated (no terminating byte)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this cid a torrent of either shape?
|
||||||
|
pub fn is_torrent_cid(mscid: &MsCid) -> bool {
|
||||||
|
is_btih(mscid) || is_btih_file(mscid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A torrent cid as `(infohash, file_index)`; a whole-torrent cid is file 0.
|
||||||
|
/// `None` for anything else.
|
||||||
|
pub fn torrent_ref_of_cid(cid: &str) -> Option<([u8; 20], usize)> {
|
||||||
|
let mscid = parse_record_cid(cid).ok()?;
|
||||||
|
if let Ok(r) = decode_btih_file(&mscid) {
|
||||||
|
return Some((r.infohash20, r.file_index));
|
||||||
|
}
|
||||||
|
infohash20_from_mscid(&mscid).ok().map(|ih| (ih, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A torrent cid's v1 infohash (whole-torrent and per-file share it).
|
||||||
|
pub fn infohash_of_cid(cid: &str) -> Option<[u8; 20]> {
|
||||||
|
torrent_ref_of_cid(cid).map(|(ih, _)| ih)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase hex.
|
||||||
|
pub fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let mut s = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
let _ = write!(s, "{b:02x}");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase hex of a v1 infohash — the magnet-cache and seed-unit key.
|
||||||
|
pub fn infohash20_hex(infohash20: &[u8; 20]) -> String {
|
||||||
|
to_hex(infohash20)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a 40-char hex infohash.
|
||||||
|
pub fn infohash20_from_hex(s: &str) -> Option<[u8; 20]> {
|
||||||
|
if s.len() != 40 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 20];
|
||||||
|
for (i, pair) in s.as_bytes().chunks(2).enumerate() {
|
||||||
|
let hi = (pair[0] as char).to_digit(16)?;
|
||||||
|
let lo = (pair[1] as char).to_digit(16)?;
|
||||||
|
out[i] = ((hi << 4) | lo) as u8;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort MIME from a filename extension; `application/octet-stream` when
|
||||||
|
/// unknown so the browser sniffs.
|
||||||
|
pub fn content_type_from_name(name: &str) -> String {
|
||||||
|
let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||||
|
let ct = match ext.as_str() {
|
||||||
|
"mkv" => "video/x-matroska",
|
||||||
|
"mp4" | "m4v" => "video/mp4",
|
||||||
|
"webm" => "video/webm",
|
||||||
|
"avi" => "video/x-msvideo",
|
||||||
|
"mov" => "video/quicktime",
|
||||||
|
"ts" | "mts" | "m2ts" => "video/mp2t",
|
||||||
|
"mpg" | "mpeg" => "video/mpeg",
|
||||||
|
"wmv" => "video/x-ms-wmv",
|
||||||
|
"flv" => "video/x-flv",
|
||||||
|
"ogv" => "video/ogg",
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"aac" => "audio/aac",
|
||||||
|
"ogg" | "oga" => "audio/ogg",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
ct.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn base32_lower(input: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut buffer: u64 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for &b in input {
|
||||||
|
buffer = (buffer << 8) | b as u64;
|
||||||
|
bits += 8;
|
||||||
|
while bits >= 5 {
|
||||||
|
bits -= 5;
|
||||||
|
out.push(ALPHABET[((buffer >> bits) & 0x1F) as usize] as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bits > 0 {
|
||||||
|
out.push(ALPHABET[((buffer << (5 - bits)) & 0x1F) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_cid(codec: u8, mh_code: u8, digest: &[u8]) -> MsCid {
|
||||||
|
let mut wire = vec![0x01u8, codec, mh_code, digest.len() as u8];
|
||||||
|
wire.extend_from_slice(digest);
|
||||||
|
MsCid::from_str(&format!("b{}", base32_lower(&wire))).expect("cid parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_uvarint(mut v: u64, out: &mut Vec<u8>) {
|
||||||
|
loop {
|
||||||
|
let mut byte = (v & 0x7f) as u8;
|
||||||
|
v >>= 7;
|
||||||
|
if v != 0 {
|
||||||
|
byte |= 0x80;
|
||||||
|
}
|
||||||
|
out.push(byte);
|
||||||
|
if v == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_cid_varint(codec: u64, mh_code: u64, digest: &[u8]) -> MsCid {
|
||||||
|
let mut wire = vec![0x01u8];
|
||||||
|
write_uvarint(codec, &mut wire);
|
||||||
|
write_uvarint(mh_code, &mut wire);
|
||||||
|
write_uvarint(digest.len() as u64, &mut wire);
|
||||||
|
wire.extend_from_slice(digest);
|
||||||
|
MsCid::from_str(&format!("b{}", base32_lower(&wire))).expect("cid parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bt_file_digest(infohash: &[u8], index: u64) -> Vec<u8> {
|
||||||
|
let mut d = infohash.to_vec();
|
||||||
|
write_uvarint(index, &mut d);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
fn v1_file_cid(infohash: &[u8; 20], index: u64) -> MsCid {
|
||||||
|
make_cid_varint(BTIH_V1_FILE_CODEC, BTIH_V1_FILE_CODEC, &bt_file_digest(infohash, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn btih_cid_is_recognised_and_decodes() {
|
||||||
|
let infohash = [7u8; 20];
|
||||||
|
let cid = make_cid(0x55, MH_SHA1 as u8, &infohash);
|
||||||
|
assert!(is_btih(&cid));
|
||||||
|
assert_eq!(infohash20_from_mscid(&cid).unwrap(), infohash);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipfs_sha256_cid_is_not_btih() {
|
||||||
|
let cid = make_cid(0x55, 0x12, &[0u8; 32]);
|
||||||
|
assert!(!is_btih(&cid));
|
||||||
|
assert!(infohash20_from_mscid(&cid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dagpb_cid_is_not_btih() {
|
||||||
|
assert!(!is_btih(&make_cid(0x70, 0x12, &[0u8; 32])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn btih_v1_file_cid_is_recognised_and_decodes() {
|
||||||
|
let infohash = [7u8; 20];
|
||||||
|
let cid = v1_file_cid(&infohash, 4);
|
||||||
|
assert!(is_btih_file(&cid));
|
||||||
|
assert!(!is_btih(&cid));
|
||||||
|
let r = decode_btih_file(&cid).unwrap();
|
||||||
|
assert_eq!(r.infohash20, infohash);
|
||||||
|
assert_eq!(r.file_index, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn btih_v1_file_index_zero_and_multibyte_roundtrip() {
|
||||||
|
assert_eq!(decode_btih_file(&v1_file_cid(&[0xab; 20], 0)).unwrap().file_index, 0);
|
||||||
|
assert_eq!(decode_btih_file(&v1_file_cid(&[0x11; 20], 200)).unwrap().file_index, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn btih_v2_file_cid_recognised_but_rejected() {
|
||||||
|
let cid = make_cid_varint(BTIH_V2_FILE_CODEC, BTIH_V2_FILE_CODEC, &bt_file_digest(&[0x22; 32], 1));
|
||||||
|
assert!(is_btih_file(&cid));
|
||||||
|
assert!(decode_btih_file(&cid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whole_torrent_cid_is_index_zero_and_not_a_file_cid() {
|
||||||
|
let infohash = [9u8; 20];
|
||||||
|
let cid = make_cid(0x55, MH_SHA1 as u8, &infohash);
|
||||||
|
assert!(is_btih(&cid));
|
||||||
|
assert!(!is_btih_file(&cid));
|
||||||
|
assert!(decode_btih_file(&cid).is_err());
|
||||||
|
assert_eq!(torrent_ref_of_cid(&cid.to_string()), Some((infohash, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_btih_file_rejects_missing_index_varint() {
|
||||||
|
let cid = make_cid_varint(BTIH_V1_FILE_CODEC, BTIH_V1_FILE_CODEC, &[3u8; 20]);
|
||||||
|
assert!(decode_btih_file(&cid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_uvarint_rejects_truncated_and_reads_multibyte() {
|
||||||
|
assert!(read_uvarint(&[0x80]).is_err());
|
||||||
|
assert_eq!(read_uvarint(&[0xAC, 0x02]).unwrap(), (300, 2));
|
||||||
|
assert_eq!(read_uvarint(&[0x05, 0xFF]).unwrap(), (5, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_ref_of_a_file_cid_keeps_its_index() {
|
||||||
|
let cid = v1_file_cid(&[5u8; 20], 3).to_string();
|
||||||
|
assert_eq!(torrent_ref_of_cid(&cid), Some(([5u8; 20], 3)));
|
||||||
|
assert_eq!(infohash_of_cid(&cid), Some([5u8; 20]));
|
||||||
|
assert_eq!(torrent_ref_of_cid("not a cid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_round_trips() {
|
||||||
|
let ih = [0xabu8; 20];
|
||||||
|
assert_eq!(infohash20_from_hex(&infohash20_hex(&ih)), Some(ih));
|
||||||
|
assert_eq!(infohash20_from_hex("zz"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_token_form_and_cidv0() {
|
||||||
|
assert!(parse_record_cid("midhash256:bafy").is_err());
|
||||||
|
assert!(parse_record_cid("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_type_guesses_common_media() {
|
||||||
|
assert_eq!(content_type_from_name("movie.mkv"), "video/x-matroska");
|
||||||
|
assert_eq!(content_type_from_name("clip.MP4"), "video/mp4");
|
||||||
|
assert_eq!(content_type_from_name("noext"), "application/octet-stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! The hull side of the contract: an HTTP client for one transport plugin.
|
||||||
|
//!
|
||||||
|
//! [`RemoteTransport`] covers the common routes and gives typed helpers for a
|
||||||
|
//! plugin's extra ones (`get_json`, `post_json`, …). Byte responses come back as
|
||||||
|
//! an axum [`Response`] whose body streams straight from the plugin, so the hull
|
||||||
|
//! relays a multi-GB range without buffering it.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::dto::{
|
||||||
|
Deleted, Health, Job, Jobs, Manifest, ReconcileReport, ReconcileRequest,
|
||||||
|
};
|
||||||
|
use super::error::ApiError;
|
||||||
|
use super::focus::FocusSnapshot;
|
||||||
|
|
||||||
|
/// Request headers never forwarded to a plugin (hop-by-hop, or describing the
|
||||||
|
/// client's connection to the hull rather than the hull's to the plugin).
|
||||||
|
const HOP_BY_HOP: &[&str] = &[
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"host",
|
||||||
|
"content-length",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Client for one plugin at `base` (e.g. `http://metashare-torrent:3000`).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RemoteTransport {
|
||||||
|
id: String,
|
||||||
|
base: String,
|
||||||
|
http: reqwest::Client,
|
||||||
|
/// For control calls (jobs, focus, reconcile). `/raw` has no overall
|
||||||
|
/// timeout — a body can legitimately stream for the length of a film.
|
||||||
|
control_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteTransport {
|
||||||
|
/// `id` names the tier in error messages (`torrent`, `nzb`, `ipfs`).
|
||||||
|
pub fn new(id: impl Into<String>, base: impl Into<String>, http: reqwest::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
base: base.into().trim_end_matches('/').to_string(),
|
||||||
|
http,
|
||||||
|
control_timeout: Duration::from_secs(30),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_control_timeout(mut self, t: Duration) -> Self {
|
||||||
|
self.control_timeout = t;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base(&self) -> &str {
|
||||||
|
&self.base
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.base, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unreachable(&self, e: impl std::fmt::Display) -> ApiError {
|
||||||
|
ApiError::Upstream(format!("{} plugin unreachable: {e}", self.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- common routes ---------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn manifest(&self) -> Result<Manifest, ApiError> {
|
||||||
|
self.get_json("/manifest").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn health(&self) -> Result<Health, ApiError> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.get(self.url("/health"))
|
||||||
|
.timeout(self.control_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
r.json::<Health>().await.map_err(|e| self.unreachable(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /raw/:cid`, forwarding the client's request headers plus `extra`.
|
||||||
|
/// Returns the plugin's response as-is (any status), with its body streaming.
|
||||||
|
pub async fn raw(
|
||||||
|
&self,
|
||||||
|
cid: &str,
|
||||||
|
client_headers: &HeaderMap,
|
||||||
|
extra: &[(&str, String)],
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let mut req = self.http.get(self.url(&format!("/raw/{cid}")));
|
||||||
|
req = req.headers(forwardable(client_headers));
|
||||||
|
for (k, v) in extra {
|
||||||
|
req = req.header(*k, v);
|
||||||
|
}
|
||||||
|
let resp = req.send().await.map_err(|e| self.unreachable(e))?;
|
||||||
|
Ok(relay(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn jobs(&self) -> Result<Vec<Job>, ApiError> {
|
||||||
|
Ok(self.get_json::<Jobs>("/jobs").await?.jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, cid: &str, keep: &[String]) -> Result<Deleted, ApiError> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.delete(self.url(&format!("/jobs/{cid}")))
|
||||||
|
.query(&[("keep", keep.join(","))])
|
||||||
|
.timeout(self.control_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
decode(r).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reconcile(&self, req: &ReconcileRequest) -> Result<ReconcileReport, ApiError> {
|
||||||
|
// A reconcile can delete many units; give it more room than a poll.
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.post(self.url("/reconcile"))
|
||||||
|
.json(req)
|
||||||
|
.timeout(self.control_timeout * 10)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
decode(r).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push_focus(&self, snap: &FocusSnapshot) -> Result<(), ApiError> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.put(self.url("/focus"))
|
||||||
|
.json(snap)
|
||||||
|
.timeout(self.control_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
if r.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::Upstream(format!("{} plugin /focus: {}", self.id, r.status())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- typed helpers for extra routes ------------------------------------
|
||||||
|
|
||||||
|
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.get(self.url(path))
|
||||||
|
.timeout(self.control_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
decode(r).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_json<B: Serialize + ?Sized, T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T, ApiError> {
|
||||||
|
self.post_json_timeout(path, body, self.control_timeout).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_json_timeout<B: Serialize + ?Sized, T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<T, ApiError> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.post(self.url(path))
|
||||||
|
.json(body)
|
||||||
|
.timeout(timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
decode(r).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_json<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), ApiError> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.put(self.url(path))
|
||||||
|
.json(body)
|
||||||
|
.timeout(self.control_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
if r.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(error_from(r).await)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward an arbitrary request and relay the response verbatim — for the
|
||||||
|
/// public routes a plugin now answers (`/ipfs/:cid`, `/api/peers`, …).
|
||||||
|
pub async fn forward(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
client_headers: &HeaderMap,
|
||||||
|
body: Option<bytes::Bytes>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.request(method, self.url(path_and_query))
|
||||||
|
.headers(forwardable(client_headers));
|
||||||
|
if let Some(b) = body {
|
||||||
|
req = req.body(b);
|
||||||
|
}
|
||||||
|
let resp = req.send().await.map_err(|e| self.unreachable(e))?;
|
||||||
|
Ok(relay(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client request headers worth forwarding to a plugin.
|
||||||
|
fn forwardable(h: &HeaderMap) -> reqwest::header::HeaderMap {
|
||||||
|
let mut out = reqwest::header::HeaderMap::new();
|
||||||
|
for (k, v) in h {
|
||||||
|
if HOP_BY_HOP.contains(&k.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (Ok(k), Ok(v)) = (
|
||||||
|
reqwest::header::HeaderName::from_bytes(k.as_str().as_bytes()),
|
||||||
|
reqwest::header::HeaderValue::from_bytes(v.as_bytes()),
|
||||||
|
) {
|
||||||
|
out.append(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plugin response as an axum response: same status, same headers (minus
|
||||||
|
/// hop-by-hop), body streamed.
|
||||||
|
pub fn relay(resp: reqwest::Response) -> Response {
|
||||||
|
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for (k, v) in resp.headers() {
|
||||||
|
let name = k.as_str();
|
||||||
|
if matches!(name, "connection" | "keep-alive" | "transfer-encoding" | "trailer" | "upgrade") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (Ok(k), Ok(v)) = (
|
||||||
|
HeaderName::from_bytes(name.as_bytes()),
|
||||||
|
HeaderValue::from_bytes(v.as_bytes()),
|
||||||
|
) {
|
||||||
|
headers.append(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body = Body::from_stream(resp.bytes_stream());
|
||||||
|
let mut out = (status, body).into_response();
|
||||||
|
*out.headers_mut() = headers;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode<T: DeserializeOwned>(r: reqwest::Response) -> Result<T, ApiError> {
|
||||||
|
if !r.status().is_success() {
|
||||||
|
return Err(error_from(r).await);
|
||||||
|
}
|
||||||
|
r.json::<T>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Upstream(format!("plugin returned malformed JSON: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a plugin error response back onto [`ApiError`], keeping its message so
|
||||||
|
/// a relayed error reads the same as the in-process one did.
|
||||||
|
pub async fn error_from(r: reqwest::Response) -> ApiError {
|
||||||
|
let status = r.status().as_u16();
|
||||||
|
let msg = r
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
|
||||||
|
.unwrap_or_else(|| format!("plugin error {status}"));
|
||||||
|
match status {
|
||||||
|
400 => ApiError::BadRequest(msg),
|
||||||
|
404 => ApiError::NotFound,
|
||||||
|
504 => ApiError::GatewayTimeout(msg),
|
||||||
|
503 if msg == "swarm task is gone" => ApiError::SwarmGone,
|
||||||
|
_ => ApiError::Upstream(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hop_by_hop_headers_are_not_forwarded() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("range", HeaderValue::from_static("bytes=0-1"));
|
||||||
|
h.insert("host", HeaderValue::from_static("x"));
|
||||||
|
h.insert("connection", HeaderValue::from_static("close"));
|
||||||
|
let f = forwardable(&h);
|
||||||
|
assert!(f.contains_key("range"));
|
||||||
|
assert!(!f.contains_key("host"));
|
||||||
|
assert!(!f.contains_key("connection"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
//! A transport plugin's own settings — the "config plane".
|
||||||
|
//!
|
||||||
|
//! Same model as a feeder's (`crate::config`): the plugin declares a
|
||||||
|
//! [`ConfigSchema`], keeps its values in `<state_dir>/config.json`, and serves a
|
||||||
|
//! schema-driven page that meta-share's dashboard embeds through its plugin
|
||||||
|
//! proxy (`/api/plugins/:name/ui/config`). Keys stay on the plugin; the hull
|
||||||
|
//! carries no protocol-specific settings.
|
||||||
|
//!
|
||||||
|
//! Routes (mounted by [`serve`](super::serve) when
|
||||||
|
//! [`TransportPlugin::config`](super::plugin::TransportPlugin::config) is
|
||||||
|
//! `Some`):
|
||||||
|
//!
|
||||||
|
//! - `GET /config` → the page ([`CONFIG_PAGE_HTML`]); every call it makes
|
||||||
|
//! is relative to its own path, so the proxy prefix is irrelevant.
|
||||||
|
//! - `GET /config/schema` → [`ConfigSchema`].
|
||||||
|
//! - `GET /config/values` → effective values, secrets redacted to `<key>_set`.
|
||||||
|
//! - `PUT /config/values` → merge (blank secret keeps the stored one), persist
|
||||||
|
//! atomically, then exit so the container restarts on the new values
|
||||||
|
//! (`{"saved":true,"reloading":true}`). Plugins read their config once at
|
||||||
|
//! boot — a restart is the one apply path that is right for every one of them.
|
||||||
|
//!
|
||||||
|
//! **Effective values** = `config.json` if it exists and parses as an object,
|
||||||
|
//! else the `seed` the plugin derived from its env (its pre-config-plane
|
||||||
|
//! settings source). A plugin deserializes [`ConfigPlane::effective`] into its
|
||||||
|
//! own typed struct with serde defaults, so a missing key is never fatal.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::config::{merge, redact, ConfigSchema, CONFIG_PAGE_HTML};
|
||||||
|
|
||||||
|
/// Env var naming the plugin's own writable state dir.
|
||||||
|
pub const STATE_DIR_ENV: &str = "META_SHARE_PLUGIN_STATE_DIR";
|
||||||
|
/// Default state dir (the plugin app's own `AppData/<app>/state` mount).
|
||||||
|
pub const DEFAULT_STATE_DIR: &str = "/state";
|
||||||
|
/// File name of the stored values inside the state dir.
|
||||||
|
pub const CONFIG_FILE: &str = "config.json";
|
||||||
|
|
||||||
|
/// The plugin's state dir from [`STATE_DIR_ENV`], else [`DEFAULT_STATE_DIR`].
|
||||||
|
pub fn state_dir_from_env() -> PathBuf {
|
||||||
|
std::env::var(STATE_DIR_ENV)
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_STATE_DIR.to_string())
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One plugin's schema + stored values.
|
||||||
|
pub struct ConfigPlane {
|
||||||
|
schema: ConfigSchema,
|
||||||
|
path: PathBuf,
|
||||||
|
seed: Value,
|
||||||
|
restart_on_save: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigPlane {
|
||||||
|
/// `seed` is what the plugin would run with if no `config.json` existed
|
||||||
|
/// (normally its env-derived settings, serialized).
|
||||||
|
pub fn new(schema: ConfigSchema, state_dir: &Path, seed: Value) -> Self {
|
||||||
|
Self {
|
||||||
|
schema,
|
||||||
|
path: state_dir.join(CONFIG_FILE),
|
||||||
|
seed,
|
||||||
|
restart_on_save: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Don't exit after a save (tests, or a plugin that hot-applies).
|
||||||
|
pub fn without_restart(mut self) -> Self {
|
||||||
|
self.restart_on_save = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn schema(&self) -> &ConfigSchema {
|
||||||
|
&self.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored values, if `config.json` exists and is a JSON object.
|
||||||
|
pub fn stored(&self) -> Option<Value> {
|
||||||
|
let bytes = std::fs::read(&self.path).ok()?;
|
||||||
|
match serde_json::from_slice::<Value>(&bytes) {
|
||||||
|
Ok(v @ Value::Object(_)) => Some(v),
|
||||||
|
Ok(_) | Err(_) => {
|
||||||
|
warn!(path = %self.path.display(), "plugin config is not a JSON object; using the seed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stored values, else the seed.
|
||||||
|
pub fn effective(&self) -> Value {
|
||||||
|
self.stored().unwrap_or_else(|| self.seed.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist `values` atomically (tmp + rename), creating the state dir.
|
||||||
|
pub fn save(&self, values: &Value) -> std::io::Result<()> {
|
||||||
|
if let Some(dir) = self.path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
let body = serde_json::to_vec_pretty(values)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
std::fs::write(&tmp, body)?;
|
||||||
|
std::fs::rename(&tmp, &self.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `values` as the initial config **only** if no `config.json` exists
|
||||||
|
/// yet — the one-shot migration hook (e.g. the nzb plugin importing the
|
||||||
|
/// NNTP block meta-share used to own). Returns whether it wrote.
|
||||||
|
pub fn seed_file_if_absent(&self, values: &Value) -> std::io::Result<bool> {
|
||||||
|
if self.path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.save(values)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `/config*` routes.
|
||||||
|
pub fn routes(self: Arc<Self>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/config", get(page))
|
||||||
|
.route("/config/schema", get(schema))
|
||||||
|
.route("/config/values", get(values_get).put(values_put))
|
||||||
|
.with_state(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lenient readers for a config object as the page writes it: an empty number
|
||||||
|
/// is `null`, an empty text is `""`, and a hand-edited file may hold a number as
|
||||||
|
/// a string. Each returns `None` for "not set" so the caller falls back to its
|
||||||
|
/// default.
|
||||||
|
pub mod read {
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// A non-blank, trimmed string.
|
||||||
|
pub fn text(v: &Value, key: &str) -> Option<String> {
|
||||||
|
match v.get(key)? {
|
||||||
|
Value::String(s) => Some(s.trim().to_string()).filter(|s| !s.is_empty()),
|
||||||
|
Value::Number(n) => Some(n.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn number(v: &Value, key: &str) -> Option<f64> {
|
||||||
|
match v.get(key)? {
|
||||||
|
Value::Number(n) => n.as_f64(),
|
||||||
|
Value::String(s) => s.trim().parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-negative integer (fractions truncate, negatives are "not set").
|
||||||
|
pub fn uint(v: &Value, key: &str) -> Option<u64> {
|
||||||
|
number(v, key).filter(|n| n.is_finite() && *n >= 0.0).map(|n| n as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flag(v: &Value, key: &str) -> Option<bool> {
|
||||||
|
match v.get(key)? {
|
||||||
|
Value::Bool(b) => Some(*b),
|
||||||
|
Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"1" | "true" | "yes" | "on" => Some(true),
|
||||||
|
"0" | "false" | "no" | "off" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list of non-blank strings (a string is split on commas/newlines).
|
||||||
|
pub fn list(v: &Value, key: &str) -> Option<Vec<String>> {
|
||||||
|
let items: Vec<String> = match v.get(key)? {
|
||||||
|
Value::Array(a) => a.iter().filter_map(|x| x.as_str()).map(str::to_string).collect(),
|
||||||
|
Value::String(s) => s.split([',', '\n']).map(str::to_string).collect(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(items.into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page() -> Html<&'static str> {
|
||||||
|
Html(CONFIG_PAGE_HTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn schema(State(p): State<Arc<ConfigPlane>>) -> Json<ConfigSchema> {
|
||||||
|
Json(p.schema.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn values_get(State(p): State<Arc<ConfigPlane>>) -> Json<Value> {
|
||||||
|
Json(redact(&p.effective(), &p.schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn values_put(
|
||||||
|
State(p): State<Arc<ConfigPlane>>,
|
||||||
|
Json(incoming): Json<Value>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
if !incoming.is_object() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "expected a JSON object").into_response());
|
||||||
|
}
|
||||||
|
let merged = merge(&p.effective(), &incoming, &p.schema);
|
||||||
|
p.save(&merged).map_err(|e| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("write config: {e}")).into_response()
|
||||||
|
})?;
|
||||||
|
if !p.restart_on_save {
|
||||||
|
info!(path = %p.path.display(), "plugin config saved");
|
||||||
|
return Ok(Json(serde_json::json!({ "saved": true, "reloading": false })));
|
||||||
|
}
|
||||||
|
info!(path = %p.path.display(), "plugin config saved; restarting to apply");
|
||||||
|
// Let this response flush, then exit; `restart: unless-stopped` brings the
|
||||||
|
// plugin back and it reads config.json at boot.
|
||||||
|
tokio::spawn(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
Ok(Json(serde_json::json!({ "saved": true, "reloading": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::ConfigField;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::Request;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn schema() -> ConfigSchema {
|
||||||
|
ConfigSchema {
|
||||||
|
fields: vec![
|
||||||
|
ConfigField::text("host", "Host"),
|
||||||
|
ConfigField::secret("pass", "Password"),
|
||||||
|
ConfigField::number("connections", "Connections"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plane(dir: &Path) -> Arc<ConfigPlane> {
|
||||||
|
Arc::new(
|
||||||
|
ConfigPlane::new(schema(), dir, serde_json::json!({"host":"seed.example","connections":4}))
|
||||||
|
.without_restart(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(app: Router, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(uri)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(match body {
|
||||||
|
Some(b) => Body::from(serde_json::to_vec(&b).unwrap()),
|
||||||
|
None => Body::empty(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let r = app.oneshot(req).await.unwrap();
|
||||||
|
let status = r.status();
|
||||||
|
let bytes = axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap();
|
||||||
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seed_until_saved_then_file_wins_and_secret_is_kept() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = plane(dir.path());
|
||||||
|
assert_eq!(p.effective()["host"], "seed.example");
|
||||||
|
|
||||||
|
let (st, v) = call(Arc::clone(&p).routes(), "GET", "/config/values", None).await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
assert_eq!(v["host"], "seed.example");
|
||||||
|
assert_eq!(v["pass_set"], false);
|
||||||
|
|
||||||
|
let (st, _) = call(
|
||||||
|
Arc::clone(&p).routes(),
|
||||||
|
"PUT",
|
||||||
|
"/config/values",
|
||||||
|
Some(serde_json::json!({"host":"news.example","pass":"s3cret"})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
let stored = p.stored().unwrap();
|
||||||
|
assert_eq!(stored["host"], "news.example");
|
||||||
|
assert_eq!(stored["pass"], "s3cret");
|
||||||
|
assert_eq!(stored["connections"], 4, "absent field keeps the effective value");
|
||||||
|
|
||||||
|
// A blank secret keeps the stored one; the GET never leaks it.
|
||||||
|
call(Arc::clone(&p).routes(), "PUT", "/config/values", Some(serde_json::json!({"pass":""}))).await;
|
||||||
|
assert_eq!(p.stored().unwrap()["pass"], "s3cret");
|
||||||
|
let (_, v) = call(Arc::clone(&p).routes(), "GET", "/config/values", None).await;
|
||||||
|
assert!(v.get("pass").is_none());
|
||||||
|
assert_eq!(v["pass_set"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schema_and_page_are_served() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = plane(dir.path());
|
||||||
|
let (st, v) = call(Arc::clone(&p).routes(), "GET", "/config/schema", None).await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
assert_eq!(v["fields"].as_array().unwrap().len(), 3);
|
||||||
|
let r = p
|
||||||
|
.routes()
|
||||||
|
.oneshot(Request::get("/config").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_file_if_absent_writes_once() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = plane(dir.path());
|
||||||
|
assert!(p.seed_file_if_absent(&serde_json::json!({"host":"a"})).unwrap());
|
||||||
|
assert!(!p.seed_file_if_absent(&serde_json::json!({"host":"b"})).unwrap());
|
||||||
|
assert_eq!(p.effective()["host"], "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readers_treat_blank_as_unset() {
|
||||||
|
let v = serde_json::json!({"a":"", "b":" x ", "n":null, "m":"7", "f":"off", "l":["a"," ",""], "s":"p, q"});
|
||||||
|
assert_eq!(read::text(&v, "a"), None);
|
||||||
|
assert_eq!(read::text(&v, "b").as_deref(), Some("x"));
|
||||||
|
assert_eq!(read::uint(&v, "n"), None);
|
||||||
|
assert_eq!(read::uint(&v, "m"), Some(7));
|
||||||
|
assert_eq!(read::flag(&v, "f"), Some(false));
|
||||||
|
assert_eq!(read::list(&v, "l"), Some(vec!["a".to_string()]));
|
||||||
|
assert_eq!(read::list(&v, "s"), Some(vec!["p".to_string(), "q".to_string()]));
|
||||||
|
assert_eq!(read::text(&v, "missing"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_object_file_falls_back_to_the_seed() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join(CONFIG_FILE), b"[1,2]").unwrap();
|
||||||
|
assert_eq!(plane(dir.path()).effective()["host"], "seed.example");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
//! Wire types of the transport-plugin contract (hull ⇄ plugin, JSON over HTTP).
|
||||||
|
//!
|
||||||
|
//! Only what is common to every plugin lives here. A plugin's own extra routes
|
||||||
|
//! (ipfs `/share`, torrent `/commits`, …) carry their DTOs in the per-protocol
|
||||||
|
//! modules next to this one, so the hull and the plugin still compile against
|
||||||
|
//! one definition.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Bumped on any breaking change to the common routes below. The hull refuses
|
||||||
|
/// a plugin whose `/manifest` reports a different major contract.
|
||||||
|
pub const CONTRACT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// `X-MetaMesh-Lane`: the hull's lane decision for a `/raw` request.
|
||||||
|
pub const HDR_LANE: &str = "x-metamesh-lane";
|
||||||
|
/// `X-MetaMesh-Player`: forwarded verbatim from the client — marks a genuine
|
||||||
|
/// play (commits, provisional focus).
|
||||||
|
pub const HDR_PLAYER: &str = "x-metamesh-player";
|
||||||
|
/// `X-MetaMesh-Magnet`: the magnet the hull resolved for a torrent cid.
|
||||||
|
pub const HDR_MAGNET: &str = "x-metamesh-magnet";
|
||||||
|
/// Seed facts a plugin attaches to a successful `/raw` response, so the hull
|
||||||
|
/// records the seed row *before* it relays the body (the monolith wrote the
|
||||||
|
/// row inside the fetch, ahead of the first byte). Internal: the hull strips
|
||||||
|
/// every `x-metamesh-seed-*` header before relaying.
|
||||||
|
pub const HDR_SEED_NAME: &str = "x-metamesh-seed-name";
|
||||||
|
pub const HDR_SEED_SIZE: &str = "x-metamesh-seed-size";
|
||||||
|
/// Prefix of the seed headers the hull strips from a relayed response.
|
||||||
|
pub const INTERNAL_HEADER_PREFIX: &str = "x-metamesh-seed-";
|
||||||
|
/// `x-metamesh-meter: egress` on a `/raw` response: the hull floors this body
|
||||||
|
/// against the playback focus when it relays it. Set exactly where the
|
||||||
|
/// monolith metered (`focus::gated_stream`) — torrent ranges, Usenet file
|
||||||
|
/// reads, IPFS dag ranges — and *not* on single-block IPFS answers (posters),
|
||||||
|
/// which the monolith never throttled. Internal; stripped by the hull.
|
||||||
|
pub const HDR_METER: &str = "x-metamesh-meter";
|
||||||
|
pub const METER_EGRESS: &str = "egress";
|
||||||
|
|
||||||
|
/// Encode free text (a file name) for an internal header value: every byte
|
||||||
|
/// outside a conservative visible-ASCII set becomes `%XX`. Inverse:
|
||||||
|
/// [`header_text_decode`].
|
||||||
|
pub fn header_text_encode(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for b in s.bytes() {
|
||||||
|
if b.is_ascii_alphanumeric() || b" ._-()[]+,".contains(&b) {
|
||||||
|
out.push(b as char);
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("%{b:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`header_text_encode`]. Invalid escapes pass through literally.
|
||||||
|
pub fn header_text_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
let hi = (bytes[i + 1] as char).to_digit(16);
|
||||||
|
let lo = (bytes[i + 2] as char).to_digit(16);
|
||||||
|
if let (Some(hi), Some(lo)) = (hi, lo) {
|
||||||
|
out.push((hi * 16 + lo) as u8);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /manifest`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Manifest {
|
||||||
|
/// Protocol id: `torrent` | `nzb` | `ipfs` (a competing implementation of a
|
||||||
|
/// protocol reports the same id — that's what makes it a drop-in).
|
||||||
|
pub id: String,
|
||||||
|
/// Implementation name + version, for the dashboard.
|
||||||
|
pub implementation: String,
|
||||||
|
pub version: String,
|
||||||
|
pub contract: u32,
|
||||||
|
pub capabilities: Capabilities,
|
||||||
|
/// Serves a config plane (`/config`, `/config/schema`, `/config/values`).
|
||||||
|
/// Set by the harness from [`TransportPlugin::config`](super::plugin::TransportPlugin::config);
|
||||||
|
/// absent in older manifests.
|
||||||
|
#[serde(default)]
|
||||||
|
pub config: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Capabilities {
|
||||||
|
/// Can fetch bytes for a cid (`/raw`).
|
||||||
|
pub fetch: bool,
|
||||||
|
/// Serves what it fetched to other peers (seeds).
|
||||||
|
pub share: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /health`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct Health {
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub detail: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Health {
|
||||||
|
pub fn ok() -> Self {
|
||||||
|
Self { ok: true, detail: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One unit of bytes a plugin holds — the eviction unit as the plugin knows it
|
||||||
|
/// (a torrent, an NZB job). `GET /jobs`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct Job {
|
||||||
|
/// Stable unit key (a torrent's infohash hex, an nzb job's cid).
|
||||||
|
pub id: String,
|
||||||
|
/// The cids this unit answers for, as far as the plugin can name them.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cids: Vec<String>,
|
||||||
|
/// Bytes on disk right now.
|
||||||
|
pub bytes_on_disk: u64,
|
||||||
|
/// Every selected byte is present.
|
||||||
|
pub complete: bool,
|
||||||
|
/// Downloading in the background on its own (exempt from eviction).
|
||||||
|
pub filling: bool,
|
||||||
|
/// Plugin-defined state label (`live`, `paused`, `streaming`, …).
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: String,
|
||||||
|
/// Plugin-specific detail the hull renders opaquely (live transfer stats).
|
||||||
|
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||||
|
pub extra: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
pub struct Jobs {
|
||||||
|
pub jobs: Vec<Job>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /jobs/:cid` query: the *other* cids of the same unit the hull still
|
||||||
|
/// wants (sibling files of one torrent). The plugin keeps those.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
pub struct DeleteQuery {
|
||||||
|
/// Comma-separated cids.
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteQuery {
|
||||||
|
pub fn keep_list(&self) -> Vec<String> {
|
||||||
|
self.keep
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /jobs/:cid` → the bytes actually unlinked, which is what the hull's
|
||||||
|
/// eviction budget credits.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Deleted {
|
||||||
|
pub bytes_freed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /reconcile`: delete every unit no wanted cid names, and any on-disk
|
||||||
|
/// leftover no unit owns older than `grace_secs`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
pub struct ReconcileRequest {
|
||||||
|
pub wanted: Vec<String>,
|
||||||
|
pub grace_secs: u64,
|
||||||
|
/// Only meaningful for plugins whose state lives in a lazily-started
|
||||||
|
/// session (torrent): bring it up even if nothing asked for bytes yet,
|
||||||
|
/// because persisted units are on disk and only the session can say whose.
|
||||||
|
#[serde(default)]
|
||||||
|
pub start_session: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ReconcileReport {
|
||||||
|
/// Units deleted because nothing wanted them.
|
||||||
|
pub units_deleted: u64,
|
||||||
|
pub unit_bytes_freed: u64,
|
||||||
|
/// On-disk leftovers removed.
|
||||||
|
pub orphans_deleted: u64,
|
||||||
|
pub orphan_bytes_freed: u64,
|
||||||
|
/// The plugin couldn't tell what it owns (session down): nothing deleted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file inside a promoted cache container.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PromotedFile {
|
||||||
|
/// Path relative to the container directory.
|
||||||
|
pub rel: String,
|
||||||
|
/// Absolute path on the shared `/data` volume.
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin → hull notifications, `POST {hull}/internal/events` (see
|
||||||
|
/// [`super::hull::HullClient`]). The hull acknowledges once it has acted, so a
|
||||||
|
/// plugin that must not proceed before the hull indexed something can wait.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Event {
|
||||||
|
/// The plugin now holds bytes under `cid`: the hull upserts a seed row.
|
||||||
|
Seed {
|
||||||
|
cid: String,
|
||||||
|
/// `torrent` | `ipfs` (the hull's `SeedKind`).
|
||||||
|
kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size_bytes: u64,
|
||||||
|
/// The container the bytes live in, when there is one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
container: Option<String>,
|
||||||
|
},
|
||||||
|
/// A completed fetch was published as a cache container (`rename(2)` into
|
||||||
|
/// `cache/`). The hull indexes it in the material index.
|
||||||
|
Promoted {
|
||||||
|
container: String,
|
||||||
|
cids: Vec<String>,
|
||||||
|
files: Vec<PromotedFile>,
|
||||||
|
},
|
||||||
|
/// The content behind `cid` turned out to be an archive: the hull marks
|
||||||
|
/// the meta-core record `fileType=archive`.
|
||||||
|
Archive { cid: String },
|
||||||
|
/// Bytes behind these cids are gone: the hull drops their seed rows.
|
||||||
|
Vanished { cids: Vec<String> },
|
||||||
|
/// What the plugin knows about `cid` changed (a new availability verdict):
|
||||||
|
/// the hull drops its cached probe grade.
|
||||||
|
ProbeStale { cid: String },
|
||||||
|
/// A fetch finished and its file is complete at `path` (still under
|
||||||
|
/// `tmp/`). The hull indexes it for local-first reads and decides what to
|
||||||
|
/// do next (the Usenet → IPFS re-seed).
|
||||||
|
Ready {
|
||||||
|
cid: String,
|
||||||
|
path: String,
|
||||||
|
size: u64,
|
||||||
|
},
|
||||||
|
/// Any event type this hull doesn't know yet. Ignored.
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_text_round_trips_unicode_and_reserved() {
|
||||||
|
for s in ["Plain Name.mkv", "Été 100% \"quoted\"/x.mkv", "", "%", "a%2"] {
|
||||||
|
assert_eq!(header_text_decode(&header_text_encode(s)), s);
|
||||||
|
}
|
||||||
|
assert!(header_text_encode("é/").is_ascii());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_query_splits_and_trims() {
|
||||||
|
let q = DeleteQuery { keep: " a, ,b,".into() };
|
||||||
|
assert_eq!(q.keep_list(), vec!["a".to_string(), "b".to_string()]);
|
||||||
|
assert!(DeleteQuery::default().keep_list().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_are_tagged_and_unknown_tolerated() {
|
||||||
|
let e = Event::Archive { cid: "b".into() };
|
||||||
|
let j = serde_json::to_string(&e).unwrap();
|
||||||
|
assert_eq!(j, r#"{"type":"archive","cid":"b"}"#);
|
||||||
|
let u: Event = serde_json::from_str(r#"{"type":"from_the_future","x":1}"#).unwrap();
|
||||||
|
assert_eq!(u, Event::Unknown);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! The one error type every transport HTTP surface answers with.
|
||||||
|
//!
|
||||||
|
//! Moved verbatim from meta-share `crates/meta-share/src/api/errors.rs`. It lives
|
||||||
|
//! here, not in the hull, because a plugin's error body is relayed to meta-share's
|
||||||
|
//! public API unchanged: the JSON shape `{ "error": "<message>" }` and the
|
||||||
|
//! status per variant are the contract every dashboard and peer depends on, and
|
||||||
|
//! the hull and its plugins must not be able to drift on it.
|
||||||
|
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ApiError {
|
||||||
|
BadRequest(String),
|
||||||
|
NotFound,
|
||||||
|
SwarmGone,
|
||||||
|
/// This peer is consumer-only (no `META_CORE_URL`) so it can't satisfy a
|
||||||
|
/// raw-bytes request directly. The cross-peer proxy turns this into a
|
||||||
|
/// clearer 502 with hint text; the direct route returns 503.
|
||||||
|
ConsumerOnly,
|
||||||
|
Upstream(String),
|
||||||
|
/// Gateway-side compute exceeded its deadline. Distinct from `Upstream` so
|
||||||
|
/// the dashboard can surface a clearer "the source fetcher is slow" message
|
||||||
|
/// instead of a generic 502.
|
||||||
|
GatewayTimeout(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let (status, msg) = match self {
|
||||||
|
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
||||||
|
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
|
||||||
|
ApiError::SwarmGone => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"swarm task is gone".into(),
|
||||||
|
),
|
||||||
|
ApiError::ConsumerOnly => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"this peer has no META_CORE_URL; raw bytes are only served by the source peer".into(),
|
||||||
|
),
|
||||||
|
ApiError::Upstream(m) => (StatusCode::BAD_GATEWAY, m),
|
||||||
|
ApiError::GatewayTimeout(m) => (StatusCode::GATEWAY_TIMEOUT, m),
|
||||||
|
};
|
||||||
|
(status, Json(serde_json::json!({ "error": msg }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ApiError::BadRequest(m) => write!(f, "bad request: {m}"),
|
||||||
|
ApiError::NotFound => write!(f, "not found"),
|
||||||
|
ApiError::SwarmGone => write!(f, "swarm task is gone"),
|
||||||
|
ApiError::ConsumerOnly => write!(f, "consumer-only peer"),
|
||||||
|
ApiError::Upstream(m) => write!(f, "upstream: {m}"),
|
||||||
|
ApiError::GatewayTimeout(m) => write!(f, "timeout: {m}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ApiError {}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! Serving a byte range straight off a file on the shared `/data` volume.
|
||||||
|
//!
|
||||||
|
//! The hull's local-first read and the usenet plugin's materialised/partial
|
||||||
|
//! file reads answer with the same headers, so they share this. Moved from
|
||||||
|
//! meta-share's `nzb::serve::serve_file_range`, minus the focus metering: the
|
||||||
|
//! caller meters the body (the hull wraps every response it serves, a plugin
|
||||||
|
//! doesn't meter its `/raw` at all — the hull does on relay).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
use super::range::{parse_range_header, RangeOutcome};
|
||||||
|
|
||||||
|
/// Stream a complete on-disk file of `total` bytes, honouring a single
|
||||||
|
/// `Range:` (206) else 200; 416 for an unsatisfiable range.
|
||||||
|
///
|
||||||
|
/// `Err` carries the IO error that stopped the open/seek, so a caller can tell
|
||||||
|
/// "the file moved under us" apart from a served response.
|
||||||
|
pub async fn serve_file_range(
|
||||||
|
path: &Path,
|
||||||
|
total: u64,
|
||||||
|
range: Option<&HeaderValue>,
|
||||||
|
) -> std::io::Result<Response> {
|
||||||
|
let ctype = content_type(path);
|
||||||
|
match parse_range_header(range, total) {
|
||||||
|
RangeOutcome::Unsatisfiable => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
HeaderValue::from_str(&format!("bytes */{total}")).unwrap(),
|
||||||
|
);
|
||||||
|
Ok((StatusCode::RANGE_NOT_SATISFIABLE, headers).into_response())
|
||||||
|
}
|
||||||
|
RangeOutcome::Ok { start, end } => {
|
||||||
|
let len = end - start + 1;
|
||||||
|
let mut file = tokio::fs::File::open(path).await?;
|
||||||
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||||
|
let body = Body::from_stream(ReaderStream::new(file.take(len)));
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));
|
||||||
|
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||||
|
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(len));
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
HeaderValue::from_str(&format!("bytes {start}-{end}/{total}")).unwrap(),
|
||||||
|
);
|
||||||
|
Ok((StatusCode::PARTIAL_CONTENT, headers, body).into_response())
|
||||||
|
}
|
||||||
|
// Absent / Unsupported → full 200.
|
||||||
|
_ => {
|
||||||
|
let file = tokio::fs::File::open(path).await?;
|
||||||
|
let body = Body::from_stream(ReaderStream::new(file));
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));
|
||||||
|
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||||
|
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(total));
|
||||||
|
Ok((StatusCode::OK, headers, body).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coarse content-type from extension — enough for the player to pick a demuxer.
|
||||||
|
pub fn content_type(path: &Path) -> &'static str {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("mkv") => "video/x-matroska",
|
||||||
|
Some("mp4") | Some("m4v") => "video/mp4",
|
||||||
|
Some("webm") => "video/webm",
|
||||||
|
Some("avi") => "video/x-msvideo",
|
||||||
|
Some("mov") => "video/quicktime",
|
||||||
|
Some("ts") | Some("m2ts") => "video/mp2t",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn body(r: Response) -> Vec<u8> {
|
||||||
|
axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn range_full_and_unsatisfiable() {
|
||||||
|
let p = std::env::temp_dir().join(format!("file-range-{}.mkv", std::process::id()));
|
||||||
|
std::fs::write(&p, b"0123456789").unwrap();
|
||||||
|
let r = serve_file_range(&p, 10, Some(&HeaderValue::from_static("bytes=2-4"))).await.unwrap();
|
||||||
|
assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT);
|
||||||
|
assert_eq!(r.headers()[header::CONTENT_RANGE], "bytes 2-4/10");
|
||||||
|
assert_eq!(r.headers()[header::CONTENT_TYPE], "video/x-matroska");
|
||||||
|
assert_eq!(body(r).await, b"234");
|
||||||
|
let r = serve_file_range(&p, 10, None).await.unwrap();
|
||||||
|
assert_eq!(r.status(), StatusCode::OK);
|
||||||
|
assert_eq!(body(r).await, b"0123456789");
|
||||||
|
let r = serve_file_range(&p, 10, Some(&HeaderValue::from_static("bytes=20-"))).await.unwrap();
|
||||||
|
assert_eq!(r.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||||
|
assert_eq!(r.headers()[header::CONTENT_RANGE], "bytes */10");
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
assert_eq!(
|
||||||
|
serve_file_range(&p, 10, None).await.unwrap_err().kind(),
|
||||||
|
std::io::ErrorKind::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
//! Playback focus, as a transport plugin sees it.
|
||||||
|
//!
|
||||||
|
//! meta-share's hull owns the focus *policy*: `POST/DELETE /api/playback/:cid`,
|
||||||
|
//! the leases, the TTL reaper, which sibling CIDs make up a title. A plugin only
|
||||||
|
//! needs to *obey* it: pause a torrent that isn't focused, park a background
|
||||||
|
//! fill, floor a non-focused ingress. So the hull pushes a [`FocusSnapshot`]
|
||||||
|
//! (`PUT /focus`) on every transition, plus periodically so a restarted plugin
|
||||||
|
//! recovers, and the plugin answers its gates from a local [`FocusView`].
|
||||||
|
//!
|
||||||
|
//! The read side mirrors meta-share's `focus::FocusGate` method for method, so
|
||||||
|
//! code moved out of meta-share keeps its call sites. Two things are
|
||||||
|
//! deliberately different from the in-process gate:
|
||||||
|
//!
|
||||||
|
//! - **No leases.** A view never expires an entry on its own; only the next
|
||||||
|
//! snapshot changes it. The hull's reaper is the single source of truth.
|
||||||
|
//! - **Per-process buckets.** `throttle_in`/`throttle_out` floor non-focused
|
||||||
|
//! traffic at `bg_rate_bytes` *per process*, where the monolith shared one
|
||||||
|
//! bucket across tiers. HTTP response bodies are still metered once, by the
|
||||||
|
//! hull, when it relays a plugin's `/raw` — plugins must not meter their own
|
||||||
|
//! `/raw` egress, or a peer pulling through the hull pays twice.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
/// How often a parked background task re-checks whether the focus cleared.
|
||||||
|
/// Same value as meta-share's `focus::PARK_POLL`.
|
||||||
|
pub const PARK_POLL: Duration = Duration::from_millis(750);
|
||||||
|
|
||||||
|
/// Burst allowance as a multiple of the per-second rate, floored at
|
||||||
|
/// [`MIN_BURST_BYTES`]. Same values as meta-share's `focus.rs`.
|
||||||
|
const BURST_SECS: u64 = 2;
|
||||||
|
const MIN_BURST_BYTES: u64 = 512 * 1024;
|
||||||
|
|
||||||
|
/// Which bandwidth lane a byte flow belongs to.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Lane {
|
||||||
|
/// Belongs to a title a viewer is playing right now — never gated.
|
||||||
|
Focused,
|
||||||
|
/// Everything else. Parked or throttled while any title is focused;
|
||||||
|
/// unrestricted otherwise.
|
||||||
|
Background,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lane {
|
||||||
|
pub fn is_focused(self) -> bool {
|
||||||
|
matches!(self, Lane::Focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire value of the `X-MetaMesh-Lane` header.
|
||||||
|
pub fn as_header(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Lane::Focused => "focused",
|
||||||
|
Lane::Background => "background",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `X-MetaMesh-Lane` header. An absent or unknown value is
|
||||||
|
/// `Focused`: a request the hull didn't classify must never be starved.
|
||||||
|
pub fn from_header(v: Option<&str>) -> Lane {
|
||||||
|
match v {
|
||||||
|
Some("background") => Lane::Background,
|
||||||
|
_ => Lane::Focused,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One focused title: its group key plus every identity it is reachable under.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FocusTitle {
|
||||||
|
/// The record's canonical cid — the stable per-title key.
|
||||||
|
pub group: String,
|
||||||
|
/// Every sibling cid of the title.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cids: Vec<String>,
|
||||||
|
/// The torrent infohashes among those siblings, lowercase hex, pre-decoded
|
||||||
|
/// by the hull so a torrent plugin never re-parses cids on a transition.
|
||||||
|
#[serde(default)]
|
||||||
|
pub infohashes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole focus state, pushed by the hull on `PUT /focus`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FocusSnapshot {
|
||||||
|
/// Master switch (`META_SHARE_FOCUS_ENABLED`). Off → every gate is a no-op.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Bytes/s a non-focused flow is floored at. `0` → hard park.
|
||||||
|
pub bg_rate_bytes: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub titles: Vec<FocusTitle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FocusSnapshot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
bg_rate_bytes: 10 * 1024,
|
||||||
|
titles: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
titles: Vec<FocusTitle>,
|
||||||
|
cids: HashSet<String>,
|
||||||
|
infohashes: HashSet<[u8; 20]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plugin's local replica of the hull's focus gate.
|
||||||
|
pub struct FocusView {
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
/// `titles.len()` as an atomic, so the resting state is one relaxed load.
|
||||||
|
n: AtomicUsize,
|
||||||
|
enabled: std::sync::atomic::AtomicBool,
|
||||||
|
rate: AtomicU64,
|
||||||
|
out_bucket: TokenBucket,
|
||||||
|
in_bucket: TokenBucket,
|
||||||
|
/// Bumped on every applied snapshot that changed something. A torrent
|
||||||
|
/// plugin watches it to re-run its pause/unpause pass.
|
||||||
|
changed: watch::Sender<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusView {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
let d = FocusSnapshot::default();
|
||||||
|
let (changed, _) = watch::channel(0);
|
||||||
|
let burst = (d.bg_rate_bytes * BURST_SECS).max(MIN_BURST_BYTES);
|
||||||
|
Arc::new(Self {
|
||||||
|
inner: Mutex::new(Inner {
|
||||||
|
titles: Vec::new(),
|
||||||
|
cids: HashSet::new(),
|
||||||
|
infohashes: HashSet::new(),
|
||||||
|
}),
|
||||||
|
n: AtomicUsize::new(0),
|
||||||
|
enabled: std::sync::atomic::AtomicBool::new(d.enabled),
|
||||||
|
rate: AtomicU64::new(d.bg_rate_bytes),
|
||||||
|
out_bucket: TokenBucket::new(d.bg_rate_bytes, burst),
|
||||||
|
in_bucket: TokenBucket::new(d.bg_rate_bytes, burst),
|
||||||
|
changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the state with `snap`. Returns `true` when anything changed (and
|
||||||
|
/// notifies [`subscribe`](Self::subscribe)rs).
|
||||||
|
pub fn apply(&self, snap: FocusSnapshot) -> bool {
|
||||||
|
let mut cids = HashSet::new();
|
||||||
|
let mut infohashes = HashSet::new();
|
||||||
|
for t in &snap.titles {
|
||||||
|
cids.extend(t.cids.iter().cloned());
|
||||||
|
cids.insert(t.group.clone());
|
||||||
|
infohashes.extend(t.infohashes.iter().filter_map(|h| decode_hex20(h)));
|
||||||
|
}
|
||||||
|
let changed = {
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
let changed = inner.titles != snap.titles
|
||||||
|
|| self.enabled.load(Ordering::Relaxed) != snap.enabled
|
||||||
|
|| self.rate.load(Ordering::Relaxed) != snap.bg_rate_bytes;
|
||||||
|
inner.titles = snap.titles;
|
||||||
|
inner.cids = cids;
|
||||||
|
inner.infohashes = infohashes;
|
||||||
|
self.n.store(inner.titles.len(), Ordering::Relaxed);
|
||||||
|
changed
|
||||||
|
};
|
||||||
|
self.enabled.store(snap.enabled, Ordering::Relaxed);
|
||||||
|
if self.rate.swap(snap.bg_rate_bytes, Ordering::Relaxed) != snap.bg_rate_bytes {
|
||||||
|
let burst = (snap.bg_rate_bytes * BURST_SECS).max(MIN_BURST_BYTES);
|
||||||
|
self.out_bucket.reconfigure(snap.bg_rate_bytes, burst);
|
||||||
|
self.in_bucket.reconfigure(snap.bg_rate_bytes, burst);
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
self.changed.send_modify(|g| *g += 1);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current state, as the hull last pushed it.
|
||||||
|
pub fn snapshot(&self) -> FocusSnapshot {
|
||||||
|
FocusSnapshot {
|
||||||
|
enabled: self.enabled.load(Ordering::Relaxed),
|
||||||
|
bg_rate_bytes: self.rate.load(Ordering::Relaxed),
|
||||||
|
titles: self.inner.lock().unwrap().titles.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires after every snapshot that changed something.
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<u64> {
|
||||||
|
self.changed.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bg_rate_bytes(&self) -> u64 {
|
||||||
|
self.rate.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is *any* title focused?
|
||||||
|
pub fn any(&self) -> bool {
|
||||||
|
self.enabled.load(Ordering::Relaxed) && self.n.load(Ordering::Relaxed) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.n.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which lane does a byte flow for `cid` belong to? Same rule as the hull:
|
||||||
|
/// nothing focused → everything is `Focused`.
|
||||||
|
pub fn lane(&self, cid: &str) -> Lane {
|
||||||
|
if !self.any() {
|
||||||
|
return Lane::Focused;
|
||||||
|
}
|
||||||
|
if self.inner.lock().unwrap().cids.contains(cid) {
|
||||||
|
Lane::Focused
|
||||||
|
} else {
|
||||||
|
Lane::Background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_cid_focused(&self, cid: &str) -> bool {
|
||||||
|
self.inner.lock().unwrap().cids.contains(cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focused_infohashes(&self) -> HashSet<[u8; 20]> {
|
||||||
|
self.inner.lock().unwrap().infohashes.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_infohash_focused(&self, infohash: &[u8; 20]) -> bool {
|
||||||
|
self.inner.lock().unwrap().infohashes.contains(infohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park a background task while any title is focused.
|
||||||
|
pub async fn wait_turn(&self, lane: Lane) {
|
||||||
|
if lane.is_focused() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while self.any() {
|
||||||
|
tokio::time::sleep(PARK_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`wait_turn`](Self::wait_turn) with a deadline; `false` if the focus
|
||||||
|
/// outlasted `budget`.
|
||||||
|
pub async fn wait_turn_timeout(&self, lane: Lane, budget: Duration) -> bool {
|
||||||
|
if lane.is_focused() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let deadline = Instant::now() + budget;
|
||||||
|
while self.any() {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(PARK_POLL).await;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Floor a non-focused egress of `n` bytes (bitswap block service).
|
||||||
|
pub async fn throttle_out(&self, lane: Lane, n: u64) {
|
||||||
|
if lane.is_focused() || !self.any() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.bg_rate_bytes() == 0 {
|
||||||
|
self.wait_turn(lane).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.out_bucket.consume(n).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Floor a non-focused ingress of `n` bytes that a caller is waiting on.
|
||||||
|
pub async fn throttle_in(&self, lane: Lane, n: u64) {
|
||||||
|
if lane.is_focused() || !self.any() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.bg_rate_bytes() == 0 {
|
||||||
|
self.wait_turn(lane).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.in_bucket.consume(n).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meter a byte stream against a view's egress floor. Same shape as
|
||||||
|
/// meta-share's `focus::gated_stream`.
|
||||||
|
pub fn gated_stream<S, B, E>(
|
||||||
|
stream: S,
|
||||||
|
focus: Arc<FocusView>,
|
||||||
|
lane: Lane,
|
||||||
|
) -> impl futures::Stream<Item = Result<B, E>>
|
||||||
|
where
|
||||||
|
S: futures::Stream<Item = Result<B, E>>,
|
||||||
|
B: AsRef<[u8]>,
|
||||||
|
{
|
||||||
|
use futures::StreamExt;
|
||||||
|
stream.then(move |item| {
|
||||||
|
let focus = Arc::clone(&focus);
|
||||||
|
async move {
|
||||||
|
if let Ok(bytes) = &item {
|
||||||
|
focus.throttle_out(lane, bytes.as_ref().len() as u64).await;
|
||||||
|
}
|
||||||
|
item
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase hex of a 20-byte infohash — the wire form in [`FocusTitle`].
|
||||||
|
pub fn infohash_hex(ih: &[u8; 20]) -> String {
|
||||||
|
let mut s = String::with_capacity(40);
|
||||||
|
for b in ih {
|
||||||
|
s.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_hex20(s: &str) -> Option<[u8; 20]> {
|
||||||
|
if s.len() != 40 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 20];
|
||||||
|
for (i, pair) in s.as_bytes().chunks(2).enumerate() {
|
||||||
|
let hi = (pair[0] as char).to_digit(16)?;
|
||||||
|
let lo = (pair[1] as char).to_digit(16)?;
|
||||||
|
out[i] = ((hi << 4) | lo) as u8;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaky bucket that always waits (never errors on `n > burst`). Copied from
|
||||||
|
/// meta-share's `focus.rs`, plus `reconfigure` for a rate pushed at runtime.
|
||||||
|
struct TokenBucket {
|
||||||
|
state: Mutex<BucketState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BucketState {
|
||||||
|
tokens: f64,
|
||||||
|
last: Instant,
|
||||||
|
rate: u64,
|
||||||
|
burst: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenBucket {
|
||||||
|
fn new(rate: u64, burst: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Mutex::new(BucketState {
|
||||||
|
tokens: burst as f64,
|
||||||
|
last: Instant::now(),
|
||||||
|
rate,
|
||||||
|
burst,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconfigure(&self, rate: u64, burst: u64) {
|
||||||
|
let mut s = self.state.lock().unwrap();
|
||||||
|
s.rate = rate;
|
||||||
|
s.burst = burst;
|
||||||
|
s.tokens = s.tokens.min(burst as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume(&self, n: u64) {
|
||||||
|
let wait = {
|
||||||
|
let mut s = self.state.lock().unwrap();
|
||||||
|
if s.rate == 0 || n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(s.last).as_secs_f64();
|
||||||
|
s.last = now;
|
||||||
|
s.tokens = (s.tokens + elapsed * s.rate as f64).min(s.burst as f64);
|
||||||
|
s.tokens -= n as f64;
|
||||||
|
if s.tokens >= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Duration::from_secs_f64(-s.tokens / s.rate as f64)
|
||||||
|
};
|
||||||
|
tokio::time::sleep(wait).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn snap(titles: Vec<FocusTitle>) -> FocusSnapshot {
|
||||||
|
FocusSnapshot {
|
||||||
|
titles,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_focused_means_every_flow_is_focused() {
|
||||||
|
let v = FocusView::new();
|
||||||
|
assert!(!v.any());
|
||||||
|
assert_eq!(v.lane("bafyanything"), Lane::Focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn siblings_and_group_are_focused_others_background() {
|
||||||
|
let v = FocusView::new();
|
||||||
|
v.apply(snap(vec![FocusTitle {
|
||||||
|
group: "g".into(),
|
||||||
|
cids: vec!["a".into(), "b".into()],
|
||||||
|
infohashes: vec![infohash_hex(&[7u8; 20])],
|
||||||
|
}]));
|
||||||
|
assert!(v.any());
|
||||||
|
assert_eq!(v.lane("a"), Lane::Focused);
|
||||||
|
assert_eq!(v.lane("g"), Lane::Focused);
|
||||||
|
assert_eq!(v.lane("z"), Lane::Background);
|
||||||
|
assert!(v.is_infohash_focused(&[7u8; 20]));
|
||||||
|
assert!(!v.is_infohash_focused(&[8u8; 20]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_gate_focuses_nothing() {
|
||||||
|
let v = FocusView::new();
|
||||||
|
v.apply(FocusSnapshot {
|
||||||
|
enabled: false,
|
||||||
|
bg_rate_bytes: 1,
|
||||||
|
titles: vec![FocusTitle {
|
||||||
|
group: "g".into(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert!(!v.any());
|
||||||
|
assert_eq!(v.lane("z"), Lane::Focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_reports_change_only_on_change() {
|
||||||
|
let v = FocusView::new();
|
||||||
|
let rx = v.subscribe();
|
||||||
|
let s = snap(vec![FocusTitle {
|
||||||
|
group: "g".into(),
|
||||||
|
..Default::default()
|
||||||
|
}]);
|
||||||
|
assert!(v.apply(s.clone()));
|
||||||
|
assert!(!v.apply(s));
|
||||||
|
assert_eq!(*rx.borrow(), 1);
|
||||||
|
assert!(v.apply(snap(vec![])));
|
||||||
|
assert_eq!(*rx.borrow(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lane_header_round_trip_defaults_to_focused() {
|
||||||
|
assert_eq!(Lane::from_header(Some("background")), Lane::Background);
|
||||||
|
assert_eq!(Lane::from_header(Some("focused")), Lane::Focused);
|
||||||
|
assert_eq!(Lane::from_header(None), Lane::Focused);
|
||||||
|
assert_eq!(Lane::from_header(Some("junk")), Lane::Focused);
|
||||||
|
assert_eq!(Lane::from_header(Some(Lane::Background.as_header())), Lane::Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_json_round_trips() {
|
||||||
|
let s = snap(vec![FocusTitle {
|
||||||
|
group: "g".into(),
|
||||||
|
cids: vec!["a".into()],
|
||||||
|
infohashes: vec![infohash_hex(&[1u8; 20])],
|
||||||
|
}]);
|
||||||
|
let back: FocusSnapshot = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||||
|
assert_eq!(back, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! Filesystem helpers shared by the hull and the plugins that own a corner of
|
||||||
|
//! the shared `/data` volume: free space, on-disk size, and the grace-period
|
||||||
|
//! removal both `reclaim` passes (cache containers in the hull, `bt-temp/` in
|
||||||
|
//! the torrent plugin) are built on. Moved verbatim from meta-share
|
||||||
|
//! `reclaim.rs` / `eviction.rs`.
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// Free space (bytes available to an unprivileged process) on the filesystem
|
||||||
|
/// backing `path`; `None` when the query fails or the platform isn't supported.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn available_bytes(path: &Path) -> Option<u64> {
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||||
|
// SAFETY: `c_path` is a valid NUL-terminated path; `statvfs` only writes
|
||||||
|
// into the zeroed-out struct and returns 0 on success.
|
||||||
|
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||||
|
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
|
||||||
|
if rc != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
#[allow(clippy::unnecessary_cast)]
|
||||||
|
Some((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn available_bytes(_path: &Path) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apparent bytes under `dir` (file lengths, subdirectories walked, symlinks
|
||||||
|
/// skipped). `0` when absent. Blocking.
|
||||||
|
pub fn dir_size_blocking(dir: &Path) -> u64 {
|
||||||
|
let mut total = 0u64;
|
||||||
|
let mut stack = vec![dir.to_path_buf()];
|
||||||
|
while let Some(d) = stack.pop() {
|
||||||
|
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
match entry.file_type() {
|
||||||
|
Ok(ft) if ft.is_dir() => stack.push(entry.path()),
|
||||||
|
Ok(ft) if ft.is_file() => {
|
||||||
|
if let Ok(meta) = entry.metadata() {
|
||||||
|
total = total.saturating_add(meta.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes `path` actually occupies: allocated blocks, not apparent length (a
|
||||||
|
/// streaming-only torrent's file is sparse and its length is the whole film).
|
||||||
|
/// Blocking.
|
||||||
|
pub fn disk_usage_blocking(path: &Path) -> u64 {
|
||||||
|
let mut total = 0u64;
|
||||||
|
let mut stack = vec![path.to_path_buf()];
|
||||||
|
while let Some(p) = stack.pop() {
|
||||||
|
let Ok(meta) = std::fs::symlink_metadata(&p) else { continue };
|
||||||
|
if meta.is_dir() {
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||||
|
stack.extend(rd.flatten().map(|e| e.path()));
|
||||||
|
}
|
||||||
|
} else if meta.is_file() {
|
||||||
|
total = total.saturating_add(allocated(&meta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn allocated(meta: &std::fs::Metadata) -> u64 {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
meta.blocks().saturating_mul(512)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn allocated(meta: &std::fs::Metadata) -> u64 {
|
||||||
|
meta.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level entries of `dir` that `is_owned` doesn't claim.
|
||||||
|
pub async fn unowned_entries(
|
||||||
|
dir: &Path,
|
||||||
|
is_owned: impl Fn(&std::ffi::OsStr) -> bool,
|
||||||
|
) -> Vec<(PathBuf, OsString)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(mut rd) = tokio::fs::read_dir(dir).await else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if !is_owned(&name) {
|
||||||
|
out.push((entry.path(), name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of [`remove_if_stale`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Removal {
|
||||||
|
Removed(u64),
|
||||||
|
TooYoung,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Has nothing under `path` changed for `grace`? Unreadable → `false` (leave it).
|
||||||
|
pub async fn is_stale(path: &Path, grace: Duration, now: SystemTime) -> bool {
|
||||||
|
let p = path.to_path_buf();
|
||||||
|
let newest = tokio::task::spawn_blocking(move || newest_change_blocking(&p))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
match newest {
|
||||||
|
Some(t) => now.duration_since(t).map(|age| age >= grace).unwrap_or(false),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a file or directory tree once nothing under it changed for `grace`.
|
||||||
|
/// Returns the bytes it occupied on disk.
|
||||||
|
pub async fn remove_if_stale(path: &Path, grace: Duration, now: SystemTime) -> Removal {
|
||||||
|
if !grace.is_zero() && !is_stale(path, grace, now).await {
|
||||||
|
return Removal::TooYoung;
|
||||||
|
}
|
||||||
|
let p = path.to_path_buf();
|
||||||
|
let res = tokio::task::spawn_blocking(move || {
|
||||||
|
let bytes = disk_usage_blocking(&p);
|
||||||
|
let r = match std::fs::symlink_metadata(&p) {
|
||||||
|
Ok(m) if m.is_dir() => std::fs::remove_dir_all(&p),
|
||||||
|
Ok(_) => std::fs::remove_file(&p),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
r.map(|()| bytes)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(Ok(b)) => Removal::Removed(b),
|
||||||
|
Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Removal::Removed(0),
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
warn!(path = %path.display(), error = %e, "reclaim: removal failed");
|
||||||
|
Removal::Failed
|
||||||
|
}
|
||||||
|
Err(_) => Removal::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent modification or status change of `path` or anything under
|
||||||
|
/// it. ctime matters: a container renamed from `tmp/` into `cache/` keeps its
|
||||||
|
/// old mtime, and only ctime records the move.
|
||||||
|
pub fn newest_change_blocking(path: &Path) -> Option<SystemTime> {
|
||||||
|
let mut newest: Option<SystemTime> = None;
|
||||||
|
let mut stack = vec![path.to_path_buf()];
|
||||||
|
while let Some(p) = stack.pop() {
|
||||||
|
let Ok(meta) = std::fs::symlink_metadata(&p) else {
|
||||||
|
if p == path {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for t in [meta.modified().ok(), change_time(&meta)].into_iter().flatten() {
|
||||||
|
newest = Some(newest.map_or(t, |n| n.max(t)));
|
||||||
|
}
|
||||||
|
if meta.is_dir() {
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||||
|
stack.extend(rd.flatten().map(|e| e.path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn change_time(meta: &std::fs::Metadata) -> Option<SystemTime> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let secs = u64::try_from(meta.ctime()).ok()?;
|
||||||
|
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn change_time(_meta: &std::fs::Metadata) -> Option<SystemTime> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn young_entries_survive_and_zero_grace_removes() {
|
||||||
|
let d = std::env::temp_dir().join(format!("fsutil-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(d.join("a")).unwrap();
|
||||||
|
std::fs::write(d.join("a/f"), vec![1u8; 4096]).unwrap();
|
||||||
|
let now = SystemTime::now();
|
||||||
|
assert_eq!(
|
||||||
|
remove_if_stale(&d.join("a"), Duration::from_secs(3600), now).await,
|
||||||
|
Removal::TooYoung
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
remove_if_stale(&d.join("a"), Duration::ZERO, now).await,
|
||||||
|
Removal::Removed(_)
|
||||||
|
));
|
||||||
|
assert!(!d.join("a").exists());
|
||||||
|
assert_eq!(remove_if_stale(&d.join("gone"), Duration::ZERO, now).await, Removal::Removed(0));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unowned_lists_only_unclaimed_names() {
|
||||||
|
let d = std::env::temp_dir().join(format!("fsutil-own-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(d.join("keep")).unwrap();
|
||||||
|
std::fs::create_dir_all(d.join("drop")).unwrap();
|
||||||
|
let got = unowned_entries(&d, |n| n == "keep").await;
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].1, "drop");
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sizes_of_a_missing_dir_are_zero() {
|
||||||
|
assert_eq!(dir_size_blocking(Path::new("/definitely/not/here")), 0);
|
||||||
|
assert_eq!(disk_usage_blocking(Path::new("/definitely/not/here")), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
//! The plugin → hull direction: callbacks a plugin makes into meta-share.
|
||||||
|
//!
|
||||||
|
//! The hull serves these on a **separate internal listener**
|
||||||
|
//! (`META_SHARE_INTERNAL_LISTEN`, default `0.0.0.0:3001`) that no deployment
|
||||||
|
//! publishes: they reach meta-core's records and write to them, so they must
|
||||||
|
//! never sit behind the public perimeter. A plugin finds it at
|
||||||
|
//! `META_SHARE_HULL_URL` (default `http://metashare-app:3001`).
|
||||||
|
//!
|
||||||
|
//! Routes:
|
||||||
|
//!
|
||||||
|
//! - `POST /internal/events` ← [`Event`] → `204` once the hull acted.
|
||||||
|
//! - `GET /internal/records/:cid[?cached=1]` → [`RecordInfo`] (`404` = no
|
||||||
|
//! record). `cached=1` answers from the hull's record cache only (no
|
||||||
|
//! meta-core round trip) — for display names on a hot path.
|
||||||
|
//! - `GET /internal/nzb/manifest/:cid` → the `.nzb` bytes the cid resolves to,
|
||||||
|
//! with [`HDR_MANIFEST_SOURCE`] (`record` | `redeem`) and, for `record`,
|
||||||
|
//! [`HDR_MANIFEST_CID`]. `404` = not a Usenet cid; any other error carries
|
||||||
|
//! `{"error"}` — the manifest could not be redeemed or fetched. Parsing and
|
||||||
|
//! the `nzb-posting` digest check stay in the plugin.
|
||||||
|
//! - `GET /internal/network` → [`NetworkInfo`]: the hull's own endpoints a
|
||||||
|
//! plugin needs to know (the ipfs plugin announces `peer_url` in identify).
|
||||||
|
//! - `GET /internal/legacy/usenet` → the NNTP block meta-share's `settings.json`
|
||||||
|
//! held before the nzb plugin owned its settings (`404` = none). One-shot
|
||||||
|
//! migration source for the nzb plugin's config plane.
|
||||||
|
//! - `GET /health` → `200` once the hull has finished its boot steps (the
|
||||||
|
//! ipfs plugin waits on it before opening `ipfs/blocks.redb`).
|
||||||
|
//!
|
||||||
|
//! Why callbacks and not plugin-side logic: these need what only the hull has —
|
||||||
|
//! the record cache and meta-core, the gateway redeem, the IPFS tier. Plugins
|
||||||
|
//! never call each other; the hull routes every such edge.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::dto::Event;
|
||||||
|
|
||||||
|
/// Default hull callback base, matching the dev and store compose service name.
|
||||||
|
pub const DEFAULT_HULL_URL: &str = "http://metashare-app:3001";
|
||||||
|
|
||||||
|
/// Where the hull got a manifest: the record's `manifest` pointer, or a
|
||||||
|
/// gateway redeem.
|
||||||
|
pub const HDR_MANIFEST_SOURCE: &str = "x-metamesh-manifest-source";
|
||||||
|
/// The content cid of a `record`-sourced manifest.
|
||||||
|
pub const HDR_MANIFEST_CID: &str = "x-metamesh-manifest-cid";
|
||||||
|
|
||||||
|
/// `.nzb` bytes as the hull resolved them.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ManifestBytes {
|
||||||
|
pub bytes: bytes::Bytes,
|
||||||
|
/// `true` when redeemed through a gateway, `false` for the record pointer.
|
||||||
|
pub redeemed: bool,
|
||||||
|
pub manifest_cid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a plugin may need to know about a record.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct RecordInfo {
|
||||||
|
/// meta-core's hash id for the record (write-back key).
|
||||||
|
#[serde(default)]
|
||||||
|
pub hash_id: Option<String>,
|
||||||
|
/// The record's `fileName`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_name: Option<String>,
|
||||||
|
/// The record's display title.
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hull's endpoints (`GET /internal/network`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct NetworkInfo {
|
||||||
|
/// meta-core base URL the hull talks to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub meta_core_url: Option<String>,
|
||||||
|
/// The URL other peers reach this meta-share at (announced in identify).
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin-side client for the hull's internal API. A client with no base URL
|
||||||
|
/// (`META_SHARE_HULL_URL=` empty, or [`HullClient::disabled`]) answers every call
|
||||||
|
/// with "not available", which is what unit tests and a standalone plugin get.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HullClient {
|
||||||
|
base: Option<String>,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HullClient {
|
||||||
|
pub fn new(base: Option<String>, http: reqwest::Client) -> Self {
|
||||||
|
let base = base
|
||||||
|
.map(|b| b.trim().trim_end_matches('/').to_string())
|
||||||
|
.filter(|b| !b.is_empty());
|
||||||
|
Self { base, http }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From `META_SHARE_HULL_URL`, defaulting to [`DEFAULT_HULL_URL`]; an
|
||||||
|
/// explicitly empty value disables callbacks.
|
||||||
|
pub fn from_env(http: reqwest::Client) -> Self {
|
||||||
|
let base = match std::env::var("META_SHARE_HULL_URL") {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(_) => Some(DEFAULT_HULL_URL.to_string()),
|
||||||
|
};
|
||||||
|
Self::new(base, http)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disabled() -> Self {
|
||||||
|
Self::new(None, reqwest::Client::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.base.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> Result<String, String> {
|
||||||
|
self.base
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| format!("{b}{path}"))
|
||||||
|
.ok_or_else(|| "hull callbacks disabled (META_SHARE_HULL_URL unset)".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notify the hull; returns once it has acted.
|
||||||
|
pub async fn emit(&self, ev: &Event) -> Result<(), String> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.post(self.url("/internal/events")?)
|
||||||
|
.json(ev)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("hull unreachable: {e}"))?;
|
||||||
|
if r.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("hull refused event: {}", r.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET` a JSON resource; `404` → `Ok(None)`.
|
||||||
|
pub async fn get_json<T: DeserializeOwned>(&self, path: &str, timeout: Duration) -> Result<Option<T>, String> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.get(self.url(path)?)
|
||||||
|
.timeout(timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("hull unreachable: {e}"))?;
|
||||||
|
if r.status().as_u16() == 404 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !r.status().is_success() {
|
||||||
|
return Err(error_message(r).await);
|
||||||
|
}
|
||||||
|
r.json::<T>().await.map(Some).map_err(|e| format!("hull sent malformed JSON: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET` raw bytes; `404` → `Ok(None)`, any other failure → the hull's
|
||||||
|
/// `{"error"}` message.
|
||||||
|
pub async fn get_bytes(&self, path: &str, timeout: Duration) -> Result<Option<bytes::Bytes>, String> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.get(self.url(path)?)
|
||||||
|
.timeout(timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("hull unreachable: {e}"))?;
|
||||||
|
if r.status().as_u16() == 404 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !r.status().is_success() {
|
||||||
|
return Err(error_message(r).await);
|
||||||
|
}
|
||||||
|
r.bytes().await.map(Some).map_err(|e| format!("hull body: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `.nzb` behind a Usenet cid. `Ok(None)` = not a Usenet cid.
|
||||||
|
pub async fn nzb_manifest(&self, cid: &str, timeout: Duration) -> Result<Option<ManifestBytes>, String> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.get(self.url(&format!("/internal/nzb/manifest/{cid}"))?)
|
||||||
|
.timeout(timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("hull unreachable: {e}"))?;
|
||||||
|
if r.status().as_u16() == 404 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !r.status().is_success() {
|
||||||
|
return Err(error_message(r).await);
|
||||||
|
}
|
||||||
|
let h = r.headers();
|
||||||
|
let redeemed = h
|
||||||
|
.get(HDR_MANIFEST_SOURCE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v == "redeem");
|
||||||
|
let manifest_cid = h
|
||||||
|
.get(HDR_MANIFEST_CID)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(str::to_string);
|
||||||
|
let bytes = r.bytes().await.map_err(|e| format!("hull body: {e}"))?;
|
||||||
|
Ok(Some(ManifestBytes { bytes, redeemed, manifest_cid }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`RecordInfo`] for `cid` (record cache, else meta-core), or `None` when
|
||||||
|
/// the hull has no record.
|
||||||
|
pub async fn record(&self, cid: &str) -> Result<Option<RecordInfo>, String> {
|
||||||
|
self.get_json(&format!("/internal/records/{cid}"), Duration::from_secs(15)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hull's endpoints.
|
||||||
|
pub async fn network(&self) -> Result<Option<NetworkInfo>, String> {
|
||||||
|
self.get_json("/internal/network", Duration::from_secs(5)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The NNTP block meta-share's own settings held before the nzb plugin had
|
||||||
|
/// a config plane; `Ok(None)` = there is none.
|
||||||
|
pub async fn legacy_usenet(&self) -> Result<Option<super::nzb::UsenetSettings>, String> {
|
||||||
|
self.get_json("/internal/legacy/usenet", Duration::from_secs(5)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until the hull's internal listener answers `GET /health` with 2xx,
|
||||||
|
/// or `max` elapses. Returns whether it came up.
|
||||||
|
pub async fn wait_ready(&self, max: Duration) -> bool {
|
||||||
|
let Ok(url) = self.url("/health") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let deadline = tokio::time::Instant::now() + max;
|
||||||
|
loop {
|
||||||
|
if let Ok(r) = self.http.get(&url).timeout(Duration::from_secs(3)).send().await {
|
||||||
|
if r.status().is_success() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`RecordInfo`] from the hull's record cache only.
|
||||||
|
pub async fn cached_record(&self, cid: &str) -> Result<Option<RecordInfo>, String> {
|
||||||
|
self.get_json(&format!("/internal/records/{cid}?cached=1"), Duration::from_secs(5)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn error_message(r: reqwest::Response) -> String {
|
||||||
|
let status = r.status();
|
||||||
|
r.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
|
||||||
|
.unwrap_or_else(|| format!("hull error {status}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_disabled_client_refuses_without_network() {
|
||||||
|
let h = HullClient::disabled();
|
||||||
|
assert!(!h.is_enabled());
|
||||||
|
assert!(h.emit(&Event::Archive { cid: "x".into() }).await.is_err());
|
||||||
|
assert!(h.record("x").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_base_disables() {
|
||||||
|
assert!(!HullClient::new(Some(" ".into()), reqwest::Client::new()).is_enabled());
|
||||||
|
assert!(HullClient::new(Some("http://h:3001/".into()), reqwest::Client::new()).is_enabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! IPFS-specific half of the contract: the ipfs plugin's extra routes.
|
||||||
|
//!
|
||||||
|
//! The ipfs plugin owns the libp2p swarm (identify / kad / mdns / bitswap), the
|
||||||
|
//! blockstore (`ipfs/blocks.redb`: material blocks + leaf refs into files on the
|
||||||
|
//! shared volume), bitswap ingress and the peer directory. These routes are the
|
||||||
|
//! hull's facade onto that — each mirrors one blockstore / swarm call the hull
|
||||||
|
//! made in-process, so the hull's logic (seed rows, eviction, meta-core links,
|
||||||
|
//! probe grading) did not have to change shape.
|
||||||
|
//!
|
||||||
|
//! Public passthrough (the hull relays meta-share's own route verbatim):
|
||||||
|
//!
|
||||||
|
//! - `GET /ipfs/:cid` — the read-only IPFS gateway.
|
||||||
|
//!
|
||||||
|
//! Internal (all under `/ipfs-tier/`):
|
||||||
|
//!
|
||||||
|
//! | route | in-process equivalent |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `GET peers` → [`PeersInfo`] | `swarm::Command::Peers` |
|
||||||
|
//! | `GET directory` → [`Directory`] | `swarm::PeerDirectory` (gateway discovery) |
|
||||||
|
//! | `GET local/:cid` (Range) → bytes / `404` | `files::bitswap::try_local_block` |
|
||||||
|
//! | `GET has/:cid` → [`Present`] | `block_store.get(cid).is_some()` |
|
||||||
|
//! | `GET complete/:cid` → [`Complete`] | `local_dag_complete` |
|
||||||
|
//! | `GET cat/:cid?max=` → bytes | `ipfs_walker::get_block` (+ dag-pb assemble) |
|
||||||
|
//! | `GET fetch/:cid?timeout_ms=` → block bytes | `block_store.get` → `bitswap_get_block` |
|
||||||
|
//! | `PUT block/:cid` ← bytes | `blockstore::put_ipfs_block` |
|
||||||
|
//! | `POST import` ← file bytes → [`Imported`] | `put_ipfs_blocks(compute_ipfs_blocks(..))` |
|
||||||
|
//! | `POST forget/:cid` | `api::seeds::remove_ipfs_blocks` |
|
||||||
|
//! | `POST forget-library` ← [`ForgetLibrary`] | `block_store.remove_library_dag` |
|
||||||
|
//! | `POST provide/:cid`, `POST unprovide/:cid` | `Command::Provide` / `StopProviding` |
|
||||||
|
//! | `POST drop-refs` ← [`DropRefs`] | `block_store.drop_refs_for_materials` |
|
||||||
|
//! | `GET first-leaf/:cid` → [`FirstLeaf`] | `block_store.first_leaf_backing` |
|
||||||
|
//! | `GET rel-path/:midhash` → [`RelPath`] | `resolver().rel_path_for` |
|
||||||
|
//! | `POST share/library` ← [`ShareLibrary`] → [`Shared`] | `ipfs_seed::seed_file_as_refs` |
|
||||||
|
//! | `POST share/container` ← [`ShareContainer`] → [`Shared`] | the seal / rebuild re-chunk |
|
||||||
|
//! | `GET stats/blockstore` | `/api/stats/blockstore` body |
|
||||||
|
//! | `GET debug/:cid` | `/api/debug/blockstore/:cid` body |
|
||||||
|
//!
|
||||||
|
//! Plugin → hull, over [`super::hull`]: [`Event::Seed`](super::Event::Seed)
|
||||||
|
//! (a bitswap fetch now seeds), [`Event::Promoted`](super::Event::Promoted) (an
|
||||||
|
//! ingress job was renamed into `cache/`), and `GET /internal/records/:cid` for
|
||||||
|
//! a display name.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct PeersInfo {
|
||||||
|
pub local_peer_id: String,
|
||||||
|
pub connected: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One peer of the directory, with its freshness as *ages* (the hull rebuilds
|
||||||
|
/// its own `Instant`s from them).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct DirectoryPeer {
|
||||||
|
pub peer_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
/// Milliseconds since the gateway last advertised `nzbFetch`; `None` = not.
|
||||||
|
#[serde(default)]
|
||||||
|
pub nzb_fetch_age_ms: Option<u64>,
|
||||||
|
/// Milliseconds since the caps were fetched; `None` = never.
|
||||||
|
#[serde(default)]
|
||||||
|
pub caps_age_ms: Option<u64>,
|
||||||
|
/// The gateway's redeem claims, as the gateway serves them
|
||||||
|
/// (`{codec, field, hosts, sources}`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub redeems: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct Directory {
|
||||||
|
pub peers: Vec<DirectoryPeer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Present {
|
||||||
|
pub present: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Complete {
|
||||||
|
pub complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Imported {
|
||||||
|
pub root: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ForgetLibrary {
|
||||||
|
pub root: String,
|
||||||
|
pub midhash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct DropRefs {
|
||||||
|
/// `(container, rel)` pairs.
|
||||||
|
pub materials: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a dag's first leaf points (the filestore's `LeafBacking`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LeafBacking {
|
||||||
|
/// A ref into a file meta-core owns, by midhash256.
|
||||||
|
MetaCore(String),
|
||||||
|
/// A ref into a file in a cache container.
|
||||||
|
Cache,
|
||||||
|
/// The leaf's bytes are stored material (swarm-fetched, no file behind it).
|
||||||
|
Material,
|
||||||
|
/// The root or a node on the way down is absent.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct FirstLeaf {
|
||||||
|
pub backing: LeafBacking,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct RelPath {
|
||||||
|
pub rel: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed a meta-core library file as refs `(midhash, offset, len)`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ShareLibrary {
|
||||||
|
/// Path relative to the files volume (what meta-core's WebDAV serves).
|
||||||
|
pub rel: String,
|
||||||
|
pub midhash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed a file in a cache container as refs `(container, rel, offset, len)`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ShareContainer {
|
||||||
|
pub container: String,
|
||||||
|
pub rel: String,
|
||||||
|
/// Absolute path on the shared volume.
|
||||||
|
pub path: String,
|
||||||
|
/// Verify the resulting dag is fully held before answering (the seal does;
|
||||||
|
/// the rebuild doesn't).
|
||||||
|
#[serde(default)]
|
||||||
|
pub verify_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Shared {
|
||||||
|
pub root: String,
|
||||||
|
pub size: u64,
|
||||||
|
/// Leaf refs written.
|
||||||
|
pub refs: u64,
|
||||||
|
/// `verify_complete` was asked and the dag is fully held locally.
|
||||||
|
#[serde(default)]
|
||||||
|
pub complete: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! Content sniffing — what the bytes *are*, independent of what they are called.
|
||||||
|
//!
|
||||||
|
//! Every classifier in the usenet plugin's `archive` keys off the filename quoted in an NZB
|
||||||
|
//! subject. A fully obfuscated posting has no filename to key off, so all of them
|
||||||
|
//! fail **open**: `subject_is_par2` misses the parity volumes, `subject_is_rar`
|
||||||
|
//! misses the archive, and `pick_media`'s `best_video.or(best_any)` then elects
|
||||||
|
//! the largest arbitrary blob as "the movie". That blob is what gets served, and
|
||||||
|
//! — far worse — what the Usenet → IPFS re-seed hashes and publishes as
|
||||||
|
//! the record's content identity.
|
||||||
|
//!
|
||||||
|
//! Names are advisory. Bytes are not. This module is the byte half.
|
||||||
|
//!
|
||||||
|
//! The resolution order mirrors `filename-tool`'s `FileTypeConfigurable`
|
||||||
|
//! (the source of truth named by `METADATA_KEYS.md` §`fileType`): sniff the
|
||||||
|
//! content first, compare with the extension when there is one, and on
|
||||||
|
//! **disagreement return `undefined` rather than guessing** — a wrong `fileType`
|
||||||
|
//! routes a file to a handler that cannot open it.
|
||||||
|
|
||||||
|
/// A container format recognised from its leading bytes.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* exhaustive over "every format that exists" — it only needs
|
||||||
|
/// to separate payload from wrapper from parity well enough to decide whether a
|
||||||
|
/// posting has resolved. [`Magic::Unknown`] is a first-class answer, and callers
|
||||||
|
/// must treat it as "cannot prove anything", never as "bad".
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Magic {
|
||||||
|
/// RAR 1.5–4.x — signature `52 61 72 21 1A 07 00`.
|
||||||
|
Rar4,
|
||||||
|
/// RAR 5.0+ — signature `52 61 72 21 1A 07 01 00`.
|
||||||
|
Rar5,
|
||||||
|
/// par2 recovery data. **Never payload**, whatever its size.
|
||||||
|
Par2,
|
||||||
|
SevenZip,
|
||||||
|
/// A zip that is not an EPUB (includes `.cbz`, which `METADATA_KEYS.md`
|
||||||
|
/// §`fileType` classifies as `archive`).
|
||||||
|
Zip,
|
||||||
|
/// A zip whose first entry is the stored `mimetype` = `application/epub+zip`.
|
||||||
|
/// Split from [`Magic::Zip`] because an EPUB **is** the document, not a
|
||||||
|
/// wrapper around one — three correct ebook postings on watch.nsl.sh are
|
||||||
|
/// this shape and a naive "zip ⇒ archive" rule condemns all of them.
|
||||||
|
Epub,
|
||||||
|
/// Palm database holding an ebook (`BOOK`/`TEXt` type at offset 60) — the
|
||||||
|
/// `CR!…`-named MobiPocket/eReader files an indexer hands back for a book.
|
||||||
|
PalmDoc,
|
||||||
|
Matroska,
|
||||||
|
Mp4,
|
||||||
|
/// RIFF container (AVI, and also WAV — the extension disambiguates).
|
||||||
|
Riff,
|
||||||
|
Pdf,
|
||||||
|
/// Nothing recognised. Not an error: see the type-level note above.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Magic {
|
||||||
|
/// The `METADATA_KEYS.md` §`fileType` value these bytes imply, or `None`
|
||||||
|
/// when the content says nothing.
|
||||||
|
///
|
||||||
|
/// [`Magic::Riff`] is deliberately `None`: RIFF is AVI *and* WAV, i.e. a
|
||||||
|
/// video and an audio type, and guessing between them is exactly the
|
||||||
|
/// "disagreement returns undefined" case the registry legislates.
|
||||||
|
pub fn file_type(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Magic::Matroska | Magic::Mp4 => Some("video"),
|
||||||
|
Magic::Epub | Magic::PalmDoc | Magic::Pdf => Some("document"),
|
||||||
|
Magic::Rar4 | Magic::Rar5 | Magic::SevenZip | Magic::Zip => Some("archive"),
|
||||||
|
// Parity is not a fileType — it is not payload at all. Callers route
|
||||||
|
// on `is_parity()` before they ever ask for a type.
|
||||||
|
Magic::Par2 => None,
|
||||||
|
Magic::Riff | Magic::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extension these bytes imply, for naming a materialised file whose
|
||||||
|
/// posted name carried none (or carried an obfuscated one) — see
|
||||||
|
/// the usenet plugin's `naming`.
|
||||||
|
///
|
||||||
|
/// Abstains wherever the bytes do not pick a single extension, on the same
|
||||||
|
/// discipline as [`Magic::file_type`]: [`Magic::Riff`] is AVI *and* WAV, and
|
||||||
|
/// [`Magic::PalmDoc`] is `.prc`/`.mobi`/`.pdb`. [`Magic::Par2`] abstains for
|
||||||
|
/// a stronger reason — parity is never the payload, so it never gets named.
|
||||||
|
pub fn extension(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Magic::Matroska => Some("mkv"),
|
||||||
|
Magic::Mp4 => Some("mp4"),
|
||||||
|
Magic::Pdf => Some("pdf"),
|
||||||
|
Magic::Epub => Some("epub"),
|
||||||
|
Magic::Rar4 | Magic::Rar5 => Some("rar"),
|
||||||
|
Magic::SevenZip => Some("7z"),
|
||||||
|
Magic::Zip => Some("zip"),
|
||||||
|
Magic::Riff | Magic::PalmDoc | Magic::Par2 | Magic::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// par2 recovery data: present to repair the payload, never to *be* it.
|
||||||
|
///
|
||||||
|
/// One posting on watch.nsl.sh (`…gk2iwj5i…`, claiming `video`/`movie`)
|
||||||
|
/// materialised to a single 122 MB par2 file and published a dag-pb root
|
||||||
|
/// over it, because `subject_is_par2` reads the `.par2` extension the
|
||||||
|
/// obfuscated subject did not carry.
|
||||||
|
pub fn is_parity(self) -> bool {
|
||||||
|
matches!(self, Magic::Par2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multi-volume wrapper that must be unwrapped before anything inside it
|
||||||
|
/// can be addressed. `Epub`/`PalmDoc` are containers in the file-format
|
||||||
|
/// sense but **not** in this one — they are the document.
|
||||||
|
pub fn is_archive(self) -> bool {
|
||||||
|
matches!(self, Magic::Rar4 | Magic::Rar5 | Magic::SevenZip | Magic::Zip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes needed for a confident answer. The EPUB probe is the deepest reader:
|
||||||
|
/// local header (30) + `"mimetype"` (8) + extra field + `"application/epub+zip"`
|
||||||
|
/// (20). 128 covers any sane extra-field length.
|
||||||
|
pub const SNIFF_LEN: usize = 128;
|
||||||
|
|
||||||
|
/// Classify a file from its leading bytes. Short input is fine — every probe
|
||||||
|
/// bounds-checks and falls through to [`Magic::Unknown`].
|
||||||
|
pub fn sniff(head: &[u8]) -> Magic {
|
||||||
|
// RAR5 before RAR4: the RAR4 signature is a prefix of neither, but the
|
||||||
|
// shared first 6 bytes make ordering worth being explicit about.
|
||||||
|
if head.starts_with(b"Rar!\x1a\x07\x01\x00") {
|
||||||
|
return Magic::Rar5;
|
||||||
|
}
|
||||||
|
if head.starts_with(b"Rar!\x1a\x07\x00") {
|
||||||
|
return Magic::Rar4;
|
||||||
|
}
|
||||||
|
if head.starts_with(b"PAR2\x00PKT") {
|
||||||
|
return Magic::Par2;
|
||||||
|
}
|
||||||
|
if head.starts_with(b"7z\xbc\xaf\x27\x1c") {
|
||||||
|
return Magic::SevenZip;
|
||||||
|
}
|
||||||
|
if head.starts_with(b"%PDF-") {
|
||||||
|
return Magic::Pdf;
|
||||||
|
}
|
||||||
|
// EBML — Matroska and WebM share it; both demux the same way here.
|
||||||
|
if head.starts_with(b"\x1a\x45\xdf\xa3") {
|
||||||
|
return Magic::Matroska;
|
||||||
|
}
|
||||||
|
if head.len() >= 8 && &head[4..8] == b"ftyp" {
|
||||||
|
return Magic::Mp4;
|
||||||
|
}
|
||||||
|
if head.starts_with(b"RIFF") {
|
||||||
|
return Magic::Riff;
|
||||||
|
}
|
||||||
|
if head.starts_with(b"PK\x03\x04") {
|
||||||
|
return if is_epub(head) { Magic::Epub } else { Magic::Zip };
|
||||||
|
}
|
||||||
|
if is_palm_ebook(head) {
|
||||||
|
return Magic::PalmDoc;
|
||||||
|
}
|
||||||
|
Magic::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An EPUB is a zip whose **first** entry is an uncompressed `mimetype` member
|
||||||
|
/// holding exactly `application/epub+zip` (OCF 3, §4.2 — the rule exists so the
|
||||||
|
/// type is readable without inflating anything). That is what separates a book
|
||||||
|
/// from a `.cbz` or a scene `.zip`.
|
||||||
|
fn is_epub(head: &[u8]) -> bool {
|
||||||
|
const NAME_AT: usize = 30;
|
||||||
|
const NAME: &[u8] = b"mimetype";
|
||||||
|
const VALUE: &[u8] = b"application/epub+zip";
|
||||||
|
|
||||||
|
// Stored (method 0) is mandatory for this member; anything else is not an
|
||||||
|
// OCF container even if it happens to hold the string.
|
||||||
|
if head.len() < NAME_AT + NAME.len() || u16::from_le_bytes([head[8], head[9]]) != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if &head[NAME_AT..NAME_AT + NAME.len()] != NAME {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let extra = u16::from_le_bytes([head[28], head[29]]) as usize;
|
||||||
|
let at = NAME_AT + NAME.len() + extra;
|
||||||
|
head.len() >= at + VALUE.len() && &head[at..at + VALUE.len()] == VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Palm database (`name[32]`, then `type` at 60 and `creator` at 64) whose
|
||||||
|
/// type marks it an ebook. Covers MobiPocket (`BOOK`/`MOBI`), eReader
|
||||||
|
/// (`BOOK`/`PNRd`) and PalmDOC (`TEXt`/`REAd`).
|
||||||
|
fn is_palm_ebook(head: &[u8]) -> bool {
|
||||||
|
const TYPE_AT: usize = 60;
|
||||||
|
head.len() >= TYPE_AT + 4 && matches!(&head[TYPE_AT..TYPE_AT + 4], b"BOOK" | b"TEXt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `fileType` an extension implies, for the agreement check. Mirrors the
|
||||||
|
/// table in `METADATA_KEYS.md` §`fileType`, whose own source of truth is
|
||||||
|
/// `filename-tool`'s `ExtentionsMapping.ts`. Only the values reachable from a
|
||||||
|
/// Usenet posting are listed; anything else answers `None` ("no opinion"),
|
||||||
|
/// which agrees with everything.
|
||||||
|
pub fn file_type_from_extension(name: &str) -> Option<&'static str> {
|
||||||
|
let ext = name.rsplit_once('.')?.1.to_ascii_lowercase();
|
||||||
|
Some(match ext.as_str() {
|
||||||
|
"mkv" | "mp4" | "webm" | "avi" | "mov" | "wmv" | "flv" | "m4v" | "mpg" | "mpeg" | "3gp"
|
||||||
|
| "ts" | "m2ts" | "mts" | "vob" | "ogm" | "ogv" | "divx" | "xvid" => "video",
|
||||||
|
"mp3" | "wav" | "flac" | "aac" | "ogg" | "oga" | "opus" | "m4a" | "m4b" | "wma" | "mka" => {
|
||||||
|
"audio"
|
||||||
|
}
|
||||||
|
"pdf" | "epub" | "mobi" | "azw" | "azw3" | "djvu" | "txt" | "doc" | "docx" | "prc" => {
|
||||||
|
"document"
|
||||||
|
}
|
||||||
|
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "cbz" | "cbr" => "archive",
|
||||||
|
"srt" | "sub" | "ass" | "ssa" | "vtt" | "sbv" | "smi" => "subtitle",
|
||||||
|
"par2" => return None, // parity, not a fileType — see `Magic::is_parity`
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one file's `fileType` from its bytes and its name together.
|
||||||
|
///
|
||||||
|
/// The registry's rule, verbatim: *"sniff the MIME from the first bytes; if it
|
||||||
|
/// maps to a definite value, compare it with the extension's value — agreement
|
||||||
|
/// wins, **disagreement returns `undefined`**"*. `None` here is that
|
||||||
|
/// `undefined`: it means "these two sources disagree, do not route on it",
|
||||||
|
/// which is a different and strictly weaker statement than "wrong".
|
||||||
|
pub fn resolve_file_type(head: &[u8], name: &str) -> Option<&'static str> {
|
||||||
|
let sniffed = sniff(head).file_type();
|
||||||
|
let from_name = file_type_from_extension(name);
|
||||||
|
match (sniffed, from_name) {
|
||||||
|
(Some(a), Some(b)) if a == b => Some(a),
|
||||||
|
(Some(_), Some(_)) => None, // disagreement ⇒ undefined
|
||||||
|
(Some(a), None) => Some(a), // no usable extension: content decides
|
||||||
|
(None, Some(b)) => Some(b), // unrecognised bytes: the name is all we have
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "magic_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! Tests for [`super`] — content sniffing.
|
||||||
|
//!
|
||||||
|
//! Every fixture below is **real**: the leading bytes of files materialised on
|
||||||
|
//! watch.nsl.sh, captured 2026-09-16. Synthetic headers would have agreed with
|
||||||
|
//! whatever this module happened to implement; these are the bytes that actually
|
||||||
|
//! defeated the name-based classifiers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Decode a hex fixture. Panics on bad input — these are consts, not user data.
|
||||||
|
fn hx(s: &str) -> Vec<u8> {
|
||||||
|
assert!(s.len() % 2 == 0, "odd-length hex fixture");
|
||||||
|
(0..s.len() / 2)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).expect("hex"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume 1 of 35 of the trigger posting — `[smol] Tensei Oujo … S01E05v2`,
|
||||||
|
/// `bagcsaaa5brqxa2jonz5geltmnftglnrqztt2n23topk2bsnglvhjmty`. Extension-less
|
||||||
|
/// name, RAR5, method 0 (store). The EBML header of the contained MKV is
|
||||||
|
/// visible inside these same 200 bytes, at the volume's data offset.
|
||||||
|
const RAR5_VOL1: &str = "526172211a0701002409274c1001050c010b0101bac0d7af80808080004ce244be8b0102130b94bfd7af80800004eefaa1818d8000b683021a946591800001635b736d6f6c5d2054656e736569204f756a6f20746f2054656e736169205265696a6f75206e6f204d61686f75204b616b756d6569202d205330314530357632202842442031303830702048455643204f70757329205b39394434344436465d2e6d6b760a0313184c91644af1d2231a45dfa3a34286810142f7810142f2810442f381084282886d61";
|
||||||
|
|
||||||
|
/// Volume 2 — a *continuation* volume. Byte-identical signature to volume 1,
|
||||||
|
/// which is why "does it start with `Rar!`" cannot tell you which volume you
|
||||||
|
/// hold, and why electing "the largest file" picks one at random.
|
||||||
|
const RAR5_VOL2: &str = "526172211a0701000d6002471101050c03010b0101bac0d7af808080800030554b778b01021b0b93bfd7af80800004eefaa1818d8000b6830236ead743800001635b736d6f6c5d2054656e736569204f756a6f20746f2054656e736169205265696a6f75206e6f204d61686f75204b616b756d6569202d205330314530357632202842442031303830702048455643204f70757329205b39394434344436465d2e6d6b760a0313184c91644af1d223da5b7aaacbc910a46f8b3a8886bc4bfaff0518a54e4a2eb791";
|
||||||
|
|
||||||
|
/// RAR4 head, from `…gkigqufwwvhpkbhijvufrdcshogy` (claims `video`/`movie`).
|
||||||
|
const RAR4: &str = "526172211a0700197a7311000d00000000000000d9427423916f0069ff3f067a";
|
||||||
|
|
||||||
|
/// par2 head, from `…gk2iwj5iy3cyzafghckcab7j2fuy` — the posting that
|
||||||
|
/// materialised to a *single* par2 file and published a dag-pb root over it.
|
||||||
|
const PAR2: &str = "5041523200504b54083b1a00000000000226e38aa627ac7ee833f4fc92637a69";
|
||||||
|
|
||||||
|
/// A genuine EPUB — `Brandon Sanderson - Mistborn`, `…gkdthedplbm5qwmtedo2ymyct2si`.
|
||||||
|
/// Stored `mimetype` member first, exactly as OCF requires.
|
||||||
|
const EPUB: &str = "504b0304140000000000ee523a486f61ab2c1400000014000000080000006d696d65747970656170706c69636174696f6e2f657075622b7a6970504b0304140002080800ee523a4827f0a79ab4000000";
|
||||||
|
|
||||||
|
/// A MobiPocket Palm database — the `CR!…`-named ebook from `…gle4fanocf5txcdocnto6dh4au4a`.
|
||||||
|
/// Type `BOOK` at offset 60, creator `MOBI` at 64.
|
||||||
|
const PALM: &str = "43522158593841474857463731314e4642394a44314443314d374b4548344d000000000057d30d1e57d3113200000000000000000000000000000000424f4f4b4d4f4249000001df";
|
||||||
|
|
||||||
|
/// A Matroska head from a posting that resolved correctly.
|
||||||
|
const MKV: &str = "1a45dfa3a34286810142f7810142f281";
|
||||||
|
|
||||||
|
// ---- the six shapes that mattered -------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_real_headers_are_each_recognised() {
|
||||||
|
assert_eq!(sniff(&hx(RAR5_VOL1)), Magic::Rar5);
|
||||||
|
assert_eq!(sniff(&hx(RAR5_VOL2)), Magic::Rar5);
|
||||||
|
assert_eq!(sniff(&hx(RAR4)), Magic::Rar4);
|
||||||
|
assert_eq!(sniff(&hx(PAR2)), Magic::Par2);
|
||||||
|
assert_eq!(sniff(&hx(EPUB)), Magic::Epub);
|
||||||
|
assert_eq!(sniff(&hx(PALM)), Magic::PalmDoc);
|
||||||
|
assert_eq!(sniff(&hx(MKV)), Magic::Matroska);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression that started this: three correct ebook postings on the box are
|
||||||
|
/// zips, and a "zip ⇒ archive" rule condemns all of them. An EPUB is the
|
||||||
|
/// document, not a wrapper around one.
|
||||||
|
#[test]
|
||||||
|
fn an_epub_is_a_document_not_an_archive() {
|
||||||
|
let m = sniff(&hx(EPUB));
|
||||||
|
assert_eq!(m.file_type(), Some("document"));
|
||||||
|
assert!(!m.is_archive(), "an epub must not route into the unwrapper");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `.cbz` and a scene `.zip` share the EPUB signature for four bytes and
|
||||||
|
/// diverge at the first member. `METADATA_KEYS.md` §`fileType` puts `cbz` in
|
||||||
|
/// `archive`, so plain-zip ⇒ archive is the correct fallback.
|
||||||
|
#[test]
|
||||||
|
fn a_zip_without_the_ocf_mimetype_member_is_an_archive() {
|
||||||
|
let mut z = hx(EPUB);
|
||||||
|
// Corrupt just the mimetype *value*; everything else stays a valid zip.
|
||||||
|
let at = 38;
|
||||||
|
z[at..at + 3].copy_from_slice(b"XXX");
|
||||||
|
assert_eq!(sniff(&z), Magic::Zip);
|
||||||
|
assert_eq!(sniff(&z).file_type(), Some("archive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OCF requires the `mimetype` member to be *stored*. A deflated one is not an
|
||||||
|
/// OCF container even if the bytes happen to spell the string.
|
||||||
|
#[test]
|
||||||
|
fn a_deflated_mimetype_member_is_not_an_epub() {
|
||||||
|
let mut z = hx(EPUB);
|
||||||
|
z[8] = 8; // method = deflate
|
||||||
|
assert_eq!(sniff(&z), Magic::Zip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parity_is_not_a_file_type_at_all() {
|
||||||
|
let m = sniff(&hx(PAR2));
|
||||||
|
assert!(m.is_parity());
|
||||||
|
assert_eq!(m.file_type(), None, "par2 must never present as payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_rar_generation_reads_as_an_archive() {
|
||||||
|
for f in [RAR4, RAR5_VOL1, RAR5_VOL2] {
|
||||||
|
let m = sniff(&hx(f));
|
||||||
|
assert!(m.is_archive());
|
||||||
|
assert_eq!(m.file_type(), Some("archive"));
|
||||||
|
assert!(!m.is_parity());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Unknown is an answer, not an error -------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrecognised_and_truncated_input_is_unknown_not_a_panic() {
|
||||||
|
assert_eq!(sniff(b""), Magic::Unknown);
|
||||||
|
assert_eq!(sniff(b"Rar"), Magic::Unknown);
|
||||||
|
assert_eq!(sniff(b"PK\x03"), Magic::Unknown);
|
||||||
|
assert_eq!(sniff(&[0u8; 8]), Magic::Unknown);
|
||||||
|
assert_eq!(sniff(b"not a container at all"), Magic::Unknown);
|
||||||
|
assert_eq!(sniff(&hx(MKV)[..2]), Magic::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The holed posting on the box (`…gkisayoy…`, 837 MB of zeros) sniffs Unknown.
|
||||||
|
/// It is already refused upstream by `disk::first_hole`; this only pins that
|
||||||
|
/// Unknown carries no `fileType` claim either way.
|
||||||
|
#[test]
|
||||||
|
fn a_hole_claims_nothing() {
|
||||||
|
let zeros = vec![0u8; SNIFF_LEN];
|
||||||
|
assert_eq!(sniff(&zeros), Magic::Unknown);
|
||||||
|
assert_eq!(sniff(&zeros).file_type(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RIFF is AVI *and* WAV — a video type and an audio type. Guessing between
|
||||||
|
/// them is precisely the disagreement case the registry legislates away.
|
||||||
|
#[test]
|
||||||
|
fn riff_is_recognised_but_declines_to_name_a_type() {
|
||||||
|
let m = sniff(b"RIFF\x00\x00\x00\x00AVI LIST");
|
||||||
|
assert_eq!(m, Magic::Riff);
|
||||||
|
assert_eq!(m.file_type(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- name + bytes together ---------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_decides_when_the_name_says_nothing() {
|
||||||
|
// The obfuscated case: no extension anywhere.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_file_type(&hx(RAR5_VOL1), "IuYf3jBKJOukfUesqNCw0LoUc1Gqxxmilo0ICJJMeddjB6"),
|
||||||
|
Some("archive")
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_file_type(&hx(MKV), "56GqgfEUMp7RAqF94AGp"), Some("video"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agreement_between_name_and_bytes_wins() {
|
||||||
|
assert_eq!(resolve_file_type(&hx(MKV), "The.Movie.2019.1080p.mkv"), Some("video"));
|
||||||
|
assert_eq!(resolve_file_type(&hx(EPUB), "book.epub"), Some("document"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `METADATA_KEYS.md` §`fileType`: *"disagreement returns `undefined`"*. A file
|
||||||
|
/// named `.mkv` whose bytes are a RAR is not a video and is not reliably an
|
||||||
|
/// archive either — it is a thing we refuse to route on.
|
||||||
|
#[test]
|
||||||
|
fn disagreement_returns_undefined_rather_than_guessing() {
|
||||||
|
assert_eq!(resolve_file_type(&hx(RAR5_VOL1), "episode.mkv"), None);
|
||||||
|
assert_eq!(resolve_file_type(&hx(MKV), "release.rar"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_header_falls_back_to_the_extension() {
|
||||||
|
assert_eq!(resolve_file_type(b"\x00\x01\x02\x03", "movie.mkv"), Some("video"));
|
||||||
|
assert_eq!(resolve_file_type(b"", "chapter.cbz"), Some("archive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neither_source_has_an_opinion() {
|
||||||
|
assert_eq!(resolve_file_type(b"\x00\x01\x02\x03", "blob"), None);
|
||||||
|
// par2 is deliberately absent from the extension table: it is parity, and
|
||||||
|
// `Magic::is_parity` is how callers route it.
|
||||||
|
assert_eq!(file_type_from_extension("recovery.par2"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_extension_table_matches_the_registry_for_the_shapes_we_see() {
|
||||||
|
assert_eq!(file_type_from_extension("a.mkv"), Some("video"));
|
||||||
|
assert_eq!(file_type_from_extension("a.MKV"), Some("video"));
|
||||||
|
assert_eq!(file_type_from_extension("a.epub"), Some("document"));
|
||||||
|
assert_eq!(file_type_from_extension("a.cbz"), Some("archive"));
|
||||||
|
assert_eq!(file_type_from_extension("a.rar"), Some("archive"));
|
||||||
|
assert_eq!(file_type_from_extension("a.srt"), Some("subtitle"));
|
||||||
|
assert_eq!(file_type_from_extension("no-extension-here"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MKV inside the trigger posting is *stored*, so its EBML header sits in
|
||||||
|
/// plaintext a few hundred bytes into volume 1. That is what makes the
|
||||||
|
/// offset-map unwrap possible at all, and it is worth pinning independently of
|
||||||
|
/// the RAR parser.
|
||||||
|
#[test]
|
||||||
|
fn the_stored_mkv_header_is_visible_inside_volume_one() {
|
||||||
|
let v1 = hx(RAR5_VOL1);
|
||||||
|
let ebml = v1
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == [0x1a, 0x45, 0xdf, 0xa3])
|
||||||
|
.expect("EBML header should be plainly visible inside a stored RAR volume");
|
||||||
|
assert_eq!(ebml, 174, "volume 1's data offset, per the RAR5 header chain");
|
||||||
|
assert_eq!(sniff(&v1[ebml..]), Magic::Matroska);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//! **Transport plugins** — the contract between meta-share's protocol-agnostic
|
||||||
|
//! hull and the processes that actually move bytes (`meta-transport-torrent`,
|
||||||
|
//! `meta-transport-nzb`, `meta-transport-ipfs`, or a competing implementation
|
||||||
|
//! of any of them).
|
||||||
|
//!
|
||||||
|
//! Same split as gateway ⇄ feeders: the plugin finds and moves bytes for one
|
||||||
|
//! protocol; the hull keeps everything that is policy and bookkeeping — the
|
||||||
|
//! public HTTP API, the material index, seed rows, eviction, focus, meta-core.
|
||||||
|
//! Plugins never call each other and never talk to meta-core's record API;
|
||||||
|
//! every cross-tier edge goes through the hull.
|
||||||
|
//!
|
||||||
|
//! - [`plugin::TransportPlugin`] + [`serve::serve_transport`]: the plugin side.
|
||||||
|
//! - [`client::RemoteTransport`]: the hull side.
|
||||||
|
//! - [`hull::HullClient`]: plugin → hull callbacks (internal port only).
|
||||||
|
//! - [`config::ConfigPlane`]: the plugin's own settings + the config page the
|
||||||
|
//! hull's dashboard embeds.
|
||||||
|
//! - [`focus::FocusView`]: the plugin's replica of the hull's playback focus.
|
||||||
|
//! - [`dto`]: common wire types; [`error::ApiError`] / [`range`]: shared so a
|
||||||
|
//! relayed plugin response is byte-identical to what the monolith answered.
|
||||||
|
//! - [`cid`]: CID parsing + cid-shape decoders, shared so every process
|
||||||
|
//! classifies a cid the same way.
|
||||||
|
//! - [`torrent`]: the torrent plugin's extra routes + magnet helpers.
|
||||||
|
//! - [`ipfs`]: the ipfs plugin's extra routes (the hull's blockstore/swarm facade).
|
||||||
|
//! - [`nzb`]: Usenet cid shapes, the usenet plugin's extra routes + settings.
|
||||||
|
//! - [`magic`]: content sniffing; [`file`]: range-serving a file off `/data`.
|
||||||
|
//! - `testkit` (feature `testkit`): conformance checks for any implementation.
|
||||||
|
//!
|
||||||
|
//! Bytes on disk: every plugin mounts the same `/data` volume as the hull, so a
|
||||||
|
//! plugin-written file is opened by the hull directly (local-first reads) and
|
||||||
|
//! `rename(2)` between `tmp/` and `cache/` stays atomic.
|
||||||
|
|
||||||
|
pub mod cid;
|
||||||
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
|
pub mod dto;
|
||||||
|
pub mod error;
|
||||||
|
pub mod file;
|
||||||
|
pub mod focus;
|
||||||
|
pub mod fsutil;
|
||||||
|
pub mod hull;
|
||||||
|
pub mod ipfs;
|
||||||
|
pub mod magic;
|
||||||
|
pub mod nzb;
|
||||||
|
pub mod plugin;
|
||||||
|
pub mod range;
|
||||||
|
pub mod serve;
|
||||||
|
pub mod torrent;
|
||||||
|
#[cfg(any(test, feature = "testkit"))]
|
||||||
|
pub mod testkit;
|
||||||
|
|
||||||
|
pub use client::RemoteTransport;
|
||||||
|
pub use config::ConfigPlane;
|
||||||
|
pub use dto::{
|
||||||
|
Capabilities, Deleted, Event, Health, Job, Jobs, Manifest, PromotedFile, ReconcileReport,
|
||||||
|
ReconcileRequest, CONTRACT_VERSION,
|
||||||
|
};
|
||||||
|
pub use error::ApiError;
|
||||||
|
pub use focus::{FocusSnapshot, FocusTitle, FocusView, Lane};
|
||||||
|
pub use hull::{HullClient, NetworkInfo, RecordInfo};
|
||||||
|
pub use plugin::TransportPlugin;
|
||||||
|
pub use serve::serve_transport;
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
//! Usenet cid shapes, shared by the hull and the usenet transport plugin, plus
|
||||||
|
//! the plugin's extra-route DTOs and its settings.
|
||||||
|
//!
|
||||||
|
//! The codec helpers are moved verbatim from meta-share `nzb/mod.rs`; the hull
|
||||||
|
//! dispatches and grades with them, the plugin decodes with them, so they must
|
||||||
|
//! be one copy.
|
||||||
|
//!
|
||||||
|
//! Extra routes of the usenet plugin (all under `/nzb/`):
|
||||||
|
//!
|
||||||
|
//! - `GET /nzb/status/:cid` → the `kind:"nzb"` status JSON (never starts a job)
|
||||||
|
//! - `GET /nzb/jobs` → `{"jobs":[…]}` in-flight jobs (`NzbActivity` JSON)
|
||||||
|
//! - `DELETE /nzb/jobs/:cid` → `{"cancelled":bool}`
|
||||||
|
//! - `GET /nzb/capability` → [`Capability`]
|
||||||
|
//! - `GET /nzb/verdict/:cid` → `Option<String>` remembered verdict
|
||||||
|
//! - `POST /nzb/verify/:cid` → `Option<String>` verdict, verified if free
|
||||||
|
//! - `POST /nzb/test-nntp` ← [`NntpTest`] → `{"ok","message"}`
|
||||||
|
//! - `POST /nzb/restart` → `204`, then the process exits (settings changed)
|
||||||
|
//! - `POST /nzb/seal/:cid/claim` → [`SealClaim`] / `409` (not `Ready`, or already claimed)
|
||||||
|
//! - `POST /nzb/seal/:cid/promote`→ [`SealPrepared`] (publish into `cache/` + the hole/magic gates)
|
||||||
|
//! - `POST /nzb/seal/:cid/done` ← [`SealDone`]
|
||||||
|
//! - `POST /nzb/seal/:cid/abort` → `204`
|
||||||
|
//!
|
||||||
|
//! `/raw/:cid` may answer `409` with header [`HDR_REDIRECT_CID`] — "these bytes
|
||||||
|
//! now live under that cid (an IPFS root) and my copy is gone": the hull
|
||||||
|
//! re-dispatches to it, exactly where the monolith fell back to the blockstore.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::cid::MsCid;
|
||||||
|
|
||||||
|
/// `409` + this header on `/raw`: re-dispatch the request to this cid.
|
||||||
|
pub const HDR_REDIRECT_CID: &str = "x-metamesh-redirect-cid";
|
||||||
|
|
||||||
|
/// `GET /nzb/capability`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Capability {
|
||||||
|
/// The plugin holds an NNTP pool (host+user+pass configured).
|
||||||
|
pub nntp: bool,
|
||||||
|
/// The NNTP provider host (never the account), when credentialed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /nzb/test-nntp` — a candidate connection, already merged with the
|
||||||
|
/// stored secret by the hull (which owns `settings.json`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
pub struct NntpTest {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub user: String,
|
||||||
|
pub pass: String,
|
||||||
|
pub tls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SealClaim {
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SealPrepared {
|
||||||
|
/// The published file, under `cache/<cid>/`.
|
||||||
|
pub path: String,
|
||||||
|
/// Its name inside the container.
|
||||||
|
pub rel: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SealDone {
|
||||||
|
pub root: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usenet (NZB) retrieval settings — the `usenet` section of the hull's
|
||||||
|
/// `settings.json`. The plugin reads the file read-only at boot (the hull owns
|
||||||
|
/// it and restarts the plugin when it changes).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct UsenetSettings {
|
||||||
|
pub nntp_host: Option<String>,
|
||||||
|
pub nntp_port: Option<u16>,
|
||||||
|
pub nntp_user: Option<String>,
|
||||||
|
/// Secret.
|
||||||
|
pub nntp_pass: Option<String>,
|
||||||
|
pub nntp_tls: bool,
|
||||||
|
pub nntp_connections: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UsenetSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
nntp_host: None,
|
||||||
|
nntp_port: None,
|
||||||
|
nntp_user: None,
|
||||||
|
nntp_pass: None,
|
||||||
|
nntp_tls: true,
|
||||||
|
nntp_connections: 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_nonempty(key: &str) -> Option<String> {
|
||||||
|
std::env::var(key)
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsenetSettings {
|
||||||
|
/// DEPRECATED seed path: the legacy `META_SHARE_NNTP_*` env vars. Only
|
||||||
|
/// consulted when `settings.json` doesn't exist yet.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let mut s = Self::default();
|
||||||
|
if let Some(v) = env_nonempty("META_SHARE_NNTP_HOST") {
|
||||||
|
s.nntp_host = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = env_nonempty("META_SHARE_NNTP_PORT").and_then(|v| v.parse().ok()) {
|
||||||
|
s.nntp_port = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = env_nonempty("META_SHARE_NNTP_USER") {
|
||||||
|
s.nntp_user = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = env_nonempty("META_SHARE_NNTP_PASS") {
|
||||||
|
s.nntp_pass = Some(v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("META_SHARE_NNTP_TLS") {
|
||||||
|
s.nntp_tls = !matches!(v.trim(), "0" | "false" | "no");
|
||||||
|
}
|
||||||
|
if let Some(v) = env_nonempty("META_SHARE_NNTP_CONNECTIONS").and_then(|v| v.parse().ok()) {
|
||||||
|
s.nntp_connections = v;
|
||||||
|
}
|
||||||
|
s.nntp_connections = s.nntp_connections.clamp(1, 100);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `usenet` section of `<config_dir>/settings.json`, or the legacy env
|
||||||
|
/// seed when the file is absent or unparseable.
|
||||||
|
pub fn load(config_dir: &std::path::Path) -> Self {
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct File {
|
||||||
|
usenet: UsenetSettings,
|
||||||
|
}
|
||||||
|
match std::fs::read(config_dir.join("settings.json")) {
|
||||||
|
Ok(bytes) => match serde_json::from_slice::<File>(&bytes) {
|
||||||
|
Ok(f) => f.usenet,
|
||||||
|
Err(_) => Self::from_env(),
|
||||||
|
},
|
||||||
|
Err(_) => Self::from_env(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MetaMesh-private multicodec for a self-describing Newznab **release** locator
|
||||||
|
/// (`nzb-release`). Mirrors `meta-feeder-sdk`'s `hash::NZB_RELEASE_CODEC`. The
|
||||||
|
/// cid's identity multihash embeds `{host, id}` directly, so the feeder emits it
|
||||||
|
/// with **no `.nzb` download** and the credentialed peer decodes the locator
|
||||||
|
/// straight from the cid at playback — no KV side-table.
|
||||||
|
pub const NZB_RELEASE_CODEC: u64 = 0x1005;
|
||||||
|
|
||||||
|
/// MetaMesh-private multicodec for a **self-scanned Usenet posting**
|
||||||
|
/// (`nzb-posting`). Mirrors `meta-feeder-sdk`'s `hash::NZB_POSTING_CODEC`.
|
||||||
|
///
|
||||||
|
/// The crucial difference from [`NZB_RELEASE_CODEC`]: this is a **digest over
|
||||||
|
/// the article Message-ID set**, not a locator. It embeds no indexer host, so
|
||||||
|
/// **any peer with a plain NNTP provider can redeem it — no indexer credential**.
|
||||||
|
/// That portability is the entire point of scanning Usenet ourselves.
|
||||||
|
///
|
||||||
|
/// Two consequences the code must respect:
|
||||||
|
/// - It is **not reversible**. There is no `decode_*` for it. The `.nzb`
|
||||||
|
/// manifest arrives out of band, as an ordinary sha2-256 cid named by the
|
||||||
|
/// record's `manifest` field (see the hull's manifest resolution).
|
||||||
|
/// - It is **not bitswap-fetchable under itself**. Bitswap derives a block's
|
||||||
|
/// cid by hashing the block; this digest is over the id set, so a want for it
|
||||||
|
/// could never be satisfied. Never offer it to the swarm.
|
||||||
|
///
|
||||||
|
/// See `meta-gateway/docs/others/self-hosted-usenet-indexer-study.md` §5.
|
||||||
|
pub const NZB_POSTING_CODEC: u64 = 0x1003;
|
||||||
|
|
||||||
|
/// Does this cid address a Usenet **release locator** (codec `0x1005`) — the
|
||||||
|
/// self-describing `{host, id}` form that must be redeemed against a specific
|
||||||
|
/// indexer with a matching credential?
|
||||||
|
///
|
||||||
|
/// Narrower than [`is_nzb_cid`] on purpose: only the locator carries a host, so
|
||||||
|
/// only the locator gates on an indexer key (held by a gateway's feeder). Use this where
|
||||||
|
/// the *host* matters; use [`is_nzb_cid`] where "should this go to the Usenet
|
||||||
|
/// transport" matters.
|
||||||
|
pub fn is_nzb_locator(mscid: &MsCid) -> bool {
|
||||||
|
mscid.codec() == NZB_RELEASE_CODEC
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this cid address a **self-scanned Usenet posting** (codec `0x1003`)?
|
||||||
|
pub fn is_nzb_posting(mscid: &MsCid) -> bool {
|
||||||
|
mscid.codec() == NZB_POSTING_CODEC
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this cid go to the **Usenet transport** at all — either form?
|
||||||
|
///
|
||||||
|
/// This is the `/raw` dispatch predicate: both shapes end at
|
||||||
|
/// the usenet plugin's `/raw`, which resolves a manifest (differently per
|
||||||
|
/// form — see the hull's manifest resolution), NNTP-fetches the articles, and
|
||||||
|
/// materialises/streams the media. Everything downstream of manifest
|
||||||
|
/// resolution is identical for the two.
|
||||||
|
pub fn is_nzb_cid(mscid: &MsCid) -> bool {
|
||||||
|
is_nzb_locator(mscid) || is_nzb_posting(mscid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String convenience over [`is_nzb_cid`] — either Usenet form. Used by the
|
||||||
|
/// playback-focus endpoints, which see the bare CID string. There is no
|
||||||
|
/// locator-only counterpart: every string-typed call site that used to ask
|
||||||
|
/// "is this specifically a `0x1005` locator" now needs "is this Usenet at
|
||||||
|
/// all" — see the six dispatch sites widened alongside `nzb-posting`.
|
||||||
|
pub fn cid_is_nzb(cid: &str) -> bool {
|
||||||
|
super::cid::parse_record_cid(cid)
|
||||||
|
.map(|c| is_nzb_cid(&c))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-derive an `nzb-posting` digest from a manifest's Message-IDs.
|
||||||
|
///
|
||||||
|
/// ⚠ **MIRRORED LOGIC** — byte-for-byte equivalent to
|
||||||
|
/// `meta_feeder_sdk::hash::compute_nzb_posting_cid` (the gateway/feeder side
|
||||||
|
/// mints with it; we verify with it). The two crates cannot share a path-dep,
|
||||||
|
/// so the normalisation rule is duplicated and must be changed on both sides
|
||||||
|
/// together. A drift here does not error — it silently makes every self-scanned
|
||||||
|
/// posting fail verification and become unplayable.
|
||||||
|
///
|
||||||
|
/// The rule: trim, strip a surrounding `<…>`, drop empties, dedupe, sort
|
||||||
|
/// lexicographically, join with `\n`, SHA-256. Case is preserved (a Message-ID
|
||||||
|
/// local part is case-sensitive per RFC 5322).
|
||||||
|
pub fn nzb_posting_digest<S: AsRef<str>>(message_ids: &[S]) -> [u8; 32] {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
let mut ids: Vec<&str> = message_ids
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let t = s.as_ref().trim();
|
||||||
|
t.strip_prefix('<')
|
||||||
|
.and_then(|r| r.strip_suffix('>'))
|
||||||
|
.unwrap_or(t)
|
||||||
|
})
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for (i, id) in ids.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
hasher.update(b"\n");
|
||||||
|
}
|
||||||
|
hasher.update(id.as_bytes());
|
||||||
|
}
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded self-describing `nzb-release` locator: the Newznab **API base**,
|
||||||
|
/// recovered from the cid's identity-multihash digest. The release id stays in
|
||||||
|
/// the cid — only the feeder that redeems it reads it.
|
||||||
|
pub struct NzbLocator {
|
||||||
|
/// The indexer's Newznab API base, **scheme-less, authority + optional
|
||||||
|
/// path** (`api.nzb.life`, `api.nzbgeek.info/api`). Stamped by the feeder
|
||||||
|
/// from the indexer aggregator's own configured `baseUrl` — i.e. the URL
|
||||||
|
/// that aggregator itself queries, so it is redeemable by construction.
|
||||||
|
///
|
||||||
|
/// ⚠ This used to be a bare `host` taken from the release's `<guid>`
|
||||||
|
/// **permalink**, which is only the API host by coincidence. nzb.life
|
||||||
|
/// serves both from `api.nzb.life` so it worked; nzbgeek's permalinks are on
|
||||||
|
/// `nzbgeek.info` while its API is on `api.nzbgeek.info/api`, so every
|
||||||
|
/// nzbgeek release was unredeemable — `https://nzbgeek.info/api?t=get`
|
||||||
|
/// answers 302→HTML and the grab died in `nzb xml parse`.
|
||||||
|
///
|
||||||
|
/// Because it may carry a path it is **not** a matching key: use
|
||||||
|
/// [`authority()`](Self::authority) for that.
|
||||||
|
pub api_base: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NzbLocator {
|
||||||
|
/// The host to match against the gateways' redeem claims — the authority
|
||||||
|
/// alone, path stripped.
|
||||||
|
///
|
||||||
|
/// The base may carry a path (`api.nzbgeek.info/api`) that a claim never
|
||||||
|
/// does: operators configure, and feeders advertise, plain hosts.
|
||||||
|
pub fn authority(&self) -> &str {
|
||||||
|
match self.api_base.split_once('/') {
|
||||||
|
Some((authority, _path)) => authority,
|
||||||
|
None => &self.api_base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalise an indexer host for matching: drop any `http(s)://` scheme, trim a
|
||||||
|
/// trailing slash and surrounding whitespace, lowercase. A locator's
|
||||||
|
/// [`NzbLocator::authority`] passes through this before it is compared with the
|
||||||
|
/// hosts a gateway advertises, so a claim for `https://api.example.com/` and one
|
||||||
|
/// for `api.example.com` match the same releases.
|
||||||
|
pub fn normalize_indexer_host(raw: &str) -> String {
|
||||||
|
raw.trim()
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a self-describing `nzb-release` cid (codec `0x1005`) into its
|
||||||
|
/// API base. The identity-multihash digest is
|
||||||
|
/// `varint(base_len) ‖ api_base ‖ id_bytes` — the exact inverse of
|
||||||
|
/// meta-feeder-sdk's `hash::compute_nzb_release_cid`; `id_bytes` is the
|
||||||
|
/// hex-decoded release id, which only the redeeming feeder reads. Errors
|
||||||
|
/// (mapped to 4xx by the caller) on a wrong codec or a malformed digest.
|
||||||
|
///
|
||||||
|
/// The wire shape is unchanged from when this field held a bare host — only the
|
||||||
|
/// *meaning* of those bytes widened to "authority + optional path", so old cids
|
||||||
|
/// still decode (they just name an unredeemable host; see [`NzbLocator`]).
|
||||||
|
pub fn decode_nzb_release_cid(mscid: &MsCid) -> Result<NzbLocator> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
mscid.codec() == NZB_RELEASE_CODEC,
|
||||||
|
"not an nzb-release cid (codec 0x{:x}, want 0x{:x})",
|
||||||
|
mscid.codec(),
|
||||||
|
NZB_RELEASE_CODEC
|
||||||
|
);
|
||||||
|
let digest = mscid.hash().digest();
|
||||||
|
let (base_len, consumed) =
|
||||||
|
super::cid::read_uvarint(digest).context("nzb-release digest: base-length varint")?;
|
||||||
|
let base_len = usize::try_from(base_len)
|
||||||
|
.map_err(|_| anyhow::anyhow!("nzb-release base length {base_len} overflows usize"))?;
|
||||||
|
let rest = &digest[consumed..];
|
||||||
|
anyhow::ensure!(
|
||||||
|
rest.len() > base_len,
|
||||||
|
"nzb-release digest truncated: base_len={base_len}, only {} byte(s) left (need base + id)",
|
||||||
|
rest.len()
|
||||||
|
);
|
||||||
|
let base_bytes = &rest[..base_len];
|
||||||
|
let api_base = std::str::from_utf8(base_bytes)
|
||||||
|
.context("nzb-release api base is not valid utf-8")?
|
||||||
|
.to_string();
|
||||||
|
Ok(NzbLocator { api_base })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::transport::cid::parse_record_cid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_load_reads_the_usenet_section_and_ignores_the_rest() {
|
||||||
|
let d = std::env::temp_dir().join(format!("nzb-settings-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&d).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
d.join("settings.json"),
|
||||||
|
r#"{"network":{"meta_core_url":"x"},"usenet":{"nntp_host":"news.example","nntp_user":"u","nntp_pass":"p","nntp_tls":true,"nntp_connections":7,"indexers":[1]}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let s = UsenetSettings::load(&d);
|
||||||
|
assert_eq!(s.nntp_host.as_deref(), Some("news.example"));
|
||||||
|
assert_eq!(s.nntp_connections, 7);
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// RFC 4648 base32 lowercase, no padding (multibase `b` body).
|
||||||
|
fn base32_lower(input: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
|
||||||
|
let mut out = String::new();
|
||||||
|
let (mut buffer, mut bits): (u64, u32) = (0, 0);
|
||||||
|
for &b in input {
|
||||||
|
buffer = (buffer << 8) | b as u64;
|
||||||
|
bits += 8;
|
||||||
|
while bits >= 5 {
|
||||||
|
bits -= 5;
|
||||||
|
out.push(ALPHABET[((buffer >> bits) & 0x1F) as usize] as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bits > 0 {
|
||||||
|
out.push(ALPHABET[((buffer << (5 - bits)) & 0x1F) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||||
|
while v >= 0x80 {
|
||||||
|
out.push((v as u8) | 0x80);
|
||||||
|
v >>= 7;
|
||||||
|
}
|
||||||
|
out.push(v as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a self-describing nzb-release cid exactly as meta-feeder-sdk's
|
||||||
|
/// `compute_nzb_release_cid` does: identity multihash over
|
||||||
|
/// `varint(host_len) ‖ host ‖ id_bytes`.
|
||||||
|
fn make_nzb_cid(host: &str, id_bytes: &[u8]) -> String {
|
||||||
|
let mut digest = Vec::new();
|
||||||
|
varint(host.len() as u64, &mut digest);
|
||||||
|
digest.extend_from_slice(host.as_bytes());
|
||||||
|
digest.extend_from_slice(id_bytes);
|
||||||
|
let mut wire = vec![0x01u8];
|
||||||
|
varint(NZB_RELEASE_CODEC, &mut wire);
|
||||||
|
wire.push(0x00); // identity multihash
|
||||||
|
varint(digest.len() as u64, &mut wire);
|
||||||
|
wire.extend_from_slice(&digest);
|
||||||
|
format!("b{}", base32_lower(&wire))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_round_trips_the_base() {
|
||||||
|
let cid = make_nzb_cid("api.example.com", &[0x48, 0x5c, 0x52, 0xf0]);
|
||||||
|
let mscid = parse_record_cid(&cid).expect("parse");
|
||||||
|
assert!(is_nzb_locator(&mscid));
|
||||||
|
let loc = decode_nzb_release_cid(&mscid).expect("decode");
|
||||||
|
assert_eq!(loc.api_base, "api.example.com");
|
||||||
|
// No path ⇒ authority is the whole thing.
|
||||||
|
assert_eq!(loc.authority(), "api.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nzbgeek shape, and the whole reason `authority()` exists: its API
|
||||||
|
/// lives at `https://api.nzbgeek.info/api` while a feeder claims the plain
|
||||||
|
/// host. The base keeps the path; every match must drop it.
|
||||||
|
#[test]
|
||||||
|
fn a_base_with_a_path_matches_on_its_authority() {
|
||||||
|
let cid = make_nzb_cid("api.nzbgeek.info/api", &[0x19, 0xfe, 0x38, 0xe4]);
|
||||||
|
let mscid = parse_record_cid(&cid).expect("parse");
|
||||||
|
let loc = decode_nzb_release_cid(&mscid).expect("decode");
|
||||||
|
assert_eq!(loc.api_base, "api.nzbgeek.info/api");
|
||||||
|
assert_eq!(
|
||||||
|
loc.authority(),
|
||||||
|
"api.nzbgeek.info",
|
||||||
|
"can_redeem / probe / redeem claims all compare on the authority"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A port is part of the authority, not a path — it must survive the split.
|
||||||
|
#[test]
|
||||||
|
fn a_port_stays_in_the_authority() {
|
||||||
|
let cid = make_nzb_cid("indexer.local:5076/api", &[0x01, 0x02]);
|
||||||
|
let mscid = parse_record_cid(&cid).expect("parse");
|
||||||
|
let loc = decode_nzb_release_cid(&mscid).expect("decode");
|
||||||
|
assert_eq!(loc.authority(), "indexer.local:5076");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_wrong_codec() {
|
||||||
|
// codec 0x1001 (btih-v1-file), not an nzb-release locator.
|
||||||
|
let mut wire = vec![0x01u8];
|
||||||
|
varint(0x1001, &mut wire);
|
||||||
|
wire.extend_from_slice(&[0x00, 0x02, 0xAB, 0xCD]); // identity mh, 2-byte digest
|
||||||
|
let cid = format!("b{}", base32_lower(&wire));
|
||||||
|
let mscid = parse_record_cid(&cid).expect("parse");
|
||||||
|
assert!(!is_nzb_locator(&mscid));
|
||||||
|
assert!(decode_nzb_release_cid(&mscid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_truncated_digest() {
|
||||||
|
// host_len claims the whole digest, leaving no id bytes.
|
||||||
|
let cid = make_nzb_cid("api.example.com", &[]);
|
||||||
|
let mscid = parse_record_cid(&cid).expect("parse");
|
||||||
|
assert!(decode_nzb_release_cid(&mscid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an nzb-posting cid exactly as meta-feeder-sdk's
|
||||||
|
/// `compute_nzb_posting_cid` does: codec 0x1003 + sha2-256 over the
|
||||||
|
/// normalised, sorted, newline-joined Message-ID set.
|
||||||
|
fn make_posting_cid(message_ids: &[&str]) -> String {
|
||||||
|
let digest = nzb_posting_digest(message_ids);
|
||||||
|
let mut wire = vec![0x01u8];
|
||||||
|
varint(NZB_POSTING_CODEC, &mut wire);
|
||||||
|
wire.push(0x12); // sha2-256
|
||||||
|
wire.push(0x20); // 32 bytes
|
||||||
|
wire.extend_from_slice(&digest);
|
||||||
|
format!("b{}", base32_lower(&wire))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⚠ THE MIRROR TEST. `nzb_posting_digest` here must agree byte-for-byte
|
||||||
|
/// with `meta_feeder_sdk::hash::compute_nzb_posting_cid`'s digest — the
|
||||||
|
/// gateway mints with one, we verify with the other, and a drift silently
|
||||||
|
/// makes every self-scanned posting unplayable rather than erroring.
|
||||||
|
///
|
||||||
|
/// This vector is the same one pinned on the SDK side
|
||||||
|
/// (`hash::tests::nzb_posting_cid_wire_shape`): a single id `[email protected]`
|
||||||
|
/// digests to plain `sha256("[email protected]")`.
|
||||||
|
#[test]
|
||||||
|
fn posting_digest_matches_the_sdk_mint() {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let expect: [u8; 32] = Sha256::digest(b"[email protected]").into();
|
||||||
|
assert_eq!(nzb_posting_digest(&["[email protected]"]), expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The normalisation rule, mirrored from the SDK: order-independent,
|
||||||
|
/// bracket/blank/dupe-insensitive, case-SENSITIVE.
|
||||||
|
#[test]
|
||||||
|
fn posting_digest_normalisation_matches_the_mint() {
|
||||||
|
// Order does not matter — this is why we hash a sorted set and never
|
||||||
|
// the raw `.nzb` bytes (the retired 0x1004's mistake).
|
||||||
|
assert_eq!(
|
||||||
|
nzb_posting_digest(&["[email protected]", "[email protected]", "[email protected]"]),
|
||||||
|
nzb_posting_digest(&["[email protected]", "[email protected]", "[email protected]"])
|
||||||
|
);
|
||||||
|
// Angle brackets, whitespace, empties and duplicates all normalise away.
|
||||||
|
assert_eq!(
|
||||||
|
nzb_posting_digest(&["[email protected]", "[email protected]"]),
|
||||||
|
nzb_posting_digest(&[" <[email protected]> ", "[email protected]", "", " ", "[email protected]"])
|
||||||
|
);
|
||||||
|
// Case is preserved (RFC 5322: the local part is case-sensitive).
|
||||||
|
assert_ne!(
|
||||||
|
nzb_posting_digest(&["[email protected]"]),
|
||||||
|
nzb_posting_digest(&["[email protected]"])
|
||||||
|
);
|
||||||
|
// A different article set is a different posting.
|
||||||
|
assert_ne!(
|
||||||
|
nzb_posting_digest(&["[email protected]", "[email protected]"]),
|
||||||
|
nzb_posting_digest(&["[email protected]", "[email protected]"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both Usenet forms route to the Usenet transport, but only the `0x1005`
|
||||||
|
/// locator is host-bound. Keeping the two predicates distinct is what lets
|
||||||
|
/// `can_redeem` skip the credential check for a posting.
|
||||||
|
#[test]
|
||||||
|
fn posting_and_locator_are_both_nzb_but_only_one_is_a_locator() {
|
||||||
|
let posting = parse_record_cid(&make_posting_cid(&["[email protected]"])).expect("parse posting");
|
||||||
|
assert!(is_nzb_posting(&posting));
|
||||||
|
assert!(is_nzb_cid(&posting), "postings go to the Usenet transport");
|
||||||
|
assert!(
|
||||||
|
!is_nzb_locator(&posting),
|
||||||
|
"a posting names no indexer host, so it must NOT be treated as a locator (that would send it down the credential-gated grab path)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let locator = parse_record_cid(&make_nzb_cid("api.example.com", &[0xab, 0xcd]))
|
||||||
|
.expect("parse locator");
|
||||||
|
assert!(is_nzb_locator(&locator));
|
||||||
|
assert!(is_nzb_cid(&locator));
|
||||||
|
assert!(!is_nzb_posting(&locator));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A posting cid must never decode as a release locator — the digest is not
|
||||||
|
/// a `{host, id}` payload, and misreading it would produce a garbage host.
|
||||||
|
#[test]
|
||||||
|
fn posting_cid_does_not_decode_as_a_locator() {
|
||||||
|
let posting = parse_record_cid(&make_posting_cid(&["[email protected]", "[email protected]"]))
|
||||||
|
.expect("parse posting");
|
||||||
|
assert!(decode_nzb_release_cid(&posting).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The string convenience wrappers agree with the parsed forms, including
|
||||||
|
/// on garbage input (which must be `false`, never a panic).
|
||||||
|
#[test]
|
||||||
|
fn cid_string_helpers_agree() {
|
||||||
|
let posting = make_posting_cid(&["[email protected]"]);
|
||||||
|
assert!(cid_is_nzb(&posting));
|
||||||
|
|
||||||
|
let locator = make_nzb_cid("api.example.com", &[0x01]);
|
||||||
|
assert!(cid_is_nzb(&locator));
|
||||||
|
|
||||||
|
assert!(!cid_is_nzb("not-a-cid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//! The plugin side of the contract: what a transport implementation provides.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
use super::config::ConfigPlane;
|
||||||
|
use super::dto::{Deleted, Health, Job, Manifest, ReconcileReport, ReconcileRequest};
|
||||||
|
use super::error::ApiError;
|
||||||
|
use super::focus::FocusView;
|
||||||
|
|
||||||
|
/// One transport implementation (BitTorrent, Usenet, IPFS, or a competing
|
||||||
|
/// implementation of one of them). [`serve_transport`](super::serve::serve_transport)
|
||||||
|
/// wraps it in the common routes; [`extra_routes`](Self::extra_routes) adds its
|
||||||
|
/// protocol-specific ones.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TransportPlugin: Send + Sync + 'static {
|
||||||
|
fn manifest(&self) -> Manifest;
|
||||||
|
|
||||||
|
async fn health(&self) -> Health {
|
||||||
|
Health::ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /raw/:cid`. Must answer exactly what the in-process tier answered
|
||||||
|
/// in the monolith (status, `Content-*`, `Retry-After`, error body) — the
|
||||||
|
/// hull relays it to meta-share's public API untouched. Must **not** meter
|
||||||
|
/// its own egress: the hull does, on relay.
|
||||||
|
async fn raw(&self, cid: String, headers: HeaderMap) -> Response;
|
||||||
|
|
||||||
|
/// `GET /jobs`.
|
||||||
|
async fn jobs(&self) -> Result<Vec<Job>, ApiError>;
|
||||||
|
|
||||||
|
/// `DELETE /jobs/:cid?keep=…`. `keep` lists sibling cids of the same unit
|
||||||
|
/// the hull still wants.
|
||||||
|
async fn delete(&self, cid: String, keep: Vec<String>) -> Result<Deleted, ApiError>;
|
||||||
|
|
||||||
|
/// `POST /reconcile`.
|
||||||
|
async fn reconcile(&self, req: ReconcileRequest) -> Result<ReconcileReport, ApiError>;
|
||||||
|
|
||||||
|
/// The view `PUT /focus` writes into.
|
||||||
|
fn focus(&self) -> &Arc<FocusView>;
|
||||||
|
|
||||||
|
/// The plugin's own settings, if it has any. `Some` mounts the `/config*`
|
||||||
|
/// routes and sets `manifest.config` — the hull's dashboard then offers a
|
||||||
|
/// "configure" panel that embeds `/config` through its proxy.
|
||||||
|
fn config(&self) -> Option<Arc<ConfigPlane>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol-specific routes, merged into the common router. Paths must not
|
||||||
|
/// collide with the common ones.
|
||||||
|
fn extra_routes(self: Arc<Self>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
//! HTTP `Range:` request-header parser.
|
||||||
|
//!
|
||||||
|
//! Pure helper: takes an inbound `Range:` header value plus the total
|
||||||
|
//! resource size in bytes, returns a [`RangeOutcome`] describing how the
|
||||||
|
//! caller should respond.
|
||||||
|
//!
|
||||||
|
//! Shared by the meta-share hull and every transport plugin so the
|
||||||
|
//! `<video>` element can seek and resume downloads identically whichever
|
||||||
|
//! process serves the bytes. Moved verbatim from meta-share
|
||||||
|
//! `crates/meta-share/src/api/range.rs`.
|
||||||
|
//!
|
||||||
|
//! ## Supported syntax
|
||||||
|
//!
|
||||||
|
//! RFC 7233 §3.1 `bytes=`-prefixed single-range only. Multi-range
|
||||||
|
//! requests (`bytes=0-99,200-299`) are surfaced as
|
||||||
|
//! [`RangeOutcome::Unsupported`] so the caller can fall back to `200 OK`
|
||||||
|
//! with the full body — the RFC permits ignoring multi-range, and
|
||||||
|
//! every common client (browser video element, curl, wget) accepts the
|
||||||
|
//! degraded response.
|
||||||
|
//!
|
||||||
|
//! ### Returned shape
|
||||||
|
//!
|
||||||
|
//! - `bytes=N-M` → `Ok((N, min(M, total-1)))`
|
||||||
|
//! - `bytes=N-` → `Ok((N, total-1))`
|
||||||
|
//! - `bytes=-M` → `Ok((max(0, total-M), total-1))` (suffix)
|
||||||
|
//! - `bytes=N-` with `N >= total` → `Unsatisfiable`
|
||||||
|
//! - `bytes=N-M` with `N > M` → `Unsatisfiable`
|
||||||
|
//! - missing / blank / malformed → `Unsatisfiable` for non-`Absent`,
|
||||||
|
//! `Absent` only for the literal "no header" case (callers pass
|
||||||
|
//! `Option<&HeaderValue>`).
|
||||||
|
//!
|
||||||
|
//! The function is total: it never panics, never allocates, and always
|
||||||
|
//! returns one of the four variants.
|
||||||
|
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
|
||||||
|
/// Outcome of parsing an inbound `Range:` header.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RangeOutcome {
|
||||||
|
/// Header absent on the request — caller serves `200 OK` with the
|
||||||
|
/// full body.
|
||||||
|
Absent,
|
||||||
|
/// Single-range request parsed cleanly. Inclusive `[start, end]`
|
||||||
|
/// clamped to `[0, total-1]`.
|
||||||
|
Ok { start: u64, end: u64 },
|
||||||
|
/// Multi-range or unsupported syntax — caller serves `200 OK` with
|
||||||
|
/// the full body (the RFC permits ignoring; browser video and curl
|
||||||
|
/// both fall through to a full GET on a follow-up request).
|
||||||
|
Unsupported,
|
||||||
|
/// Malformed range, reversed range, or `start >= total` — caller
|
||||||
|
/// serves `416 Range Not Satisfiable` with
|
||||||
|
/// `Content-Range: bytes */{total}`.
|
||||||
|
Unsatisfiable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `Range:` header into a [`RangeOutcome`]. `header` is the raw
|
||||||
|
/// header value (or `None` if the request didn't carry one). `total` is
|
||||||
|
/// the resource's full size in bytes — used to clamp open / suffix
|
||||||
|
/// ranges and to detect out-of-bounds requests.
|
||||||
|
pub fn parse_range_header(header: Option<&HeaderValue>, total: u64) -> RangeOutcome {
|
||||||
|
let Some(value) = header else {
|
||||||
|
return RangeOutcome::Absent;
|
||||||
|
};
|
||||||
|
let Ok(s) = value.to_str() else {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
};
|
||||||
|
let s = s.trim();
|
||||||
|
// Strip the `bytes=` unit prefix. Anything else is unsupported
|
||||||
|
// (per RFC the unit is extensible, but in practice `bytes` is the
|
||||||
|
// only one any browser sends).
|
||||||
|
let Some(rest) = s.strip_prefix("bytes=") else {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
};
|
||||||
|
let rest = rest.trim();
|
||||||
|
if rest.is_empty() {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
}
|
||||||
|
// Multi-range: caller serves 200 with the full body.
|
||||||
|
if rest.contains(',') {
|
||||||
|
return RangeOutcome::Unsupported;
|
||||||
|
}
|
||||||
|
// Must contain exactly one '-' separator.
|
||||||
|
let (lhs, rhs) = match rest.split_once('-') {
|
||||||
|
Some(parts) => parts,
|
||||||
|
None => return RangeOutcome::Unsatisfiable,
|
||||||
|
};
|
||||||
|
let lhs = lhs.trim();
|
||||||
|
let rhs = rhs.trim();
|
||||||
|
// Three valid shapes:
|
||||||
|
// - "N-M" bounded range
|
||||||
|
// - "N-" open-ended (start known, end = total-1)
|
||||||
|
// - "-M" suffix (last M bytes)
|
||||||
|
if lhs.is_empty() && rhs.is_empty() {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
}
|
||||||
|
// Edge case: file is empty. Any range against a 0-byte resource is
|
||||||
|
// unsatisfiable (RFC 7233 §4.4).
|
||||||
|
if total == 0 {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
}
|
||||||
|
if lhs.is_empty() {
|
||||||
|
// Suffix range. `rhs` is the suffix length.
|
||||||
|
let Ok(suffix_len) = rhs.parse::<u64>() else {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
};
|
||||||
|
if suffix_len == 0 {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
}
|
||||||
|
let start = total.saturating_sub(suffix_len);
|
||||||
|
return RangeOutcome::Ok {
|
||||||
|
start,
|
||||||
|
end: total - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let Ok(start) = lhs.parse::<u64>() else {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
};
|
||||||
|
if start >= total {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
}
|
||||||
|
let end = if rhs.is_empty() {
|
||||||
|
total - 1
|
||||||
|
} else {
|
||||||
|
let Ok(parsed_end) = rhs.parse::<u64>() else {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
};
|
||||||
|
if parsed_end < start {
|
||||||
|
return RangeOutcome::Unsatisfiable;
|
||||||
|
}
|
||||||
|
// Clamp end to total-1 (RFC: "the last byte position is at most
|
||||||
|
// total-1").
|
||||||
|
parsed_end.min(total - 1)
|
||||||
|
};
|
||||||
|
RangeOutcome::Ok { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn hv(s: &str) -> HeaderValue {
|
||||||
|
HeaderValue::from_str(s).expect("static header value")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_when_no_header() {
|
||||||
|
assert_eq!(parse_range_header(None, 1000), RangeOutcome::Absent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closed_range_in_bounds() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=0-99")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 0, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_end_clamped_to_total_minus_one() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=100-")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 100, end: 999 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suffix_range() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=-512")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 488, end: 999 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_byte_range() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=0-0")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 0, end: 0 }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=999-999")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 999, end: 999 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suffix_clamps_to_whole_file_when_larger_than_total() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=-99999")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 0, end: 999 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_clamped_when_above_total() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=0-1000")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 0, end: 999 }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=0-999999")), 1000),
|
||||||
|
RangeOutcome::Ok { start: 0, end: 999 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_at_or_past_total_is_unsatisfiable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=1000-")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=2000-3000")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reversed_range_is_unsatisfiable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=10-9")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garbage_input_is_unsatisfiable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=abc")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("blocks=0-99")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("0-99")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_range_is_unsupported() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=0-50,100-150")), 1000),
|
||||||
|
RangeOutcome::Unsupported
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=0-50, 100-150")), 1000),
|
||||||
|
RangeOutcome::Unsupported
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_resource_is_always_unsatisfiable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=0-99")), 0),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=-10")), 0),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_length_suffix_is_unsatisfiable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range_header(Some(&hv("bytes=-0")), 1000),
|
||||||
|
RangeOutcome::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! `serve_transport` — the axum harness every transport plugin binary runs.
|
||||||
|
//!
|
||||||
|
//! # Common routes
|
||||||
|
//!
|
||||||
|
//! - `GET /manifest` → [`Manifest`](super::dto::Manifest)
|
||||||
|
//! - `GET /health` → [`Health`](super::dto::Health)
|
||||||
|
//! - `GET /raw/:cid` → the tier's byte response, verbatim
|
||||||
|
//! - `GET /jobs` → [`Jobs`](super::dto::Jobs)
|
||||||
|
//! - `DELETE /jobs/:cid?keep=` → [`Deleted`](super::dto::Deleted)
|
||||||
|
//! - `POST /reconcile` → [`ReconcileReport`](super::dto::ReconcileReport)
|
||||||
|
//! - `PUT /focus` ← [`FocusSnapshot`](super::focus::FocusSnapshot), `204`
|
||||||
|
//! - `GET /focus` → the snapshot the plugin currently obeys
|
||||||
|
//!
|
||||||
|
//! plus the `/config*` routes when [`TransportPlugin::config`] is `Some` (see
|
||||||
|
//! [`ConfigPlane`](super::config::ConfigPlane)), plus whatever
|
||||||
|
//! [`TransportPlugin::extra_routes`] adds.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use super::dto::{DeleteQuery, Jobs, ReconcileRequest};
|
||||||
|
use super::focus::FocusSnapshot;
|
||||||
|
use super::plugin::TransportPlugin;
|
||||||
|
|
||||||
|
type Shared = Arc<dyn TransportPlugin>;
|
||||||
|
|
||||||
|
/// The common router for `plugin`, merged with its extra routes.
|
||||||
|
pub fn router<P: TransportPlugin>(plugin: Arc<P>) -> Router {
|
||||||
|
let extra = Arc::clone(&plugin).extra_routes();
|
||||||
|
let config = plugin.config().map(|c| c.routes());
|
||||||
|
let shared: Shared = plugin;
|
||||||
|
let r = Router::new()
|
||||||
|
.route("/manifest", get(manifest))
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/raw/:cid", get(raw))
|
||||||
|
.route("/jobs", get(jobs))
|
||||||
|
.route("/jobs/:cid", delete(delete_job))
|
||||||
|
.route("/reconcile", post(reconcile))
|
||||||
|
.route("/focus", get(get_focus).put(put_focus))
|
||||||
|
.with_state(shared)
|
||||||
|
.merge(extra);
|
||||||
|
match config {
|
||||||
|
Some(c) => r.merge(c),
|
||||||
|
None => r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind `listen` and serve `plugin` until SIGTERM / Ctrl-C.
|
||||||
|
pub async fn serve_transport<P: TransportPlugin>(
|
||||||
|
plugin: Arc<P>,
|
||||||
|
listen: SocketAddr,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let manifest = plugin.manifest();
|
||||||
|
let id = manifest.id.clone();
|
||||||
|
let advertised = Arc::clone(&plugin);
|
||||||
|
let app = router(plugin);
|
||||||
|
let listener = tokio::net::TcpListener::bind(listen).await?;
|
||||||
|
info!(%listen, plugin = %id, "transport plugin listening");
|
||||||
|
// Beacon v2: advertise once bound; say `bye` on clean shutdown so the hull
|
||||||
|
// drops us at once instead of after the liveness window.
|
||||||
|
let port = listen.port();
|
||||||
|
// `node.name` is a short machine name; the human description stays in the
|
||||||
|
// manifest (`implementation`), which a consumer fetches over HTTP.
|
||||||
|
let beacon_name = std::env::var("SERVICE_NAME")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| format!("meta-transport-{}", manifest.id));
|
||||||
|
let beacon = crate::beacon::advertise_plugin(&beacon_name, &manifest.version, move || {
|
||||||
|
beacon_resource(advertised.as_ref(), port)
|
||||||
|
});
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
if let Some(b) = beacon {
|
||||||
|
b.bye().await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transport's beacon v2 resource: id = manifest id, cap
|
||||||
|
/// `metamesh.transport/<id>@<contract>`, `rev` = hash of the served manifest.
|
||||||
|
fn beacon_resource<P: TransportPlugin + ?Sized>(plugin: &P, listen_port: u16) -> crate::beacon::Resource {
|
||||||
|
use crate::beacon::{advertise_url, binds_from_env, caps, rev_of, Resource};
|
||||||
|
let mut m = plugin.manifest();
|
||||||
|
m.config = plugin.config().is_some();
|
||||||
|
let mut r = Resource::new(m.id.clone(), [caps::transport(&m.id, m.contract)])
|
||||||
|
.with_endpoint("http", advertise_url(listen_port))
|
||||||
|
.with_endpoint("manifest", "/manifest");
|
||||||
|
r.rev = Some(rev_of(&m));
|
||||||
|
r.binds = binds_from_env();
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves on SIGTERM (docker stop) or Ctrl-C.
|
||||||
|
pub async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
let term = async {
|
||||||
|
if let Ok(mut s) =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
{
|
||||||
|
s.recv().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let term = std::future::pending::<()>();
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = term => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn manifest(State(p): State<Shared>) -> Response {
|
||||||
|
let mut m = p.manifest();
|
||||||
|
m.config = p.config().is_some();
|
||||||
|
Json(m).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health(State(p): State<Shared>) -> Response {
|
||||||
|
let h = p.health().await;
|
||||||
|
let code = if h.ok { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE };
|
||||||
|
(code, Json(h)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn raw(State(p): State<Shared>, Path(cid): Path<String>, headers: HeaderMap) -> Response {
|
||||||
|
p.raw(cid, headers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn jobs(State(p): State<Shared>) -> Response {
|
||||||
|
match p.jobs().await {
|
||||||
|
Ok(jobs) => Json(Jobs { jobs }).into_response(),
|
||||||
|
Err(e) => e.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_job(
|
||||||
|
State(p): State<Shared>,
|
||||||
|
Path(cid): Path<String>,
|
||||||
|
Query(q): Query<DeleteQuery>,
|
||||||
|
) -> Response {
|
||||||
|
match p.delete(cid, q.keep_list()).await {
|
||||||
|
Ok(d) => Json(d).into_response(),
|
||||||
|
Err(e) => e.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile(State(p): State<Shared>, Json(req): Json<ReconcileRequest>) -> Response {
|
||||||
|
match p.reconcile(req).await {
|
||||||
|
Ok(r) => Json(r).into_response(),
|
||||||
|
Err(e) => e.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_focus(State(p): State<Shared>) -> Response {
|
||||||
|
Json(p.focus().snapshot()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_focus(State(p): State<Shared>, Json(snap): Json<FocusSnapshot>) -> StatusCode {
|
||||||
|
p.focus().apply(snap);
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! Conformance checks any transport plugin must pass — ours or a competing
|
||||||
|
//! implementation (a qBittorrent-backed torrent plugin, say). Run them against a
|
||||||
|
//! live plugin URL, or in a unit test via [`spawn_local`].
|
||||||
|
//!
|
||||||
|
//! The checks cover the *common* contract only. A plugin's byte behaviour is
|
||||||
|
//! verified by its own tests and by meta-share's contract suite against the
|
||||||
|
//! hull, which is where "identical to the monolith" is decided.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::client::RemoteTransport;
|
||||||
|
use super::dto::{Deleted, CONTRACT_VERSION};
|
||||||
|
use super::focus::{FocusSnapshot, FocusTitle};
|
||||||
|
use super::plugin::TransportPlugin;
|
||||||
|
|
||||||
|
/// A well-formed cid no plugin holds a job for: the raw/sha2-256 CID of the
|
||||||
|
/// empty block.
|
||||||
|
pub const UNKNOWN_CID: &str = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
|
||||||
|
|
||||||
|
/// Serve `plugin` on an ephemeral localhost port. Returns its address; the
|
||||||
|
/// server lives until the runtime shuts down.
|
||||||
|
pub async fn spawn_local<P: TransportPlugin>(plugin: Arc<P>) -> SocketAddr {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let app = super::serve::router(plugin);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = axum::serve(listener, app).await;
|
||||||
|
});
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run every check against the plugin at `base`. `Ok(())` or the list of
|
||||||
|
/// failures, each a one-line explanation.
|
||||||
|
pub async fn conformance(base: &str) -> Result<(), Vec<String>> {
|
||||||
|
let http = reqwest::Client::new();
|
||||||
|
let t = RemoteTransport::new("under-test", base, http.clone())
|
||||||
|
.with_control_timeout(Duration::from_secs(10));
|
||||||
|
let mut fails = Vec::new();
|
||||||
|
|
||||||
|
let mut has_config = false;
|
||||||
|
match t.manifest().await {
|
||||||
|
Ok(m) => {
|
||||||
|
has_config = m.config;
|
||||||
|
if m.id.is_empty() {
|
||||||
|
fails.push("manifest.id is empty".into());
|
||||||
|
}
|
||||||
|
if m.contract != CONTRACT_VERSION {
|
||||||
|
fails.push(format!("manifest.contract {} != {CONTRACT_VERSION}", m.contract));
|
||||||
|
}
|
||||||
|
if !m.capabilities.fetch {
|
||||||
|
fails.push("manifest.capabilities.fetch is false — a transport must fetch".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => fails.push(format!("GET /manifest: {e}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
match http.get(t.url("/health")).send().await {
|
||||||
|
Ok(r) if r.status().is_success() || r.status().as_u16() == 503 => {
|
||||||
|
if r.json::<super::dto::Health>().await.is_err() {
|
||||||
|
fails.push("GET /health body is not a Health".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(r) => fails.push(format!("GET /health: status {}", r.status())),
|
||||||
|
Err(e) => fails.push(format!("GET /health: {e}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = t.jobs().await {
|
||||||
|
fails.push(format!("GET /jobs: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let snap = FocusSnapshot {
|
||||||
|
enabled: true,
|
||||||
|
bg_rate_bytes: 4321,
|
||||||
|
titles: vec![FocusTitle {
|
||||||
|
group: "conformance-group".into(),
|
||||||
|
cids: vec!["conformance-cid".into()],
|
||||||
|
infohashes: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
match t.push_focus(&snap).await {
|
||||||
|
Ok(()) => match t.get_json::<FocusSnapshot>("/focus").await {
|
||||||
|
Ok(back) if back == snap => {}
|
||||||
|
Ok(back) => fails.push(format!("GET /focus returned {back:?}, pushed {snap:?}")),
|
||||||
|
Err(e) => fails.push(format!("GET /focus: {e}")),
|
||||||
|
},
|
||||||
|
Err(e) => fails.push(format!("PUT /focus: {e}")),
|
||||||
|
}
|
||||||
|
// Leave the plugin unfocused.
|
||||||
|
let _ = t.push_focus(&FocusSnapshot::default()).await;
|
||||||
|
|
||||||
|
match http.delete(t.url(&format!("/jobs/{UNKNOWN_CID}"))).send().await {
|
||||||
|
Ok(r) if r.status().as_u16() == 404 => {}
|
||||||
|
Ok(r) if r.status().is_success() => match r.json::<Deleted>().await {
|
||||||
|
Ok(d) if d.bytes_freed == 0 => {}
|
||||||
|
Ok(d) => fails.push(format!("DELETE unknown job freed {} bytes", d.bytes_freed)),
|
||||||
|
Err(e) => fails.push(format!("DELETE unknown job: body: {e}")),
|
||||||
|
},
|
||||||
|
Ok(r) => fails.push(format!("DELETE unknown job: status {}", r.status())),
|
||||||
|
Err(e) => fails.push(format!("DELETE unknown job: {e}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
match http.get(t.url("/raw/not-a-cid")).send().await {
|
||||||
|
Ok(r) if r.status().is_client_error() => {
|
||||||
|
let v: Option<serde_json::Value> = r.json().await.ok();
|
||||||
|
if !v.as_ref().is_some_and(|v| v.get("error").is_some()) {
|
||||||
|
fails.push("GET /raw/not-a-cid: 4xx without an {\"error\"} body".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(r) => fails.push(format!("GET /raw/not-a-cid: expected 4xx, got {}", r.status())),
|
||||||
|
Err(e) => fails.push(format!("GET /raw/not-a-cid: {e}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_config {
|
||||||
|
match http.get(t.url("/config/schema")).send().await {
|
||||||
|
Ok(r) if r.status().is_success() => {
|
||||||
|
if r.json::<crate::config::ConfigSchema>().await.is_err() {
|
||||||
|
fails.push("GET /config/schema body is not a ConfigSchema".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(r) => fails.push(format!("GET /config/schema: status {}", r.status())),
|
||||||
|
Err(e) => fails.push(format!("GET /config/schema: {e}")),
|
||||||
|
}
|
||||||
|
match http.get(t.url("/config/values")).send().await {
|
||||||
|
Ok(r) if r.status().is_success() => match r.json::<serde_json::Value>().await {
|
||||||
|
Ok(v) if v.is_object() => {}
|
||||||
|
_ => fails.push("GET /config/values is not a JSON object".into()),
|
||||||
|
},
|
||||||
|
Ok(r) => fails.push(format!("GET /config/values: status {}", r.status())),
|
||||||
|
Err(e) => fails.push(format!("GET /config/values: {e}")),
|
||||||
|
}
|
||||||
|
match http.get(t.url("/config")).send().await {
|
||||||
|
Ok(r) if r.status().is_success() => {}
|
||||||
|
Ok(r) => fails.push(format!("GET /config: status {}", r.status())),
|
||||||
|
Err(e) => fails.push(format!("GET /config: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fails.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(fails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::transport::dto::{Capabilities, Job, Manifest, ReconcileReport, ReconcileRequest};
|
||||||
|
use crate::transport::error::ApiError;
|
||||||
|
use crate::transport::focus::FocusView;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
/// The smallest plugin that should pass: holds nothing, fetches nothing.
|
||||||
|
struct Empty {
|
||||||
|
focus: Arc<FocusView>,
|
||||||
|
config: Option<Arc<crate::transport::config::ConfigPlane>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TransportPlugin for Empty {
|
||||||
|
fn manifest(&self) -> Manifest {
|
||||||
|
Manifest {
|
||||||
|
id: "empty".into(),
|
||||||
|
implementation: "testkit-stub".into(),
|
||||||
|
version: "0".into(),
|
||||||
|
contract: CONTRACT_VERSION,
|
||||||
|
capabilities: Capabilities { fetch: true, share: false },
|
||||||
|
config: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn raw(&self, _cid: String, _h: HeaderMap) -> Response {
|
||||||
|
ApiError::BadRequest("unsupported cid".into()).into_response()
|
||||||
|
}
|
||||||
|
async fn jobs(&self) -> Result<Vec<Job>, ApiError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn delete(&self, _cid: String, _keep: Vec<String>) -> Result<Deleted, ApiError> {
|
||||||
|
Ok(Deleted::default())
|
||||||
|
}
|
||||||
|
async fn reconcile(&self, _r: ReconcileRequest) -> Result<ReconcileReport, ApiError> {
|
||||||
|
Ok(ReconcileReport::default())
|
||||||
|
}
|
||||||
|
fn focus(&self) -> &Arc<FocusView> {
|
||||||
|
&self.focus
|
||||||
|
}
|
||||||
|
fn config(&self) -> Option<Arc<crate::transport::config::ConfigPlane>> {
|
||||||
|
self.config.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_empty_plugin_conforms() {
|
||||||
|
let addr = spawn_local(Arc::new(Empty { focus: FocusView::new(), config: None })).await;
|
||||||
|
conformance(&format!("http://{addr}")).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_plugin_with_a_config_plane_conforms_and_says_so() {
|
||||||
|
use crate::config::{ConfigField, ConfigSchema};
|
||||||
|
use crate::transport::config::ConfigPlane;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let plane = ConfigPlane::new(
|
||||||
|
ConfigSchema { fields: vec![ConfigField::secret("key", "Key")] },
|
||||||
|
dir.path(),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.without_restart();
|
||||||
|
let addr = spawn_local(Arc::new(Empty { focus: FocusView::new(), config: Some(Arc::new(plane)) })).await;
|
||||||
|
let base = format!("http://{addr}");
|
||||||
|
conformance(&base).await.unwrap();
|
||||||
|
let m: Manifest = reqwest::get(format!("{base}/manifest")).await.unwrap().json().await.unwrap();
|
||||||
|
assert!(m.config, "the harness must advertise the config plane");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//! BitTorrent-specific half of the contract: the torrent plugin's extra routes,
|
||||||
|
//! and the magnet helpers both sides use.
|
||||||
|
//!
|
||||||
|
//! Extra routes (all under `/torrent/`):
|
||||||
|
//!
|
||||||
|
//! - `GET /torrent/inventory` → [`Inventory`] — every managed torrent,
|
||||||
|
//! with have-bytes, per-file progress and live stats. One call per eviction
|
||||||
|
//! pass / dashboard poll instead of one per row.
|
||||||
|
//! - `GET /torrent/status/:cid` → `Option<`[`LiveStats`]`>` — peeks only,
|
||||||
|
//! never starts the session (meta-watch polls it while connecting).
|
||||||
|
//! - `GET /torrent/peers/:cid?budget_ms=&cap=` → [`PeerCount`] — query-only
|
||||||
|
//! mainline-DHT population for the availability probe.
|
||||||
|
//! - `POST /torrent/confirm` ← [`ConfirmRequest`] → [`ConfirmResponse`]
|
||||||
|
//! — metadata-only reachability probe (no torrent kept, no seed row).
|
||||||
|
//! - `PUT /torrent/commits` ← [`Commits`] — the watch-commit intents.
|
||||||
|
//! The hull owns the intent (`seeds.json`); the plugin's supervisor owns the
|
||||||
|
//! fill (disk floor, concurrency cap, stall hysteresis).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Live transfer stats for one torrent. Serialises exactly like meta-share's
|
||||||
|
/// former `bt::SeedLiveStats`, which `/api/file/:cid/status` and `/api/seeds`
|
||||||
|
/// still emit.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct LiveStats {
|
||||||
|
/// librqbit torrent state: `live` | `paused` | `initializing` | `error`.
|
||||||
|
pub state: String,
|
||||||
|
pub down_bps: u64,
|
||||||
|
pub up_bps: u64,
|
||||||
|
pub uploaded_bytes: u64,
|
||||||
|
pub downloaded_bytes: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub ratio: f64,
|
||||||
|
pub peers_live: usize,
|
||||||
|
pub peers_seen: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct FileProgress {
|
||||||
|
pub index: usize,
|
||||||
|
/// File length from the torrent metadata.
|
||||||
|
pub len: u64,
|
||||||
|
/// Bytes of it on disk.
|
||||||
|
pub have: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileProgress {
|
||||||
|
pub fn complete(&self) -> bool {
|
||||||
|
self.have >= self.len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct TorrentInfo {
|
||||||
|
/// Lowercase hex v1 infohash.
|
||||||
|
pub infohash: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// librqbit have-bytes — the eviction size of the unit.
|
||||||
|
pub have_bytes: u64,
|
||||||
|
pub streaming_only: bool,
|
||||||
|
/// Metadata resolved (`files` is empty until it is).
|
||||||
|
pub metadata: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: Vec<FileProgress>,
|
||||||
|
pub stats: LiveStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentInfo {
|
||||||
|
/// Is file `index` fully downloaded? `None` when unknowable yet.
|
||||||
|
pub fn file_complete(&self, index: usize) -> Option<bool> {
|
||||||
|
self.files.iter().find(|f| f.index == index).map(FileProgress::complete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct Inventory {
|
||||||
|
/// Is the librqbit session up? When it isn't, `torrents` is empty and the
|
||||||
|
/// hull falls back to row lengths (the session is lazy on purpose).
|
||||||
|
pub session_up: bool,
|
||||||
|
/// Does the session directory hold persisted torrents?
|
||||||
|
pub persisted: bool,
|
||||||
|
pub torrents: Vec<TorrentInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inventory {
|
||||||
|
pub fn get(&self, infohash_hex: &str) -> Option<&TorrentInfo> {
|
||||||
|
self.torrents.iter().find(|t| t.infohash == infohash_hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct PeerCount {
|
||||||
|
/// Distinct DHT peers seen; `None` when the DHT couldn't be queried.
|
||||||
|
pub peers: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
pub struct ConfirmRequest {
|
||||||
|
pub magnet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)]
|
||||||
|
pub struct ConfirmResponse {
|
||||||
|
/// Length of file 0.
|
||||||
|
pub len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct CommitIntent {
|
||||||
|
pub cid: String,
|
||||||
|
/// The seed row's `added_at` — the supervisor's recency order.
|
||||||
|
pub added_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Commits {
|
||||||
|
pub intents: Vec<CommitIntent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Magnet helpers (moved verbatim from meta-share `bt.rs`)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A bare-infohash magnet: DHT resolves peers from the infohash alone.
|
||||||
|
pub fn build_magnet(infohash20: &[u8; 20]) -> String {
|
||||||
|
format!("magnet:?xt=urn:btih:{}", super::cid::to_hex(infohash20))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-uptime open trackers appended to every magnet before librqbit sees it,
|
||||||
|
/// so a trackerless indexer magnet doesn't leave peer discovery to DHT alone.
|
||||||
|
pub const DEFAULT_TRACKERS: &[&str] = &[
|
||||||
|
"udp://tracker.opentrackr.org:1337/announce",
|
||||||
|
"udp://open.stealth.si:80/announce",
|
||||||
|
"udp://tracker.openbittorrent.com:6969/announce",
|
||||||
|
"udp://exodus.desync.com:6969/announce",
|
||||||
|
"udp://tracker.torrent.eu.org:451/announce",
|
||||||
|
"udp://open.demonii.com:1337/announce",
|
||||||
|
"http://nyaa.tracker.wf:7777/announce",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn encode_tr(url: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(url.len() * 3);
|
||||||
|
for b in url.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
out.push(b as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append [`DEFAULT_TRACKERS`] to `magnet`, skipping any already present. A
|
||||||
|
/// non-`magnet:` string is returned unchanged.
|
||||||
|
pub fn with_default_trackers(mut magnet: String) -> String {
|
||||||
|
if !magnet.starts_with("magnet:") {
|
||||||
|
return magnet;
|
||||||
|
}
|
||||||
|
for tr in DEFAULT_TRACKERS {
|
||||||
|
let enc = encode_tr(tr);
|
||||||
|
if magnet.contains(&enc) || magnet.contains(tr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
magnet.push_str("&tr=");
|
||||||
|
magnet.push_str(&enc);
|
||||||
|
}
|
||||||
|
magnet
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_tr(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
let hi = (bytes[i + 1] as char).to_digit(16);
|
||||||
|
let lo = (bytes[i + 2] as char).to_digit(16);
|
||||||
|
if let (Some(hi), Some(lo)) = (hi, lo) {
|
||||||
|
out.push((hi * 16 + lo) as u8);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decoded, de-duplicated `tr=` announce URLs of a magnet.
|
||||||
|
pub fn parse_trackers(magnet: &str) -> Vec<String> {
|
||||||
|
if !magnet.starts_with("magnet:") {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let query = magnet.splitn(2, '?').nth(1).unwrap_or("");
|
||||||
|
for pair in query.split('&') {
|
||||||
|
if let Some(val) = pair.strip_prefix("tr=") {
|
||||||
|
let url = decode_tr(val);
|
||||||
|
if !url.is_empty() && seen.insert(url.clone()) {
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_magnet_includes_infohash() {
|
||||||
|
assert_eq!(
|
||||||
|
build_magnet(&[0xab; 20]),
|
||||||
|
"magnet:?xt=urn:btih:abababababababababababababababababababab"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_default_trackers_appends_to_trackerless_magnet() {
|
||||||
|
let bare = "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Foo".to_string();
|
||||||
|
let out = with_default_trackers(bare);
|
||||||
|
assert!(out.contains("&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce"));
|
||||||
|
assert!(out.contains("&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce"));
|
||||||
|
assert!(out.contains("&dn=Foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_default_trackers_dedups_existing() {
|
||||||
|
let pre = "magnet:?xt=urn:btih:abababababababababababababababababababab\
|
||||||
|
&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce"
|
||||||
|
.to_string();
|
||||||
|
let out = with_default_trackers(pre);
|
||||||
|
assert_eq!(out.matches("tracker.opentrackr.org%3A1337").count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_default_trackers_leaves_non_magnet_untouched() {
|
||||||
|
assert_eq!(with_default_trackers("not-a-magnet".to_string()), "not-a-magnet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_trackers_round_trips_the_defaults() {
|
||||||
|
let m = with_default_trackers(build_magnet(&[1; 20]));
|
||||||
|
assert_eq!(parse_trackers(&m), DEFAULT_TRACKERS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||||
|
assert!(parse_trackers("not-a-magnet").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_stats_serialise_with_the_legacy_field_names() {
|
||||||
|
let v = serde_json::to_value(LiveStats::default()).unwrap();
|
||||||
|
for k in [
|
||||||
|
"state", "down_bps", "up_bps", "uploaded_bytes", "downloaded_bytes",
|
||||||
|
"total_bytes", "ratio", "peers_live", "peers_seen",
|
||||||
|
] {
|
||||||
|
assert!(v.get(k).is_some(), "missing {k}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user