meta-transport-ipfs 1.0.0: ipfs tier split out of meta-share
Publish image / publish (push) Failing after 14s
Publish image / publish (push) Failing after 14s
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
target
|
||||
.git
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish image
|
||||
|
||||
# Build and push the Docker image to the GitHub Container Registry
|
||||
# (ghcr.io) on version tags only.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# v1.2.3 -> 1.2.3, 1.2, 1 ; plus a moving `latest` for the newest tag.
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -0,0 +1,25 @@
|
||||
# CLAUDE.md — meta-transport-ipfs
|
||||
|
||||
meta-share's IPFS tier, split out 2026-10 (`packages/meta-share/docs/transport-plugins.md`).
|
||||
Behaviour must stay identical to the monolith's swarm / blockstore / ingress.
|
||||
|
||||
## Load-bearing invariants
|
||||
|
||||
1. **The swarm is transport-only**: identify/kad/mdns/bitswap. No kamilata, no
|
||||
gossipsub, no request_response — search is meta-search's.
|
||||
2. **`MAX_MULTIHASH_SIZE = 64`** must match meta-gateway and meta-search.
|
||||
3. **`core2` is patched** in `Cargo.toml` (every libp2p-0.56 bitswap impl needs
|
||||
the yanked `^0.4.0`); mirror changes in meta-gateway.
|
||||
4. **`ipfs_chunk.rs` is kubo-identical** (256 KiB raw leaves, fanout 174), in
|
||||
sync with meta-gateway's `hash.rs` and `meta_feeder_sdk::hash`.
|
||||
5. **Records are never blocks**; `scrub_record_blocks` removes legacy MSR1 rows at
|
||||
boot.
|
||||
6. **Fetch for our user implies seed; the swarm side is provider-only** —
|
||||
unattributed blocks over `META_SHARE_LOOSE_BLOCK_MAX_BYTES` are dropped.
|
||||
7. **`gateway_discovery.rs` is a MIRRORED file** (meta-share hull + meta-search);
|
||||
`scripts/check-mirrors.sh` checks it.
|
||||
8. **Identify announces the hull's URL**, never this container's.
|
||||
9. **Seed rows, material index and meta-core are the hull's** — report them as
|
||||
events (`seed`, `promoted`), don't write them.
|
||||
10. **Mark, don't meter**: dag-range `/raw` answers carry `x-metamesh-meter:
|
||||
egress`; single blocks don't (posters were never throttled).
|
||||
Generated
+4234
File diff suppressed because it is too large
Load Diff
+68
@@ -0,0 +1,68 @@
|
||||
[package]
|
||||
name = "meta-transport-ipfs"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "MetaMesh transport plugin: IPFS tier for meta-share — libp2p (identify/kad/mdns/bitswap), nocopy filestore, bitswap ingress."
|
||||
|
||||
[[bin]]
|
||||
name = "meta-transport-ipfs"
|
||||
path = "src/main.rs"
|
||||
|
||||
# This repo IS the crate; the empty workspace table keeps it from being absorbed
|
||||
# into a parent workspace when checked out inside meta-root.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
# The transport-plugin contract. Pinned by TAG, never by path — a path dep
|
||||
# resolves outside this repo, which is the Docker build context. See the
|
||||
# Dockerfile for building against an unreleased SDK.
|
||||
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.4.0", features = ["transport"] }
|
||||
|
||||
# libp2p — transport only: identify (address exchange, load-bearing for bitswap
|
||||
# reachability), kad (DHT provider records), mdns (LAN discovery) + bitswap.
|
||||
# `rsa` is REQUIRED for public IPFS DHT interop (bootstrap.libp2p.io has RSA
|
||||
# identities). Must stay in lockstep with meta-gateway + meta-search.
|
||||
libp2p = { version = "0.56", features = ["tokio", "tcp", "yamux", "noise", "ping", "macros", "dns", "identify", "kad", "quic", "mdns", "rsa"] }
|
||||
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["json"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The `MSR1` record-block shape, only to scrub legacy record blocks at boot.
|
||||
bincode = { version = "2", features = ["serde"] }
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
sha2 = "0.10"
|
||||
bytes = "1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Bitswap stack. `beetswap 0.5` is the libp2p bitswap behaviour; the redb
|
||||
# blockstore persists under `<META_SHARE_DATA>/ipfs/blocks.redb`.
|
||||
beetswap = "0.5"
|
||||
blockstore = { version = "0.8", features = ["redb"] }
|
||||
cid = "0.11"
|
||||
redb = "2"
|
||||
# Lets beetswap's multihasher know sha3-256 (0x16) and sha2-512 (0x13); without
|
||||
# it every BLOCK keyed by a sha3 cid is rejected.
|
||||
multihash-codetable = { version = "0.1", features = ["sha2", "sha3"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.4.0", features = ["testkit"] }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workaround for the yanked transitive `core2` (all versions). `multihash-
|
||||
# codetable 0.1.x` (via beetswap) needs `core2 = ^0.4.0`; commit `545e84b` is
|
||||
# the v0.4.0 release commit. Every libp2p-0.56 bitswap impl hits this. Mirror
|
||||
# any change in meta-gateway's Cargo.toml.
|
||||
# ---------------------------------------------------------------------------
|
||||
[patch.crates-io]
|
||||
core2 = { git = "https://github.com/bbqsrc/core2", rev = "545e84b" }
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# =============================================================================
|
||||
# meta-transport-ipfs — a MetaMesh transport plugin for meta-share.
|
||||
#
|
||||
# Build context: this repo. The transport-plugin SDK is pinned by git tag in
|
||||
# Cargo.toml; to build against an unreleased local SDK pass it as a named
|
||||
# context and it replaces the tag:
|
||||
# docker build --build-context sdk=../meta-feeder-sdk .
|
||||
# =============================================================================
|
||||
|
||||
FROM scratch AS sdk
|
||||
|
||||
FROM rust:1.89-slim-bookworm AS builder
|
||||
WORKDIR /build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config cmake libssl-dev ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Content-derived cache barrier (see meta-share's Dockerfile): BuildKit can miss
|
||||
# edits on WSL bind-mounts; a changing SRC_REV re-evaluates the COPYs below.
|
||||
ARG SRC_REV=dev
|
||||
RUN echo "source revision: ${SRC_REV}"
|
||||
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||
COPY src src
|
||||
COPY --from=sdk / /sdk/
|
||||
RUN if [ -f /sdk/Cargo.toml ]; then \
|
||||
echo "building against the local SDK (named context)"; \
|
||||
sed -i 's|meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v[0-9.]*",|meta-feeder-sdk = { path = "/sdk",|' Cargo.toml; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,id=meta-transport-ipfs-target,target=/build/target \
|
||||
cargo build --release --bin meta-transport-ipfs \
|
||||
&& cp target/release/meta-transport-ipfs /meta-transport-ipfs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /meta-transport-ipfs /usr/local/bin/meta-transport-ipfs
|
||||
|
||||
# Same uid as meta-share: the plugins share its /data volume.
|
||||
RUN useradd --system --uid 10001 --no-create-home metashare \
|
||||
&& mkdir -p /data/meta-share \
|
||||
&& chown metashare:metashare /data/meta-share
|
||||
USER metashare
|
||||
WORKDIR /data/meta-share
|
||||
|
||||
# 3000: the transport contract + the /ipfs/:cid gateway the hull relays (internal
|
||||
# network only). 4001: libp2p (P2P_LISTEN) — publish it on the host; PUBLIC_ADDR
|
||||
# announces it.
|
||||
EXPOSE 3000 4001
|
||||
|
||||
ENV HTTP_LISTEN=0.0.0.0:3000 \
|
||||
P2P_LISTEN=/ip4/0.0.0.0/tcp/4001 \
|
||||
META_SHARE_DATA=/data/meta-share \
|
||||
META_SHARE_CONFIG_DIR=/config \
|
||||
RUST_LOG=info,meta_transport_ipfs=info,libp2p=warn,beetswap=warn,yamux=warn
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/meta-transport-ipfs"]
|
||||
@@ -1 +1,37 @@
|
||||
# meta-transport-ipfs
|
||||
# meta-transport-ipfs
|
||||
|
||||
meta-share's **IPFS tier** as a transport plugin (`meta_feeder_sdk::transport`).
|
||||
|
||||
- The libp2p host: identify + kad (client) + mdns + bitswap (`beetswap`,
|
||||
`MAX_MULTIHASH_SIZE = 64`), on `P2P_LISTEN` (4001 — publish it; `PUBLIC_ADDR`
|
||||
announces it). Identify carries the **hull's** `baseUrl`, so peers keep calling
|
||||
meta-share's public API.
|
||||
- The blockstore (`<data>/ipfs/blocks.redb`): material blocks + **leaf refs into
|
||||
files** (meta-core library files over WebDAV / a local `/files` mount, and cache
|
||||
containers on the shared volume) — one copy on disk.
|
||||
- Bitswap ingress: big dag-pb fetches land in `tmp/<cid>/` and are renamed into
|
||||
`cache/`; a genuine play commits them to a background fill.
|
||||
- Gateway discovery (identify `gateways=` → `GET {baseUrl}/api/gateway/plugins`),
|
||||
exposed to the hull as `/ipfs-tier/directory`.
|
||||
- Serves the public `/ipfs/:cid` gateway (relayed by the hull) and the hull's
|
||||
`/ipfs-tier/*` facade.
|
||||
|
||||
## Configuration
|
||||
|
||||
meta-share's variables, unchanged: `P2P_LISTEN`, `PUBLIC_ADDR`, `KAD_*`,
|
||||
`BOOTSTRAP_PEERS`, `ENABLE_MDNS`, `TARGET_PEER_COUNT`, `META_SHARE_MAX_CONNS`,
|
||||
`META_SHARE_BLOCKSTORE_CACHE_MB`, `META_SHARE_INGRESS_*`,
|
||||
`META_SHARE_LOOSE_BLOCK_MAX_BYTES`, `META_SHARE_LOCAL_FILES_PATH`,
|
||||
`META_SHARE_FILES_PATH`, `META_SHARE_SEED_DHT_PROVIDE`, `META_SHARE_DATA`, plus
|
||||
`META_SHARE_HULL_URL`. `meta_core_url` / `peer_url` come from the hull's
|
||||
`settings.json` (mount `/config` read-only), env as the first-boot fallback.
|
||||
|
||||
Start it **after** the hull is healthy: the hull migrates its material index out
|
||||
of `ipfs/blocks.redb` on its first boot after the split (redb is single-process).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
docker build -t meta-transport-ipfs .
|
||||
docker build --build-context sdk=../meta-feeder-sdk -t meta-transport-ipfs .
|
||||
```
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.89"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -0,0 +1,321 @@
|
||||
//! The IPFS tier: serve from the local blockstore, fetch raw blocks over bitswap,
|
||||
//! walk dag-pb for ranges, and the hinted-HTTP fallback.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::Response,
|
||||
};
|
||||
use blockstore::Blockstore;
|
||||
use meta_feeder_sdk::transport::dto::{HDR_METER, METER_EGRESS};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::blockstore::parse_record_cid;
|
||||
use crate::focus::Lane;
|
||||
use crate::swarm::bitswap_get_block;
|
||||
|
||||
use crate::api::{ApiError, AppState};
|
||||
use crate::api::files::{bytes_to_response, raw_timeout};
|
||||
|
||||
/// The display name a seed row gets: the cached record's title, else its
|
||||
/// `fileName`, else the cid. Asked of the hull's record cache only — never a
|
||||
/// meta-core round trip on the byte path (same as the monolith).
|
||||
async fn seed_display_name(state: &AppState, cid: &str) -> String {
|
||||
if let Ok(Some(info)) = state.hull.cached_record(cid).await {
|
||||
if let Some(t) = info.title.filter(|t| !t.is_empty()) {
|
||||
return t;
|
||||
}
|
||||
if let Some(n) = info.file_name.filter(|n| !n.is_empty()) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
cid.to_string()
|
||||
}
|
||||
|
||||
/// midhash256 — the other single-block content codec alongside raw IPFS.
|
||||
const MIDHASH256_CODEC: u64 = 0x1000;
|
||||
|
||||
/// Serve `cid` straight out of the local blockstore, or `None`.
|
||||
///
|
||||
/// The block twin of `try_local_material`'s file check, and the same argument:
|
||||
/// when we already hold the bytes, no fetch machinery should run first.
|
||||
///
|
||||
/// It exists because the meta-core/WebDAV tier sits between the request and
|
||||
/// this blockstore read, and for artwork that tier is a **guaranteed miss that
|
||||
/// is not free**. Resolving a record in meta-core costs a read there, and every
|
||||
/// poster paid one before we ever looked at bytes we already had; the record's
|
||||
/// `filePath` then 404s on WebDAV (the artwork is seeded, not on meta-core's
|
||||
/// disk) and the request falls through to exactly this lookup anyway. Measured
|
||||
/// on watch.nsl.sh: ~0.5-3.0s of meta-core round-trip per poster, 220 posters
|
||||
/// on a cold home page.
|
||||
///
|
||||
/// Deliberately narrow, so nothing but that case changes:
|
||||
///
|
||||
/// - **Single-block content codecs only** (raw IPFS `0x55`, midhash256
|
||||
/// `0x1000`) — the two the single-block branch of [`fetch_raw_via_bitswap`]
|
||||
/// handles. dag-pb belongs to the walker, and every locator codec
|
||||
/// (`nzb-release`, `url`, `card`, `btih:`) must keep reaching its own
|
||||
/// dispatch below.
|
||||
|
||||
pub async fn try_local_block(
|
||||
state: &Arc<AppState>,
|
||||
cid: &str,
|
||||
inbound_range: Option<&HeaderValue>,
|
||||
) -> Option<Response> {
|
||||
let mscid = parse_record_cid(cid).ok()?;
|
||||
let codec = mscid.codec();
|
||||
if codec != crate::api::ipfs_walker::RAW_CODEC && codec != MIDHASH256_CODEC {
|
||||
return None;
|
||||
}
|
||||
|
||||
match state.block_store.get(&mscid).await {
|
||||
Ok(Some(bytes)) => {
|
||||
trace!(cid = %cid, source = "blockstore-local-first", bytes = bytes.len(),
|
||||
"served raw bytes without touching meta-core");
|
||||
Some(bytes_to_response(bytes, inbound_range))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!(cid = %cid, error = %e, "local-first blockstore lookup failed; falling through");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `player` marks a request meta-watch's byte proxy sent for a demuxer source —
|
||||
/// i.e. a viewer is blocked on these bytes. It is the *commit* signal for the
|
||||
/// IPFS tier: a title someone genuinely played is worth finishing in the
|
||||
/// background so this peer seeds the whole file rather than the slice that was
|
||||
/// watched (`crate::ingress_commit`). Deliberately not "any successful `/raw`":
|
||||
/// a probe, a `warm` and a poster fetch all reach here and none of them is a
|
||||
/// viewer. No remote peer sends the header.
|
||||
pub async fn fetch_raw_via_bitswap(
|
||||
state: &Arc<AppState>,
|
||||
cid: &str,
|
||||
inbound_range: Option<&HeaderValue>,
|
||||
lane: Lane,
|
||||
player: bool,
|
||||
) -> Result<Response, ApiError> {
|
||||
let mscid = parse_record_cid(cid)
|
||||
.map_err(|e| ApiError::BadRequest(format!("cid: {e:#}")))?;
|
||||
|
||||
let codec = mscid.codec();
|
||||
// Multi-block IPFS files: walk the dag-pb tree. The walker fetches
|
||||
// every block it needs through `ipfs_walker::get_block`, which goes
|
||||
// through the same blockstore-then-bitswap path as the single-block
|
||||
// branch below — no duplication, just a tree on top.
|
||||
if codec == crate::api::ipfs_walker::DAGPB_CODEC {
|
||||
return serve_dagpb_range(state, cid, &mscid, inbound_range, lane, player).await;
|
||||
}
|
||||
|
||||
// Single-block CIDs (midhash256, raw IPFS). Local blockstore →
|
||||
// bitswap WANT, then either slice or serve whole.
|
||||
match state.block_store.get(&mscid).await {
|
||||
Ok(Some(bytes)) => {
|
||||
trace!(cid = %cid, source = "blockstore", bytes = bytes.len(), "served raw bytes");
|
||||
return Ok(bytes_to_response(bytes, inbound_range));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => warn!(cid = %cid, error = %e, "blockstore raw lookup failed; trying bitswap"),
|
||||
}
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
match bitswap_get_block(&state.swarm_tx, mscid, raw_timeout()).await {
|
||||
Ok(bytes) => {
|
||||
trace!(
|
||||
cid = %cid,
|
||||
source = "bitswap",
|
||||
bytes = bytes.len(),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"served raw bytes via bitswap"
|
||||
);
|
||||
// The single-block twin of the meter in `ipfs_walker::get_block` —
|
||||
// this branch reaches bitswap directly and would otherwise be an
|
||||
// un-metered hole in the inbound floor.
|
||||
state.focus.throttle_in(lane, bytes.len() as u64).await;
|
||||
Ok(bytes_to_response(bytes, inbound_range))
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(
|
||||
cid = %cid,
|
||||
error = %e,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"bitswap raw fetch failed"
|
||||
);
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a multi-block dag-pb (IPFS UnixFS file) via the
|
||||
/// [`crate::api::ipfs_walker`] tree walker. Honors inbound `Range:`
|
||||
/// when present; otherwise streams the whole file.
|
||||
pub async fn serve_dagpb_range(
|
||||
state: &Arc<AppState>,
|
||||
cid: &str,
|
||||
mscid: &crate::blockstore::MsCid,
|
||||
inbound_range: Option<&HeaderValue>,
|
||||
lane: Lane,
|
||||
player: bool,
|
||||
) -> Result<Response, ApiError> {
|
||||
use axum::body::Body;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
// 1. Fetch the root block (cache → bitswap). We need it for
|
||||
// blocksizes / filesize.
|
||||
let root_block = crate::api::ipfs_walker::get_block(state, mscid, lane)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
debug!(cid = %cid, error = %e, "dag-pb root fetch failed");
|
||||
ApiError::NotFound
|
||||
})?;
|
||||
|
||||
// 2. Parse the root's PBNode to learn the total file size.
|
||||
let meta = crate::api::ipfs_walker::parse_pbnode(&root_block)
|
||||
.map_err(|e| ApiError::Upstream(format!("parse dag-pb root: {e:#}")))?;
|
||||
// No links: root is itself the data. Fall back to single-block
|
||||
// slicing.
|
||||
if meta.children.is_empty() {
|
||||
return Ok(bytes_to_response(root_block, inbound_range));
|
||||
}
|
||||
let total = meta
|
||||
.filesize
|
||||
.or_else(|| meta.declared_total())
|
||||
.ok_or_else(|| {
|
||||
ApiError::Upstream(
|
||||
"dag-pb root has no filesize and no declared blocksizes — can't serve".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Open (or adopt) the materialisation this fetch fills. From here the leaves
|
||||
// this walk pulls are written into `tmp/<cid>/` and never into `blocks.redb`
|
||||
// — the whole point of Phase 4. `None` for a file small enough that a
|
||||
// container costs more than the copy it saves, which then behaves exactly as
|
||||
// before. The job lookup comes first so the display-name resolution (a record
|
||||
// fetch) happens once per fetch rather than once per range.
|
||||
let job = match state.ingress.job(cid) {
|
||||
Some(j) => {
|
||||
j.touch();
|
||||
Some(j)
|
||||
}
|
||||
None => {
|
||||
let name = seed_display_name(state, cid).await;
|
||||
match state.ingress.begin(cid, cid, &name, total).await {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
// Materialising is an optimisation; serving is not. Fall back
|
||||
// to the pre-Phase-4 path rather than failing the request.
|
||||
warn!(cid = %cid, error = %format!("{e:#}"),
|
||||
"ingress: could not open a materialisation; leaves stay material");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if player {
|
||||
if let Some(j) = job.as_ref() {
|
||||
if j.commit() {
|
||||
debug!(cid = %cid, total,
|
||||
"genuine play — ipfs materialisation committed to a full fill");
|
||||
}
|
||||
}
|
||||
}
|
||||
let ingress_ctx = job
|
||||
.as_ref()
|
||||
.map(|j| crate::ingress::IngressCtx { job: Arc::clone(j), base: 0 });
|
||||
|
||||
// 3. Parse inbound Range (if any) against the file's total size.
|
||||
let ceiling = crate::api::ipfs_walker::max_body_bytes();
|
||||
let (start, end, partial) = match crate::api::range::parse_range_header(inbound_range, total) {
|
||||
crate::api::range::RangeOutcome::Ok { start, end } => (start, end, true),
|
||||
crate::api::range::RangeOutcome::Unsatisfiable => {
|
||||
let mut headers = HeaderMap::new();
|
||||
let cr = format!("bytes */{total}");
|
||||
headers.insert(
|
||||
header::CONTENT_RANGE,
|
||||
HeaderValue::from_str(&cr).expect("ascii content-range"),
|
||||
);
|
||||
return Ok(
|
||||
(StatusCode::RANGE_NOT_SATISFIABLE, headers, Body::empty()).into_response(),
|
||||
);
|
||||
}
|
||||
crate::api::range::RangeOutcome::Absent | crate::api::range::RangeOutcome::Unsupported => {
|
||||
// Whole file. Walker will fetch every leaf but only the
|
||||
// bytes go into the response stream — no extra
|
||||
// concatenation step.
|
||||
(0u64, total.saturating_sub(1), false)
|
||||
}
|
||||
};
|
||||
|
||||
// Reject the request when the requested range (or whole file) is
|
||||
// larger than the in-memory chunking ceiling. Done before any
|
||||
// expensive child fetches.
|
||||
let requested = end - start + 1;
|
||||
if requested as usize > ceiling {
|
||||
return Err(ApiError::Upstream(format!(
|
||||
"dag-pb requested {requested} bytes exceeds {ceiling}-byte ceiling \
|
||||
(META_SHARE_IPFS_MAX_BYTES)"
|
||||
)));
|
||||
}
|
||||
|
||||
// 4. Walk only the leaves intersecting [start, end].
|
||||
let chunks = crate::api::ipfs_walker::walk_range(
|
||||
state,
|
||||
&root_block,
|
||||
start,
|
||||
end,
|
||||
ceiling,
|
||||
lane,
|
||||
ingress_ctx.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
debug!(cid = %cid, error = %e, "dag-pb walk_range failed");
|
||||
ApiError::Upstream(format!("dag-pb walk: {e:#}"))
|
||||
})?;
|
||||
|
||||
// We now hold (and bitswap-serve to cohort peers) the leaf blocks
|
||||
// covering this request — i.e. this peer is seeding the file. Record it
|
||||
// so it appears in Settings → Seeded files. Idempotent; ranged previews
|
||||
// seed the pieces they touched, matching the torrent tier's model. Only
|
||||
// multi-block dag-pb files reach here — single raw blocks (posters,
|
||||
// thumbnails) are deliberately not auto-recorded to keep the list to
|
||||
// real files.
|
||||
//
|
||||
// **Only when the leaves stayed material.** With a job open the bytes are in
|
||||
// a `tmp/` file that no `rename(2)` has published yet: they are servable
|
||||
// (design §2a, through the in-flight set) but they are not a *seed*, and a row
|
||||
// here would bind a container that does not exist and survive a restart that
|
||||
// drops the job — the dangling shape that makes this peer a black hole.
|
||||
// `crate::ingress_commit` records the row at promotion, where the file is real.
|
||||
if job.is_none() {
|
||||
crate::api::record_ipfs_seed(state, cid, &seed_display_name(state, cid).await, total, None).await;
|
||||
}
|
||||
|
||||
// Egress meter. Distinct from the inbound charge in `ipfs_walker::get_block`:
|
||||
// that paid for pulling the blocks off the swarm, this pays for pushing them
|
||||
// back out. Applied by the hull when it relays this body (`HDR_METER`).
|
||||
let body = Body::from_stream(crate::api::ipfs_walker::chunks_to_stream(chunks));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HDR_METER, HeaderValue::from_static(METER_EGRESS));
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCEPT_RANGES,
|
||||
HeaderValue::from_static("bytes"),
|
||||
);
|
||||
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(requested));
|
||||
if partial {
|
||||
let cr = format!("bytes {start}-{end}/{total}");
|
||||
headers.insert(
|
||||
header::CONTENT_RANGE,
|
||||
HeaderValue::from_str(&cr).expect("ascii content-range"),
|
||||
);
|
||||
Ok((StatusCode::PARTIAL_CONTENT, headers, body).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::OK, headers, body).into_response())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The bitswap byte path, plus the few helpers it shared with meta-share's
|
||||
//! other byte tiers.
|
||||
|
||||
pub mod bitswap;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::Response;
|
||||
|
||||
use crate::api::range::{parse_range_header, RangeOutcome};
|
||||
|
||||
/// Marks a request from meta-watch's byte proxy for a demuxer source.
|
||||
pub const PLAYER_HEADER: &str = "x-metamesh-player";
|
||||
|
||||
/// Single bitswap block fetch budget (`META_SHARE_BITSWAP_RAW_TIMEOUT_SECS`).
|
||||
const BITSWAP_RAW_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn raw_timeout() -> Duration {
|
||||
std::env::var("META_SHARE_BITSWAP_RAW_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(BITSWAP_RAW_TIMEOUT)
|
||||
}
|
||||
|
||||
/// An in-memory block as a range response: `206` slice, `416`, or `200`.
|
||||
pub fn bytes_to_response(bytes: Vec<u8>, inbound_range: Option<&HeaderValue>) -> Response {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::response::IntoResponse;
|
||||
let total = bytes.len() as u64;
|
||||
match parse_range_header(inbound_range, total) {
|
||||
RangeOutcome::Ok { start, end } => {
|
||||
let s = start as usize;
|
||||
let e = end as usize;
|
||||
let body = bytes[s..=e].to_vec();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
|
||||
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len() as u64));
|
||||
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
let cr = format!("bytes {start}-{end}/{total}");
|
||||
headers.insert(header::CONTENT_RANGE, HeaderValue::from_str(&cr).expect("ascii content-range"));
|
||||
(StatusCode::PARTIAL_CONTENT, headers, Body::from(body)).into_response()
|
||||
}
|
||||
RangeOutcome::Unsatisfiable => {
|
||||
let mut headers = HeaderMap::new();
|
||||
let cr = format!("bytes */{total}");
|
||||
headers.insert(header::CONTENT_RANGE, HeaderValue::from_str(&cr).expect("ascii content-range"));
|
||||
(StatusCode::RANGE_NOT_SATISFIABLE, headers, Body::empty()).into_response()
|
||||
}
|
||||
RangeOutcome::Absent | RangeOutcome::Unsupported => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
|
||||
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(total));
|
||||
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
(StatusCode::OK, headers, Body::from(bytes)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! `GET /ipfs/{cid}` — IPFS HTTP gateway endpoint (M13).
|
||||
//!
|
||||
//! Resolves an IPFS-compatible cid by:
|
||||
//!
|
||||
//! 1. **Codec dispatch.** Raw cids (codec `0x55`) resolve to a single
|
||||
//! block; dag-pb cids (codec `0x70`) carry a UnixFS file and need
|
||||
//! recursive child fetches.
|
||||
//! 2. **Block fetch via bitswap.** Each block is requested through the
|
||||
//! swarm task via [`crate::swarm::bitswap_get_block`]. The gateway
|
||||
//! tier is the seed; sibling consumers that cached the block on a
|
||||
//! previous fetch also answer.
|
||||
//! 3. **Local cache short-circuit.** Before issuing a bitswap fetch,
|
||||
//! we check `AppState.block_store` directly — a repeat hit on the
|
||||
//! same cid never goes back over the wire.
|
||||
//! 4. **Reassembly.** For dag-pb cids, child cids are extracted from
|
||||
//! the PBNode `Links` field (tag 2) in wire order, fetched in
|
||||
//! parallel (bounded fan-out), and concatenated. Internal nodes
|
||||
//! recurse; raw leaves contribute their body bytes verbatim.
|
||||
//!
|
||||
//! ## Codecs we don't handle
|
||||
//!
|
||||
//! Midhash cids (codec `0x1000`, multibase `bagacb…`) deliberately
|
||||
//! fall through with `400 Bad Request`. They aren't IPFS-spec — the
|
||||
//! caller should use the legacy `/api/peer/{peer}/file/{cid}/raw`
|
||||
//! cross-peer proxy for those instead. The UI's `isIpfsCompatibleCid`
|
||||
//! check already routes correctly; this endpoint enforces the
|
||||
//! invariant server-side.
|
||||
//!
|
||||
//! ## Content-Type
|
||||
//!
|
||||
//! IPFS doesn't carry mime types. The endpoint accepts `?type=<mime>`
|
||||
//! as an explicit hint from the caller (the UI passes the search
|
||||
//! hit's `content_type` field). When the hint is absent or empty we
|
||||
//! fall back to `application/octet-stream` — the browser still
|
||||
//! renders images, audio, and video from that for `<img>` / `<audio>`
|
||||
//! / `<video>` tags, but won't display HTML / PDF inline.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::api::ipfs_walker::{
|
||||
assemble_dagpb_file, get_block, max_body_bytes, DAGPB_CODEC, RAW_CODEC,
|
||||
};
|
||||
use crate::blockstore::MsCid;
|
||||
|
||||
/// Query-string parameters.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct IpfsParams {
|
||||
/// Optional `Content-Type` hint. The UI passes the search hit's
|
||||
/// declared `content_type` here so `<img>` tags render correctly.
|
||||
/// Empty / absent → `application/octet-stream`.
|
||||
#[serde(default, rename = "type")]
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /ipfs/:cid` handler.
|
||||
pub async fn get_ipfs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(cid_str): Path<String>,
|
||||
Query(params): Query<IpfsParams>,
|
||||
) -> Response {
|
||||
let cid: MsCid = match MsCid::from_str(&cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!(cid = %cid_str, error = %e, "ipfs: cid parse failed");
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid cid `{cid_str}`: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Codec gate: only raw + dag-pb. Midhash records have their own
|
||||
// path (`/api/peer/{peer}/file/{cid}/raw`); rejecting them here
|
||||
// makes the UI's codec dispatch authoritative.
|
||||
let codec = cid.codec();
|
||||
match codec {
|
||||
RAW_CODEC | DAGPB_CODEC => {}
|
||||
other => {
|
||||
debug!(cid = %cid_str, codec = format!("0x{:x}", other), "ipfs: unsupported codec");
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("cid codec 0x{other:x} not supported by /ipfs (raw=0x55, dag-pb=0x70 only)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The public IPFS gateway is, by definition, someone else pulling bytes out
|
||||
// of this peer. It gets the focus lane of whatever title the cid belongs to —
|
||||
// which, while our own viewer is watching something else, means the background
|
||||
// floor. Serving a *focused* cid stays full-speed (the viewer may be the one
|
||||
// asking).
|
||||
let lane = state.focus.lane(&cid_str);
|
||||
|
||||
let max_bytes = max_body_bytes();
|
||||
let body = match fetch_ipfs_file(&state, &cid, max_bytes, lane).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(cid = %cid_str, error = %e, "ipfs: fetch failed");
|
||||
return error_response(StatusCode::BAD_GATEWAY, format!("fetch `{cid_str}`: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let content_type = params
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
debug!(
|
||||
cid = %cid_str,
|
||||
codec = format!("0x{:x}", codec),
|
||||
bytes = body.len(),
|
||||
content_type = %content_type,
|
||||
"ipfs: served"
|
||||
);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_LENGTH, body.len().to_string())
|
||||
// Immutable cache: cids are content-addressed, so the bytes
|
||||
// for a given cid never change. Browsers cache aggressively.
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400, immutable")
|
||||
.body(Body::from(body))
|
||||
.expect("static response builder")
|
||||
}
|
||||
|
||||
/// Resolve `cid` to file bytes. For raw codec, one bitswap fetch.
|
||||
/// For dag-pb, recursive UnixFS traversal.
|
||||
async fn fetch_ipfs_file(
|
||||
state: &AppState,
|
||||
cid: &MsCid,
|
||||
max_bytes: usize,
|
||||
lane: crate::focus::Lane,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let codec = cid.codec();
|
||||
let block = get_block(state, cid, lane).await?;
|
||||
match codec {
|
||||
RAW_CODEC => {
|
||||
if block.len() > max_bytes {
|
||||
anyhow::bail!(
|
||||
"raw block exceeds {max_bytes}-byte ceiling ({} bytes)",
|
||||
block.len()
|
||||
);
|
||||
}
|
||||
Ok(block)
|
||||
}
|
||||
DAGPB_CODEC => assemble_dagpb_file(state, &block, max_bytes, lane).await,
|
||||
other => anyhow::bail!("unsupported codec 0x{other:x}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, msg: String) -> Response {
|
||||
(status, axum::Json(serde_json::json!({ "error": msg }))).into_response()
|
||||
}
|
||||
|
||||
|
||||
// PBNode-parsing tests live in `super::ipfs_walker::tests` — that's where
|
||||
// the parser actually lives. This handler is just a codec-dispatch shell.
|
||||
@@ -0,0 +1,636 @@
|
||||
//! Shared dag-pb / UnixFS walker.
|
||||
//!
|
||||
//! Originally lived inside `super::ipfs_gateway` next to the
|
||||
//! `/ipfs/{cid}` handler. Lifted out here so the canonical byte-fetch
|
||||
//! endpoint (`super::files::get_file_raw`) can also assemble (and
|
||||
//! partial-fetch) multi-block dag-pb files — needed for HTTP `Range:`
|
||||
//! support on gateway-seeded videos. The `/ipfs/{cid}` route still
|
||||
//! delegates here for back-compat (see [`assemble_dagpb_file`]).
|
||||
//!
|
||||
//! ## Capabilities exposed
|
||||
//!
|
||||
//! - [`parse_pbnode`] — given a PBNode block, return the link cids
|
||||
//! *with their blocksizes* plus the UnixFS `filesize`. Newer surface
|
||||
//! than the original `parse_pbnode_links`: that one only returned the
|
||||
//! cids, which was enough for whole-file assembly but not for range
|
||||
//! slicing (we need offsets to know which children to fetch).
|
||||
//! - [`walk_range`] — yield a stream of `Bytes` chunks that together
|
||||
//! cover `[start, end]` of the underlying UnixFS file. Fetches only
|
||||
//! the leaf blocks that intersect the range; slices the boundary
|
||||
//! leaves. Recurses into internal dag-pb children for balanced-tree
|
||||
//! files.
|
||||
//! - [`assemble_dagpb_file`] — back-compat shim. Equivalent to
|
||||
//! `walk_range(0, filesize-1)` collected into one `Vec<u8>`. Used by
|
||||
//! the legacy `/ipfs/{cid}` handler.
|
||||
//! - [`get_block`] — single-block fetch (cache hit → bitswap).
|
||||
//! - Codec constants [`RAW_CODEC`] / [`DAGPB_CODEC`] and the byte
|
||||
//! ceiling helper [`max_body_bytes`].
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use blockstore::Blockstore;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{self, Stream, StreamExt, TryStreamExt};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::blockstore::MsCid;
|
||||
use crate::focus::Lane;
|
||||
use crate::swarm::bitswap_get_block;
|
||||
|
||||
pub const RAW_CODEC: u64 = 0x55;
|
||||
pub const DAGPB_CODEC: u64 = 0x70;
|
||||
|
||||
/// Per-block timeout for a single bitswap fetch during dag-pb walks.
|
||||
pub const BITSWAP_BLOCK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Concurrency cap for parallel child-block fetches.
|
||||
pub const PARALLEL_FETCH: usize = 8;
|
||||
|
||||
/// Hard ceiling on the assembled body size for one walked file.
|
||||
/// Aborts the response rather than streaming an unbounded payload —
|
||||
/// bitswap doesn't know the final UnixFS file's size until it walks the
|
||||
/// tree, so a malicious cid could otherwise exhaust memory. Default
|
||||
/// 256 MiB; override via `META_SHARE_IPFS_MAX_BYTES`.
|
||||
pub fn max_body_bytes() -> usize {
|
||||
std::env::var("META_SHARE_IPFS_MAX_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(256 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// Parsed metadata for a dag-pb PBNode that wraps a UnixFS File.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PbNodeMeta {
|
||||
/// Link cids paired with their declared blocksizes (UnixFS Data
|
||||
/// tag 4, one per link in link order). When the encoder doesn't
|
||||
/// write blocksizes, the per-link size is `None` — the walker
|
||||
/// can't range-slice in that case and the caller has to fall back
|
||||
/// to full assembly.
|
||||
pub children: Vec<(MsCid, Option<u64>)>,
|
||||
/// UnixFS Data tag 3 — the file's total payload size. Optional
|
||||
/// because some PBNode shapes (directories, headerless raw wraps)
|
||||
/// don't carry it.
|
||||
pub filesize: Option<u64>,
|
||||
}
|
||||
|
||||
impl PbNodeMeta {
|
||||
/// Sum of declared blocksizes when every link has one declared.
|
||||
/// Used when `filesize` is absent to derive the same number.
|
||||
pub fn declared_total(&self) -> Option<u64> {
|
||||
let mut total: u64 = 0;
|
||||
for (_, sz) in &self.children {
|
||||
let s = (*sz)?;
|
||||
total = total.checked_add(s)?;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PBNode block. Generalises the original `parse_pbnode_links`
|
||||
/// to also pull blocksizes (UnixFS Data tag 4) and filesize (Data tag
|
||||
/// 3) out of the inner Data field, zipped against the link order.
|
||||
pub fn parse_pbnode(bytes: &[u8]) -> anyhow::Result<PbNodeMeta> {
|
||||
let mut links: Vec<MsCid> = Vec::new();
|
||||
let mut blocksizes: Vec<u64> = Vec::new();
|
||||
let mut filesize: Option<u64> = None;
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let (tag_wire, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let field = (tag_wire >> 3) as u32;
|
||||
let wire_type = (tag_wire & 0x7) as u8;
|
||||
match (field, wire_type) {
|
||||
// Outer tag 2 = Links (length-delimited PBLink).
|
||||
(2, 2) => {
|
||||
let (link_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let link_end = cursor
|
||||
.checked_add(link_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBLink length overflow"))?;
|
||||
if link_end > bytes.len() {
|
||||
anyhow::bail!("PBLink length runs off end of buffer");
|
||||
}
|
||||
let link_bytes = &bytes[cursor..link_end];
|
||||
cursor = link_end;
|
||||
if let Some(hash) = parse_pblink_hash(link_bytes)? {
|
||||
let cid = MsCid::try_from(hash.as_slice()).map_err(|e| {
|
||||
anyhow::anyhow!("PBLink.Hash isn't a valid cid: {e}")
|
||||
})?;
|
||||
links.push(cid);
|
||||
}
|
||||
}
|
||||
// Outer tag 1 = Data (length-delimited). The Data field
|
||||
// itself encodes a UnixFS protobuf; recurse to pluck out
|
||||
// filesize (tag 3) and blocksizes (tag 4 repeated).
|
||||
(1, 2) => {
|
||||
let (data_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let data_end = cursor
|
||||
.checked_add(data_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBNode Data length overflow"))?;
|
||||
if data_end > bytes.len() {
|
||||
anyhow::bail!("PBNode Data length runs off end of buffer");
|
||||
}
|
||||
let (fsz, sizes) = parse_unixfs_data(&bytes[cursor..data_end])?;
|
||||
if fsz.is_some() {
|
||||
filesize = fsz;
|
||||
}
|
||||
blocksizes = sizes;
|
||||
cursor = data_end;
|
||||
}
|
||||
// Skip unknown / unrecognised fields per proto3 rules.
|
||||
(_, 0) => {
|
||||
let (_, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
}
|
||||
(_, 2) => {
|
||||
let (skip_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
cursor = cursor
|
||||
.checked_add(skip_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown field skip overflow"))?;
|
||||
if cursor > bytes.len() {
|
||||
anyhow::bail!("unknown field length runs off end of buffer");
|
||||
}
|
||||
}
|
||||
(_, 1) => {
|
||||
cursor = cursor
|
||||
.checked_add(8)
|
||||
.ok_or_else(|| anyhow::anyhow!("fixed64 skip overflow"))?;
|
||||
}
|
||||
(_, 5) => {
|
||||
cursor = cursor
|
||||
.checked_add(4)
|
||||
.ok_or_else(|| anyhow::anyhow!("fixed32 skip overflow"))?;
|
||||
}
|
||||
_ => anyhow::bail!("unsupported wire type {wire_type} on field {field}"),
|
||||
}
|
||||
}
|
||||
// Zip links with blocksizes. If counts don't match, we deliberately
|
||||
// pair as many as we can and leave the rest with `None` — the
|
||||
// walker handles that by falling back to whole-block fetches for
|
||||
// those leaves (it can't slice without a known size).
|
||||
let mut children: Vec<(MsCid, Option<u64>)> = Vec::with_capacity(links.len());
|
||||
for (i, cid) in links.into_iter().enumerate() {
|
||||
let sz = blocksizes.get(i).copied();
|
||||
children.push((cid, sz));
|
||||
}
|
||||
Ok(PbNodeMeta { children, filesize })
|
||||
}
|
||||
|
||||
/// Parse the inner UnixFS Data protobuf message. Returns `(filesize,
|
||||
/// blocksizes)`. Both can be empty / `None` for headerless or
|
||||
/// directory-shaped Data.
|
||||
fn parse_unixfs_data(bytes: &[u8]) -> anyhow::Result<(Option<u64>, Vec<u64>)> {
|
||||
let mut filesize: Option<u64> = None;
|
||||
let mut blocksizes: Vec<u64> = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let (tag_wire, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let field = (tag_wire >> 3) as u32;
|
||||
let wire_type = (tag_wire & 0x7) as u8;
|
||||
match (field, wire_type) {
|
||||
// UnixFS tag 3 = filesize (varint).
|
||||
(3, 0) => {
|
||||
let (v, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
filesize = Some(v);
|
||||
}
|
||||
// UnixFS tag 4 = blocksizes (varint, repeated).
|
||||
(4, 0) => {
|
||||
let (v, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
blocksizes.push(v);
|
||||
}
|
||||
// Everything else (Type, inline Data, hashType, fanout) is
|
||||
// either irrelevant to slicing or a future-proofing tag we
|
||||
// skip.
|
||||
(_, 0) => {
|
||||
let (_, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
}
|
||||
(_, 2) => {
|
||||
let (skip_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
cursor = cursor
|
||||
.checked_add(skip_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("UnixFS field skip overflow"))?;
|
||||
if cursor > bytes.len() {
|
||||
anyhow::bail!("UnixFS field length runs off end of buffer");
|
||||
}
|
||||
}
|
||||
(_, 1) => cursor = cursor.checked_add(8).unwrap_or(usize::MAX),
|
||||
(_, 5) => cursor = cursor.checked_add(4).unwrap_or(usize::MAX),
|
||||
_ => anyhow::bail!("unsupported UnixFS Data wire type {wire_type} on field {field}"),
|
||||
}
|
||||
}
|
||||
Ok((filesize, blocksizes))
|
||||
}
|
||||
|
||||
/// Inside a PBLink, extract `Hash` (tag 1). Returns `Ok(None)` if the
|
||||
/// link is malformed in a tolerable way (no Hash field) — the link is
|
||||
/// then skipped at the caller.
|
||||
fn parse_pblink_hash(bytes: &[u8]) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let (tag_wire, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let field = (tag_wire >> 3) as u32;
|
||||
let wire_type = (tag_wire & 0x7) as u8;
|
||||
match (field, wire_type) {
|
||||
(1, 2) => {
|
||||
let (len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let end = cursor
|
||||
.checked_add(len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBLink.Hash length overflow"))?;
|
||||
if end > bytes.len() {
|
||||
anyhow::bail!("PBLink.Hash runs off end of buffer");
|
||||
}
|
||||
return Ok(Some(bytes[cursor..end].to_vec()));
|
||||
}
|
||||
(_, 0) => {
|
||||
let (_, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
}
|
||||
(_, 2) => {
|
||||
let (skip_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
cursor = cursor
|
||||
.checked_add(skip_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBLink field skip overflow"))?;
|
||||
if cursor > bytes.len() {
|
||||
anyhow::bail!("PBLink field length runs off end of buffer");
|
||||
}
|
||||
}
|
||||
(_, 1) => cursor = cursor.checked_add(8).unwrap_or(usize::MAX),
|
||||
(_, 5) => cursor = cursor.checked_add(4).unwrap_or(usize::MAX),
|
||||
_ => anyhow::bail!("unsupported PBLink wire type {wire_type} on field {field}"),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Decode one LEB128 unsigned varint. Returns `(value, bytes_consumed)`.
|
||||
fn read_varint(bytes: &[u8]) -> anyhow::Result<(u64, usize)> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift: u32 = 0;
|
||||
let mut n: usize = 0;
|
||||
for &b in bytes {
|
||||
n += 1;
|
||||
value |= ((b & 0x7F) as u64) << shift;
|
||||
if b & 0x80 == 0 {
|
||||
return Ok((value, n));
|
||||
}
|
||||
shift += 7;
|
||||
if shift >= 64 {
|
||||
anyhow::bail!("varint overflow (more than 10 bytes)");
|
||||
}
|
||||
}
|
||||
anyhow::bail!("varint truncated")
|
||||
}
|
||||
|
||||
/// Fetch one block by cid. Cache short-circuit first; on miss, issues
|
||||
/// a bitswap fetch and (because the bitswap behaviour shares the same
|
||||
/// store) implicitly caches the result for future requests.
|
||||
///
|
||||
/// This is the single point at which an IPFS file byte enters the process, so it
|
||||
/// is where the playback focus meters **inbound** bitswap: a block fetched for a
|
||||
/// title nobody is watching is charged against the background rate
|
||||
/// ([`FocusGate::throttle_in`](crate::focus::FocusGate::throttle_in)) while a
|
||||
/// title is playing. `lane` is resolved once per request from the *requested* cid
|
||||
/// and threaded down — never re-derived here, because a dag-pb **leaf** cid isn't
|
||||
/// a sibling of the record and wouldn't resolve.
|
||||
///
|
||||
/// A blockstore hit is free: it moved no bytes over the wire, so it isn't charged.
|
||||
pub async fn get_block(state: &AppState, cid: &MsCid, lane: Lane) -> anyhow::Result<Vec<u8>> {
|
||||
if let Some(bytes) = blockstore_get(state.block_store.as_ref(), cid).await? {
|
||||
debug!(%cid, "ipfs walker: blockstore cache hit");
|
||||
return Ok(bytes);
|
||||
}
|
||||
debug!(%cid, "ipfs walker: cache miss, issuing bitswap fetch");
|
||||
let bytes = bitswap_get_block(&state.swarm_tx, cid.clone(), BITSWAP_BLOCK_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
debug!(%cid, bytes = bytes.len(), "ipfs walker: bitswap fetch returned");
|
||||
state.focus.throttle_in(lane, bytes.len() as u64).await;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn blockstore_get<B: Blockstore>(
|
||||
store: &B,
|
||||
cid: &MsCid,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
store
|
||||
.get(cid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blockstore get: {e}"))
|
||||
}
|
||||
|
||||
/// Back-compat: assemble the whole UnixFS file by walking every leaf in
|
||||
/// link order. Equivalent to `walk_range(0, filesize-1)` collected into
|
||||
/// a single `Vec<u8>`. Used by the legacy `/ipfs/{cid}` handler.
|
||||
///
|
||||
/// Note: this still builds a single Vec in memory. The streaming entry
|
||||
/// point for callers who can consume chunks is [`walk_range`].
|
||||
pub fn assemble_dagpb_file<'a>(
|
||||
state: &'a AppState,
|
||||
root_block: &'a [u8],
|
||||
max_bytes: usize,
|
||||
lane: Lane,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<Vec<u8>>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let meta = parse_pbnode(root_block)?;
|
||||
if meta.children.is_empty() {
|
||||
// No links — return the raw block bytes; caller decides how
|
||||
// to render application/octet-stream.
|
||||
return Ok(root_block.to_vec());
|
||||
}
|
||||
// Parallel-fetch every child whole (legacy semantics).
|
||||
let children = stream::iter(meta.children.into_iter().map(|(child_cid, _sz)| async move {
|
||||
let codec = child_cid.codec();
|
||||
let block = get_block(state, &child_cid, lane).await?;
|
||||
match codec {
|
||||
RAW_CODEC => Ok::<Vec<u8>, anyhow::Error>(block),
|
||||
DAGPB_CODEC => assemble_dagpb_file(state, &block, max_bytes, lane).await,
|
||||
other => anyhow::bail!("unsupported child codec 0x{other:x}"),
|
||||
}
|
||||
}))
|
||||
.buffered(PARALLEL_FETCH)
|
||||
.try_collect::<Vec<Vec<u8>>>()
|
||||
.await?;
|
||||
let total: usize = children.iter().map(Vec::len).sum();
|
||||
if total > max_bytes {
|
||||
anyhow::bail!(
|
||||
"assembled UnixFS file would be {total} bytes — exceeds {max_bytes}-byte ceiling"
|
||||
);
|
||||
}
|
||||
let mut out = Vec::with_capacity(total);
|
||||
for chunk in children {
|
||||
out.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk a dag-pb tree yielding only the bytes inside `[start, end]`
|
||||
/// (inclusive). Returns the chunks as a `Vec<Bytes>` so the caller can
|
||||
/// hand them to `axum::body::Body::from_stream(stream::iter(...))`
|
||||
/// without re-concatenating into a single buffer.
|
||||
///
|
||||
/// Skips leaves wholly outside `[start, end]` — no bitswap fetch for
|
||||
/// them — and slices the boundary leaves to align. Recurses into
|
||||
/// internal dag-pb children with the sub-range translated into the
|
||||
/// child's offset space.
|
||||
///
|
||||
/// `max_bytes` is the cap on the resulting body size — i.e. the
|
||||
/// requested range size, NOT the whole file. So a 4 GiB file with
|
||||
/// `start=0, end=65535` succeeds even when the whole file would have
|
||||
/// hit the ceiling.
|
||||
///
|
||||
/// A leaf without a declared blocksize forces a fall-back fetch + size
|
||||
/// inspection. Errors when a child codec is neither raw nor dag-pb.
|
||||
/// `ingress` carries the in-flight materialisation this walk is filling, if any
|
||||
/// (`crate::ingress`). It is what turns a walk into a *download*: every raw leaf
|
||||
/// is registered with its **file-absolute** offset before its WANT goes out, so
|
||||
/// the block that comes back can be written straight into the file instead of
|
||||
/// into `blocks.redb`. `None` walks behave exactly as they did before Phase 4.
|
||||
pub fn walk_range<'a>(
|
||||
state: &'a Arc<AppState>,
|
||||
root_block: &'a [u8],
|
||||
start: u64,
|
||||
end: u64,
|
||||
max_bytes: usize,
|
||||
lane: Lane,
|
||||
ingress: Option<&'a crate::ingress::IngressCtx>,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = anyhow::Result<Vec<Bytes>>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
if start > end {
|
||||
anyhow::bail!("walk_range: empty range start={start} end={end}");
|
||||
}
|
||||
let requested = end - start + 1;
|
||||
if requested as usize > max_bytes {
|
||||
anyhow::bail!(
|
||||
"walk_range: requested {requested} bytes exceeds {max_bytes}-byte ceiling"
|
||||
);
|
||||
}
|
||||
let meta = parse_pbnode(root_block)?;
|
||||
if meta.children.is_empty() {
|
||||
// No links — root IS the data. Slice it.
|
||||
let total = root_block.len() as u64;
|
||||
if end >= total {
|
||||
anyhow::bail!("walk_range: end={end} past root block size={total}");
|
||||
}
|
||||
let s = start as usize;
|
||||
let e = end as usize;
|
||||
return Ok(vec![Bytes::copy_from_slice(&root_block[s..=e])]);
|
||||
}
|
||||
|
||||
let mut out: Vec<Bytes> = Vec::new();
|
||||
// Cumulative offset of the current child in the file's byte space.
|
||||
let mut cursor: u64 = 0;
|
||||
for (child_cid, child_size_opt) in meta.children {
|
||||
// Without a declared blocksize we have to fetch the child
|
||||
// to learn its size — that defeats the purpose of range
|
||||
// walking but is correct for headerless blocks. We bound
|
||||
// the cost with the recursion + max_bytes ceiling.
|
||||
let (child_block, child_size) = match child_size_opt {
|
||||
Some(sz) => (None, sz),
|
||||
None => {
|
||||
let b = get_block(state, &child_cid, lane).await?;
|
||||
let sz = b.len() as u64;
|
||||
(Some(b), sz)
|
||||
}
|
||||
};
|
||||
let child_start = cursor;
|
||||
let child_end = cursor.saturating_add(child_size);
|
||||
cursor = child_end;
|
||||
// Skip children wholly before `[start, end]`.
|
||||
if child_end <= start {
|
||||
continue;
|
||||
}
|
||||
// Stop once we're past the requested range.
|
||||
if child_start > end {
|
||||
break;
|
||||
}
|
||||
// Range inside the child's local byte space:
|
||||
// overlap of [start, end] with [child_start, child_end-1].
|
||||
let overlap_lo = start.max(child_start);
|
||||
let overlap_hi = end.min(child_end - 1);
|
||||
let local_lo = overlap_lo - child_start;
|
||||
let local_hi = overlap_hi - child_start;
|
||||
|
||||
let codec = child_cid.codec();
|
||||
match codec {
|
||||
RAW_CODEC => {
|
||||
// Claim the leaf *before* the fetch: the block may come back
|
||||
// on another task, and an unclaimed arrival is indistinguishable
|
||||
// from an unsolicited one (which we drop). `base` makes the
|
||||
// offset file-absolute through however many dag levels deep
|
||||
// this leaf sits.
|
||||
if let Some(ctx) = ingress {
|
||||
state.ingress.expect_leaf(
|
||||
ctx,
|
||||
child_cid.to_bytes(),
|
||||
ctx.base.saturating_add(child_start),
|
||||
child_size,
|
||||
);
|
||||
}
|
||||
let block = match child_block {
|
||||
Some(b) => b,
|
||||
None => get_block(state, &child_cid, lane).await?,
|
||||
};
|
||||
if (block.len() as u64) < child_size {
|
||||
anyhow::bail!(
|
||||
"leaf block shorter than declared blocksize ({} < {})",
|
||||
block.len(),
|
||||
child_size
|
||||
);
|
||||
}
|
||||
let s = local_lo as usize;
|
||||
let e = local_hi as usize;
|
||||
out.push(Bytes::copy_from_slice(&block[s..=e]));
|
||||
}
|
||||
DAGPB_CODEC => {
|
||||
let block = match child_block {
|
||||
Some(b) => b,
|
||||
None => get_block(state, &child_cid, lane).await?,
|
||||
};
|
||||
// Recurse with the sub-range translated into the
|
||||
// child's own offset space — and the ingress base translated
|
||||
// the opposite way, so leaves keep reporting where they land
|
||||
// in the file rather than in this subtree.
|
||||
let nested_ctx = ingress.map(|c| c.nested(child_start));
|
||||
let nested = walk_range(
|
||||
state,
|
||||
&block,
|
||||
local_lo,
|
||||
local_hi,
|
||||
max_bytes,
|
||||
lane,
|
||||
nested_ctx.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
out.extend(nested);
|
||||
}
|
||||
other => anyhow::bail!("unsupported child codec 0x{other:x}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Adapter: convert a `Vec<Bytes>` chunk list into a `Stream` that
|
||||
/// `axum::body::Body::from_stream` accepts. The stream yields
|
||||
/// `Result<Bytes, std::io::Error>` per axum's body-stream bound.
|
||||
pub fn chunks_to_stream(
|
||||
chunks: Vec<Bytes>,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
|
||||
stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn cid_wire(codec: u8, digest: &[u8; 32]) -> Vec<u8> {
|
||||
let mut w = Vec::with_capacity(36);
|
||||
w.push(0x01);
|
||||
w.push(codec);
|
||||
w.push(0x12);
|
||||
w.push(0x20);
|
||||
w.extend_from_slice(digest);
|
||||
w
|
||||
}
|
||||
fn write_varint(value: u64, out: &mut Vec<u8>) {
|
||||
let mut v = value;
|
||||
while v >= 0x80 {
|
||||
out.push((v as u8) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.push(v as u8);
|
||||
}
|
||||
fn write_varint_field(field: u32, value: u64, out: &mut Vec<u8>) {
|
||||
write_varint((field as u64) << 3, out);
|
||||
write_varint(value, out);
|
||||
}
|
||||
fn write_bytes_field(field: u32, value: &[u8], out: &mut Vec<u8>) {
|
||||
write_varint(((field as u64) << 3) | 2, out);
|
||||
write_varint(value.len() as u64, out);
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
|
||||
/// Build a 2-leaf dag-pb root encoding `chunk_a || chunk_b` exactly
|
||||
/// the way the meta-gateway encoder does.
|
||||
fn make_root(chunk_a: &[u8], chunk_b: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let cid_a = cid_wire(0x55, &Sha256::digest(chunk_a).into());
|
||||
let cid_b = cid_wire(0x55, &Sha256::digest(chunk_b).into());
|
||||
|
||||
let mut link_a = Vec::new();
|
||||
write_bytes_field(1, &cid_a, &mut link_a);
|
||||
write_varint_field(3, chunk_a.len() as u64, &mut link_a);
|
||||
let mut link_b = Vec::new();
|
||||
write_bytes_field(1, &cid_b, &mut link_b);
|
||||
write_varint_field(3, chunk_b.len() as u64, &mut link_b);
|
||||
|
||||
let mut unixfs_data = Vec::new();
|
||||
write_varint_field(1, 2, &mut unixfs_data); // Type=File
|
||||
write_varint_field(3, (chunk_a.len() + chunk_b.len()) as u64, &mut unixfs_data);
|
||||
write_varint_field(4, chunk_a.len() as u64, &mut unixfs_data);
|
||||
write_varint_field(4, chunk_b.len() as u64, &mut unixfs_data);
|
||||
|
||||
let mut pbnode = Vec::new();
|
||||
write_bytes_field(2, &link_a, &mut pbnode);
|
||||
write_bytes_field(2, &link_b, &mut pbnode);
|
||||
write_bytes_field(1, &unixfs_data, &mut pbnode);
|
||||
(pbnode, cid_a, cid_b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pbnode_emits_links_with_blocksizes_and_filesize() {
|
||||
let chunk_a: &[u8] = b"AAAA";
|
||||
let chunk_b: &[u8] = b"BBBBB";
|
||||
let (root, _ca, _cb) = make_root(chunk_a, chunk_b);
|
||||
let meta = parse_pbnode(&root).expect("parse");
|
||||
assert_eq!(meta.filesize, Some(9));
|
||||
assert_eq!(meta.children.len(), 2);
|
||||
assert_eq!(meta.children[0].1, Some(4));
|
||||
assert_eq!(meta.children[1].1, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pbnode_links_empty_when_no_links() {
|
||||
let mut data = Vec::new();
|
||||
write_varint_field(1, 2, &mut data); // Type=File
|
||||
let mut pbnode = Vec::new();
|
||||
write_bytes_field(1, &data, &mut pbnode);
|
||||
let meta = parse_pbnode(&pbnode).expect("parse");
|
||||
assert!(meta.children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pbnode_truncated_returns_err() {
|
||||
let mut bytes = Vec::new();
|
||||
// tag 2 wire-type 2, length 100, but only 5 bytes follow.
|
||||
bytes.push((2u8 << 3) | 2);
|
||||
bytes.push(100);
|
||||
bytes.extend_from_slice(b"short");
|
||||
assert!(parse_pbnode(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_total_matches_filesize() {
|
||||
let chunk_a: &[u8] = b"AAAA";
|
||||
let chunk_b: &[u8] = b"BBBBB";
|
||||
let (root, _, _) = make_root(chunk_a, chunk_b);
|
||||
let meta = parse_pbnode(&root).expect("parse");
|
||||
assert_eq!(meta.declared_total(), Some(9));
|
||||
assert_eq!(meta.declared_total(), meta.filesize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The plugin's slice of meta-share's former `api` module: the state the moved
|
||||
//! IPFS code reads (kept under the same names, so it moved unedited), the dag
|
||||
//! walker, the `/ipfs/:cid` gateway handler and the bitswap byte path.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use meta_feeder_sdk::transport::{Event, FocusView, HullClient};
|
||||
use tokio::sync::{mpsc, OnceCell};
|
||||
use tracing::debug;
|
||||
|
||||
pub mod files;
|
||||
pub mod ipfs_gateway;
|
||||
pub mod ipfs_walker;
|
||||
|
||||
pub use meta_feeder_sdk::transport::ApiError;
|
||||
pub mod range {
|
||||
pub use meta_feeder_sdk::transport::range::*;
|
||||
}
|
||||
|
||||
/// What the IPFS tier reaches for — the subset of meta-share's `AppState` it
|
||||
/// used, under the same field names.
|
||||
pub struct AppState {
|
||||
pub block_store: Arc<crate::filestore::SharedBlockstore>,
|
||||
pub ingress: Arc<crate::ingress::IngressRegistry>,
|
||||
pub swarm_tx: mpsc::Sender<crate::swarm::Command>,
|
||||
pub peer_directory: crate::swarm::PeerDirectory,
|
||||
/// The playback focus the hull pushes.
|
||||
pub focus: Arc<FocusView>,
|
||||
pub http: reqwest::Client,
|
||||
/// Callbacks into the hull (seed rows, promoted containers, display names).
|
||||
pub hull: HullClient,
|
||||
pub data_dir: PathBuf,
|
||||
/// For reading library bytes (refs into meta-core files) — byte reads only.
|
||||
pub meta_core_url: Option<String>,
|
||||
pub webdav_url_cache: Arc<OnceCell<String>>,
|
||||
/// Announce seeded cids on the public DHT (`META_SHARE_SEED_DHT_PROVIDE`,
|
||||
/// default = public mode). Decided here, where `PUBLIC_ADDR` is known; the
|
||||
/// hull always asks and this gates.
|
||||
pub seed_dht_provide: bool,
|
||||
}
|
||||
|
||||
/// "This peer now seeds `cid`" — the hull writes the seed row (and decides on
|
||||
/// the DHT announce). Every seed this plugin reports on its own is cache-origin;
|
||||
/// library seeds are recorded by the hull, which drives them.
|
||||
pub async fn record_ipfs_seed(state: &AppState, cid: &str, name: &str, size: u64, container: Option<&str>) {
|
||||
let ev = Event::Seed {
|
||||
cid: cid.to_string(),
|
||||
kind: "ipfs".into(),
|
||||
name: name.to_string(),
|
||||
size_bytes: size,
|
||||
container: container.map(str::to_string),
|
||||
};
|
||||
if let Err(e) = state.hull.emit(&ev).await {
|
||||
debug!(cid, error = %e, "seed notification not delivered");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
//! Consumer-side IPFS blockstore. Shared between the bitswap behaviour
|
||||
//! (M12 — `swarm/mod.rs`) and the `/ipfs/{cid}` HTTP gateway (M13).
|
||||
//!
|
||||
//! When bitswap fetches a block on behalf of `/ipfs/{cid}`, the
|
||||
//! Behaviour's `Blockstore::put_keyed` stores it locally. Future requests
|
||||
//! for the same cid (from this peer OR from another peer that connects
|
||||
//! to us via bitswap) read directly from the store — that's how
|
||||
//! multi-source pulls emerge: the gateway is the seed, every consumer
|
||||
//! that fetched it once becomes a re-seeding candidate.
|
||||
//!
|
||||
//! Lives under `{META_SHARE_DATA}/ipfs/blocks.redb`. Persistent across
|
||||
//! restarts. There is no GC in v1 — operators wipe the file to clear
|
||||
//! the cache. Storage is bounded only by the size of cids the consumer
|
||||
//! has actually fetched; the worst-case footprint is a few hundred MB
|
||||
//! per gigabyte of media fetched through the gateway tier.
|
||||
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use blockstore::{Blockstore, RedbBlockstore};
|
||||
use cid::CidGeneric;
|
||||
use tokio::fs;
|
||||
|
||||
/// Multihash byte ceiling. 64 matches the gateway side; both ends must
|
||||
/// agree because the const parameter feeds into beetswap's
|
||||
/// `Behaviour<const MAX_MULTIHASH_SIZE, B>`.
|
||||
pub const MAX_MULTIHASH_SIZE: usize = 64;
|
||||
|
||||
/// CID type alias matching the blockstore's generic param.
|
||||
pub type MsCid = CidGeneric<MAX_MULTIHASH_SIZE>;
|
||||
|
||||
/// Magic prefix for bincode-encoded `Record` blocks stored in the
|
||||
/// blockstore. Future record-shape changes bump the version (e.g.
|
||||
/// `MSR2`); decoders reject mismatched prefixes loudly so we never
|
||||
/// confuse one shape for another.
|
||||
///
|
||||
/// Lives next to the blockstore because the rest of the module owns
|
||||
/// the on-disk representation; the `Record`-side helpers in `store.rs`
|
||||
/// just consume this constant.
|
||||
pub const RECORD_BLOCK_MAGIC: &[u8; 4] = b"MSR1";
|
||||
|
||||
/// Parse a `Record.cid` into an `MsCid` usable by the blockstore + beetswap.
|
||||
///
|
||||
/// **Strict**: a bare CIDv1 multibase string and nothing else. This is the
|
||||
/// reference parse behaviour, matching `cid.Decode` in
|
||||
/// `packages/meta-core/internal/cid/rank.go`. Two shapes are deliberately
|
||||
/// rejected:
|
||||
///
|
||||
/// - **The `<algo>:<cid>` token form** (`midhash256:bagacb…`). Removed — a
|
||||
/// CIDv1 already names its hash function in the multicodec, so the prefix
|
||||
/// was redundant and let one digest be spelled two ways. This function used
|
||||
/// to strip it, which kept the deprecated shape alive on the wire.
|
||||
/// - **CIDv0** (`Qm…`). `MsCid::from_str` accepts it and reports codec
|
||||
/// dag-pb, so a CIDv0 used to rank 40 here while meta-core ranked it 0 —
|
||||
/// the two disagreed about the top of the ladder. Pinned by the
|
||||
/// `cidv0-rejected` golden vector.
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// redb page-cache ceiling for the blockstore. redb's own default is **1 GiB**
|
||||
/// (`redb::Builder::new` → `set_cache_size(1 << 30)`), which the convenience
|
||||
/// `RedbBlockstore::open` inherits. Under a multi-GB nzb re-seed (thousands of
|
||||
/// block-writes) that cache fills toward 1 GiB of resident B-tree pages and
|
||||
/// OOM-kills a memory-capped peer. 256 MiB keeps write batching healthy while
|
||||
/// bounding the footprint. Override with `META_SHARE_BLOCKSTORE_CACHE_MB`.
|
||||
const DEFAULT_BLOCKSTORE_CACHE_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
fn blockstore_cache_bytes() -> usize {
|
||||
std::env::var("META_SHARE_BLOCKSTORE_CACHE_MB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.map(|mb| mb.saturating_mul(1024 * 1024))
|
||||
.unwrap_or(DEFAULT_BLOCKSTORE_CACHE_BYTES)
|
||||
}
|
||||
|
||||
/// Open the persistent consumer-side blockstore at
|
||||
/// `<data_dir>/ipfs/blocks.redb`. Creates the parent dir on first run.
|
||||
///
|
||||
/// Both bitswap's `Behaviour::new(Arc<Blockstore>)` and the
|
||||
/// `/ipfs/{cid}` HTTP handler take the same `Arc<RedbBlockstore>`
|
||||
/// returned here; redb provides snapshot isolation so concurrent
|
||||
/// reads-while-writes are safe.
|
||||
///
|
||||
/// Opens the underlying `redb::Database` with an explicit, bounded cache (see
|
||||
/// [`DEFAULT_BLOCKSTORE_CACHE_BYTES`]) rather than `RedbBlockstore::open`, whose
|
||||
/// 1 GiB redb default OOMs a memory-capped peer during a large re-seed.
|
||||
pub async fn open_redb_blockstore(data_dir: &Path) -> Result<Arc<RedbBlockstore>> {
|
||||
let ipfs_dir = data_dir.join("ipfs");
|
||||
fs::create_dir_all(&ipfs_dir).await.with_context(|| {
|
||||
format!("create ipfs dir at {}", ipfs_dir.display())
|
||||
})?;
|
||||
let db_path = ipfs_dir.join("blocks.redb");
|
||||
let db_path_disp = db_path.display().to_string();
|
||||
let cache_bytes = blockstore_cache_bytes();
|
||||
let db = tokio::task::spawn_blocking(move || {
|
||||
redb::Database::builder()
|
||||
.set_cache_size(cache_bytes)
|
||||
.create(db_path)
|
||||
})
|
||||
.await
|
||||
.context("join redb open task")?
|
||||
.with_context(|| format!("open redb Database at {db_path_disp}"))?;
|
||||
Ok(Arc::new(RedbBlockstore::new(Arc::new(db))))
|
||||
}
|
||||
|
||||
/// A [`Blockstore`] that charges every block it hands out against the playback
|
||||
/// focus's outbound budget. **Only `beetswap::Behaviour` gets one of these** —
|
||||
/// every local read path keeps the raw `Arc<RedbBlockstore>` from
|
||||
/// [`open_redb_blockstore`], so nothing this peer does for itself can be
|
||||
/// throttled by its own gate.
|
||||
///
|
||||
/// This is the one lever that exists for outbound bitswap: beetswap answers a
|
||||
/// remote WANT by calling `store.get()` on whatever blockstore it was built with
|
||||
/// (`beetswap::server::get_multiple_cids_from_store`), and that call happens
|
||||
/// inside a spawned task on beetswap's `FuturesUnordered` — never on the swarm
|
||||
/// poll loop — so awaiting a token bucket in here slows block *service* without
|
||||
/// stalling the swarm. There is no rate knob anywhere else in the bitswap stack.
|
||||
///
|
||||
/// ## Why the throttle is after the read, not before it
|
||||
///
|
||||
/// beetswap's **client** side also calls `store.get()` — before issuing a WANT,
|
||||
/// to check whether we already hold the block (`beetswap::client`, "if the
|
||||
/// blockstore doesn't have the data, add the CID to the wantlist"). Gating on the
|
||||
/// way *in* would therefore delay our own outbound WANTs, i.e. throttle the very
|
||||
/// stream the focus exists to protect. Charging only when the inner store returns
|
||||
/// `Some` sidesteps it exactly: a client precheck for a block we don't have
|
||||
/// returns `None` instantly and its WANT goes out unimpeded, and we spend tokens
|
||||
/// only when we are genuinely about to hand bytes to a peer.
|
||||
///
|
||||
/// ## Why it isn't per-title
|
||||
///
|
||||
/// The server-side `get()` sees a bare CID and no request context, and a dag-pb
|
||||
/// **leaf** cid is not a sibling on any record — so "is this block part of the
|
||||
/// focused title?" is not answerable here. The bucket is therefore aggregate:
|
||||
/// while any title is focused, *all* bitswap block service is floored at the
|
||||
/// background rate, including the focused title's own blocks if a third peer asks
|
||||
/// for them. That's the right priority — our viewer's inbound beats a stranger's
|
||||
/// outbound — and it's the honest reading of "pause seeding while I'm watching".
|
||||
pub struct GatedBlockstore<B> {
|
||||
inner: Arc<B>,
|
||||
focus: Arc<meta_feeder_sdk::transport::FocusView>,
|
||||
}
|
||||
|
||||
impl<B> GatedBlockstore<B> {
|
||||
pub fn new(inner: Arc<B>, focus: Arc<meta_feeder_sdk::transport::FocusView>) -> Self {
|
||||
Self { inner, focus }
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: Blockstore> Blockstore for GatedBlockstore<B> {
|
||||
async fn get<const S: usize>(&self, cid: &CidGeneric<S>) -> blockstore::Result<Option<Vec<u8>>> {
|
||||
let out = self.inner.get(cid).await?;
|
||||
if let Some(bytes) = &out {
|
||||
// `Lane::Background`: a block leaving this peer over bitswap is, by
|
||||
// construction, being served to someone else. See the type docs for
|
||||
// why this isn't resolved per-title.
|
||||
self.focus
|
||||
.throttle_out(crate::focus::Lane::Background, bytes.len() as u64)
|
||||
.await;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn put_keyed<const S: usize>(
|
||||
&self,
|
||||
cid: &CidGeneric<S>,
|
||||
data: &[u8],
|
||||
) -> blockstore::Result<()> {
|
||||
// Inbound blocks bitswap fetched for us. Metered at the WANT site
|
||||
// (`ipfs_walker::get_block`), where the lane is actually known — charging
|
||||
// again here would double-count.
|
||||
self.inner.put_keyed(cid, data).await
|
||||
}
|
||||
|
||||
async fn remove<const S: usize>(&self, cid: &CidGeneric<S>) -> blockstore::Result<()> {
|
||||
self.inner.remove(cid).await
|
||||
}
|
||||
|
||||
/// Forwarded explicitly so a presence check doesn't route through the gated
|
||||
/// `get` above and pay for bytes it never returns.
|
||||
async fn has<const S: usize>(&self, cid: &CidGeneric<S>) -> blockstore::Result<bool> {
|
||||
self.inner.has(cid).await
|
||||
}
|
||||
|
||||
async fn close(self) -> blockstore::Result<()> {
|
||||
// `Blockstore::close` takes `self` by value but the inner store is shared
|
||||
// (`Arc`), so there is nothing we may consume. Nothing in meta-share calls
|
||||
// it — the redb file is closed when the process exits.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Default ceiling for a block this peer will keep as **material** when no
|
||||
/// ingress job claims it: records, posters, thumbnails, dag-pb internal nodes —
|
||||
/// the small objects that have no file to live in and that the poster-recovery
|
||||
/// path depends on. Override with `META_SHARE_LOOSE_BLOCK_MAX_BYTES`.
|
||||
const DEFAULT_LOOSE_BLOCK_MAX_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
fn loose_block_max_bytes() -> u64 {
|
||||
std::env::var("META_SHARE_LOOSE_BLOCK_MAX_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_LOOSE_BLOCK_MAX_BYTES)
|
||||
}
|
||||
|
||||
/// Routes an arriving bitswap block to **a file** instead of the block table
|
||||
/// whenever some in-flight fetch is expecting it. Phase 4's single write
|
||||
/// decision; see `crate::ingress`.
|
||||
///
|
||||
/// Only `beetswap::Behaviour` is given one of these, because its client is the
|
||||
/// one thing that writes blocks this peer did not produce. Every local write path
|
||||
/// keeps talking to the store underneath, so nothing else changes shape.
|
||||
///
|
||||
/// | block | destination |
|
||||
/// |---|---|
|
||||
/// | a leaf an [`crate::ingress::IngressJob`] is waiting for | that job's file, by `pwrite` at the leaf's offset |
|
||||
/// | anything else ≤ `META_SHARE_LOOSE_BLOCK_MAX_BYTES` | material, as before — records, posters, internal nodes |
|
||||
/// | anything else, larger | dropped |
|
||||
///
|
||||
/// **Why the fall-through keeps small objects rather than dropping them.** The
|
||||
/// job lookup answers "is this file content we are materialising?", not "did we
|
||||
/// ask for this?". A poster, an `MSR1` record and a dag-pb node all arrive
|
||||
/// unattributed by construction — they have no file to belong to — and they are
|
||||
/// exactly what the artwork and record-resolution paths need locally. The size
|
||||
/// ceiling is what keeps that exception from quietly becoming the old behaviour:
|
||||
/// file content arrives as 256 KiB leaves *of a job*, so a large unattributed
|
||||
/// block means something walked a dag without opening a job, and the honest
|
||||
/// answer is to serve it from memory and keep nothing.
|
||||
pub struct IngressRouter<B> {
|
||||
inner: Arc<B>,
|
||||
ingress: Arc<crate::ingress::IngressRegistry>,
|
||||
loose_max: u64,
|
||||
}
|
||||
|
||||
impl<B> IngressRouter<B> {
|
||||
pub fn new(inner: Arc<B>, ingress: Arc<crate::ingress::IngressRegistry>) -> Self {
|
||||
Self { inner, ingress, loose_max: loose_block_max_bytes() }
|
||||
}
|
||||
|
||||
/// Explicit ceiling, so a test can exercise the drop branch without a 1 MiB
|
||||
/// fixture (and without an env var, which every other test in the process
|
||||
/// would see).
|
||||
#[cfg(test)]
|
||||
fn with_loose_max(
|
||||
inner: Arc<B>,
|
||||
ingress: Arc<crate::ingress::IngressRegistry>,
|
||||
loose_max: u64,
|
||||
) -> Self {
|
||||
Self { inner, ingress, loose_max }
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: Blockstore> Blockstore for IngressRouter<B> {
|
||||
async fn get<const S: usize>(&self, cid: &CidGeneric<S>) -> blockstore::Result<Option<Vec<u8>>> {
|
||||
self.inner.get(cid).await
|
||||
}
|
||||
|
||||
async fn put_keyed<const S: usize>(
|
||||
&self,
|
||||
cid: &CidGeneric<S>,
|
||||
data: &[u8],
|
||||
) -> blockstore::Result<()> {
|
||||
if self.ingress.accept(&cid.to_bytes(), data).await {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() as u64 <= self.loose_max {
|
||||
return self.inner.put_keyed(cid, data).await;
|
||||
}
|
||||
tracing::debug!(%cid, bytes = data.len(),
|
||||
"blockstore: unattributed block over the loose ceiling; not stored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove<const S: usize>(&self, cid: &CidGeneric<S>) -> blockstore::Result<()> {
|
||||
self.inner.remove(cid).await
|
||||
}
|
||||
|
||||
async fn has<const S: usize>(&self, cid: &CidGeneric<S>) -> blockstore::Result<bool> {
|
||||
self.inner.has(cid).await
|
||||
}
|
||||
|
||||
async fn close(self) -> blockstore::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist every block from [`crate::ipfs_chunk::IpfsBlocks`] (leaves +
|
||||
/// internal dag-pb nodes) into the blockstore. After this returns, the
|
||||
/// peer can answer a bitswap WANT for any cid in the dag — including the
|
||||
/// root — and the `/ipfs/{cid}` gateway resolves the file from the local
|
||||
/// store without a network round-trip.
|
||||
///
|
||||
/// Mirror of meta-gateway's `blockstore::put_ipfs_blocks` (the gateway
|
||||
/// seeds upstream-fetched bytes the same way). These are real IPFS blocks
|
||||
/// (raw `0x55` / dag-pb `0x70`), keyed by their own content CID — they
|
||||
/// never collide with the `MSR1` record blocks ingest writes, which are
|
||||
/// keyed by the record's canonical CID.
|
||||
///
|
||||
/// Errors are returned, not panicked: the caller (the event-driven seeder)
|
||||
/// logs-and-continues so a single block-write failure doesn't abort the
|
||||
/// seed loop.
|
||||
pub async fn put_ipfs_blocks<B: Blockstore>(
|
||||
store: &B,
|
||||
blocks: &crate::ipfs_chunk::IpfsBlocks,
|
||||
) -> Result<()> {
|
||||
for (cid_str, bytes) in &blocks.blocks {
|
||||
let cid: MsCid =
|
||||
MsCid::from_str(cid_str).with_context(|| format!("parse ipfs cid `{cid_str}`"))?;
|
||||
store
|
||||
.put_keyed(&cid, bytes.as_ref())
|
||||
.await
|
||||
.with_context(|| format!("blockstore put_keyed `{cid_str}` ({} bytes)", bytes.len()))?;
|
||||
tracing::debug!(cid = %cid_str, bytes = bytes.len(), "ipfs block stored");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist a single IPFS block by its string cid — the incremental counterpart
|
||||
/// of [`put_ipfs_blocks`], used by the streaming re-seed
|
||||
/// ([`crate::ipfs_chunk::stream_ipfs_blocks`]) so a multi-GB posting is chunked
|
||||
/// into the store without ever holding the whole file (or all its blocks) at once.
|
||||
pub async fn put_ipfs_block<B: Blockstore>(store: &B, cid_str: &str, bytes: &[u8]) -> Result<()> {
|
||||
let cid: MsCid =
|
||||
MsCid::from_str(cid_str).with_context(|| format!("parse ipfs cid `{cid_str}`"))?;
|
||||
store
|
||||
.put_keyed(&cid, bytes)
|
||||
.await
|
||||
.with_context(|| format!("blockstore put_keyed `{cid_str}` ({} bytes)", bytes.len()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_record_cid_accepts_bare_cidv1() {
|
||||
let bare = "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4";
|
||||
parse_record_cid(bare).expect("a bare CIDv1 is the only accepted shape");
|
||||
}
|
||||
|
||||
/// The `<algo>:<cid>` token form was removed. This function used to strip
|
||||
/// the prefix, which is what kept the deprecated shape alive on the wire —
|
||||
/// rejecting it is the point.
|
||||
#[test]
|
||||
fn parse_record_cid_rejects_typed_prefix() {
|
||||
let typed = "midhash256:bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4";
|
||||
assert!(
|
||||
parse_record_cid(typed).is_err(),
|
||||
"the typed token form must be rejected, not silently stripped"
|
||||
);
|
||||
}
|
||||
|
||||
/// CIDv0 used to parse here and report codec dag-pb — ranking 40, the top
|
||||
/// tier — while meta-core's strict decoder rejected it and ranked it 0. The
|
||||
/// two disagreed about the top of the ladder.
|
||||
#[test]
|
||||
fn parse_record_cid_rejects_cidv0() {
|
||||
assert!(
|
||||
parse_record_cid("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG").is_err(),
|
||||
"CIDv0 must be rejected so Rust and Go agree (see the cidv0-rejected golden vector)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_record_cid_rejects_garbage() {
|
||||
assert!(parse_record_cid("not-a-cid").is_err());
|
||||
assert!(parse_record_cid("midhash256:").is_err());
|
||||
assert!(parse_record_cid("").is_err());
|
||||
}
|
||||
|
||||
/// Round-trip a single payload via put_keyed → store.get. Validates that
|
||||
/// the cid-string ↔ CidGeneric parse round-trip the gateway side
|
||||
/// produces is symmetric on the consumer side.
|
||||
#[tokio::test]
|
||||
async fn put_keyed_and_get_roundtrip() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let store = open_redb_blockstore(dir.path()).await.expect("open");
|
||||
let payload = b"hello bitswap";
|
||||
|
||||
// Build a raw-codec cid the same way `crate::hash::compute_ipfs_blocks`
|
||||
// would on the gateway side, so we exercise the same cid shape
|
||||
// that arrives over the wire.
|
||||
let digest: [u8; 32] = Sha256::digest(payload).into();
|
||||
// CIDv1 wire: [version=0x01][codec=0x55][mh code=0x12][len=0x20][digest].
|
||||
let mut wire = Vec::with_capacity(36);
|
||||
wire.push(0x01);
|
||||
wire.push(0x55);
|
||||
wire.push(0x12);
|
||||
wire.push(0x20);
|
||||
wire.extend_from_slice(&digest);
|
||||
let cid_str = format!("b{}", base32_lower_no_padding(&wire));
|
||||
|
||||
let cid = MsCid::from_str(&cid_str).expect("parse");
|
||||
store.put_keyed(&cid, payload).await.expect("put");
|
||||
let got = store.get(&cid).await.expect("get").expect("present");
|
||||
assert_eq!(got, payload);
|
||||
}
|
||||
|
||||
/// Same base32 alphabet `crate::hash` uses on the gateway side —
|
||||
/// inlined here because that module lives in another package.
|
||||
fn base32_lower_no_padding(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(5) * 8);
|
||||
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;
|
||||
let idx = ((buffer >> bits) & 0x1F) as usize;
|
||||
out.push(ALPHABET[idx] as char);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
let idx = ((buffer << (5 - bits)) & 0x1F) as usize;
|
||||
out.push(ALPHABET[idx] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The routing table in `IngressRouter::put_keyed`, which is where Phase 4
|
||||
/// decides whether an arriving block becomes a file or a row.
|
||||
mod ingress_router {
|
||||
use super::*;
|
||||
use crate::ingress::{IngressCtx, IngressRegistry};
|
||||
use blockstore::InMemoryBlockstore;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn leaf_cid(bytes: &[u8]) -> MsCid {
|
||||
parse_record_cid(&crate::ipfs_chunk::compute_ipfs_cid(bytes)).expect("cid")
|
||||
}
|
||||
|
||||
/// A leaf some fetch is waiting for goes to that fetch's file, and
|
||||
/// **not** into the block table — the whole point of the phase.
|
||||
#[tokio::test]
|
||||
async fn expected_leaf_lands_in_the_file_not_the_block_table() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let ingress = Arc::new(IngressRegistry::with_min_bytes(
|
||||
dir.path().join("tmp"),
|
||||
dir.path().join("cache"),
|
||||
1,
|
||||
));
|
||||
let inner = Arc::new(InMemoryBlockstore::<64>::new());
|
||||
let router = IngressRouter::new(Arc::clone(&inner), Arc::clone(&ingress));
|
||||
|
||||
let payload = vec![b'x'; 64];
|
||||
let job = ingress
|
||||
.begin("bafycontainer", "bafycontainer", "movie.mkv", 64)
|
||||
.await
|
||||
.expect("begin")
|
||||
.expect("job");
|
||||
let ctx = IngressCtx { job: Arc::clone(&job), base: 0 };
|
||||
let cid = leaf_cid(&payload);
|
||||
ingress.expect_leaf(&ctx, cid.to_bytes(), 0, 64);
|
||||
|
||||
router.put_keyed(&cid, &payload).await.expect("put");
|
||||
assert!(!inner.has(&cid).await.expect("has"), "must not be material");
|
||||
assert_eq!(tokio::fs::read(&job.path).await.expect("read"), payload);
|
||||
}
|
||||
|
||||
/// Records, posters, thumbnails and dag-pb internal nodes arrive
|
||||
/// unattributed by construction — they have no file to belong to, and the
|
||||
/// artwork and record-resolution paths need them locally.
|
||||
#[tokio::test]
|
||||
async fn small_unattributed_block_stays_material() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let ingress = Arc::new(IngressRegistry::new(
|
||||
dir.path().join("tmp"),
|
||||
dir.path().join("cache"),
|
||||
));
|
||||
let inner = Arc::new(InMemoryBlockstore::<64>::new());
|
||||
let router = IngressRouter::new(Arc::clone(&inner), ingress);
|
||||
let payload = b"a poster".to_vec();
|
||||
let cid = leaf_cid(&payload);
|
||||
router.put_keyed(&cid, &payload).await.expect("put");
|
||||
assert_eq!(inner.get(&cid).await.expect("get"), Some(payload));
|
||||
}
|
||||
|
||||
/// Provider-only: file content arrives as leaves *of a job*, so a large
|
||||
/// block nobody is waiting for is content we were never asked to keep.
|
||||
#[tokio::test]
|
||||
async fn large_unattributed_block_is_dropped() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let ingress = Arc::new(IngressRegistry::new(
|
||||
dir.path().join("tmp"),
|
||||
dir.path().join("cache"),
|
||||
));
|
||||
let inner = Arc::new(InMemoryBlockstore::<64>::new());
|
||||
let router = IngressRouter::with_loose_max(Arc::clone(&inner), ingress, 8);
|
||||
let payload = vec![b'z'; 4096];
|
||||
let cid = leaf_cid(&payload);
|
||||
router.put_keyed(&cid, &payload).await.expect("put");
|
||||
assert!(!inner.has(&cid).await.expect("has"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
//! The ipfs plugin's configuration — the swarm/reachability half of what used to
|
||||
//! be meta-share's `config.rs`, same variable names. The network *endpoints*
|
||||
//! (`meta_core_url`, `peer_url`) are read from the hull's `settings.json`
|
||||
//! (read-only), so identify keeps announcing the **hull's** `baseUrl`.
|
||||
//!
|
||||
//! Process-wide configuration assembled from environment variables.
|
||||
//!
|
||||
//! Every operator-visible knob is declared here as a typed field on
|
||||
//! [`Config`]; `Config::from_env()` does all parsing in one place. The
|
||||
//! repetitive `std::env::var("FOO").ok().and_then(|s| s.parse().ok())
|
||||
//! .unwrap_or(default)` shape that was scattered through `main.rs` is
|
||||
//! collapsed via [`env_parse_or`] / [`env_bool_or`].
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use libp2p::Multiaddr;
|
||||
|
||||
use crate::swarm::{self, GatewayCapsConfig, KadConfig};
|
||||
|
||||
/// Read a parseable env var; fall back to `default` on unset / parse
|
||||
/// failure. Silently swallows parse errors — the original ladder did the
|
||||
/// same; tightening is a separate, breaking-change conversation.
|
||||
fn env_parse_or<T: FromStr>(name: &str, default: T) -> T {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read a boolean env var. `1/true/yes/on` (case-insensitive) → true;
|
||||
/// `0/false/no/off` → false; missing → `default`. Anything else is
|
||||
/// treated as truthy (matches the original `ENABLE_MDNS` semantics).
|
||||
fn env_bool_or(name: &str, default: bool) -> bool {
|
||||
match std::env::var(name) {
|
||||
Ok(s) => !matches!(s.trim().to_lowercase().as_str(), "0" | "false" | "no" | "off"),
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Read an env var, treating unset and blank as the same thing (absent).
|
||||
/// Distinct from `env_multiaddrs`: callers need to tell "operator set this
|
||||
/// explicitly" apart from "unset", because an explicit setting overrides the
|
||||
/// mode-derived default.
|
||||
fn env_opt(name: &str) -> Option<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Read a comma-separated env var into a `Vec<Multiaddr>`, propagating
|
||||
/// the first parse error. Empty / unset → empty vec.
|
||||
fn env_multiaddrs(name: &str) -> Result<Vec<Multiaddr>> {
|
||||
std::env::var(name)
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.trim().parse::<Multiaddr>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.with_context(|| format!("{name} must be comma-separated multiaddrs"))
|
||||
}
|
||||
|
||||
/// Local-mode libp2p listen default. Unchanged from the historical default:
|
||||
/// the port is stable so a sibling container can be pointed at it directly.
|
||||
const DEFAULT_P2P_LISTEN: &str = "/ip4/0.0.0.0/tcp/4001";
|
||||
/// Public-mode fallback port, used when `PUBLIC_ADDR` carries no port to
|
||||
/// copy (a bare `/dns4/host`). Normally the port is lifted off the public
|
||||
/// addr so the announced and bound ports can't drift.
|
||||
const DEFAULT_P2P_PORT_PUBLIC: u16 = 4001;
|
||||
|
||||
/// Whether this peer is reachable from outside its own network.
|
||||
///
|
||||
/// The single switch operators flip. It decides whether the peer
|
||||
/// *announces itself to the world*:
|
||||
///
|
||||
/// - [`NetworkMode::Local`] (default, `PUBLIC_ADDR` unset): mDNS / LAN only.
|
||||
/// No public-DHT bootstrap, no provider records, no external address.
|
||||
/// - [`NetworkMode::Public`]: the operator has told us a dialable address.
|
||||
/// We register it as an external address (identify + kad then carry it),
|
||||
/// bootstrap into the public DHT, and publish provider records.
|
||||
///
|
||||
/// The default matters: a container behind docker's bridge NAT observes only
|
||||
/// `127.0.0.1` / `172.x` for itself, so a peer that publishes to the public
|
||||
/// DHT from there advertises a presence it does not have — every peer that
|
||||
/// finds it burns a dial that can never connect.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NetworkMode {
|
||||
Local,
|
||||
Public(Multiaddr),
|
||||
}
|
||||
|
||||
impl NetworkMode {
|
||||
pub fn is_public(&self) -> bool {
|
||||
matches!(self, NetworkMode::Public(_))
|
||||
}
|
||||
|
||||
pub fn public_addr(&self) -> Option<&Multiaddr> {
|
||||
match self {
|
||||
NetworkMode::Public(addr) => Some(addr),
|
||||
NetworkMode::Local => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NetworkMode::Local => "local",
|
||||
NetworkMode::Public(_) => "public",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The networking decisions derived from the mode plus any explicit env
|
||||
/// overrides. Produced by [`resolve_network`] — a pure function, so the whole
|
||||
/// decision table is unit-testable without touching process env (which
|
||||
/// matters here: `Config::from_env` also reads settings.json off disk).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkConfig {
|
||||
pub mode: NetworkMode,
|
||||
pub listen_p2p: Multiaddr,
|
||||
pub kad_bootstrap_peers: Vec<Multiaddr>,
|
||||
pub kad_provide_enabled: bool,
|
||||
}
|
||||
|
||||
/// The whole reachability decision table, as a pure function.
|
||||
///
|
||||
/// **An explicitly-set env var always wins over the mode-derived default** —
|
||||
/// the mode only supplies defaults.
|
||||
pub fn resolve_network(
|
||||
public_addr: Option<String>,
|
||||
listen: Option<String>,
|
||||
kad_bootstrap: Option<String>,
|
||||
) -> Result<NetworkConfig> {
|
||||
let mode = match public_addr.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
|
||||
Some(raw) => {
|
||||
let addr = raw
|
||||
.parse::<Multiaddr>()
|
||||
.with_context(|| format!("PUBLIC_ADDR is not a valid multiaddr: {raw}"))?;
|
||||
// Loud, not silent: an operator who sets a public address has
|
||||
// declared "peers can reach me here". If that can't possibly be
|
||||
// true (0.0.0.0, loopback, port 0), publishing it sends every
|
||||
// peer that finds us into a dial loop. Refuse to start.
|
||||
if !swarm::is_dialable_addr(&addr) {
|
||||
anyhow::bail!(
|
||||
"PUBLIC_ADDR={addr} is not dialable by a remote peer \
|
||||
(unspecified/loopback host, or port 0). Set it to the address peers \
|
||||
actually reach this peer on, e.g. /ip4/1.2.3.4/tcp/4001, or leave it \
|
||||
unset to run in local (mDNS-only) mode."
|
||||
);
|
||||
}
|
||||
NetworkMode::Public(addr)
|
||||
}
|
||||
None => NetworkMode::Local,
|
||||
};
|
||||
|
||||
let listen_p2p = match listen.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
|
||||
Some(raw) => raw
|
||||
.parse::<Multiaddr>()
|
||||
.with_context(|| format!("P2P_LISTEN must be a valid multiaddr: {raw}"))?,
|
||||
None => match &mode {
|
||||
NetworkMode::Local => DEFAULT_P2P_LISTEN.parse().expect("hardcoded listen addr"),
|
||||
// Bind the port we told the world to reach us on, so the two
|
||||
// can't drift.
|
||||
NetworkMode::Public(addr) => {
|
||||
let port = tcp_port_of(addr).unwrap_or(DEFAULT_P2P_PORT_PUBLIC);
|
||||
format!("/ip4/0.0.0.0/tcp/{port}")
|
||||
.parse()
|
||||
.expect("derived public listen addr")
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let (Some(pub_port), Some(listen_port)) = (
|
||||
mode.public_addr().and_then(tcp_port_of),
|
||||
tcp_port_of(&listen_p2p),
|
||||
) {
|
||||
if pub_port != listen_port {
|
||||
tracing::warn!(
|
||||
announced_port = pub_port,
|
||||
listen_port,
|
||||
"PUBLIC_ADDR announces a different port than P2P_LISTEN binds. That is only \
|
||||
correct if a port mapping translates between them."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `KAD_BOOTSTRAP_PEERS` set explicitly (to anything, including the `none`
|
||||
// sentinel) wins. Unset, the mode decides: a local peer has no business
|
||||
// dialing the public IPFS bootstraps.
|
||||
let explicit_bootstrap = kad_bootstrap.is_some();
|
||||
let kad_bootstrap_peers = match kad_bootstrap {
|
||||
Some(raw) => parse_kad_bootstrap_peers(&raw)?,
|
||||
None if mode.is_public() => swarm::DEFAULT_KAD_BOOTSTRAPS
|
||||
.iter()
|
||||
.map(|s| s.parse::<Multiaddr>().expect("hardcoded multiaddr"))
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// Publish provider records only when someone can act on them: we're
|
||||
// publicly dialable, OR the operator explicitly pointed us at a DHT (a
|
||||
// private/self-hosted one) and so clearly meant to publish. Either way
|
||||
// it's moot without a bootstrap peer to publish through.
|
||||
let kad_provide_enabled =
|
||||
!kad_bootstrap_peers.is_empty() && (mode.is_public() || explicit_bootstrap);
|
||||
|
||||
Ok(NetworkConfig {
|
||||
mode,
|
||||
listen_p2p,
|
||||
kad_bootstrap_peers,
|
||||
kad_provide_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
/// The TCP port carried by a multiaddr, if any.
|
||||
fn tcp_port_of(addr: &Multiaddr) -> Option<u16> {
|
||||
addr.iter().find_map(|p| match p {
|
||||
libp2p::multiaddr::Protocol::Tcp(port) => Some(port),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an explicitly-set `KAD_BOOTSTRAP_PEERS`. The `none`/`off`/`disabled`
|
||||
/// sentinels mean "no DHT bootstrap at all" (parity with meta-gateway, which
|
||||
/// has had them all along); a bare empty string means the same.
|
||||
fn parse_kad_bootstrap_peers(raw: &str) -> Result<Vec<Multiaddr>> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.eq_ignore_ascii_case("none")
|
||||
|| trimmed.eq_ignore_ascii_case("off")
|
||||
|| trimmed.eq_ignore_ascii_case("disabled")
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
trimmed
|
||||
.split(',')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.trim().parse::<Multiaddr>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("KAD_BOOTSTRAP_PEERS must be comma-separated multiaddrs")
|
||||
}
|
||||
|
||||
/// The ipfs plugin's configuration.
|
||||
pub struct Config {
|
||||
pub listen_p2p: Multiaddr,
|
||||
pub http_addr: SocketAddr,
|
||||
/// Reachability posture + everything derived from it. See [`NetworkMode`].
|
||||
pub network: NetworkConfig,
|
||||
pub bootstrap_peers: Vec<Multiaddr>,
|
||||
pub redial_interval: Duration,
|
||||
pub kad: KadConfig,
|
||||
pub enable_mdns: bool,
|
||||
pub gateway_caps: GatewayCapsConfig,
|
||||
/// The hull's API URL — announced in identify (`baseUrl=`), which is what
|
||||
/// peers and gateways call back on.
|
||||
pub peer_api_url: Option<String>,
|
||||
/// For reading library bytes (refs into meta-core files).
|
||||
pub meta_core_url: Option<String>,
|
||||
pub files_path_prefix: String,
|
||||
pub local_files_root: Option<PathBuf>,
|
||||
pub data_dir: PathBuf,
|
||||
/// Announce newly-seeded cids on the public DHT (`META_SHARE_SEED_DHT_PROVIDE`).
|
||||
pub seed_dht_provide: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
// Reachability posture first — the listen addr, the kad bootstrap
|
||||
// list, and whether we publish provider records all fall out of it.
|
||||
// `P2P_ANNOUNCE` is the deprecated spelling of `PUBLIC_ADDR`.
|
||||
let network = resolve_network(
|
||||
env_opt("PUBLIC_ADDR").or_else(|| {
|
||||
env_opt("P2P_ANNOUNCE").inspect(|_| {
|
||||
tracing::warn!(
|
||||
"P2P_ANNOUNCE is deprecated; use PUBLIC_ADDR (same value — a \
|
||||
dialable multiaddr)"
|
||||
);
|
||||
})
|
||||
}),
|
||||
env_opt("P2P_LISTEN"),
|
||||
// NOT `env_opt`: `KAD_BOOTSTRAP_PEERS=` (set, but empty) is the
|
||||
// existing opt-out spelling and must stay an explicit "no
|
||||
// bootstraps", not decay into "unset → use the defaults".
|
||||
std::env::var("KAD_BOOTSTRAP_PEERS").ok(),
|
||||
)?;
|
||||
let listen_p2p = network.listen_p2p.clone();
|
||||
|
||||
let http_addr: SocketAddr = std::env::var("HTTP_LISTEN")
|
||||
.unwrap_or_else(|_| "0.0.0.0:3000".to_string())
|
||||
.parse()
|
||||
.context("HTTP_LISTEN must be a valid socket addr (e.g. 0.0.0.0:3000)")?;
|
||||
|
||||
let bootstrap_peers = env_multiaddrs("BOOTSTRAP_PEERS")?;
|
||||
|
||||
// Auto-redial interval. Set to 0 to disable; v0.2 keeps bootstrap
|
||||
// multiaddrs alive across drops so a leaf doesn't silently
|
||||
// disconnect when its bootstrap target restarts.
|
||||
let redial_interval = Duration::from_secs(env_parse_or("BOOTSTRAP_REDIAL_SECS", 30));
|
||||
|
||||
let kad = kad_config_from_env(&network)?;
|
||||
|
||||
// mDNS local-network discovery. Default ON — purely additive.
|
||||
// Disable (`ENABLE_MDNS=false`) only on networks that block
|
||||
// multicast (some VPNs, some mDNS-isolating switches).
|
||||
let enable_mdns = env_bool_or("ENABLE_MDNS", true);
|
||||
|
||||
// Gateway capability fetch. The refresh window is sized to identify's
|
||||
// ~5-minute re-announce cadence: a connected gateway is re-fetched
|
||||
// roughly once per identify, which keeps the capability inside the peer
|
||||
// directory's 600s freshness TTL without a dedicated keep-alive timer.
|
||||
let gateway_caps = swarm::GatewayCapsConfig {
|
||||
fetch_timeout: Duration::from_secs(env_parse_or(
|
||||
"META_SHARE_GATEWAY_CAP_FETCH_TIMEOUT_SECS",
|
||||
5,
|
||||
)),
|
||||
refresh_after: Duration::from_secs(env_parse_or(
|
||||
"META_SHARE_GATEWAY_CAP_REFRESH_SECS",
|
||||
300,
|
||||
)),
|
||||
};
|
||||
|
||||
// The network endpoints are the hull's settings (`settings.json`,
|
||||
// seeded once from env by the hull). Read-only here; env fallback when
|
||||
// the file doesn't exist yet.
|
||||
let config_dir: PathBuf = std::env::var("META_SHARE_CONFIG_DIR")
|
||||
.unwrap_or_else(|_| "/config".to_string())
|
||||
.into();
|
||||
let net = crate::settings::NetworkSettings::load(&config_dir);
|
||||
let peer_api_url = net
|
||||
.peer_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim_end_matches('/').to_string());
|
||||
let meta_core_url = net
|
||||
.meta_core_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let files_path_prefix =
|
||||
std::env::var("META_SHARE_FILES_PATH").unwrap_or_else(|_| "/files".to_string());
|
||||
let local_files_root = std::env::var("META_SHARE_LOCAL_FILES_PATH")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let data_dir: PathBuf = std::env::var("META_SHARE_DATA")
|
||||
.unwrap_or_else(|_| "/data/meta-share".to_string())
|
||||
.into();
|
||||
let seed_dht_provide = env_bool_or("META_SHARE_SEED_DHT_PROVIDE", network.mode.is_public());
|
||||
|
||||
Ok(Config {
|
||||
listen_p2p,
|
||||
http_addr,
|
||||
network,
|
||||
bootstrap_peers,
|
||||
redial_interval,
|
||||
kad,
|
||||
enable_mdns,
|
||||
gateway_caps,
|
||||
peer_api_url,
|
||||
meta_core_url,
|
||||
files_path_prefix,
|
||||
local_files_root,
|
||||
data_dir,
|
||||
seed_dht_provide,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// kad-DHT discovery config: namespace, cadences, connection floor.
|
||||
///
|
||||
/// The bootstrap list and the provide gate are NOT read here — they are
|
||||
/// derived from the reachability mode by [`resolve_network`] and passed in,
|
||||
/// so there is exactly one place that decides whether this peer talks to the
|
||||
/// public DHT. Cohorts can still override `KAD_NAMESPACE` for isolation, and
|
||||
/// prepend known cohort peers to `KAD_BOOTSTRAP_PEERS` to speed up cold-start.
|
||||
fn kad_config_from_env(network: &NetworkConfig) -> Result<KadConfig> {
|
||||
let namespace =
|
||||
std::env::var("KAD_NAMESPACE").unwrap_or_else(|_| "metamesh-share-default".to_string());
|
||||
Ok(KadConfig {
|
||||
bootstrap_peers: network.kad_bootstrap_peers.clone(),
|
||||
provide_enabled: network.kad_provide_enabled,
|
||||
namespace,
|
||||
// Provider records expire on remote peers (~24h default); 6h is
|
||||
// the kubo default re-publish cadence.
|
||||
provide_interval: Duration::from_secs(env_parse_or("KAD_PROVIDE_SECS", 6 * 60 * 60)),
|
||||
// 5 min — steady-state topup when at floor.
|
||||
discovery_interval: Duration::from_secs(env_parse_or("KAD_DISCOVERY_SECS", 5 * 60)),
|
||||
// Faster cadence when we're below the connection floor. Also
|
||||
// acts as the rate-limit floor for reactive kicks on
|
||||
// `ConnectionClosed`.
|
||||
fast_discovery_interval: Duration::from_secs(env_parse_or("KAD_FAST_DISCOVERY_SECS", 60)),
|
||||
// Floor, not ceiling: extra connections beyond this aren't dropped.
|
||||
target_peer_count: env_parse_or("TARGET_PEER_COUNT", 10),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// The reachability decision table. These assert the *behaviour that keeps a
|
||||
/// dev box off the public DHT*, so treat a failure here as a real regression,
|
||||
/// not a stale expectation to update.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn s(v: &str) -> Option<String> {
|
||||
Some(v.to_string())
|
||||
}
|
||||
|
||||
/// The headline: with nothing configured, we do NOT touch the public DHT.
|
||||
/// Before this change the same input dialed four public bootstrap nodes
|
||||
/// and published a provider record from behind docker's NAT.
|
||||
#[test]
|
||||
fn local_mode_is_the_default_and_publishes_nothing() {
|
||||
let n = resolve_network(None, None, None).unwrap();
|
||||
assert_eq!(n.mode, NetworkMode::Local);
|
||||
assert!(n.kad_bootstrap_peers.is_empty());
|
||||
assert!(!n.kad_provide_enabled);
|
||||
assert_eq!(n.listen_p2p.to_string(), "/ip4/0.0.0.0/tcp/4001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_addr_turns_on_the_dht_and_derives_the_listen_port() {
|
||||
let n = resolve_network(s("/ip4/1.2.3.4/tcp/4001"), None, None).unwrap();
|
||||
assert!(n.mode.is_public());
|
||||
assert_eq!(
|
||||
n.kad_bootstrap_peers.len(),
|
||||
swarm::DEFAULT_KAD_BOOTSTRAPS.len()
|
||||
);
|
||||
assert!(n.kad_provide_enabled);
|
||||
assert_eq!(n.listen_p2p.to_string(), "/ip4/0.0.0.0/tcp/4001");
|
||||
}
|
||||
|
||||
/// Explicit config wins: an operator pointing us at a private DHT means
|
||||
/// to publish, even with no public address.
|
||||
#[test]
|
||||
fn explicit_bootstrap_peers_re_enable_publishing_in_local_mode() {
|
||||
let n = resolve_network(
|
||||
None,
|
||||
None,
|
||||
s("/ip4/10.0.0.1/tcp/4001/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n.mode, NetworkMode::Local);
|
||||
assert_eq!(n.kad_bootstrap_peers.len(), 1);
|
||||
assert!(n.kad_provide_enabled);
|
||||
}
|
||||
|
||||
/// `KAD_BOOTSTRAP_PEERS=` (set, but empty) was already the opt-out
|
||||
/// spelling before this change. It must stay explicit — not decay into
|
||||
/// "unset, so use the public defaults".
|
||||
#[test]
|
||||
fn empty_bootstrap_string_stays_an_explicit_opt_out() {
|
||||
let n = resolve_network(s("/ip4/1.2.3.4/tcp/4001"), None, s("")).unwrap();
|
||||
assert!(n.mode.is_public());
|
||||
assert!(n.kad_bootstrap_peers.is_empty());
|
||||
assert!(!n.kad_provide_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_listen_overrides_the_derived_port() {
|
||||
let n = resolve_network(
|
||||
s("/ip4/1.2.3.4/tcp/4001"),
|
||||
s("/ip4/0.0.0.0/tcp/9999"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n.listen_p2p.to_string(), "/ip4/0.0.0.0/tcp/9999");
|
||||
}
|
||||
|
||||
/// The failure this guards against is silent: publish 0.0.0.0 and every
|
||||
/// peer that finds you burns a dial forever. Fail startup instead.
|
||||
#[test]
|
||||
fn undialable_public_addr_is_a_hard_error() {
|
||||
for bad in [
|
||||
"/ip4/0.0.0.0/tcp/4001",
|
||||
"/ip4/127.0.0.1/tcp/4001",
|
||||
"/ip4/1.2.3.4/tcp/0",
|
||||
] {
|
||||
let err = resolve_network(s(bad), None, None).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("not dialable"),
|
||||
"expected a dialability error for {bad}, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
//! The meta-gateway **discovery contract** — everything a peer needs to
|
||||
//! recognise a gateway on the swarm and read back what it can serve.
|
||||
//!
|
||||
//! # Mirrored file — keep byte-identical
|
||||
//!
|
||||
//! This file exists twice, and the two copies must stay identical:
|
||||
//!
|
||||
//! - `packages/meta-share/crates/meta-share/src/gateway_discovery.rs` (this file)
|
||||
//! - `packages/meta-search/crates/meta-search/src/gateway_discovery.rs`
|
||||
//!
|
||||
//! `diff` between them must be empty. Duplicated rather than shared via a cargo
|
||||
//! path-dep because both are independently-checked-out git submodules and a
|
||||
//! `path = "../meta-search/..."` dep breaks when either is cloned alone. This is
|
||||
//! the same convention meta-gateway's `protocol.rs` ↔ meta-search's
|
||||
//! `gateway_wire.rs` already follow.
|
||||
//!
|
||||
//! # Drift contract
|
||||
//!
|
||||
//! The three things below are a wire contract with **meta-gateway**, and drift
|
||||
//! is silent — a parser that stops recognising the token doesn't error, it just
|
||||
//! quietly never finds a gateway. Any change must land in all three crates:
|
||||
//!
|
||||
//! 1. **The identify `gateways=` token.** meta-gateway advertises
|
||||
//! `meta-gateway/<ver> [gateways=<csv>] [baseUrl=<url>]` in its libp2p
|
||||
//! identify `agent_version` (`meta-gateway/src/swarm/mod.rs::build_agent_version`,
|
||||
//! pinned by its parser-compat test). Presence of a non-empty `gateways=`
|
||||
//! token is how a peer tells "this is a gateway" from "this is a sibling
|
||||
//! peer" — nothing else on the swarm sets it. The `baseUrl=` half is parsed
|
||||
//! by each crate's `swarm::identify_agent` (peer-level, not gateway-level).
|
||||
//! 2. **The `metamesh-gateway` kad namespace.** Every meta-gateway peer
|
||||
//! `start_providing`s on `namespace_key(GATEWAY_NAMESPACE)`; a peer walking
|
||||
//! the DHT for gateways `get_providers` the same key. (Both crates already
|
||||
//! carry an identical `namespace_key` in `swarm::kad_discovery`.)
|
||||
//! 3. **`GET {baseUrl}/api/gateway/plugins`.** The *reliable* capability source
|
||||
//! — it replaced a gossipsub heartbeat feed that dropped publishes often
|
||||
//! enough to leave the meta-watch home page empty. It is plain HTTP off the
|
||||
//! identify-learned `baseUrl`, deliberately **not** a libp2p protocol, so a
|
||||
//! transport-only peer can read it without adding a behaviour to its swarm.
|
||||
//!
|
||||
//! # Why a bare fetch, not a directory write
|
||||
//!
|
||||
//! [`fetch_gateway_caps`] returns the parsed response and writes nothing. The
|
||||
//! two consumers want different halves of it and store them in differently-shaped
|
||||
//! directories, so the *fetch* is common and the *recording* is not:
|
||||
//!
|
||||
//! - **meta-search** reads `plugins[]` (per-upstream file-type / content-kind
|
||||
//! facets) to route search fan-out.
|
||||
//! - **meta-share** reads `nzbFetch` to find a gateway that can materialise and
|
||||
//! serve `nzb-release` bytes, then reverse-proxies `/api/file/:cid/raw` to it.
|
||||
//!
|
||||
//! That seam is what lets one file serve both crates.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Single gateway namespace string. SHA-256 of this is the kad `RecordKey`
|
||||
/// every meta-gateway peer `start_providing`s on, and the one a peer hunting
|
||||
/// for gateways `get_providers` against. Mirrors meta-gateway's
|
||||
/// `kad_helpers::GATEWAY_NAMESPACE` byte-for-byte — drift here means walking a
|
||||
/// key the gateway never publishes on, which looks exactly like "no gateways
|
||||
/// exist".
|
||||
///
|
||||
/// `allow(dead_code)`: **meta-search** walks this namespace; **meta-share**
|
||||
/// currently does not. It doesn't need to — it meets gateways via mDNS on the
|
||||
/// local mesh, or via an operator-configured `BOOTSTRAP_PEERS` across hosts. A
|
||||
/// DHT walk would in fact be inert there today: a peer in local mode
|
||||
/// (`PUBLIC_ADDR` unset) has no kad bootstrap peers, hence an empty routing
|
||||
/// table and nothing to query. The constant stays here because it is part of
|
||||
/// the gateway contract, and it's the key meta-share would walk if it ever
|
||||
/// joins the public DHT.
|
||||
#[allow(dead_code)]
|
||||
pub const GATEWAY_NAMESPACE: &str = "metamesh-gateway";
|
||||
|
||||
/// Token prefix in a peer's identify `agent_version` carrying its served
|
||||
/// gateway upstreams (CSV). Emitted by **meta-gateway only** — no meta-share or
|
||||
/// meta-search peer ever sets it, which is what makes a non-empty value a
|
||||
/// reliable "this peer is a gateway" signal.
|
||||
pub const AGENT_GATEWAYS_TOKEN: &str = "gateways=";
|
||||
|
||||
/// Extract the gateway upstream list from a remote peer's `agent_version`.
|
||||
/// Empty `Vec` when the token is absent (i.e. the peer is not a gateway) or
|
||||
/// carries no value. Empty CSV entries are filtered out so `gateways=` and
|
||||
/// `gateways=foo,,bar` both behave sensibly.
|
||||
///
|
||||
/// Keep the parse pattern (`split_ascii_whitespace` + `strip_prefix`) in step
|
||||
/// with meta-gateway's `build_agent_version` parser-compat test.
|
||||
pub fn parse_gateways(agent_version: &str) -> Vec<String> {
|
||||
agent_version
|
||||
.split_ascii_whitespace()
|
||||
.find_map(|tok| tok.strip_prefix(AGENT_GATEWAYS_TOKEN))
|
||||
.map(|csv| {
|
||||
csv.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether a peer's `agent_version` marks it as a meta-gateway. The predicate
|
||||
/// behind "should I fetch this peer's capabilities?".
|
||||
///
|
||||
/// `allow(dead_code)`: used by meta-share. meta-search needs the upstream *list*
|
||||
/// anyway (it stores it for search routing), so it calls [`parse_gateways`] and
|
||||
/// tests the result for emptiness rather than parsing twice.
|
||||
#[allow(dead_code)]
|
||||
pub fn advertises_gateways(agent_version: &str) -> bool {
|
||||
!parse_gateways(agent_version).is_empty()
|
||||
}
|
||||
|
||||
/// Shape of `GET {baseUrl}/api/gateway/plugins` on a meta-gateway peer. Only
|
||||
/// the fields consumers read are declared; `#[serde(default)]` throughout
|
||||
/// tolerates an older gateway that predates a field (it degrades to "no
|
||||
/// capability" rather than failing the whole parse).
|
||||
///
|
||||
/// `allow(dead_code)`: each crate reads only *half* of this — meta-search wants
|
||||
/// `plugins[]` for routing, meta-share wants `nzb_fetch` and the plugins' redeem
|
||||
/// claims — so whichever half the compiling
|
||||
/// crate ignores looks dead to it. That's inherent to a mirrored file, and
|
||||
/// silencing it here (rather than at one crate's use site) is what keeps the two
|
||||
/// copies byte-identical.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct GatewayPluginsResponse {
|
||||
/// The gateway's per-upstream plugins. Consumed by meta-search for search
|
||||
/// fan-out routing; ignored by meta-share.
|
||||
#[serde(default)]
|
||||
pub plugins: Vec<GatewayPluginInfo>,
|
||||
/// Gateway-wide Usenet byte-serving capability: `true` when this gateway
|
||||
/// fronts a credentialed meta-share and can materialise + serve
|
||||
/// `nzb-release` bytes via `/api/file/:cid/raw`. Consumed by meta-share to
|
||||
/// pick a forward target; ignored by meta-search.
|
||||
#[serde(default, rename = "nzbFetch")]
|
||||
pub nzb_fetch: bool,
|
||||
}
|
||||
|
||||
/// One upstream plugin on a gateway. `enabled: false` upstreams are served by
|
||||
/// nobody — routing to one just bounces — so consumers skip them.
|
||||
///
|
||||
/// `allow(dead_code)`: read by meta-search (search fan-out routing), ignored by
|
||||
/// meta-share. See [`GatewayPluginsResponse`].
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GatewayPluginInfo {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub file_types: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub content_kinds: Vec<String>,
|
||||
/// The locator families this upstream's feeder can **redeem** — turn a
|
||||
/// metered locator cid into bytes through
|
||||
/// `POST {baseUrl}/api/file/:cid/redeem`. Read by meta-share to pick a redeem
|
||||
/// target; ignored by meta-search. Absent on a gateway that predates redeem.
|
||||
#[serde(default)]
|
||||
pub redeems: Vec<RedeemClaim>,
|
||||
}
|
||||
|
||||
/// `RedeemClaim::codec` for the Newznab release locator (`0x1005`): the `.nzb`
|
||||
/// behind it costs an indexer grab.
|
||||
#[allow(dead_code)]
|
||||
pub const REDEEM_NZB_RELEASE: &str = "nzb-release";
|
||||
/// `RedeemClaim::codec` for the provider-file locator (`0x100A`): the file
|
||||
/// behind it costs the provider's download quota.
|
||||
#[allow(dead_code)]
|
||||
pub const REDEEM_PROVIDER_FILE: &str = "provider-file";
|
||||
|
||||
/// One locator family a gateway plugin can redeem — the credential for it lives
|
||||
/// on that plugin's feeder, never on the peer asking.
|
||||
///
|
||||
/// - `codec` — [`REDEEM_NZB_RELEASE`] or [`REDEEM_PROVIDER_FILE`].
|
||||
/// - `field` — the pointer the gateway merges onto the locator's record once it
|
||||
/// has redeemed it (`manifest` / `file`), naming the bytes' content cid.
|
||||
/// - `hosts` — for `nzb-release`: the indexer hosts a key is configured for.
|
||||
/// - `sources` — for `provider-file`: the provider tokens (`opensubtitles`).
|
||||
///
|
||||
/// `allow(dead_code)`: read by meta-share, ignored by meta-search.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct RedeemClaim {
|
||||
#[serde(default)]
|
||||
pub codec: String,
|
||||
#[serde(default)]
|
||||
pub field: String,
|
||||
#[serde(default)]
|
||||
pub hosts: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl RedeemClaim {
|
||||
/// Does this claim cover `key` for `codec` — an indexer host for
|
||||
/// `nzb-release`, a source token for `provider-file`?
|
||||
///
|
||||
/// Hosts compare on the bare authority, case-insensitively, so a feeder that
|
||||
/// advertises `https://API.nzb.life/api` still matches the `api.nzb.life` a
|
||||
/// locator decodes to.
|
||||
pub fn covers(&self, codec: &str, key: &str) -> bool {
|
||||
if self.codec != codec {
|
||||
return false;
|
||||
}
|
||||
if codec == REDEEM_NZB_RELEASE {
|
||||
let want = bare_authority(key);
|
||||
self.hosts.iter().any(|h| bare_authority(h) == want)
|
||||
} else {
|
||||
self.sources.iter().any(|s| s.eq_ignore_ascii_case(key.trim()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `https://Api.Example.com/api/` → `api.example.com`.
|
||||
#[allow(dead_code)]
|
||||
fn bare_authority(raw: &str) -> String {
|
||||
let t = raw.trim();
|
||||
let t = t.strip_prefix("https://").or_else(|| t.strip_prefix("http://")).unwrap_or(t);
|
||||
t.split('/').next().unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl GatewayPluginsResponse {
|
||||
/// Every redeem claim of every **enabled** plugin. A disabled upstream is
|
||||
/// served by nobody, so its claims are too.
|
||||
pub fn redeem_claims(&self) -> Vec<RedeemClaim> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.filter(|p| p.enabled)
|
||||
.flat_map(|p| p.redeems.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a gateway peer's served capabilities over HTTP, off the
|
||||
/// identify-learned `base_url`. Pure: it returns the parsed response and
|
||||
/// records nothing — the caller decides what to keep (see the module docs).
|
||||
///
|
||||
/// Callers treat this as best-effort. Every failure mode here (unreachable
|
||||
/// peer, non-2xx, unparseable body) is a transient fact about one peer, so the
|
||||
/// caller should log-and-continue, keep whatever it already held, and retry on
|
||||
/// the next identify.
|
||||
pub async fn fetch_gateway_caps(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
timeout: Duration,
|
||||
) -> reqwest::Result<GatewayPluginsResponse> {
|
||||
let url = format!("{}/api/gateway/plugins", base_url.trim_end_matches('/'));
|
||||
http.get(&url)
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<GatewayPluginsResponse>()
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_gateways_returns_empty_when_token_absent() {
|
||||
// A sibling meta-share/meta-search peer: baseUrl, but no gateways token.
|
||||
let av = "meta-share/0.1.0 baseUrl=http://x:9";
|
||||
assert!(parse_gateways(av).is_empty());
|
||||
assert!(!advertises_gateways(av));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gateways_returns_full_csv() {
|
||||
// Matches the corpus meta-gateway's parser-compat test pins down.
|
||||
let av = "meta-gateway/0.1.0 gateways=arxiv,giphy,gutenberg,pubmed,wikicommons";
|
||||
assert_eq!(
|
||||
parse_gateways(av),
|
||||
vec!["arxiv", "giphy", "gutenberg", "pubmed", "wikicommons"],
|
||||
);
|
||||
assert!(advertises_gateways(av));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gateways_handles_single_value() {
|
||||
assert_eq!(parse_gateways("meta-gateway/0.1.0 gateways=arxiv"), vec!["arxiv"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gateways_filters_empty_csv_entries() {
|
||||
// Defensive: a malformed token shouldn't surface "" entries to callers
|
||||
// that index a map by upstream id.
|
||||
let av = "meta-gateway/0.1.0 gateways=arxiv,,gutenberg,";
|
||||
assert_eq!(parse_gateways(av), vec!["arxiv", "gutenberg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gateways_returns_empty_when_token_has_no_value() {
|
||||
let av = "meta-gateway/0.1.0 gateways=";
|
||||
assert!(parse_gateways(av).is_empty());
|
||||
// A gateway with every plugin disabled omits the token's value; it must
|
||||
// not be mistaken for a gateway we can route to.
|
||||
assert!(!advertises_gateways(av));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gateways_coexists_with_base_url_token_in_either_order() {
|
||||
let a = "meta-gateway/0.1.0 baseUrl=http://x:9 gateways=arxiv";
|
||||
let b = "meta-gateway/0.1.0 gateways=arxiv baseUrl=http://x:9";
|
||||
assert_eq!(parse_gateways(a), vec!["arxiv"]);
|
||||
assert_eq!(parse_gateways(b), vec!["arxiv"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_response_tolerates_gateway_predating_nzb_fetch() {
|
||||
// An older gateway sends neither `nzbFetch` nor the facet arrays.
|
||||
let r: GatewayPluginsResponse =
|
||||
serde_json::from_str(r#"{"plugins":[{"id":"arxiv","enabled":true}]}"#).unwrap();
|
||||
assert!(!r.nzb_fetch);
|
||||
assert_eq!(r.plugins.len(), 1);
|
||||
assert!(r.plugins[0].file_types.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_response_reads_nzb_fetch_and_facets() {
|
||||
let r: GatewayPluginsResponse = serde_json::from_str(
|
||||
r#"{"plugins":[{"id":"prowlarr","enabled":true,
|
||||
"file_types":["video"],"content_kinds":["movie","episode"]}],
|
||||
"nzbFetch":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.nzb_fetch);
|
||||
assert_eq!(r.plugins[0].file_types, vec!["video"]);
|
||||
assert_eq!(r.plugins[0].content_kinds, vec!["movie", "episode"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_response_reads_redeem_claims_of_enabled_plugins_only() {
|
||||
let r: GatewayPluginsResponse = serde_json::from_str(
|
||||
r#"{"plugins":[
|
||||
{"id":"usenet","enabled":true,"redeems":[
|
||||
{"codec":"nzb-release","field":"manifest","hosts":["api.nzb.life"],"sources":[]}]},
|
||||
{"id":"opensubtitles","enabled":false,"redeems":[
|
||||
{"codec":"provider-file","field":"file","hosts":[],"sources":["opensubtitles"]}]},
|
||||
{"id":"arxiv","enabled":true}],
|
||||
"nzbFetch":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let claims = r.redeem_claims();
|
||||
assert_eq!(claims.len(), 1, "the disabled plugin's claim must not count");
|
||||
assert!(claims[0].covers(REDEEM_NZB_RELEASE, "api.nzb.life"));
|
||||
assert!(!claims[0].covers(REDEEM_PROVIDER_FILE, "api.nzb.life"));
|
||||
assert!(r.plugins[2].redeems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_claim_matches_on_the_bare_authority() {
|
||||
let c = RedeemClaim {
|
||||
codec: REDEEM_NZB_RELEASE.into(),
|
||||
field: "manifest".into(),
|
||||
hosts: vec!["https://API.nzbgeek.info/api/".into()],
|
||||
sources: Vec::new(),
|
||||
};
|
||||
assert!(c.covers(REDEEM_NZB_RELEASE, "api.nzbgeek.info"));
|
||||
assert!(!c.covers(REDEEM_NZB_RELEASE, "api.nzb.life"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_response_tolerates_a_gateway_with_no_plugins_but_nzb_fetch() {
|
||||
// The shape that trips a naive debounce: nothing to route searches to,
|
||||
// but it CAN serve nzb bytes. Must parse, and must report the cap.
|
||||
let r: GatewayPluginsResponse = serde_json::from_str(r#"{"plugins":[],"nzbFetch":true}"#).unwrap();
|
||||
assert!(r.nzb_fetch);
|
||||
assert!(r.plugins.is_empty());
|
||||
}
|
||||
}
|
||||
+681
@@ -0,0 +1,681 @@
|
||||
//! Bitswap ingress as **files** — Phase 4 of `docs/unified-cache-filesystem.md`.
|
||||
//!
|
||||
//! ## Why
|
||||
//!
|
||||
//! Three of the four ways bytes enter this peer put them in a file and register
|
||||
//! the dag's leaves as refs: Usenet into `cache/<cid>/` ([`crate::nzb::serve`]),
|
||||
//! the library into meta-core's `/files` ([`crate::ipfs_seed`]), and the torrent
|
||||
//! tier into its own sparse files. Bitswap was the exception — every fetched
|
||||
//! leaf was written into `blocks.redb` as **material bytes**, a second full copy
|
||||
//! of the file with no GC (`crate::blockstore`) and no compaction anywhere in the
|
||||
//! crate. Measured consequence on a live peer: **112 GiB of blockstore against 59
|
||||
//! GB of cache it was serving entirely through refs** — unreclaimable, because
|
||||
//! redb never returns pages to the filesystem.
|
||||
//!
|
||||
//! This module closes that: a fetch materialises into `tmp/<cid>/`, is promoted
|
||||
//! into `cache/<cid>/` by `rename(2)` once the dag is whole, and its leaves
|
||||
//! become refs into that file. The same shape the Usenet tier already proved,
|
||||
//! where the measurement was *"a 1.49 GB posting sealed with the blockstore
|
||||
//! growing 0 MB"*.
|
||||
//!
|
||||
//! ## "Fetch for our user implies seed"
|
||||
//!
|
||||
//! `CLAUDE.md` invariant 7 used to read "fetch implies seed", which was true only
|
||||
//! because every received block was written material. It is narrowed here: a
|
||||
//! fetch **driven by this peer's HTTP API** is ours and is kept; the libp2p side
|
||||
//! is provider-only — we answer WANTs from what we hold and never persist a block
|
||||
//! that no local request asked for. The enforcement point is
|
||||
//! [`IngressRegistry::accept`], called from `crate::blockstore::IngressRouter`:
|
||||
//! a block is written to a file only when a job is *expecting* that exact leaf,
|
||||
//! and anything unattributable is dropped rather than stored.
|
||||
//!
|
||||
//! ## Serving while filling (design §2a)
|
||||
//!
|
||||
//! Rule R1 says leaf refs may only point at `cache/`, never `tmp/` — a ref into a
|
||||
//! file the next `rename(2)` invalidates would advertise blocks this peer cannot
|
||||
//! produce. Applied flatly that would stop the IPFS tier contributing for the
|
||||
//! whole duration of a download, which is a swarm regression the design
|
||||
//! explicitly narrows away: *a bitswap-filled `tmp/` file may serve the leaves it
|
||||
//! has already received, from its own in-flight set.*
|
||||
//!
|
||||
//! [`IngressJob::read_leaf`] is that set. It is consulted by
|
||||
//! [`crate::filestore::FilestoreBlockstore::get`] after the material store and
|
||||
//! the refs table both miss — so nothing is persisted, R1 holds literally, and
|
||||
//! because the lookup sits *under* `GatedBlockstore` an in-flight leaf served to
|
||||
//! a stranger is throttled like any other block. It also keeps **our own**
|
||||
//! re-reads local: after Phase 4 a leaf is no longer in redb, so without this a
|
||||
//! second range over the same bytes would re-WANT it off the swarm.
|
||||
//!
|
||||
//! ## Ownership
|
||||
//!
|
||||
//! One job per **initiating cid** — the cid the HTTP client asked for, which is
|
||||
//! immutable (D1 in the design doc). The elected canonical of a record moves the
|
||||
//! moment a dag-pb root outranks a locator, so it can never name a directory.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::unix::fs::FileExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::ipfs_chunk::LeafRef;
|
||||
|
||||
/// Below this, a dag-pb file stays material rather than becoming a container.
|
||||
/// A container costs a directory, an index row, one ref per leaf and an eviction
|
||||
/// unit; for a small multi-block asset that is more bookkeeping than the bytes it
|
||||
/// saves. Override with `META_SHARE_INGRESS_MIN_BYTES`.
|
||||
const DEFAULT_INGRESS_MIN_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// How long a job with no arrivals and no commit survives before the sweeper
|
||||
/// discards it and its partial file. Override with `META_SHARE_INGRESS_JOB_TTL_SECS`.
|
||||
const DEFAULT_JOB_TTL_SECS: u64 = 30 * 60;
|
||||
|
||||
fn env_u64(key: &str, default: u64) -> u64 {
|
||||
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Where a leaf this peer is waiting for belongs in the file being filled.
|
||||
#[derive(Clone)]
|
||||
struct Pending {
|
||||
job: Arc<IngressJob>,
|
||||
offset: u64,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
/// Initiating cid → job.
|
||||
jobs: HashMap<String, Arc<IngressJob>>,
|
||||
/// Leaf cid (wire bytes) → where it goes. The router's whole lookup, and the
|
||||
/// reason an unsolicited block is distinguishable from a wanted one.
|
||||
pending: HashMap<Vec<u8>, Pending>,
|
||||
}
|
||||
|
||||
/// Process-wide registry of in-flight bitswap materialisations.
|
||||
pub struct IngressRegistry {
|
||||
inner: RwLock<Inner>,
|
||||
tmp_dir: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
min_bytes: u64,
|
||||
job_ttl_secs: u64,
|
||||
}
|
||||
|
||||
/// A leaf's placement in the file, resolved from the walk. Threaded into
|
||||
/// `ipfs_walker::walk_range` so every child it descends through is registered
|
||||
/// with its **file-absolute** offset before the fetch is issued.
|
||||
pub struct IngressCtx {
|
||||
pub job: Arc<IngressJob>,
|
||||
/// Absolute offset, in the file's byte space, of the node being walked.
|
||||
pub base: u64,
|
||||
}
|
||||
|
||||
impl IngressCtx {
|
||||
/// A context for a nested dag-pb child at `child_start` within this node.
|
||||
pub fn nested(&self, child_start: u64) -> IngressCtx {
|
||||
IngressCtx {
|
||||
job: Arc::clone(&self.job),
|
||||
base: self.base.saturating_add(child_start),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IngressRegistry {
|
||||
/// Build against the shared cache tree (`crate::material::storage_dirs`), so
|
||||
/// ingress promotes into the same `cache/` every other tier does.
|
||||
pub fn from_data_dir(data_dir: &Path) -> Self {
|
||||
let (tmp_dir, cache_dir) = crate::material::storage_dirs(data_dir);
|
||||
Self::new(tmp_dir, cache_dir)
|
||||
}
|
||||
|
||||
/// Same registry with an explicit container floor, so a test can exercise
|
||||
/// the accept/complete machinery on a few bytes instead of the 4 MiB a real
|
||||
/// container is worth.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_min_bytes(tmp_dir: PathBuf, cache_dir: PathBuf, min_bytes: u64) -> Self {
|
||||
let mut r = Self::new(tmp_dir, cache_dir);
|
||||
r.min_bytes = min_bytes;
|
||||
r
|
||||
}
|
||||
|
||||
pub fn new(tmp_dir: PathBuf, cache_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
inner: RwLock::new(Inner::default()),
|
||||
tmp_dir,
|
||||
cache_dir,
|
||||
min_bytes: env_u64("META_SHARE_INGRESS_MIN_BYTES", DEFAULT_INGRESS_MIN_BYTES),
|
||||
job_ttl_secs: env_u64("META_SHARE_INGRESS_JOB_TTL_SECS", DEFAULT_JOB_TTL_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_dir(&self) -> &Path {
|
||||
&self.cache_dir
|
||||
}
|
||||
|
||||
pub fn tmp_dir(&self) -> &Path {
|
||||
&self.tmp_dir
|
||||
}
|
||||
|
||||
/// Start (or adopt) the job filling `container`. `Ok(None)` when the file is
|
||||
/// too small to be worth a container — the caller then behaves exactly as
|
||||
/// before this module existed and the leaves stay material.
|
||||
///
|
||||
/// Creating the sparse file up front is what makes every later write a plain
|
||||
/// `pwrite` at a known offset: leaves arrive out of order by nature, and a
|
||||
/// file that already has its final length has nowhere to race.
|
||||
pub async fn begin(
|
||||
&self,
|
||||
container: &str,
|
||||
root: &str,
|
||||
name: &str,
|
||||
total: u64,
|
||||
) -> Result<Option<Arc<IngressJob>>> {
|
||||
if total < self.min_bytes {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(existing) = self.job(container) {
|
||||
return Ok(Some(existing));
|
||||
}
|
||||
let rel = sanitize_rel(name, container);
|
||||
let dir = self.tmp_dir.join(container);
|
||||
let path = dir.join(&rel);
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.with_context(|| format!("create ingress tmp dir {}", dir.display()))?;
|
||||
|
||||
let file_path = path.clone();
|
||||
let file = tokio::task::spawn_blocking(move || -> std::io::Result<std::fs::File> {
|
||||
let f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&file_path)?;
|
||||
f.set_len(total)?;
|
||||
Ok(f)
|
||||
})
|
||||
.await
|
||||
.context("join ingress file create")?
|
||||
.with_context(|| format!("create sparse file {}", path.display()))?;
|
||||
|
||||
let job = Arc::new(IngressJob {
|
||||
container: container.to_string(),
|
||||
root: root.to_string(),
|
||||
rel,
|
||||
path,
|
||||
total,
|
||||
file: Arc::new(file),
|
||||
state: RwLock::new(JobState {
|
||||
received: HashMap::new(),
|
||||
received_bytes: 0,
|
||||
last_touch: now_secs(),
|
||||
promoted: false,
|
||||
committed: false,
|
||||
filling: false,
|
||||
}),
|
||||
});
|
||||
// Re-check under the write lock: two concurrent range requests for the
|
||||
// same cid both reach `begin`, and the loser must adopt the winner's job
|
||||
// rather than hand out a second handle to the same file.
|
||||
let mut inner = self.inner.write().expect("ingress lock");
|
||||
let job = inner
|
||||
.jobs
|
||||
.entry(container.to_string())
|
||||
.or_insert(job)
|
||||
.clone();
|
||||
debug!(container, total, path = %job.path.display(), "ingress: filling");
|
||||
Ok(Some(job))
|
||||
}
|
||||
|
||||
pub fn job(&self, container: &str) -> Option<Arc<IngressJob>> {
|
||||
self.inner.read().expect("ingress lock").jobs.get(container).cloned()
|
||||
}
|
||||
|
||||
pub fn jobs(&self) -> Vec<Arc<IngressJob>> {
|
||||
self.inner.read().expect("ingress lock").jobs.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Declare that `job` wants `leaf_cid` at `offset`. Called by the walker for
|
||||
/// every raw leaf it is about to fetch, *before* the WANT goes out, so the
|
||||
/// block can be attributed the moment it lands.
|
||||
pub fn expect_leaf(&self, ctx: &IngressCtx, leaf_cid_bytes: Vec<u8>, offset: u64, len: u64) {
|
||||
if ctx.job.is_promoted() || len == 0 {
|
||||
return;
|
||||
}
|
||||
let mut inner = self.inner.write().expect("ingress lock");
|
||||
inner.pending.insert(
|
||||
leaf_cid_bytes,
|
||||
Pending { job: Arc::clone(&ctx.job), offset, len },
|
||||
);
|
||||
}
|
||||
|
||||
/// Route an arriving block. `true` when it belonged to a job and was written
|
||||
/// to that job's file — the caller must then **not** store it as material.
|
||||
///
|
||||
/// This is the provider-only boundary: a block nobody is waiting for returns
|
||||
/// `false` and is dropped by the router.
|
||||
pub async fn accept(&self, cid_bytes: &[u8], bytes: &[u8]) -> bool {
|
||||
let pending = {
|
||||
let inner = self.inner.read().expect("ingress lock");
|
||||
match inner.pending.get(cid_bytes) {
|
||||
Some(p) => p.clone(),
|
||||
None => return false,
|
||||
}
|
||||
};
|
||||
// A leaf shorter than declared would tear a hole at the next offset;
|
||||
// refuse it and let the block fall through to the material path, where
|
||||
// the dag walk's own length check reports it.
|
||||
if bytes.len() as u64 != pending.len {
|
||||
warn!(offset = pending.offset, declared = pending.len, got = bytes.len(),
|
||||
container = %pending.job.container,
|
||||
"ingress: leaf length disagrees with the dag; not writing it to the file");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = pending.job.write_leaf(pending.offset, bytes).await {
|
||||
warn!(container = %pending.job.container, offset = pending.offset,
|
||||
error = %format!("{e:#}"), "ingress: leaf write failed; keeping it material");
|
||||
return false;
|
||||
}
|
||||
pending.job.mark_received(cid_bytes, pending.offset, pending.len);
|
||||
self.inner.write().expect("ingress lock").pending.remove(cid_bytes);
|
||||
true
|
||||
}
|
||||
|
||||
/// Does some job already hold this leaf? The presence half of
|
||||
/// [`read_leaf`](Self::read_leaf), for `has()` — which must agree with `get`
|
||||
/// or this peer advertises blocks it declines to serve, but must not pay a
|
||||
/// 256 KiB `pread` to answer.
|
||||
pub fn has_leaf(&self, cid_bytes: &[u8]) -> bool {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("ingress lock")
|
||||
.jobs
|
||||
.values()
|
||||
.any(|j| j.received_at(cid_bytes).is_some())
|
||||
}
|
||||
|
||||
/// The §2a read: bytes of a leaf some job has already received. `None` when
|
||||
/// no job holds it — which is the common case and costs one map lookup.
|
||||
pub async fn read_leaf(&self, cid_bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let (job, offset, len) = {
|
||||
let inner = self.inner.read().expect("ingress lock");
|
||||
let mut found = None;
|
||||
for job in inner.jobs.values() {
|
||||
if let Some((offset, len)) = job.received_at(cid_bytes) {
|
||||
found = Some((Arc::clone(job), offset, len));
|
||||
break;
|
||||
}
|
||||
}
|
||||
found?
|
||||
};
|
||||
match job.read_at(offset, len).await {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => {
|
||||
debug!(container = %job.container, offset, error = %format!("{e:#}"),
|
||||
"ingress: in-flight leaf read failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a job and every leaf still pending for it. Does **not** touch the
|
||||
/// file — promotion has already renamed it, or the caller is discarding it.
|
||||
pub fn close(&self, container: &str) {
|
||||
let mut inner = self.inner.write().expect("ingress lock");
|
||||
inner.jobs.remove(container);
|
||||
inner.pending.retain(|_, p| p.job.container != container);
|
||||
}
|
||||
|
||||
/// Jobs whose dag is fully present in their file — ready to promote.
|
||||
pub fn complete_jobs(&self) -> Vec<Arc<IngressJob>> {
|
||||
self.jobs().into_iter().filter(|j| j.is_complete() && !j.is_promoted()).collect()
|
||||
}
|
||||
|
||||
/// Jobs idle past the TTL. The caller decides whether a job is exempt
|
||||
/// (committed titles are still being filled).
|
||||
pub fn stale_jobs(&self) -> Vec<Arc<IngressJob>> {
|
||||
let cutoff = now_secs().saturating_sub(self.job_ttl_secs);
|
||||
self.jobs().into_iter().filter(|j| j.last_touch() < cutoff).collect()
|
||||
}
|
||||
|
||||
/// Discard a job's partial file along with the job.
|
||||
pub async fn discard(&self, container: &str) {
|
||||
self.close(container);
|
||||
let dir = self.tmp_dir.join(container);
|
||||
if let Err(e) = tokio::fs::remove_dir_all(&dir).await {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
debug!(container, dir = %dir.display(), error = %e,
|
||||
"ingress: discarding the partial dir failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot sweep: delete `tmp/` dirs left by jobs that died mid-fill.
|
||||
///
|
||||
/// Safe by construction and deliberately asymmetric with the cache scrub,
|
||||
/// which *keeps* orphan dirs: a directory under `cache/` is complete bytes
|
||||
/// that eviction owns, while one under `tmp/` is incomplete by definition —
|
||||
/// nothing indexes it, nothing serves it after a restart (the in-flight set
|
||||
/// lived in memory), and its content is re-fetchable. Leaving them would be a
|
||||
/// slow leak of exactly the disk this phase is trying to save.
|
||||
///
|
||||
/// Skips `<cid>.work` scratch dirs, which belong to the Usenet bridge.
|
||||
pub async fn sweep_tmp(&self) -> u64 {
|
||||
let mut removed = 0u64;
|
||||
let mut rd = match tokio::fs::read_dir(&self.tmp_dir).await {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let live: Vec<String> = self.jobs().iter().map(|j| j.container.clone()).collect();
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.ends_with(".work") || live.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
if tokio::fs::remove_dir_all(entry.path()).await.is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
debug!(removed, "ingress: swept abandoned tmp dirs");
|
||||
}
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
struct JobState {
|
||||
/// Leaf cid wire bytes → (offset, len). Both the §2a serving set and, via
|
||||
/// `received_bytes`, the completion test.
|
||||
received: HashMap<Vec<u8>, (u64, u64)>,
|
||||
received_bytes: u64,
|
||||
last_touch: u64,
|
||||
promoted: bool,
|
||||
/// A viewer played this title; `crate::ingress_commit` should finish the file.
|
||||
committed: bool,
|
||||
/// A background fill is running for this job right now. Read from the job
|
||||
/// rather than tracked by the supervisor so a pass that overlaps the previous
|
||||
/// one can't start a second fill over the same file.
|
||||
filling: bool,
|
||||
}
|
||||
|
||||
/// One file being filled from the swarm.
|
||||
pub struct IngressJob {
|
||||
/// The immutable cid that initiated the fetch — the container's name.
|
||||
pub container: String,
|
||||
/// The dag-pb root actually walked. Same value as `container` on today's
|
||||
/// path; kept separate because the container name may not be a content id.
|
||||
pub root: String,
|
||||
/// File name inside the container.
|
||||
pub rel: String,
|
||||
/// `tmp/<container>/<rel>`.
|
||||
pub path: PathBuf,
|
||||
pub total: u64,
|
||||
file: Arc<std::fs::File>,
|
||||
state: RwLock<JobState>,
|
||||
}
|
||||
|
||||
impl IngressJob {
|
||||
/// `pwrite` — no seek, so concurrent leaf writes to one file never race.
|
||||
async fn write_leaf(&self, offset: u64, bytes: &[u8]) -> Result<()> {
|
||||
let file = Arc::clone(&self.file);
|
||||
let buf = bytes.to_vec();
|
||||
tokio::task::spawn_blocking(move || file.write_all_at(&buf, offset))
|
||||
.await
|
||||
.context("join leaf write")?
|
||||
.context("pwrite leaf")
|
||||
}
|
||||
|
||||
async fn read_at(&self, offset: u64, len: u64) -> Result<Vec<u8>> {
|
||||
let file = Arc::clone(&self.file);
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
file.read_exact_at(&mut buf, offset)?;
|
||||
Ok(buf)
|
||||
})
|
||||
.await
|
||||
.context("join leaf read")?
|
||||
.context("pread leaf")
|
||||
}
|
||||
|
||||
fn mark_received(&self, cid_bytes: &[u8], offset: u64, len: u64) {
|
||||
let mut st = self.state.write().expect("job lock");
|
||||
st.last_touch = now_secs();
|
||||
if st.received.insert(cid_bytes.to_vec(), (offset, len)).is_none() {
|
||||
st.received_bytes = st.received_bytes.saturating_add(len);
|
||||
}
|
||||
}
|
||||
|
||||
fn received_at(&self, cid_bytes: &[u8]) -> Option<(u64, u64)> {
|
||||
self.state.read().expect("job lock").received.get(cid_bytes).copied()
|
||||
}
|
||||
|
||||
pub fn received_bytes(&self) -> u64 {
|
||||
self.state.read().expect("job lock").received_bytes
|
||||
}
|
||||
|
||||
/// Every leaf of the file is on disk.
|
||||
///
|
||||
/// Leaves tile the file exactly — kubo chunking leaves no gaps and no
|
||||
/// overlaps — so the received bytes summing to the declared size *is* full
|
||||
/// coverage. No bitmap, which is what the design's §2a narrowing is careful
|
||||
/// to avoid inventing: the arrival set the fetch already keeps is enough.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.received_bytes() >= self.total && self.total > 0
|
||||
}
|
||||
|
||||
pub fn is_promoted(&self) -> bool {
|
||||
self.state.read().expect("job lock").promoted
|
||||
}
|
||||
|
||||
pub fn last_touch(&self) -> u64 {
|
||||
self.state.read().expect("job lock").last_touch
|
||||
}
|
||||
|
||||
pub fn touch(&self) {
|
||||
self.state.write().expect("job lock").last_touch = now_secs();
|
||||
}
|
||||
|
||||
pub fn mark_promoted(&self) {
|
||||
self.state.write().expect("job lock").promoted = true;
|
||||
}
|
||||
|
||||
/// A viewer genuinely played this title, so the rest of the file is worth
|
||||
/// fetching in the background rather than stopping at the playhead. Returns
|
||||
/// `true` on the transition only, so a per-range call doesn't log per range.
|
||||
///
|
||||
/// **In-memory, unlike the torrent tier's `SeedEntry.committed`.** The intent
|
||||
/// only has to outlive the job, and the job dies with the process — a restart
|
||||
/// drops a half-filled `tmp/` file anyway (`sweep_tmp`), so there is nothing
|
||||
/// for a persisted flag to re-arm. Once promoted, filling is finished by
|
||||
/// definition.
|
||||
pub fn commit(&self) -> bool {
|
||||
let mut st = self.state.write().expect("job lock");
|
||||
if st.committed {
|
||||
return false;
|
||||
}
|
||||
st.committed = true;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_committed(&self) -> bool {
|
||||
self.state.read().expect("job lock").committed
|
||||
}
|
||||
|
||||
/// Claim the fill slot. `false` when one is already running.
|
||||
pub fn begin_fill(&self) -> bool {
|
||||
let mut st = self.state.write().expect("job lock");
|
||||
if st.filling {
|
||||
return false;
|
||||
}
|
||||
st.filling = true;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn end_fill(&self) {
|
||||
self.state.write().expect("job lock").filling = false;
|
||||
}
|
||||
|
||||
pub fn is_filling(&self) -> bool {
|
||||
self.state.read().expect("job lock").filling
|
||||
}
|
||||
|
||||
/// Every received leaf as a [`LeafRef`], for registration against the
|
||||
/// promoted file.
|
||||
///
|
||||
/// This is why bitswap ingress is cheaper to seal than the Usenet tier: a
|
||||
/// ref *is* `(cid, offset, len)`, and the walk already knew all three for
|
||||
/// every leaf, so promotion registers them directly instead of re-reading
|
||||
/// the file end-to-end to re-derive cids it was told.
|
||||
pub fn leaf_refs(&self) -> Vec<LeafRef> {
|
||||
let st = self.state.read().expect("job lock");
|
||||
st.received
|
||||
.iter()
|
||||
.filter_map(|(cid_bytes, (offset, len))| {
|
||||
let cid = crate::blockstore::MsCid::try_from(cid_bytes.as_slice()).ok()?;
|
||||
Some(LeafRef { cid: cid.to_string(), offset: *offset, len: *len })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A safe file name inside the container dir. Falls back to the cid when the
|
||||
/// record's display name is empty or path-like — the name is cosmetic (the
|
||||
/// container's identity is its directory), so anything unusable becomes the cid
|
||||
/// rather than an error.
|
||||
fn sanitize_rel(name: &str, container: &str) -> String {
|
||||
let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
|
||||
let cleaned: String = base
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '\0' | '\x1f'))
|
||||
.collect();
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
container.to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Default floor — for the "small objects stay material" assertion.
|
||||
fn reg(dir: &Path) -> IngressRegistry {
|
||||
IngressRegistry::new(dir.join("tmp"), dir.join("cache"))
|
||||
}
|
||||
|
||||
/// Floor of one byte, so a 16-byte fixture is a container.
|
||||
fn reg_tiny(dir: &Path) -> IngressRegistry {
|
||||
IngressRegistry::with_min_bytes(dir.join("tmp"), dir.join("cache"), 1)
|
||||
}
|
||||
|
||||
/// Wire bytes of the raw-leaf cid for `bytes`. Anything ≤ 256 KiB is a
|
||||
/// single leaf, so `compute_ipfs_cid` *is* the leaf cid.
|
||||
fn leaf_cid(bytes: &[u8]) -> Vec<u8> {
|
||||
crate::blockstore::parse_record_cid(&crate::ipfs_chunk::compute_ipfs_cid(bytes))
|
||||
.expect("cid")
|
||||
.to_bytes()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_out_of_order_leaves_and_completes() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let r = reg_tiny(dir.path());
|
||||
let a = vec![b'a'; 8];
|
||||
let b = vec![b'b'; 8];
|
||||
let job = r
|
||||
.begin("bafyc", "bafyc", "movie.mkv", 16)
|
||||
.await
|
||||
.expect("begin")
|
||||
.expect("job");
|
||||
let ctx = IngressCtx { job: Arc::clone(&job), base: 0 };
|
||||
r.expect_leaf(&ctx, leaf_cid(&a), 0, 8);
|
||||
r.expect_leaf(&ctx, leaf_cid(&b), 8, 8);
|
||||
|
||||
// Second leaf first — arrival order is the swarm's business, not ours.
|
||||
assert!(r.accept(&leaf_cid(&b), &b).await);
|
||||
assert!(!job.is_complete());
|
||||
assert!(r.accept(&leaf_cid(&a), &a).await);
|
||||
assert!(job.is_complete());
|
||||
|
||||
let on_disk = tokio::fs::read(&job.path).await.expect("read");
|
||||
assert_eq!(on_disk, [a.clone(), b.clone()].concat());
|
||||
assert_eq!(r.read_leaf(&leaf_cid(&b)).await.expect("in-flight read"), b);
|
||||
}
|
||||
|
||||
/// The provider-only property: a block no job asked for is not ours to keep.
|
||||
#[tokio::test]
|
||||
async fn unattributed_block_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let r = reg(dir.path());
|
||||
let stray = vec![b'z'; 8];
|
||||
assert!(!r.accept(&leaf_cid(&stray), &stray).await);
|
||||
assert!(r.read_leaf(&leaf_cid(&stray)).await.is_none());
|
||||
}
|
||||
|
||||
/// A leaf that doesn't match its declared length would tear a hole at the
|
||||
/// next offset, so it must not reach the file.
|
||||
#[tokio::test]
|
||||
async fn short_leaf_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let r = reg_tiny(dir.path());
|
||||
let job = r.begin("bafyc", "bafyc", "x.mkv", 16).await.expect("begin").expect("job");
|
||||
let ctx = IngressCtx { job, base: 0 };
|
||||
let short = vec![b'a'; 4];
|
||||
r.expect_leaf(&ctx, leaf_cid(&short), 0, 8);
|
||||
assert!(!r.accept(&leaf_cid(&short), &short).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn small_files_stay_material() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let r = reg(dir.path());
|
||||
assert!(r.begin("bafyc", "bafyc", "poster.jpg", 1024).await.expect("begin").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_leaf_is_counted_once() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let r = reg_tiny(dir.path());
|
||||
let job = r.begin("bafyc", "bafyc", "x.mkv", 16).await.expect("begin").expect("job");
|
||||
let ctx = IngressCtx { job: Arc::clone(&job), base: 0 };
|
||||
let a = vec![b'a'; 8];
|
||||
r.expect_leaf(&ctx, leaf_cid(&a), 0, 8);
|
||||
assert!(r.accept(&leaf_cid(&a), &a).await);
|
||||
// Re-expect + re-accept the same leaf (a second range over it).
|
||||
r.expect_leaf(&ctx, leaf_cid(&a), 0, 8);
|
||||
assert!(r.accept(&leaf_cid(&a), &a).await);
|
||||
assert_eq!(job.received_bytes(), 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_ctx_accumulates_absolute_offsets() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let r = reg_tiny(dir.path());
|
||||
let job = r
|
||||
.begin("c", "c", "x", 8 * 1024 * 1024)
|
||||
.await
|
||||
.expect("begin")
|
||||
.expect("job");
|
||||
let root = IngressCtx { job, base: 0 };
|
||||
assert_eq!(root.nested(1024).nested(256).base, 1280);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_rel_falls_back_to_the_cid() {
|
||||
assert_eq!(sanitize_rel("a/b/c.mkv", "cid"), "c.mkv");
|
||||
assert_eq!(sanitize_rel(" ", "cid"), "cid");
|
||||
assert_eq!(sanitize_rel("..", "cid"), "cid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Watch-commit supervisor for the **IPFS tier** — "once a viewer really watches
|
||||
//! it, finish the file" — plus the promotion that turns a finished
|
||||
//! materialisation into a cache container.
|
||||
//!
|
||||
//! ## Why a supervisor at all
|
||||
//!
|
||||
//! Playback is read-driven: `api/files/bitswap.rs` walks only the leaves a
|
||||
//! `Range` intersects. So a viewer who watches twenty minutes and closes the tab
|
||||
//! leaves the dag *incomplete*, and "promote when the dag completes" would keep
|
||||
//! almost nothing — the peer would fetch a title, serve it, and throw it away.
|
||||
//!
|
||||
//! The torrent tier already solved this shape ([`crate::bt_commit`]): a genuine
|
||||
//! play records *intent*, and a supervisor turns intent into a full download
|
||||
//! under a disk floor and a concurrency cap. This is the same design for bitswap,
|
||||
//! with the same asymmetry — intent is recorded, state is read from the job
|
||||
//! rather than stored, so a drifted view can only mislabel a pass, never strand a
|
||||
//! fill.
|
||||
//!
|
||||
//! ## What a pass does
|
||||
//!
|
||||
//! 1. **Promote** every job whose file is whole — regardless of commit. A fetch
|
||||
//! that completed on its own (`POST /api/add/…`, a `warm`, an unranged GET) is
|
||||
//! finished content and belongs in `cache/` immediately.
|
||||
//! 2. **Fill** committed jobs, up to `max_concurrent`, while free disk is above
|
||||
//! `min_free_bytes`. A commit is a want, not a promise: filling a disk to
|
||||
//! honour one is strictly worse than streaming it, and the eviction sweeper
|
||||
//! reclaims on its own schedule so a later pass re-arms.
|
||||
//! 3. **Discard** jobs that are idle, uncommitted and incomplete — their partial
|
||||
//! file is re-fetchable and nothing indexes it.
|
||||
//!
|
||||
//! ## Filling is a windowed re-walk, not a second fetch path
|
||||
//!
|
||||
//! The fill asks `ipfs_walker::walk_range` for the file in windows and drops the
|
||||
//! bytes. That looks wasteful and is exactly the opposite: the walker already
|
||||
//! resolves each leaf blockstore → refs → in-flight file → bitswap, so a leaf
|
||||
//! already in the `tmp/` file is a local read and only genuinely missing leaves
|
||||
//! hit the swarm. One fetch path, one place where a leaf can be attributed to a
|
||||
//! file, and no second implementation of dag traversal to drift.
|
||||
//!
|
||||
//! Windows bound memory: `walk_range` returns the range it walked, so asking for
|
||||
//! a whole 2 GB file would materialise 2 GB of `Bytes` to throw away.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::ingress::{IngressCtx, IngressJob};
|
||||
|
||||
/// Bytes pulled per `walk_range` call during a fill. Big enough that the
|
||||
/// per-window overhead is noise against 256 KiB leaves, small enough that the
|
||||
/// discarded buffer never dominates RSS.
|
||||
const FILL_WINDOW_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
fn env_u64(key: &str, default: u64) -> u64 {
|
||||
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
fn enabled() -> bool {
|
||||
!matches!(std::env::var("META_SHARE_INGRESS_COMMIT").as_deref(), Ok("0") | Ok("false"))
|
||||
}
|
||||
|
||||
/// Start the supervisor. No-op when `META_SHARE_INGRESS_COMMIT=0`, in which case
|
||||
/// jobs still promote on their own completion — only the background filling of a
|
||||
/// partially-watched title is off.
|
||||
pub fn spawn(state: Arc<AppState>) {
|
||||
let interval =
|
||||
Duration::from_secs(env_u64("META_SHARE_INGRESS_COMMIT_INTERVAL_SECS", 30).max(1));
|
||||
let max_concurrent = env_u64("META_SHARE_INGRESS_COMMIT_MAX_CONCURRENT", 2).max(1) as usize;
|
||||
// Same floor as the torrent tier's default: below this, fills stand down.
|
||||
let min_free_bytes = env_u64("META_SHARE_INGRESS_COMMIT_MIN_FREE_BYTES", 5 * 1024 * 1024 * 1024);
|
||||
let commit_enabled = enabled();
|
||||
info!(
|
||||
commit_enabled,
|
||||
max_concurrent,
|
||||
min_free_bytes,
|
||||
interval_secs = interval.as_secs(),
|
||||
"ipfs ingress supervisor started"
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(interval);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
run_pass(&state, commit_enabled, max_concurrent, min_free_bytes).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_pass(
|
||||
state: &Arc<AppState>,
|
||||
commit_enabled: bool,
|
||||
max_concurrent: usize,
|
||||
min_free_bytes: u64,
|
||||
) {
|
||||
for job in state.ingress.complete_jobs() {
|
||||
promote(state, &job).await;
|
||||
}
|
||||
|
||||
if commit_enabled {
|
||||
let free = meta_feeder_sdk::transport::fsutil::available_bytes(&state.data_dir).unwrap_or(u64::MAX);
|
||||
if free >= min_free_bytes {
|
||||
let mut started = 0usize;
|
||||
for job in state.ingress.jobs() {
|
||||
if started >= max_concurrent {
|
||||
break;
|
||||
}
|
||||
if job.is_promoted() || job.is_complete() || !job.is_committed() {
|
||||
continue;
|
||||
}
|
||||
if !job.begin_fill() {
|
||||
// Already filling from an earlier pass.
|
||||
continue;
|
||||
}
|
||||
started += 1;
|
||||
let state = Arc::clone(state);
|
||||
let job = Arc::clone(&job);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = fill(&state, &job).await {
|
||||
debug!(container = %job.container, error = %format!("{e:#}"),
|
||||
"ingress fill: stopped short; a later pass retries");
|
||||
}
|
||||
job.end_fill();
|
||||
if job.is_complete() {
|
||||
promote(&state, &job).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
debug!(free, min_free_bytes, "ingress fill: standing down, disk floor");
|
||||
}
|
||||
}
|
||||
|
||||
// Idle, uncommitted, incomplete: nothing is coming. The partial file is
|
||||
// re-fetchable and no index points at it.
|
||||
for job in state.ingress.stale_jobs() {
|
||||
if job.is_committed() || job.is_filling() || job.is_promoted() {
|
||||
continue;
|
||||
}
|
||||
debug!(container = %job.container, received = job.received_bytes(), total = job.total,
|
||||
"ingress: discarding an idle partial fetch");
|
||||
state.ingress.discard(&job.container).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch every leaf the job is still missing, in windows.
|
||||
///
|
||||
/// **The lane is the title's own, not a blanket `Background`.** A fill exists
|
||||
/// because a viewer played *this* title, and the play claimed a focus lease for
|
||||
/// it — so hardcoding `Background` makes the peer throttle the fill of the very
|
||||
/// thing being watched to `bg_rate_bytes` (10 KiB/s by default). Measured before
|
||||
/// this: 1.7 MB in 90 s. `focus.lane` answers per title, which gives the right
|
||||
/// behaviour in both directions — the focused title's fill runs free, and a fill
|
||||
/// for anything *else* is floored while someone is watching.
|
||||
async fn fill(state: &Arc<AppState>, job: &Arc<IngressJob>) -> anyhow::Result<()> {
|
||||
let lane = state.focus.lane(&job.root);
|
||||
let root = crate::blockstore::parse_record_cid(&job.root)?;
|
||||
let root_block = crate::api::ipfs_walker::get_block(state, &root, lane).await?;
|
||||
let ceiling = crate::api::ipfs_walker::max_body_bytes();
|
||||
let window = FILL_WINDOW_BYTES.min(ceiling as u64).max(1);
|
||||
let mut start = 0u64;
|
||||
while start < job.total {
|
||||
if job.is_promoted() {
|
||||
break;
|
||||
}
|
||||
let end = (start + window - 1).min(job.total - 1);
|
||||
let ctx = IngressCtx { job: Arc::clone(job), base: 0 };
|
||||
// The bytes are the point of a `/raw` request, not of a fill — the leaves
|
||||
// land in the file as a side effect of the walk, and the buffer here is
|
||||
// dropped immediately.
|
||||
// Re-resolved per window: a lease can be claimed or expire mid-fill, and
|
||||
// a multi-GB file is many windows.
|
||||
let lane = state.focus.lane(&job.root);
|
||||
let _ = crate::api::ipfs_walker::walk_range(
|
||||
state,
|
||||
&root_block,
|
||||
start,
|
||||
end,
|
||||
ceiling,
|
||||
lane,
|
||||
Some(&ctx),
|
||||
)
|
||||
.await?;
|
||||
job.touch();
|
||||
start = end.saturating_add(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a finished materialisation: file → index → refs → seed row, each step
|
||||
/// depending only on the one before it.
|
||||
///
|
||||
/// The ordering is the same one every other tier uses and it is not stylistic:
|
||||
/// a ref resolves *through* the material index, so a ref written first would be
|
||||
/// unresolvable; and a seed row written before the refs would advertise a cid
|
||||
/// this peer cannot yet serve.
|
||||
async fn promote(state: &Arc<AppState>, job: &Arc<IngressJob>) {
|
||||
if job.is_promoted() {
|
||||
return;
|
||||
}
|
||||
// Claim first: two passes can see the same complete job, and promoting twice
|
||||
// would register the refs twice against a directory the second rename didn't
|
||||
// move.
|
||||
job.mark_promoted();
|
||||
|
||||
let container = job.container.clone();
|
||||
let dir = match crate::material::promote_container(
|
||||
state.ingress.tmp_dir(),
|
||||
state.ingress.cache_dir(),
|
||||
&container,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!(container = %container, error = %e, "ingress promote: rename failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let path = dir.join(&job.rel);
|
||||
let size = match tokio::fs::metadata(&path).await {
|
||||
Ok(m) => m.len(),
|
||||
Err(e) => {
|
||||
warn!(container = %container, path = %path.display(), error = %e,
|
||||
"ingress promote: the promoted file vanished");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// The hull indexes the container for local-first reads before any ref
|
||||
// advertises its bytes: file → index → refs, as before.
|
||||
let ev = meta_feeder_sdk::transport::Event::Promoted {
|
||||
container: container.clone(),
|
||||
cids: vec![job.root.clone()],
|
||||
files: vec![meta_feeder_sdk::transport::PromotedFile {
|
||||
rel: job.rel.clone(),
|
||||
path: path.display().to_string(),
|
||||
size,
|
||||
}],
|
||||
};
|
||||
if let Err(e) = state.hull.emit(&ev).await {
|
||||
warn!(container = %container, error = %e, "ingress promote: the hull did not index the container");
|
||||
}
|
||||
|
||||
// Register every leaf as a ref into the published file. No re-chunk: the walk
|
||||
// that fetched them already knew each leaf's cid, offset and length, which is
|
||||
// exactly what a ref is. (The Usenet tier has to stream the whole file back
|
||||
// through the chunker precisely because it never saw a dag.)
|
||||
let refs = job.leaf_refs();
|
||||
let mut written = 0u64;
|
||||
for leaf in &refs {
|
||||
match state.block_store.put_cache_leaf_ref(leaf, &container, &job.rel).await {
|
||||
Ok(()) => written += 1,
|
||||
Err(e) => warn!(container = %container, cid = %leaf.cid, error = %format!("{e:#}"),
|
||||
"ingress promote: ref write failed"),
|
||||
}
|
||||
}
|
||||
|
||||
// Never seal onto a dag we don't fully hold: a hole here surfaces later as a
|
||||
// bitswap WANT stalling some peer mid-stream, long after the cause. Refs make
|
||||
// `has()` true, so this is a local walk — no network.
|
||||
let complete = match crate::blockstore::parse_record_cid(&job.root) {
|
||||
Ok(root) => crate::share::local_dag_complete(state, &root).await,
|
||||
Err(e) => {
|
||||
warn!(container = %container, error = %format!("{e:#}"),
|
||||
"ingress promote: unparseable root; not recording a seed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !complete {
|
||||
warn!(container = %container, refs = written,
|
||||
"ingress promote: dag incomplete after ref registration; file kept, not seeded");
|
||||
return;
|
||||
}
|
||||
|
||||
// The seed row, bound to the container its bytes live in (or eviction knows
|
||||
// the size but not the files and tearing it down frees nothing).
|
||||
crate::api::record_ipfs_seed(state, &job.root, &job.rel, job.total, Some(&container)).await;
|
||||
|
||||
info!(container = %container, refs = written, bytes = job.total,
|
||||
"ingress: promoted a bitswap fetch into the cache");
|
||||
state.ingress.close(&container);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
//! Kubo-compatible IPFS UnixFS chunker — the "ipfs add" direction.
|
||||
//!
|
||||
//! Takes a whole file's bytes and produces a standard IPFS CIDv1 plus
|
||||
//! **every block** of the resulting dag (leaf chunks + internal dag-pb
|
||||
//! nodes), so the caller can populate the bitswap blockstore in one pass
|
||||
//! and serve `/ipfs/{cid}` / answer bitswap WANTs for the file.
|
||||
//!
|
||||
//! Output matches `ipfs add --cid-version=1 --raw-leaves=true
|
||||
//! --chunker=size-262144 --hash=sha2-256 <file>`:
|
||||
//! - file ≤ 256 KiB → a single raw-codec (`0x55`) leaf; the leaf cid IS
|
||||
//! the file cid (no UnixFS wrapping, matching `--raw-leaves`).
|
||||
//! - larger → chunk at 256 KiB boundaries into raw leaves, then a
|
||||
//! balanced UnixFS dag-pb (`0x70`) tree over them at fanout 174.
|
||||
//!
|
||||
//! ## Parity invariant (load-bearing — see CLAUDE.md)
|
||||
//!
|
||||
//! This module is a **byte-for-byte port** of meta-gateway's
|
||||
//! `crates/meta-gateway/src/hash.rs` IPFS family (`compute_ipfs_blocks` /
|
||||
//! `compute_ipfs_cid` + the dag-pb / UnixFS / base32 / varint encoders).
|
||||
//! The two crates can't share a path-dep (meta-share is a standalone git
|
||||
//! submodule), so the code is duplicated and pinned by the same
|
||||
//! kubo-derived CID test vectors on both sides. **If you change the
|
||||
//! encoding here, change it there too** — drift silently produces CIDs
|
||||
//! that public IPFS tooling (and gateway-seeded peers) can't resolve.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use bytes::Bytes;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
/// Per-block IPFS chunk size (256 KiB), matches kubo's
|
||||
/// `--chunker=size-262144` default.
|
||||
pub const IPFS_CHUNK_SIZE: usize = 256 * 1024;
|
||||
|
||||
/// dag-pb fanout — children per internal node. Matches kubo's balanced
|
||||
/// builder default.
|
||||
pub const IPFS_FANOUT: usize = 174;
|
||||
|
||||
/// Output of [`compute_ipfs_blocks`]. Carries the root CID **plus every
|
||||
/// intermediate block** (leaf chunks AND internal dag-pb nodes) keyed by
|
||||
/// their own CID. Pass `.blocks` to the blockstore so peers can fetch the
|
||||
/// file by CID via bitswap.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IpfsBlocks {
|
||||
/// Canonical "bafy…" / "bafk…" root cid string. Identical to what
|
||||
/// [`compute_ipfs_cid`] returns for the same input.
|
||||
pub root: String,
|
||||
/// Every block — root included — keyed by its own cid. For a
|
||||
/// single-leaf file, this is a one-entry vec `[(root, payload)]`
|
||||
/// (raw codec, payload is the file bytes). For multi-leaf files, it
|
||||
/// is `leaves ++ internal_nodes` in build order; the **last** entry
|
||||
/// is the root.
|
||||
pub blocks: Vec<(String, Bytes)>,
|
||||
}
|
||||
|
||||
/// Compute a standard IPFS CIDv1 over `bytes`. See module docs for the
|
||||
/// kubo-equivalence guarantee. Thin wrapper over [`compute_ipfs_blocks`].
|
||||
///
|
||||
/// Kept for parity with meta-gateway's `hash.rs` surface (and used by the
|
||||
/// parity tests below); the seeder itself calls [`compute_ipfs_blocks`] to
|
||||
/// get the blocks alongside the root, so this convenience wrapper has no
|
||||
/// non-test caller in this crate yet.
|
||||
#[allow(dead_code)]
|
||||
pub fn compute_ipfs_cid(bytes: &[u8]) -> String {
|
||||
compute_ipfs_blocks(bytes).root
|
||||
}
|
||||
|
||||
/// Same wire-format guarantee as [`compute_ipfs_cid`], but exposes every
|
||||
/// block produced along the way (leaf chunks + internal dag-pb nodes) so
|
||||
/// callers can populate a bitswap blockstore in one pass.
|
||||
///
|
||||
/// For a single-leaf file (≤ 256 KiB), `blocks` is one entry: the raw-
|
||||
/// codec leaf containing the file bytes; its cid equals `root`.
|
||||
///
|
||||
/// For larger files, `blocks` is the leaves in chunk-order followed by
|
||||
/// each level's internal nodes in build-order. The **last** entry is the
|
||||
/// root. Storing the whole vec in a blockstore makes every cid in the
|
||||
/// tree fetchable.
|
||||
pub fn compute_ipfs_blocks(bytes: &[u8]) -> IpfsBlocks {
|
||||
// Leaves: chunk bytes into raw-codec blocks. Empty input still gets
|
||||
// one (empty) leaf so the cid is well-defined; kubo treats `ipfs add`
|
||||
// of an empty file the same way (single raw block of empty content).
|
||||
let mut blocks: Vec<(String, Bytes)> = Vec::new();
|
||||
let leaves: Vec<(Vec<u8>, u64)> = if bytes.is_empty() {
|
||||
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(b""));
|
||||
blocks.push((cid_string(&cid_wire), Bytes::new()));
|
||||
vec![(cid_wire, 0)]
|
||||
} else {
|
||||
bytes
|
||||
.chunks(IPFS_CHUNK_SIZE)
|
||||
.map(|chunk| {
|
||||
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(chunk));
|
||||
blocks.push((cid_string(&cid_wire), Bytes::copy_from_slice(chunk)));
|
||||
(cid_wire, chunk.len() as u64)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Single-leaf file: return the leaf cid as the file cid. No UnixFS
|
||||
// wrapping — matches kubo behaviour under `--raw-leaves`.
|
||||
if leaves.len() == 1 {
|
||||
let root = cid_string(&leaves[0].0);
|
||||
return IpfsBlocks { root, blocks };
|
||||
}
|
||||
|
||||
// Multi-leaf: build a balanced tree of UnixFS dag-pb internal nodes.
|
||||
// Each level groups up to `IPFS_FANOUT` children into one parent.
|
||||
// Repeat until a single root remains. Tracking tuple per node:
|
||||
// (cid_wire, total_filesize_under_this_node, tsize)
|
||||
let mut level: Vec<(Vec<u8>, u64, u64)> = leaves
|
||||
.into_iter()
|
||||
.map(|(cid, size)| (cid, size, size))
|
||||
.collect();
|
||||
|
||||
while level.len() > 1 {
|
||||
let mut next: Vec<(Vec<u8>, u64, u64)> =
|
||||
Vec::with_capacity(level.len().div_ceil(IPFS_FANOUT));
|
||||
for batch in level.chunks(IPFS_FANOUT) {
|
||||
let blocksizes: Vec<u64> = batch.iter().map(|(_, sz, _)| *sz).collect();
|
||||
let filesize: u64 = blocksizes.iter().sum();
|
||||
let unixfs_data = encode_unixfs_file(filesize, &blocksizes);
|
||||
let node_bytes = encode_dagpb_node(batch, &unixfs_data);
|
||||
let node_cid = ipfs_cid_wire(0x70, &sha2_256(&node_bytes));
|
||||
blocks.push((cid_string(&node_cid), Bytes::from(node_bytes.clone())));
|
||||
let own_tsize: u64 =
|
||||
batch.iter().map(|(_, _, t)| *t).sum::<u64>() + node_bytes.len() as u64;
|
||||
next.push((node_cid, filesize, own_tsize));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
let root = cid_string(&level[0].0);
|
||||
IpfsBlocks { root, blocks }
|
||||
}
|
||||
|
||||
/// Streaming equivalent of [`compute_ipfs_blocks`] for large files: reads the
|
||||
/// file at `path` in 256 KiB leaf chunks, handing each `(cid, bytes)` block to
|
||||
/// `sink` as it is produced (so the caller persists then drops it), and returns
|
||||
/// `(root_cid, total_size)`. Only one chunk + the small per-leaf cid metadata +
|
||||
/// the (tiny) internal nodes are ever in memory — never the whole file, nor a
|
||||
/// second copy of it. Produces byte-for-byte identical CIDs to
|
||||
/// `compute_ipfs_blocks`, so a large posting can be re-seeded on a memory-capped
|
||||
/// peer without the ~2× file-size heap spike that OOMs the whole-slice path.
|
||||
/// No production caller since the Usenet re-seed moved to [`stream_ipfs_refs`] —
|
||||
/// nothing copies a file's bytes into the blockstore any more, which is the
|
||||
/// point of the unified cache. Kept deliberately, for two reasons: it is the
|
||||
/// **material-path reference encoder** that `stream_matches_whole_slice` pins
|
||||
/// against `compute_ipfs_blocks`, and this file is byte-for-byte mirrored with
|
||||
/// meta-gateway's `hash.rs` (invariant 5), so deleting a function from one side
|
||||
/// silently drifts the pair.
|
||||
#[allow(dead_code)]
|
||||
pub async fn stream_ipfs_blocks<F, Fut>(path: &Path, mut sink: F) -> Result<(String, u64)>
|
||||
where
|
||||
F: FnMut(String, Bytes) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let file = tokio::fs::File::open(path).await?;
|
||||
let mut reader = tokio::io::BufReader::with_capacity(IPFS_CHUNK_SIZE, file);
|
||||
let mut buf = vec![0u8; IPFS_CHUNK_SIZE];
|
||||
|
||||
// Leaves: stream to the sink, keeping only (cid_wire, size) metadata.
|
||||
let mut leaves: Vec<(Vec<u8>, u64)> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
loop {
|
||||
let n = read_chunk(&mut reader, &mut buf).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk = &buf[..n];
|
||||
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(chunk));
|
||||
sink(cid_string(&cid_wire), Bytes::copy_from_slice(chunk)).await?;
|
||||
leaves.push((cid_wire, n as u64));
|
||||
total += n as u64;
|
||||
}
|
||||
|
||||
// Empty file → a single empty raw leaf (matches `ipfs add` of an empty file).
|
||||
if leaves.is_empty() {
|
||||
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(b""));
|
||||
sink(cid_string(&cid_wire), Bytes::new()).await?;
|
||||
leaves.push((cid_wire, 0));
|
||||
}
|
||||
|
||||
// Single leaf → the leaf cid IS the file cid (no UnixFS wrapping).
|
||||
if leaves.len() == 1 {
|
||||
return Ok((cid_string(&leaves[0].0), total));
|
||||
}
|
||||
|
||||
// Multi-leaf: build the balanced dag-pb tree from the leaf metadata, writing
|
||||
// each internal node to the sink (identical to `compute_ipfs_blocks`).
|
||||
let mut level: Vec<(Vec<u8>, u64, u64)> =
|
||||
leaves.into_iter().map(|(cid, size)| (cid, size, size)).collect();
|
||||
while level.len() > 1 {
|
||||
let mut next: Vec<(Vec<u8>, u64, u64)> =
|
||||
Vec::with_capacity(level.len().div_ceil(IPFS_FANOUT));
|
||||
for batch in level.chunks(IPFS_FANOUT) {
|
||||
let blocksizes: Vec<u64> = batch.iter().map(|(_, sz, _)| *sz).collect();
|
||||
let filesize: u64 = blocksizes.iter().sum();
|
||||
let unixfs_data = encode_unixfs_file(filesize, &blocksizes);
|
||||
let node_bytes = encode_dagpb_node(batch, &unixfs_data);
|
||||
let node_cid = ipfs_cid_wire(0x70, &sha2_256(&node_bytes));
|
||||
let own_tsize: u64 =
|
||||
batch.iter().map(|(_, _, t)| *t).sum::<u64>() + node_bytes.len() as u64;
|
||||
sink(cid_string(&node_cid), Bytes::from(node_bytes)).await?;
|
||||
next.push((node_cid, filesize, own_tsize));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
Ok((cid_string(&level[0].0), total))
|
||||
}
|
||||
|
||||
/// A leaf's position in its backing file — the nocopy unit. Instead of storing
|
||||
/// the 256 KiB leaf bytes, the filestore stores this: the leaf `cid` (what
|
||||
/// bitswap asks for) plus the `offset`/`len` slice of the source file that *is*
|
||||
/// those bytes. Sound because a kubo raw leaf is literally
|
||||
/// `file[offset..offset+len]` (the parity invariant above), so re-reading that
|
||||
/// slice and hashing it reproduces the same cid.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LeafRef {
|
||||
/// The raw-leaf CID string (`bafk…`), byte-identical to what
|
||||
/// [`stream_ipfs_blocks`] would emit for the same chunk.
|
||||
pub cid: String,
|
||||
/// Byte offset of the leaf within the source file.
|
||||
pub offset: u64,
|
||||
/// Leaf length in bytes (== `IPFS_CHUNK_SIZE` for every leaf but the last).
|
||||
pub len: u64,
|
||||
}
|
||||
|
||||
/// Nocopy variant of [`stream_ipfs_blocks`]: reads `reader` in 256 KiB leaf
|
||||
/// chunks and, instead of storing the leaf bytes, hands each leaf's position to
|
||||
/// `ref_sink` as a [`LeafRef`]. Internal dag-pb nodes are **synthesized**
|
||||
/// (protobuf, not file slices), so they can't be refs and go to `block_sink`
|
||||
/// for material storage — exactly as `stream_ipfs_blocks` writes them. Returns
|
||||
/// `(root_cid, total_size)`, byte-identical to the whole-slice path.
|
||||
///
|
||||
/// The reader is still consumed once end-to-end (leaf CIDs are content hashes —
|
||||
/// there is no way to know a leaf's cid without hashing its bytes), but nothing
|
||||
/// is duplicated onto disk: the caller registers refs and, at serve time, the
|
||||
/// filestore re-reads `file[offset..offset+len]` on demand. This is what lets a
|
||||
/// library file be seeded for ~0 extra bytes (refs + a handful of internal
|
||||
/// nodes) instead of a second full copy in `blocks.redb`.
|
||||
pub async fn stream_ipfs_refs<R, RF, RFut, BF, BFut>(
|
||||
reader: R,
|
||||
mut ref_sink: RF,
|
||||
mut block_sink: BF,
|
||||
) -> Result<(String, u64)>
|
||||
where
|
||||
R: AsyncReadExt + Unpin,
|
||||
RF: FnMut(LeafRef) -> RFut,
|
||||
RFut: std::future::Future<Output = Result<()>>,
|
||||
BF: FnMut(String, Bytes) -> BFut,
|
||||
BFut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let mut reader = tokio::io::BufReader::with_capacity(IPFS_CHUNK_SIZE, reader);
|
||||
let mut buf = vec![0u8; IPFS_CHUNK_SIZE];
|
||||
|
||||
// Leaves: emit a ref per chunk, keep (cid_wire, size) metadata for the tree.
|
||||
let mut leaves: Vec<(Vec<u8>, u64)> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
loop {
|
||||
let n = read_chunk(&mut reader, &mut buf).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk = &buf[..n];
|
||||
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(chunk));
|
||||
ref_sink(LeafRef {
|
||||
cid: cid_string(&cid_wire),
|
||||
offset: total,
|
||||
len: n as u64,
|
||||
})
|
||||
.await?;
|
||||
leaves.push((cid_wire, n as u64));
|
||||
total += n as u64;
|
||||
}
|
||||
|
||||
// Empty file → a single empty raw leaf. A zero-length ref is still valid:
|
||||
// reading `file[0..0]` yields the empty slice, which hashes to this cid.
|
||||
if leaves.is_empty() {
|
||||
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(b""));
|
||||
ref_sink(LeafRef { cid: cid_string(&cid_wire), offset: 0, len: 0 }).await?;
|
||||
leaves.push((cid_wire, 0));
|
||||
}
|
||||
|
||||
// Single leaf → the leaf cid IS the file cid (no UnixFS wrapping).
|
||||
if leaves.len() == 1 {
|
||||
return Ok((cid_string(&leaves[0].0), total));
|
||||
}
|
||||
|
||||
// Multi-leaf: build the balanced dag-pb tree, writing each internal node to
|
||||
// `block_sink` (material). Identical tree maths to `stream_ipfs_blocks`.
|
||||
let mut level: Vec<(Vec<u8>, u64, u64)> =
|
||||
leaves.into_iter().map(|(cid, size)| (cid, size, size)).collect();
|
||||
while level.len() > 1 {
|
||||
let mut next: Vec<(Vec<u8>, u64, u64)> =
|
||||
Vec::with_capacity(level.len().div_ceil(IPFS_FANOUT));
|
||||
for batch in level.chunks(IPFS_FANOUT) {
|
||||
let blocksizes: Vec<u64> = batch.iter().map(|(_, sz, _)| *sz).collect();
|
||||
let filesize: u64 = blocksizes.iter().sum();
|
||||
let unixfs_data = encode_unixfs_file(filesize, &blocksizes);
|
||||
let node_bytes = encode_dagpb_node(batch, &unixfs_data);
|
||||
let node_cid = ipfs_cid_wire(0x70, &sha2_256(&node_bytes));
|
||||
let own_tsize: u64 =
|
||||
batch.iter().map(|(_, _, t)| *t).sum::<u64>() + node_bytes.len() as u64;
|
||||
block_sink(cid_string(&node_cid), Bytes::from(node_bytes)).await?;
|
||||
next.push((node_cid, filesize, own_tsize));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
Ok((cid_string(&level[0].0), total))
|
||||
}
|
||||
|
||||
/// Read up to `buf.len()` bytes, looping over short reads; returns the number
|
||||
/// read (0 only at EOF). So every leaf but the last is exactly `IPFS_CHUNK_SIZE`.
|
||||
async fn read_chunk<R: AsyncReadExt + Unpin>(r: &mut R, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut filled = 0;
|
||||
while filled < buf.len() {
|
||||
let n = r.read(&mut buf[filled..]).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
filled += n;
|
||||
}
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
fn cid_string(wire: &[u8]) -> String {
|
||||
format!("b{}", base32_lower_no_padding(wire))
|
||||
}
|
||||
|
||||
/// CIDv1 wire-form bytes: `[version=0x01][codec varint][multihash code
|
||||
/// varint=0x12 sha2-256][len=0x20][digest...]`. Both `raw` (`0x55`) and
|
||||
/// `dag-pb` (`0x70`) codecs encode as single-byte varints since they're
|
||||
/// under 0x80.
|
||||
fn ipfs_cid_wire(codec: u8, digest: &[u8; 32]) -> Vec<u8> {
|
||||
debug_assert!(
|
||||
codec < 0x80,
|
||||
"codec varint must fit in one byte for this helper"
|
||||
);
|
||||
let mut wire = Vec::with_capacity(36);
|
||||
wire.push(0x01); // CIDv1
|
||||
wire.push(codec);
|
||||
wire.push(0x12); // multihash code: sha2-256
|
||||
wire.push(0x20); // digest length: 32
|
||||
wire.extend_from_slice(digest);
|
||||
wire
|
||||
}
|
||||
|
||||
fn sha2_256(bytes: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(bytes).into()
|
||||
}
|
||||
|
||||
/// LEB128 unsigned varint encoding into `out`.
|
||||
fn write_pb_varint(value: u64, out: &mut Vec<u8>) {
|
||||
let mut v = value;
|
||||
while v >= 0x80 {
|
||||
out.push((v as u8) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.push(v as u8);
|
||||
}
|
||||
|
||||
fn write_pb_varint_field(field: u32, value: u64, out: &mut Vec<u8>) {
|
||||
// `| 0` (wire type 0 = varint) is a no-op kept verbatim for byte-for-byte
|
||||
// parity with meta-gateway's encoder — the explicit wire-type makes the
|
||||
// protobuf tag construction self-documenting. See invariant 8 in CLAUDE.md.
|
||||
#[allow(clippy::identity_op)]
|
||||
write_pb_varint(((field as u64) << 3) | 0, out);
|
||||
write_pb_varint(value, out);
|
||||
}
|
||||
|
||||
fn write_pb_bytes_field(field: u32, value: &[u8], out: &mut Vec<u8>) {
|
||||
write_pb_varint(((field as u64) << 3) | 2, out); // wire type 2 = length-delimited
|
||||
write_pb_varint(value.len() as u64, out);
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
|
||||
/// UnixFS protobuf payload for a File node: `Type=File, filesize,
|
||||
/// blocksizes[]`. Used as the `Data` field of the wrapping dag-pb node.
|
||||
/// (Type=File is enum value 2 in the UnixFS schema.)
|
||||
fn encode_unixfs_file(filesize: u64, blocksizes: &[u64]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_pb_varint_field(1, 2, &mut out); // Type = File
|
||||
write_pb_varint_field(3, filesize, &mut out); // filesize
|
||||
for &bs in blocksizes {
|
||||
write_pb_varint_field(4, bs, &mut out); // blocksizes (repeated)
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// dag-pb PBNode for an internal UnixFS file node. Canonical wire order
|
||||
/// pinned by the dag-pb spec: `Links` (tag 2) first, `Data` (tag 1)
|
||||
/// second. Each PBLink emits `Hash` (tag 1) and `Tsize` (tag 3); `Name`
|
||||
/// is omitted (kubo doesn't emit it for UnixFS file children either).
|
||||
fn encode_dagpb_node(children: &[(Vec<u8>, u64, u64)], data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for (cid_wire, _filesize, tsize) in children {
|
||||
let mut link_bytes = Vec::new();
|
||||
write_pb_bytes_field(1, cid_wire, &mut link_bytes); // Hash
|
||||
write_pb_varint_field(3, *tsize, &mut link_bytes); // Tsize
|
||||
write_pb_bytes_field(2, &link_bytes, &mut out); // Links (tag 2) first
|
||||
}
|
||||
write_pb_bytes_field(1, data, &mut out); // Data (tag 1) last
|
||||
out
|
||||
}
|
||||
|
||||
/// RFC 4648 base32 with the lowercase alphabet and no padding. Used for the
|
||||
/// multibase `b` prefix.
|
||||
pub(crate) fn base32_lower_no_padding(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(5) * 8);
|
||||
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;
|
||||
let idx = ((buffer >> bits) & 0x1F) as usize;
|
||||
out.push(ALPHABET[idx] as char);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
let idx = ((buffer << (5 - bits)) & 0x1F) as usize;
|
||||
out.push(ALPHABET[idx] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The streaming chunker must produce byte-identical CIDs (root + every
|
||||
// block) to the whole-slice `compute_ipfs_blocks` — else a large posting
|
||||
// re-seeded via the streaming path publishes a root that doesn't match what
|
||||
// gateway-seeded peers expect, breaking federation silently.
|
||||
#[tokio::test]
|
||||
async fn stream_matches_whole_slice() {
|
||||
// A few sizes: single sub-chunk leaf, exact multi-leaf, multi-level tree.
|
||||
for size in [0usize, 100, IPFS_CHUNK_SIZE, IPFS_CHUNK_SIZE * 3 + 7, IPFS_CHUNK_SIZE * (IPFS_FANOUT + 5)] {
|
||||
let data: Vec<u8> = (0..size).map(|i| (i * 31 + 7) as u8).collect();
|
||||
let expected = compute_ipfs_blocks(&data);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("f.bin");
|
||||
tokio::fs::write(&path, &data).await.unwrap();
|
||||
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, usize)>::new()));
|
||||
let seen2 = seen.clone();
|
||||
let (root, total) = stream_ipfs_blocks(&path, move |cid, bytes| {
|
||||
let seen = seen2.clone();
|
||||
async move {
|
||||
seen.lock().unwrap().push((cid, bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(root, expected.root, "root mismatch at size {size}");
|
||||
assert_eq!(total as usize, size, "size mismatch at {size}");
|
||||
// Same set of block cids (order matches: leaves then internal nodes).
|
||||
let streamed: Vec<String> = seen.lock().unwrap().iter().map(|(c, _)| c.clone()).collect();
|
||||
let whole: Vec<String> = expected.blocks.iter().map(|(c, _)| c.clone()).collect();
|
||||
assert_eq!(streamed, whole, "block cid set mismatch at size {size}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_ipfs_refs_matches_whole_slice_and_reslices() {
|
||||
// Cover single-leaf, exact-boundary, multi-leaf, and multi-level trees.
|
||||
for size in [0usize, 11, IPFS_CHUNK_SIZE, IPFS_CHUNK_SIZE + 1, 3 * IPFS_CHUNK_SIZE + 7] {
|
||||
let data: Vec<u8> = (0..size).map(|i| (i * 31 + 7) as u8).collect();
|
||||
let expected = compute_ipfs_blocks(&data);
|
||||
|
||||
let refs = std::sync::Arc::new(std::sync::Mutex::new(Vec::<LeafRef>::new()));
|
||||
let nodes = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let refs2 = refs.clone();
|
||||
let nodes2 = nodes.clone();
|
||||
let (root, total) = stream_ipfs_refs(
|
||||
std::io::Cursor::new(data.clone()),
|
||||
move |r| {
|
||||
let refs = refs2.clone();
|
||||
async move {
|
||||
refs.lock().unwrap().push(r);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move |cid, _bytes| {
|
||||
let nodes = nodes2.clone();
|
||||
async move {
|
||||
nodes.lock().unwrap().push(cid);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(root, expected.root, "root mismatch at size {size}");
|
||||
assert_eq!(total as usize, size, "size mismatch at {size}");
|
||||
|
||||
let refs = refs.lock().unwrap();
|
||||
// Re-reading each ref's slice and hashing it must reproduce its cid —
|
||||
// this is the soundness property the filestore relies on.
|
||||
for r in refs.iter() {
|
||||
let slice = &data[r.offset as usize..(r.offset + r.len) as usize];
|
||||
assert_eq!(
|
||||
compute_ipfs_cid(slice),
|
||||
r.cid,
|
||||
"re-sliced leaf at {}+{} must hash to its cid (size {size})",
|
||||
r.offset,
|
||||
r.len
|
||||
);
|
||||
}
|
||||
// Leaf refs + internal-node blocks together == the whole-slice block
|
||||
// set, in the same order (leaves first, then nodes).
|
||||
let streamed: Vec<String> = refs
|
||||
.iter()
|
||||
.map(|r| r.cid.clone())
|
||||
.chain(nodes.lock().unwrap().iter().cloned())
|
||||
.collect();
|
||||
let whole: Vec<String> = expected.blocks.iter().map(|(c, _)| c.clone()).collect();
|
||||
assert_eq!(streamed, whole, "ref+node cid set mismatch at size {size}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Parity corpus -----------------------------------------------------
|
||||
//
|
||||
// These CID vectors are pinned byte-identical to meta-gateway's
|
||||
// `hash.rs` tests (which are themselves pinned against kubo's
|
||||
// `ipfs add --cid-version=1 --raw-leaves=true --chunker=size-262144
|
||||
// --hash=sha2-256`). If either side drifts, federation breaks
|
||||
// silently — a CID this peer publishes stops resolving on a
|
||||
// gateway-seeded peer (and vice versa). Keep both in lockstep.
|
||||
|
||||
#[test]
|
||||
fn base32_known_vectors() {
|
||||
assert_eq!(base32_lower_no_padding(b""), "");
|
||||
assert_eq!(base32_lower_no_padding(b"f"), "my");
|
||||
assert_eq!(base32_lower_no_padding(b"fo"), "mzxq");
|
||||
assert_eq!(base32_lower_no_padding(b"foo"), "mzxw6");
|
||||
assert_eq!(base32_lower_no_padding(b"foob"), "mzxw6yq");
|
||||
assert_eq!(base32_lower_no_padding(b"foobar"), "mzxw6ytboi");
|
||||
}
|
||||
|
||||
/// Single raw leaf — pinned against kubo:
|
||||
/// echo -n "hello world" | ipfs add --cid-version=1 --raw-leaves=true
|
||||
#[test]
|
||||
fn ipfs_cid_single_leaf_matches_kubo() {
|
||||
assert_eq!(
|
||||
compute_ipfs_cid(b"hello world"),
|
||||
"bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty file: `ipfs add` of `/dev/null` with raw-leaves.
|
||||
#[test]
|
||||
fn ipfs_cid_empty_matches_kubo() {
|
||||
assert_eq!(
|
||||
compute_ipfs_cid(b""),
|
||||
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multi-chunk file (1 MiB zeros = 4 × 256 KiB leaves + 1 dag-pb root)
|
||||
/// — pins the UnixFS dag-pb canonical encoding (Links-before-Data wire
|
||||
/// order, omitted PBLink.Name, Tsize=raw-bytes for leaves).
|
||||
#[test]
|
||||
fn ipfs_cid_multi_leaf_zeros_stable() {
|
||||
let mb_zeros = vec![0u8; 1024 * 1024];
|
||||
assert_eq!(
|
||||
compute_ipfs_cid(&mb_zeros),
|
||||
"bafybeiadh3bekpwtewjvauqeucf7yzqrb3ixsxzltnuwed4pxangtpou6m"
|
||||
);
|
||||
}
|
||||
|
||||
/// Single-leaf path: blocks vec is one entry, raw chunk, cid == root.
|
||||
#[test]
|
||||
fn ipfs_blocks_single_leaf() {
|
||||
let payload = b"hello world";
|
||||
let out = compute_ipfs_blocks(payload);
|
||||
assert_eq!(out.root, compute_ipfs_cid(payload));
|
||||
assert_eq!(out.blocks.len(), 1);
|
||||
assert_eq!(out.blocks[0].0, out.root);
|
||||
assert_eq!(out.blocks[0].1.as_ref(), payload);
|
||||
}
|
||||
|
||||
/// Multi-leaf path: 4 leaves + 1 root = 5 blocks, leaves first, root last.
|
||||
#[test]
|
||||
fn ipfs_blocks_multi_leaf() {
|
||||
let mb_zeros = vec![0u8; 1024 * 1024];
|
||||
let out = compute_ipfs_blocks(&mb_zeros);
|
||||
assert_eq!(
|
||||
out.root,
|
||||
"bafybeiadh3bekpwtewjvauqeucf7yzqrb3ixsxzltnuwed4pxangtpou6m"
|
||||
);
|
||||
assert_eq!(out.blocks.len(), 5);
|
||||
assert_eq!(out.blocks.last().unwrap().0, out.root);
|
||||
for (_cid, block) in &out.blocks[..4] {
|
||||
assert_eq!(block.len(), 256 * 1024);
|
||||
assert!(block.iter().all(|&b| b == 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty input → well-defined cid + one-entry blocks vec.
|
||||
#[test]
|
||||
fn ipfs_blocks_empty() {
|
||||
let out = compute_ipfs_blocks(b"");
|
||||
assert_eq!(
|
||||
out.root,
|
||||
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
);
|
||||
assert_eq!(out.blocks.len(), 1);
|
||||
assert_eq!(out.blocks[0].1.len(), 0);
|
||||
assert_eq!(out.blocks[0].0, out.root);
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
//! `meta-transport-ipfs` — meta-share's IPFS tier as a transport plugin.
|
||||
//!
|
||||
//! Owns the libp2p host (`P2P_LISTEN`, default `/ip4/0.0.0.0/tcp/4001`, the
|
||||
//! port meta-share always published): identify + kad + mdns + bitswap, the
|
||||
//! blockstore (`<data>/ipfs/blocks.redb`) and bitswap ingress into `tmp/` →
|
||||
//! `cache/`. Serves the transport contract on `HTTP_LISTEN` (internal only) and
|
||||
//! the public `/ipfs/:cid` gateway, which the hull relays.
|
||||
//!
|
||||
//! The swarm announces the **hull's** URL in identify (`baseUrl=`, from the
|
||||
//! hull's `settings.json`), so peers and gateways keep calling meta-share's
|
||||
//! public API exactly as before — the plugin is invisible on the wire.
|
||||
|
||||
mod api;
|
||||
mod blockstore;
|
||||
mod config;
|
||||
mod filestore;
|
||||
mod gateway_discovery;
|
||||
mod ingress;
|
||||
mod ingress_commit;
|
||||
mod ipfs_chunk;
|
||||
mod material;
|
||||
mod plugin;
|
||||
mod settings;
|
||||
mod share;
|
||||
mod store;
|
||||
mod swarm;
|
||||
mod webdav;
|
||||
|
||||
/// Path alias so the moved code keeps its `crate::focus::Lane` imports.
|
||||
mod focus {
|
||||
pub use meta_feeder_sdk::transport::focus::Lane;
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use libp2p::identity;
|
||||
use meta_feeder_sdk::transport::{serve_transport, FocusView, HullClient};
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new("info,meta_transport_ipfs=info,libp2p=warn,beetswap=warn,yamux=warn")
|
||||
});
|
||||
tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
|
||||
|
||||
let cfg = config::Config::from_env()?;
|
||||
let keypair = identity::Keypair::generate_ed25519();
|
||||
let local_peer_id = libp2p::PeerId::from(keypair.public());
|
||||
let agent_version = swarm::build_agent_version(cfg.peer_api_url.as_deref());
|
||||
let peer_directory = swarm::PeerDirectory::new();
|
||||
|
||||
info!(
|
||||
%local_peer_id,
|
||||
listen_p2p = %cfg.listen_p2p,
|
||||
http_addr = %cfg.http_addr,
|
||||
network_mode = cfg.network.mode.as_str(),
|
||||
public_addr = cfg.network.mode.public_addr().map(|a| a.to_string()).unwrap_or_default(),
|
||||
kad_provide_enabled = cfg.kad.provide_enabled,
|
||||
seed_dht_provide = cfg.seed_dht_provide,
|
||||
agent_version = %agent_version,
|
||||
"starting meta-transport-ipfs"
|
||||
);
|
||||
|
||||
let raw_block_store = blockstore::open_redb_blockstore(&cfg.data_dir)
|
||||
.await
|
||||
.context("open ipfs blockstore")?;
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(concat!("meta-transport-ipfs/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build shared reqwest client")?;
|
||||
let webdav_url_cache = Arc::new(tokio::sync::OnceCell::new());
|
||||
let redb_handle = raw_block_store.raw_db();
|
||||
let ingress = Arc::new(ingress::IngressRegistry::from_data_dir(&cfg.data_dir));
|
||||
let (_, cache_dir) = material::storage_dirs(&cfg.data_dir);
|
||||
|
||||
let filestore_resolver = Arc::new(filestore::FilestoreResolver::new(
|
||||
http.clone(),
|
||||
cfg.meta_core_url.clone(),
|
||||
cfg.files_path_prefix.clone(),
|
||||
cfg.local_files_root.clone(),
|
||||
Arc::clone(&webdav_url_cache),
|
||||
cache_dir,
|
||||
));
|
||||
// Drop the MSR1 record blocks earlier builds mirrored into the block table,
|
||||
// once, before the swarm can answer a WANT from a table mid-sweep.
|
||||
match filestore::scrub_record_blocks(Arc::clone(&redb_handle)).await {
|
||||
Ok(0) => debug!("record-block scrub: nothing to remove"),
|
||||
Ok(removed) => info!(removed, "record-block scrub: removed MSR1 record blocks from the blockstore"),
|
||||
Err(e) => warn!(error = %format!("{e:#}"), "record-block scrub failed; continuing"),
|
||||
}
|
||||
let block_store = Arc::new(filestore::FilestoreBlockstore::new(
|
||||
raw_block_store,
|
||||
redb_handle,
|
||||
filestore_resolver,
|
||||
Arc::clone(&ingress),
|
||||
));
|
||||
|
||||
let focus = FocusView::new();
|
||||
let (mut swarm, _peer_id) = swarm::build_swarm(
|
||||
keypair,
|
||||
&cfg.listen_p2p,
|
||||
cfg.enable_mdns,
|
||||
agent_version,
|
||||
Arc::clone(&block_store),
|
||||
Arc::clone(&focus),
|
||||
Arc::clone(&ingress),
|
||||
)
|
||||
.context("build swarm")?;
|
||||
if let Some(addr) = cfg.network.mode.public_addr() {
|
||||
info!(external_addr = %addr, "advertising external libp2p multiaddr");
|
||||
swarm.add_external_address(addr.clone());
|
||||
}
|
||||
let swarm_tx = swarm::spawn(
|
||||
swarm,
|
||||
local_peer_id,
|
||||
cfg.bootstrap_peers,
|
||||
cfg.redial_interval,
|
||||
cfg.kad,
|
||||
peer_directory.clone(),
|
||||
http.clone(),
|
||||
cfg.gateway_caps,
|
||||
);
|
||||
|
||||
let state = Arc::new(api::AppState {
|
||||
block_store,
|
||||
ingress,
|
||||
swarm_tx,
|
||||
peer_directory,
|
||||
focus,
|
||||
http: http.clone(),
|
||||
hull: HullClient::from_env(http),
|
||||
data_dir: cfg.data_dir.clone(),
|
||||
meta_core_url: cfg.meta_core_url.clone(),
|
||||
webdav_url_cache,
|
||||
seed_dht_provide: cfg.seed_dht_provide,
|
||||
});
|
||||
|
||||
// Bitswap ingress: sweep partial fetches a previous process left in `tmp/`,
|
||||
// then promote/fill on a timer.
|
||||
{
|
||||
let state = Arc::clone(&state);
|
||||
tokio::spawn(async move {
|
||||
state.ingress.sweep_tmp().await;
|
||||
});
|
||||
}
|
||||
ingress_commit::spawn(Arc::clone(&state));
|
||||
|
||||
serve_transport(Arc::new(plugin::IpfsPlugin { state }), cfg.http_addr).await
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The two storage-model helpers bitswap ingress shares with the rest of the
|
||||
//! `/data` volume. Moved from meta-share's `material.rs` (the material *index*
|
||||
//! stays in the hull).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
/// `(tmp, cache)`: `META_SHARE_TMP_DIR` / `META_SHARE_CACHE_DIR`, defaulting to
|
||||
/// `<data>/tmp` and `<data>/cache`. They must share a filesystem — and, in a
|
||||
/// container, a bind mount — for [`promote_container`]'s `rename(2)`.
|
||||
pub fn storage_dirs(data_dir: &Path) -> (PathBuf, PathBuf) {
|
||||
let tmp = std::env::var("META_SHARE_TMP_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| data_dir.join("tmp"));
|
||||
let cache = std::env::var("META_SHARE_CACHE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| data_dir.join("cache"));
|
||||
(tmp, cache)
|
||||
}
|
||||
|
||||
/// Publish `tmp/<cid>/` as `cache/<cid>/` with a single `rename(2)` — the one
|
||||
/// "these bytes are complete; anyone may read them" transition. A destination
|
||||
/// that already exists means a concurrent promote won: adopt it.
|
||||
pub async fn promote_container(tmp_dir: &Path, cache_dir: &Path, cid: &str) -> std::io::Result<PathBuf> {
|
||||
let from = tmp_dir.join(cid);
|
||||
let to = cache_dir.join(cid);
|
||||
if tokio::fs::try_exists(&to).await.unwrap_or(false) {
|
||||
return Ok(to);
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
match tokio::fs::rename(&from, &to).await {
|
||||
Ok(()) => debug!(cid, from = %from.display(), to = %to.display(), "material: promoted into the cache"),
|
||||
Err(e) if to.exists() => {
|
||||
debug!(cid, error = %e, "material: promote raced; adopting the existing cache dir")
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn promote_renames_then_adopts() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let (tmp, cache) = (d.path().join("tmp"), d.path().join("cache"));
|
||||
std::fs::create_dir_all(tmp.join("c1")).unwrap();
|
||||
std::fs::write(tmp.join("c1/f"), b"x").unwrap();
|
||||
let to = promote_container(&tmp, &cache, "c1").await.unwrap();
|
||||
assert_eq!(to, cache.join("c1"));
|
||||
assert!(to.join("f").exists() && !tmp.join("c1").exists());
|
||||
// Second call: destination exists → adopted, no error.
|
||||
assert_eq!(promote_container(&tmp, &cache, "c1").await.unwrap(), to);
|
||||
}
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
//! [`TransportPlugin`] for the IPFS tier, plus the public `/ipfs/:cid` gateway
|
||||
//! and the hull's `/ipfs-tier/*` facade (see `meta_feeder_sdk::transport::ipfs`).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use blockstore::Blockstore;
|
||||
use meta_feeder_sdk::transport::ipfs::{
|
||||
Complete, Directory, DropRefs, FirstLeaf, ForgetLibrary, Imported, LeafBacking, Present,
|
||||
RelPath, ShareContainer, ShareLibrary, Shared,
|
||||
};
|
||||
use meta_feeder_sdk::transport::{
|
||||
ApiError, Capabilities, Deleted, FocusView, Health, Job, Lane, Manifest, ReconcileReport,
|
||||
ReconcileRequest, TransportPlugin, CONTRACT_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::api::files::{bitswap, bytes_to_response, PLAYER_HEADER};
|
||||
use crate::api::AppState;
|
||||
use crate::blockstore::parse_record_cid;
|
||||
use crate::swarm::Command;
|
||||
|
||||
/// Ceiling on a hull → plugin object upload (`import`, `block`): the url
|
||||
/// locator's own 512 MiB cap, plus headroom.
|
||||
const IMPORT_BODY_LIMIT: usize = 600 * 1024 * 1024;
|
||||
|
||||
pub struct IpfsPlugin {
|
||||
pub state: Arc<AppState>,
|
||||
}
|
||||
|
||||
fn lane_of(headers: &HeaderMap) -> Lane {
|
||||
Lane::from_header(
|
||||
headers
|
||||
.get(meta_feeder_sdk::transport::dto::HDR_LANE)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TransportPlugin for IpfsPlugin {
|
||||
fn manifest(&self) -> Manifest {
|
||||
Manifest {
|
||||
id: "ipfs".into(),
|
||||
implementation: "meta-transport-ipfs (rust-libp2p + beetswap, nocopy filestore)".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
contract: CONTRACT_VERSION,
|
||||
capabilities: Capabilities { fetch: true, share: true },
|
||||
}
|
||||
}
|
||||
|
||||
async fn health(&self) -> Health {
|
||||
Health {
|
||||
ok: !self.state.swarm_tx.is_closed(),
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bitswap byte path (`fetch_raw_via_bitswap`): local blockstore, else a
|
||||
/// bitswap WANT; dag-pb walks only the leaves the range covers.
|
||||
async fn raw(&self, cid: String, headers: HeaderMap) -> Response {
|
||||
let lane = lane_of(&headers);
|
||||
let player = headers.contains_key(PLAYER_HEADER);
|
||||
match bitswap::fetch_raw_via_bitswap(&self.state, &cid, headers.get(header::RANGE), lane, player).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn jobs(&self) -> Result<Vec<Job>, ApiError> {
|
||||
Ok(self
|
||||
.state
|
||||
.ingress
|
||||
.jobs()
|
||||
.into_iter()
|
||||
.map(|j| Job {
|
||||
id: j.container.clone(),
|
||||
cids: vec![j.root.clone()],
|
||||
bytes_on_disk: j.received_bytes(),
|
||||
complete: j.is_complete(),
|
||||
filling: j.is_filling(),
|
||||
state: if j.is_committed() { "committed".into() } else { "streaming".into() },
|
||||
extra: serde_json::Value::Null,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Drop a cid's blocks (and any in-flight ingress for it).
|
||||
async fn delete(&self, cid: String, _keep: Vec<String>) -> Result<Deleted, ApiError> {
|
||||
let Ok(mscid) = parse_record_cid(&cid) else {
|
||||
return Err(ApiError::NotFound);
|
||||
};
|
||||
self.state.ingress.discard(&cid).await;
|
||||
crate::share::remove_dag(&self.state, &mscid).await;
|
||||
Ok(Deleted::default())
|
||||
}
|
||||
|
||||
async fn reconcile(&self, _req: ReconcileRequest) -> Result<ReconcileReport, ApiError> {
|
||||
Ok(ReconcileReport::default())
|
||||
}
|
||||
|
||||
fn focus(&self) -> &Arc<FocusView> {
|
||||
&self.state.focus
|
||||
}
|
||||
|
||||
fn extra_routes(self: Arc<Self>) -> Router {
|
||||
let gateway = Router::new()
|
||||
.route("/ipfs/:cid", get(crate::api::ipfs_gateway::get_ipfs))
|
||||
.with_state(Arc::clone(&self.state));
|
||||
Router::new()
|
||||
.route("/ipfs-tier/peers", get(peers))
|
||||
.route("/ipfs-tier/directory", get(directory))
|
||||
.route("/ipfs-tier/local/:cid", get(local))
|
||||
.route("/ipfs-tier/has/:cid", get(has))
|
||||
.route("/ipfs-tier/complete/:cid", get(complete))
|
||||
.route("/ipfs-tier/cat/:cid", get(cat))
|
||||
.route("/ipfs-tier/fetch/:cid", get(fetch))
|
||||
// Whole objects arrive here (the url locator buffers up to 512 MiB,
|
||||
// a redeem up to 32 MiB) — axum's default 2 MiB body limit would cut
|
||||
// them off. In-process there was no limit at all.
|
||||
.route(
|
||||
"/ipfs-tier/block/:cid",
|
||||
put(put_block).layer(DefaultBodyLimit::max(IMPORT_BODY_LIMIT)),
|
||||
)
|
||||
.route(
|
||||
"/ipfs-tier/import",
|
||||
post(import).layer(DefaultBodyLimit::max(IMPORT_BODY_LIMIT)),
|
||||
)
|
||||
.route("/ipfs-tier/forget/:cid", post(forget))
|
||||
.route("/ipfs-tier/forget-library", post(forget_library))
|
||||
.route("/ipfs-tier/provide/:cid", post(provide))
|
||||
.route("/ipfs-tier/unprovide/:cid", post(unprovide))
|
||||
.route("/ipfs-tier/drop-refs", post(drop_refs))
|
||||
.route("/ipfs-tier/first-leaf/:cid", get(first_leaf))
|
||||
.route("/ipfs-tier/rel-path/:midhash", get(rel_path))
|
||||
.route("/ipfs-tier/share/library", post(share_library))
|
||||
.route("/ipfs-tier/share/container", post(share_container))
|
||||
.route("/ipfs-tier/stats/blockstore", get(blockstore_stats))
|
||||
.route("/ipfs-tier/debug/:cid", get(debug_block))
|
||||
.with_state(self)
|
||||
.merge(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
type S = State<Arc<IpfsPlugin>>;
|
||||
|
||||
fn bad_cid(e: anyhow::Error) -> Response {
|
||||
ApiError::BadRequest(format!("cid: {e:#}")).into_response()
|
||||
}
|
||||
|
||||
async fn peers(State(p): S) -> Response {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
if p.state.swarm_tx.send(Command::Peers { reply: tx }).await.is_err() {
|
||||
return ApiError::SwarmGone.into_response();
|
||||
}
|
||||
match rx.await {
|
||||
Ok(info) => Json(info).into_response(),
|
||||
Err(_) => ApiError::SwarmGone.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn directory(State(p): S) -> Response {
|
||||
Json(Directory { peers: p.state.peer_directory.snapshot() }).into_response()
|
||||
}
|
||||
|
||||
/// `try_local_block`: a single raw/midhash block straight out of the store.
|
||||
async fn local(State(p): S, Path(cid): Path<String>, headers: HeaderMap) -> Response {
|
||||
match bitswap::try_local_block(&p.state, &cid, headers.get(header::RANGE)).await {
|
||||
Some(r) => r,
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn has(State(p): S, Path(cid): Path<String>) -> Response {
|
||||
let present = match parse_record_cid(&cid) {
|
||||
Ok(m) => matches!(p.state.block_store.get(&m).await, Ok(Some(_))),
|
||||
Err(_) => false,
|
||||
};
|
||||
Json(Present { present }).into_response()
|
||||
}
|
||||
|
||||
async fn complete(State(p): S, Path(cid): Path<String>) -> Response {
|
||||
let complete = match parse_record_cid(&cid) {
|
||||
Ok(m) => crate::share::local_dag_complete(&p.state, &m).await,
|
||||
Err(_) => false,
|
||||
};
|
||||
Json(Complete { complete }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CatQuery {
|
||||
#[serde(default)]
|
||||
max: Option<usize>,
|
||||
}
|
||||
|
||||
/// A block (or a dag-pb file, assembled) through `ipfs_walker::get_block` —
|
||||
/// local, else bitswap. No seed row, no ingress job: a side-effect-free read
|
||||
/// (the `.nzb` manifest fetch).
|
||||
async fn cat(State(p): S, Path(cid): Path<String>, Query(q): Query<CatQuery>) -> Response {
|
||||
let mscid = match parse_record_cid(&cid) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return bad_cid(e),
|
||||
};
|
||||
let max = q.max.unwrap_or_else(crate::api::ipfs_walker::max_body_bytes);
|
||||
let block = match crate::api::ipfs_walker::get_block(&p.state, &mscid, Lane::Focused).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
};
|
||||
let bytes = if mscid.codec() == crate::api::ipfs_walker::DAGPB_CODEC {
|
||||
match crate::api::ipfs_walker::assemble_dagpb_file(&p.state, &block, max, Lane::Focused).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
}
|
||||
} else {
|
||||
if block.len() > max {
|
||||
return ApiError::Upstream(format!(
|
||||
"{cid} is {} bytes, over the {max}-byte ceiling",
|
||||
block.len()
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
block
|
||||
};
|
||||
(StatusCode::OK, bytes).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FetchQuery {
|
||||
#[serde(default)]
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// One block: local, else a bitswap WANT bounded by `timeout_ms`. Header
|
||||
/// `x-metamesh-source: local|bitswap` says which (the probe grades on it).
|
||||
async fn fetch(State(p): S, Path(cid): Path<String>, Query(q): Query<FetchQuery>) -> Response {
|
||||
let mscid = match parse_record_cid(&cid) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return bad_cid(e),
|
||||
};
|
||||
if let Ok(Some(bytes)) = p.state.block_store.get(&mscid).await {
|
||||
return ([("x-metamesh-source", "local")], bytes).into_response();
|
||||
}
|
||||
let timeout = Duration::from_millis(q.timeout_ms.unwrap_or(5_000));
|
||||
match crate::swarm::bitswap_get_block(&p.state.swarm_tx, mscid, timeout).await {
|
||||
Ok(bytes) => ([("x-metamesh-source", "bitswap")], bytes).into_response(),
|
||||
Err(e) => {
|
||||
debug!(%cid, error = %e, "ipfs-tier fetch: bitswap miss");
|
||||
ApiError::NotFound.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_block(State(p): S, Path(cid): Path<String>, body: Bytes) -> Response {
|
||||
match crate::blockstore::put_ipfs_block(p.state.block_store.as_ref(), &cid, &body).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("put block: {e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunk a whole object into kubo-identical blocks and store them material
|
||||
/// (`put_ipfs_blocks(compute_ipfs_blocks(..))` — the url locator and redeem).
|
||||
async fn import(State(p): S, body: Bytes) -> Response {
|
||||
let size = body.len() as u64;
|
||||
let blocks = crate::ipfs_chunk::compute_ipfs_blocks(&body);
|
||||
let root = blocks.root.clone();
|
||||
match crate::blockstore::put_ipfs_blocks(p.state.block_store.as_ref(), &blocks).await {
|
||||
Ok(()) => Json(Imported { root, size }).into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("import: {e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a dag's blocks (the teardown of an ipfs seed row).
|
||||
async fn forget(State(p): S, Path(cid): Path<String>) -> Response {
|
||||
match parse_record_cid(&cid) {
|
||||
Ok(m) => {
|
||||
crate::share::remove_dag(&p.state, &m).await;
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(e) => bad_cid(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn forget_library(State(p): S, Json(req): Json<ForgetLibrary>) -> Response {
|
||||
let root = match parse_record_cid(&req.root) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return bad_cid(e),
|
||||
};
|
||||
match p.state.block_store.remove_library_dag(&root, &req.midhash).await {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn provide(State(p): S, Path(cid): Path<String>) -> Response {
|
||||
if !p.state.seed_dht_provide {
|
||||
return StatusCode::NO_CONTENT.into_response();
|
||||
}
|
||||
match parse_record_cid(&cid) {
|
||||
Ok(m) => {
|
||||
let _ = p.state.swarm_tx.send(Command::Provide { cid: m }).await;
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(e) => bad_cid(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn unprovide(State(p): S, Path(cid): Path<String>) -> Response {
|
||||
if !p.state.seed_dht_provide {
|
||||
return StatusCode::NO_CONTENT.into_response();
|
||||
}
|
||||
match parse_record_cid(&cid) {
|
||||
Ok(m) => {
|
||||
let _ = p.state.swarm_tx.send(Command::StopProviding { cid: m }).await;
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(e) => bad_cid(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn drop_refs(State(p): S, Json(req): Json<DropRefs>) -> Response {
|
||||
match p.state.block_store.drop_refs_for_materials(&req.materials).await {
|
||||
Ok(n) => Json(serde_json::json!({ "dropped": n })).into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn first_leaf(State(p): S, Path(cid): Path<String>) -> Response {
|
||||
let root = match parse_record_cid(&cid) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return bad_cid(e),
|
||||
};
|
||||
match p.state.block_store.first_leaf_backing(&root).await {
|
||||
Ok(b) => {
|
||||
let backing = match b {
|
||||
crate::filestore::LeafBacking::MetaCore(m) => LeafBacking::MetaCore(m),
|
||||
crate::filestore::LeafBacking::Cache => LeafBacking::Cache,
|
||||
crate::filestore::LeafBacking::Material => LeafBacking::Material,
|
||||
crate::filestore::LeafBacking::Unknown => LeafBacking::Unknown,
|
||||
};
|
||||
Json(FirstLeaf { backing }).into_response()
|
||||
}
|
||||
Err(e) => ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rel_path(State(p): S, Path(midhash): Path<String>) -> Response {
|
||||
match p.state.block_store.resolver().rel_path_for(&midhash).await {
|
||||
Ok(rel) => Json(RelPath { rel }).into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn share_library(State(p): S, Json(req): Json<ShareLibrary>) -> Response {
|
||||
match crate::share::share_library(&p.state, &req.rel, &req.midhash).await {
|
||||
Ok((root, size)) => Json(Shared { root, size, refs: 0, complete: false }).into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn share_container(State(p): S, Json(req): Json<ShareContainer>) -> Response {
|
||||
let path = std::path::PathBuf::from(&req.path);
|
||||
match crate::share::share_container(&p.state, &req.container, &req.rel, &path).await {
|
||||
Ok((root, size, refs)) => {
|
||||
let complete = if req.verify_complete {
|
||||
match parse_record_cid(&root) {
|
||||
Ok(m) => crate::share::local_dag_complete(&p.state, &m).await,
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
Json(Shared { root, size, refs, complete }).into_response()
|
||||
}
|
||||
Err(e) => ApiError::Upstream(format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/api/stats/blockstore` body. Walks every btree — never polled.
|
||||
async fn blockstore_stats(State(p): S) -> Response {
|
||||
let db_path = p.state.data_dir.join("ipfs").join("blocks.redb");
|
||||
match p.state.block_store.stats(Some(db_path)).await {
|
||||
Ok(stats) => Json(serde_json::to_value(stats).unwrap_or_else(|_| serde_json::json!({}))).into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("blockstore stats: {e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/api/debug/blockstore/:cid` body.
|
||||
async fn debug_block(State(p): S, Path(cid): Path<String>) -> Response {
|
||||
let mscid = match parse_record_cid(&cid) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return bad_cid(e),
|
||||
};
|
||||
let mscid_bytes_hex: String = mscid.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
|
||||
match p.state.block_store.get(&mscid).await {
|
||||
Ok(Some(bytes)) => Json(serde_json::json!({
|
||||
"has": true,
|
||||
"bytes": bytes.len(),
|
||||
"mscid_debug": format!("{:?}", mscid),
|
||||
"mscid_bytes_hex": mscid_bytes_hex,
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(None) => Json(serde_json::json!({
|
||||
"has": false,
|
||||
"bytes": 0,
|
||||
"mscid_debug": format!("{:?}", mscid),
|
||||
"mscid_bytes_hex": mscid_bytes_hex,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(e) => ApiError::Upstream(format!("blockstore.get: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Small-body helper kept for parity with the monolith's single-block path.
|
||||
#[allow(dead_code)]
|
||||
pub fn bytes_response(bytes: Vec<u8>, range: Option<&HeaderValue>) -> Response {
|
||||
bytes_to_response(bytes, range)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! The `network` section of the hull's `settings.json`, read-only.
|
||||
//!
|
||||
//! The hull owns the file (it seeds it from the deprecated env vars on first
|
||||
//! boot and edits it from the dashboard); this plugin only needs the two
|
||||
//! endpoints, and falls back to the same env-derived defaults the hull would
|
||||
//! seed when the file doesn't exist yet.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
/// Same default the hull seeds (`settings::DEFAULT_PEER_URL`).
|
||||
pub const DEFAULT_PEER_URL: &str = "http://metashare-app:3000";
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct NetworkSettings {
|
||||
pub meta_core_url: Option<String>,
|
||||
pub peer_url: Option<String>,
|
||||
}
|
||||
|
||||
impl NetworkSettings {
|
||||
/// `<config_dir>/settings.json` → `network`, else [`Self::from_env`].
|
||||
pub fn load(config_dir: &Path) -> Self {
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct File {
|
||||
network: Option<NetworkSettings>,
|
||||
}
|
||||
std::fs::read(config_dir.join("settings.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<File>(&b).ok())
|
||||
.and_then(|f| f.network)
|
||||
.unwrap_or_else(Self::from_env)
|
||||
}
|
||||
|
||||
/// The hull's first-boot seed, recomputed: `META_CORE_URL`, and
|
||||
/// `INTERNAL_BASE_URL` → `BASE_URL` → [`DEFAULT_PEER_URL`].
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
meta_core_url: env_nonempty("META_CORE_URL"),
|
||||
peer_url: internal_base_url()
|
||||
.or_else(|| env_nonempty("BASE_URL"))
|
||||
.map(|u| u.trim_end_matches('/').to_string())
|
||||
.or_else(|| Some(DEFAULT_PEER_URL.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_base_url() -> Option<String> {
|
||||
env_nonempty("INTERNAL_BASE_URL").or_else(|| {
|
||||
let legacy = env_nonempty("META_SHARE_PEER_API_URL")?;
|
||||
warn!("META_SHARE_PEER_API_URL is deprecated; rename it to INTERNAL_BASE_URL");
|
||||
Some(legacy)
|
||||
})
|
||||
}
|
||||
|
||||
fn env_nonempty(key: &str) -> Option<String> {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_network_section_of_the_hulls_file() {
|
||||
let d = std::env::temp_dir().join(format!("ipfs-settings-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(
|
||||
d.join("settings.json"),
|
||||
r#"{"network":{"meta_core_url":"http://mc:9000","peer_url":"https://share.example"},"usenet":{"nntp_host":"x"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let n = NetworkSettings::load(&d);
|
||||
assert_eq!(n.meta_core_url.as_deref(), Some("http://mc:9000"));
|
||||
assert_eq!(n.peer_url.as_deref(), Some("https://share.example"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
//! The blockstore operations the hull drives over `/ipfs-tier/*`, gathered from
|
||||
//! where they lived in the monolith:
|
||||
//!
|
||||
//! - [`local_dag_complete`] — `nzb/serve.rs` (it was always an IPFS question);
|
||||
//! - [`remove_dag`] — `api/seeds.rs::remove_ipfs_blocks`;
|
||||
//! - [`share_library`] — `ipfs_seed::seed_file_as_refs`;
|
||||
//! - [`share_container`] — the re-chunk the Usenet seal and the material
|
||||
//! rebuild both ran over a cache container.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use blockstore::Blockstore;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::api::ipfs_walker::{parse_pbnode, DAGPB_CODEC, RAW_CODEC};
|
||||
use crate::api::AppState;
|
||||
use crate::blockstore::MsCid;
|
||||
|
||||
/// Is the dag rooted at `root` fully present in the local blockstore? Local
|
||||
/// only — no bitswap, no network. Raw leaves are checked with `has` (a key
|
||||
/// lookup; for a ref it doesn't re-read and re-hash the source), internal nodes
|
||||
/// are read to learn their children. Any miss, unparseable node or unexpected
|
||||
/// codec → `false`.
|
||||
pub async fn local_dag_complete(state: &AppState, root: &MsCid) -> bool {
|
||||
let mut stack = vec![*root];
|
||||
while let Some(mscid) = stack.pop() {
|
||||
match mscid.codec() {
|
||||
RAW_CODEC => match state.block_store.has(&mscid).await {
|
||||
Ok(true) => {}
|
||||
_ => return false,
|
||||
},
|
||||
DAGPB_CODEC => match state.block_store.get(&mscid).await {
|
||||
Ok(Some(block)) => match parse_pbnode(&block) {
|
||||
Ok(meta) => stack.extend(meta.children.into_iter().map(|(child, _sz)| child)),
|
||||
Err(_) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove a cid's blocks: for a dag-pb root, walk the tree (local reads only)
|
||||
/// and remove every node and leaf; for a single block, just that key.
|
||||
/// Best-effort — an absent block is a no-op.
|
||||
pub async fn remove_dag(state: &AppState, root: &MsCid) {
|
||||
let mut stack = vec![*root];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(cid) = stack.pop() {
|
||||
if !seen.insert(cid.to_bytes()) {
|
||||
continue;
|
||||
}
|
||||
if cid.codec() == DAGPB_CODEC {
|
||||
if let Ok(Some(bytes)) = state.block_store.get(&cid).await {
|
||||
if let Ok(meta) = parse_pbnode(&bytes) {
|
||||
for (child, _sz) in meta.children {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = state.block_store.remove(&cid).await {
|
||||
debug!(%cid, error = %e, "seed delete: blockstore remove failed (block may be absent)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nocopy library seed: stream the file over meta-core's WebDAV once and
|
||||
/// register each leaf as a `(midhash, offset, len)` ref, writing only the small
|
||||
/// synthesized internal dag-pb nodes as material. Returns `(root, size)`.
|
||||
pub async fn share_library(state: &AppState, rel: &str, midhash: &str) -> Result<(String, u64)> {
|
||||
use futures::TryStreamExt;
|
||||
|
||||
let meta_core_url = state
|
||||
.meta_core_url
|
||||
.as_deref()
|
||||
.context("no META_CORE_URL: library files can't be read")?;
|
||||
// Prime the resolver's path cache so serving these leaves is a cache hit
|
||||
// rather than a meta-core round-trip per leaf.
|
||||
state.block_store.resolver().prime_path(midhash, rel).await;
|
||||
|
||||
let webdav_base = crate::webdav::resolve_webdav_url(&state.http, meta_core_url, &state.webdav_url_cache)
|
||||
.await
|
||||
.context("resolve webdav url")?;
|
||||
let encoded: String = rel
|
||||
.split('/')
|
||||
.map(crate::webdav::urlencode_path_segment)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let file_url = format!("{webdav_base}/{encoded}");
|
||||
let resp = state
|
||||
.http
|
||||
.get(&file_url)
|
||||
// Generous: a multi-GB stream must not trip the 30s default.
|
||||
.timeout(Duration::from_secs(60 * 30))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {file_url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("webdav {file_url}"))?;
|
||||
let byte_stream = resp
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
let reader = tokio_util::io::StreamReader::new(byte_stream);
|
||||
|
||||
let bs_ref = Arc::clone(&state.block_store);
|
||||
let bs_node = Arc::clone(&state.block_store);
|
||||
let mh = midhash.to_string();
|
||||
crate::ipfs_chunk::stream_ipfs_refs(
|
||||
reader,
|
||||
move |leaf| {
|
||||
let bs = Arc::clone(&bs_ref);
|
||||
let mh = mh.clone();
|
||||
async move { bs.put_leaf_ref(&leaf, &mh).await }
|
||||
},
|
||||
move |cid, bytes| {
|
||||
let bs = Arc::clone(&bs_node);
|
||||
async move { crate::blockstore::put_ipfs_block(bs.as_ref(), &cid, &bytes).await }
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("stream file into refs")
|
||||
}
|
||||
|
||||
/// Chunk a file that lives in a cache container into kubo-identical cids,
|
||||
/// storing each **leaf as a ref** into the file (`(container, rel, offset,
|
||||
/// len)`) and only the internal nodes as material. Returns `(root, size, refs)`.
|
||||
pub async fn share_container(
|
||||
state: &AppState,
|
||||
container: &str,
|
||||
rel: &str,
|
||||
path: &std::path::Path,
|
||||
) -> Result<(String, u64, u64)> {
|
||||
let file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.with_context(|| format!("open {}", path.display()))?;
|
||||
let bs_ref = state.block_store.clone();
|
||||
let bs_node = state.block_store.clone();
|
||||
let (ref_cid, ref_rel) = (container.to_string(), rel.to_string());
|
||||
let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let counted = Arc::clone(&counter);
|
||||
let (root, size) = crate::ipfs_chunk::stream_ipfs_refs(
|
||||
file,
|
||||
move |leaf| {
|
||||
let bs = bs_ref.clone();
|
||||
let (c, r) = (ref_cid.clone(), ref_rel.clone());
|
||||
let n = Arc::clone(&counted);
|
||||
async move {
|
||||
bs.put_cache_leaf_ref(&leaf, &c, &r).await?;
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move |cid, bytes| {
|
||||
let bs = bs_node.clone();
|
||||
async move { crate::blockstore::put_ipfs_block(bs.as_ref(), &cid, &bytes).await }
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("stream file into refs")?;
|
||||
Ok((root, size, counter.load(std::sync::atomic::Ordering::Relaxed)))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The record block shape (`MSR1` + bincode), kept only so the ipfs tier can
|
||||
//! recognise — and scrub — record blocks that older builds wrote into the block
|
||||
//! table (`filestore::scrub_record_blocks`). Copied from meta-share's
|
||||
//! `store.rs`; the record *cache* stays in the hull.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
/// One metadata record. `cid` is the externally-canonical CID — preferred
|
||||
/// for content addressing across the swarm, derived by rank (IPFS >
|
||||
/// sha-family > btih > midhash) from the source peer's bare-CID key-set —
|
||||
/// `canonical_cid` is no longer a stored field (see ingest::cid_rank).
|
||||
/// `cids` carries every CID variant the source peer knows about for this
|
||||
/// file, so a lookup by any of them (sha256 when canonical is ipfs, etc.)
|
||||
/// can still match locally.
|
||||
///
|
||||
/// `tokens` is retained on the wire/struct shape for backward compatibility
|
||||
/// with peers and blocks produced by older builds (it serialises empty now
|
||||
/// that there is no tokenizer); decoders tolerate either.
|
||||
///
|
||||
/// `fields` carries human-readable key/value metadata (fileName, sizeByte,
|
||||
/// mimeType, etc.) that the UI renders as a table.
|
||||
///
|
||||
/// `#[serde(default)]` on `cids` and `fields` keeps the wire format
|
||||
/// backward-compatible with peers running older builds — they emit empty
|
||||
/// values and we tolerate them on receive.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Record {
|
||||
pub cid: String,
|
||||
pub title: String,
|
||||
pub year: Option<u32>,
|
||||
pub tokens: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Encoding format errors for record blocks stored in `RedbBlockstore`.
|
||||
/// Kept narrow so callers can distinguish "wrong format on disk" from
|
||||
/// "transport / IO failure" without string-matching.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RecordBlockError {
|
||||
#[error("block too short to be an MSR1 record (got {0} bytes)")]
|
||||
TooShort(usize),
|
||||
#[error("block magic mismatch: expected MSR1, got {0:?}")]
|
||||
BadMagic([u8; 4]),
|
||||
#[error("bincode encode failed: {0}")]
|
||||
Encode(#[from] bincode::error::EncodeError),
|
||||
#[error("bincode decode failed: {0}")]
|
||||
Decode(#[from] bincode::error::DecodeError),
|
||||
}
|
||||
|
||||
impl Record {
|
||||
/// Serialise to the on-disk block form used by `RedbBlockstore`:
|
||||
/// `MSR1` magic prefix + bincode-encoded payload. The magic gives
|
||||
/// us a single-byte cheap reject on format drift; future record-
|
||||
/// shape changes bump to `MSR2` and decoders refuse the old shape
|
||||
/// loudly rather than silently producing garbage.
|
||||
///
|
||||
/// Test-only: nothing writes record blocks any more. Tests build them to
|
||||
/// exercise the boot scrub and the relay sniff that still recognise them.
|
||||
#[cfg(test)]
|
||||
pub fn encode_block(&self) -> Result<Vec<u8>, RecordBlockError> {
|
||||
let payload = bincode::serde::encode_to_vec(self, bincode::config::standard())?;
|
||||
let mut out = Vec::with_capacity(crate::blockstore::RECORD_BLOCK_MAGIC.len() + payload.len());
|
||||
out.extend_from_slice(crate::blockstore::RECORD_BLOCK_MAGIC);
|
||||
out.extend_from_slice(&payload);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode an MSR1-prefixed block back into a `Record`. Rejects
|
||||
/// anything that doesn't start with the magic — including blocks
|
||||
/// produced by the IPFS gateway tier (raw bytes / dag-pb), which
|
||||
/// share the same blockstore but never carry our prefix.
|
||||
pub fn decode_block(bytes: &[u8]) -> Result<Self, RecordBlockError> {
|
||||
let magic = crate::blockstore::RECORD_BLOCK_MAGIC;
|
||||
if bytes.len() < magic.len() {
|
||||
return Err(RecordBlockError::TooShort(bytes.len()));
|
||||
}
|
||||
let (prefix, payload) = bytes.split_at(magic.len());
|
||||
if prefix != magic {
|
||||
let mut got = [0u8; 4];
|
||||
got.copy_from_slice(prefix);
|
||||
return Err(RecordBlockError::BadMagic(got));
|
||||
}
|
||||
let (record, _) = bincode::serde::decode_from_slice(payload, bincode::config::standard())?;
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Consumer-side bitswap call path. The HTTP layer's `/ipfs/{cid}`
|
||||
//! handler calls [`bitswap_get_block`] with a cid and a timeout; the
|
||||
//! helper sends a [`Command::BitswapGet`] to the swarm task and awaits
|
||||
//! the reply oneshot.
|
||||
//!
|
||||
//! Mirrors `gateway_client.rs` in shape:
|
||||
//! - [`bitswap_get_block`] — HTTP-side ergonomics helper with timeout.
|
||||
//! - [`BitswapInflight`] — tracking map from `beetswap::QueryId` to
|
||||
//! caller reply channels. Owned by the swarm task; mutated on
|
||||
//! `Command::BitswapGet` (insert) and on `beetswap::Event::*` (fire +
|
||||
//! remove).
|
||||
//!
|
||||
//! The block-fetching path stops at the *block* layer, not the file
|
||||
//! layer: a single `bitswap.get(cid)` resolves one block. M13's
|
||||
//! `/ipfs/{cid}` endpoint handles dag-pb traversal — it issues one
|
||||
//! `bitswap_get_block` per cid (root + children) and reassembles the
|
||||
//! UnixFS file.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::blockstore::MsCid;
|
||||
|
||||
use super::{BitswapGetError, Command};
|
||||
|
||||
/// Issue a single-block bitswap fetch and await the bytes (or a
|
||||
/// transport failure / timeout). The `timeout` is enforced caller-side;
|
||||
/// beetswap itself has its own per-query budget but a runaway WANT-list
|
||||
/// shouldn't block the HTTP response.
|
||||
pub async fn bitswap_get_block(
|
||||
swarm_tx: &mpsc::Sender<Command>,
|
||||
cid: MsCid,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<u8>, BitswapGetError> {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
swarm_tx
|
||||
.send(Command::BitswapGet {
|
||||
cid,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| BitswapGetError::ReplyChannelClosed)?;
|
||||
match tokio::time::timeout(timeout, reply_rx).await {
|
||||
Ok(Ok(res)) => res,
|
||||
// Reply oneshot dropped — swarm task fell over mid-call.
|
||||
Ok(Err(_)) => Err(BitswapGetError::ReplyChannelClosed),
|
||||
Err(_) => Err(BitswapGetError::QueryFailed(format!(
|
||||
"bitswap fetch timed out after {:?}",
|
||||
timeout
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inflight map from `beetswap::QueryId` to the caller's reply channel.
|
||||
/// Owned by the swarm task; mutated on `Command::BitswapGet` (insert)
|
||||
/// and on `beetswap::Event::{GetQueryResponse, GetQueryError}` (fire +
|
||||
/// remove).
|
||||
#[derive(Default)]
|
||||
pub(super) struct BitswapInflight {
|
||||
map: HashMap<beetswap::QueryId, oneshot::Sender<Result<Vec<u8>, BitswapGetError>>>,
|
||||
}
|
||||
|
||||
impl BitswapInflight {
|
||||
pub(super) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register the reply channel for a newly-issued `bitswap.get`.
|
||||
/// The libp2p call must run *before* this — its `QueryId` is what
|
||||
/// we key by.
|
||||
pub(super) fn register(
|
||||
&mut self,
|
||||
id: beetswap::QueryId,
|
||||
reply: oneshot::Sender<Result<Vec<u8>, BitswapGetError>>,
|
||||
) {
|
||||
if self.map.insert(id, reply).is_some() {
|
||||
warn!(?id, "bitswap inflight: id collision; overwrote pending reply");
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a `beetswap::Event` from the swarm. Returns `true` if
|
||||
/// the event was a query completion that fired a reply, `false`
|
||||
/// otherwise.
|
||||
pub(super) fn on_event(&mut self, event: &beetswap::Event) -> bool {
|
||||
match event {
|
||||
beetswap::Event::GetQueryResponse { query_id, data } => {
|
||||
if let Some(tx) = self.map.remove(query_id) {
|
||||
let _ = tx.send(Ok(data.clone()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
beetswap::Event::GetQueryError { query_id, error } => {
|
||||
if let Some(tx) = self.map.remove(query_id) {
|
||||
let _ = tx.send(Err(BitswapGetError::QueryFailed(error.to_string())));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dummy_cid() -> MsCid {
|
||||
MsCid::from_str("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e")
|
||||
.expect("static cid parses")
|
||||
}
|
||||
|
||||
/// Mirrors `call_gateway_returns_swarm_gone_on_dead_channel` — if
|
||||
/// the swarm task has exited, the helper surfaces a closed-channel
|
||||
/// error rather than hanging.
|
||||
#[tokio::test]
|
||||
async fn bitswap_get_returns_channel_closed_on_dead_channel() {
|
||||
let (tx, rx) = mpsc::channel::<Command>(1);
|
||||
drop(rx);
|
||||
let res = bitswap_get_block(&tx, dummy_cid(), Duration::from_millis(50)).await;
|
||||
assert!(matches!(res, Err(BitswapGetError::ReplyChannelClosed)));
|
||||
}
|
||||
|
||||
/// Mirrors `call_gateway_returns_timeout_when_reply_never_arrives` —
|
||||
/// if the swarm task accepts the command but never delivers the
|
||||
/// reply, the caller-side timeout fires.
|
||||
#[tokio::test]
|
||||
async fn bitswap_get_returns_timeout_when_reply_never_arrives() {
|
||||
let (tx, mut rx) = mpsc::channel::<Command>(1);
|
||||
let pump = tokio::spawn(async move {
|
||||
let _held = rx.recv().await;
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
drop(_held);
|
||||
});
|
||||
let res = bitswap_get_block(&tx, dummy_cid(), Duration::from_millis(50)).await;
|
||||
pump.abort();
|
||||
match res {
|
||||
Err(BitswapGetError::QueryFailed(msg)) => {
|
||||
assert!(msg.contains("timed out"), "got `{msg}`");
|
||||
}
|
||||
other => panic!("expected timed-out QueryFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Bootstrap-multiaddr auto-redial machine. Distinct from kad-DHT
|
||||
//! discovery: this layer keeps a small set of operator-configured
|
||||
//! multiaddrs dialled across reconnects, so a leaf doesn't silently
|
||||
//! disconnect when its bootstrap target restarts.
|
||||
//!
|
||||
//! Today's failure mode (pre-v0.2): rebuilding the bootstrap peer
|
||||
//! without bouncing the leaves leaves them disconnected — they don't
|
||||
//! auto-redial. This module fixes that. kad-DHT discovery is the
|
||||
//! secondary mechanism (it'd re-establish the mesh anyway, but only at
|
||||
//! `discovery_interval` cadence).
|
||||
|
||||
use libp2p::multiaddr::Protocol;
|
||||
use libp2p::{Multiaddr, PeerId, Swarm};
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use super::Behaviour;
|
||||
|
||||
/// One bootstrap target we keep alive across reconnects.
|
||||
///
|
||||
/// `peer_id` is populated either from a `/p2p/<id>` segment in the configured
|
||||
/// multiaddr or, lacking that, from the first successful outgoing dial through
|
||||
/// this address. Once known, it's used to skip redundant dials when a
|
||||
/// connection is already up — without it, every redial tick adds a duplicate
|
||||
/// connection that only goes away on idle timeout.
|
||||
pub struct BootstrapEntry {
|
||||
pub addr: Multiaddr,
|
||||
pub peer_id: Option<PeerId>,
|
||||
}
|
||||
|
||||
impl BootstrapEntry {
|
||||
pub fn new(addr: Multiaddr) -> Self {
|
||||
let peer_id = addr.iter().find_map(|p| match p {
|
||||
Protocol::P2p(id) => Some(id),
|
||||
_ => None,
|
||||
});
|
||||
Self { addr, peer_id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-dial any bootstrap entry whose target is not currently connected.
|
||||
pub fn redial_bootstrap(swarm: &mut Swarm<Behaviour>, bootstrap: &[BootstrapEntry]) {
|
||||
for entry in bootstrap {
|
||||
if let Some(pid) = entry.peer_id {
|
||||
if swarm.is_connected(&pid) {
|
||||
trace!(addr = %entry.addr, "auto-redial: already connected, skip");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Either we never learned this entry's peer-id (so we can't tell if
|
||||
// we're connected — dial unconditionally and rely on libp2p's
|
||||
// ConnectionEstablished to populate the peer-id next time), or the
|
||||
// peer-id is known and we're not connected.
|
||||
match swarm.dial(entry.addr.clone()) {
|
||||
Ok(()) => debug!(addr = %entry.addr, "auto-redial: dial issued"),
|
||||
Err(e) => debug!(addr = %entry.addr, error = %e, "auto-redial: dial failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `endpoint_addr` (the resolved address libp2p actually
|
||||
/// dialed) was reached via the configured `bootstrap_addr`. Compares only the
|
||||
/// non-DNS, non-`/p2p/...` prefix shape — DNS resolution rewrites the addr,
|
||||
/// so byte-equality won't match. Pragmatic check: same TCP port, plus either
|
||||
/// IP-prefix shape or both having a DNS segment. Good enough to
|
||||
/// opportunistically link a learned peer-id back to a configured bootstrap
|
||||
/// entry; false matches just mean we'd skip a redial that wasn't redundant.
|
||||
pub fn addr_targets_match(bootstrap_addr: &Multiaddr, endpoint_addr: &Multiaddr) -> bool {
|
||||
let port_a = extract_tcp_port(bootstrap_addr);
|
||||
let port_b = extract_tcp_port(endpoint_addr);
|
||||
port_a.is_some() && port_a == port_b
|
||||
}
|
||||
|
||||
fn extract_tcp_port(addr: &Multiaddr) -> Option<u16> {
|
||||
addr.iter().find_map(|p| match p {
|
||||
Protocol::Tcp(port) => Some(port),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! The swarm task — owns the libp2p `Swarm`, the HTTP command channel,
|
||||
//! and the kad-DHT/bootstrap/peer-directory state, and drives them all
|
||||
//! from one `tokio::select!` loop.
|
||||
//!
|
||||
//! Refactored from the previous monolithic `swarm::spawn` closure into a
|
||||
//! `SwarmTask` struct with per-arm handler methods. The select! itself
|
||||
//! stays in [`SwarmTask::run`] because tokio's macro needs all arm futures
|
||||
//! to be constructed in one scope — `swarm` and `rx` are passed in as
|
||||
//! locals (not held on `self`) so each handler method can borrow
|
||||
//! `&mut self` cleanly without conflicting with the arm futures.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::StreamExt;
|
||||
use libp2p::core::ConnectedPoint;
|
||||
use libp2p::kad::{self, GetProvidersOk, QueryResult, RecordKey};
|
||||
use libp2p::mdns;
|
||||
use libp2p::swarm::SwarmEvent;
|
||||
use libp2p::{identify, Multiaddr, PeerId, Swarm};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
use crate::gateway_discovery::{advertises_gateways, fetch_gateway_caps};
|
||||
|
||||
use super::bootstrap::{addr_targets_match, redial_bootstrap, BootstrapEntry};
|
||||
use super::bitswap_client::BitswapInflight;
|
||||
use super::identify_agent::parse_base_url;
|
||||
use super::kad_discovery::{namespace_key, peer_id_from_multiaddr, KadConfig};
|
||||
use super::peer_directory::PeerDirectory;
|
||||
use super::{Behaviour, BehaviourEvent, Command, GatewayCapsConfig, PeersInfo};
|
||||
|
||||
/// All the state that lives across iterations of the event loop. `swarm`
|
||||
/// and the channel receivers stay as locals in [`SwarmTask::run`] so the
|
||||
/// `tokio::select!` arms can borrow them without conflicting with
|
||||
/// `&mut self` calls in the arm bodies.
|
||||
pub(super) struct SwarmTask {
|
||||
local_peer_id: PeerId,
|
||||
/// Bootstrap targets we keep alive across reconnects.
|
||||
bootstrap: Vec<BootstrapEntry>,
|
||||
/// Shared peer→info directory (populated from identify events).
|
||||
peer_directory: PeerDirectory,
|
||||
/// kad-DHT discovery state.
|
||||
kad_namespace: String,
|
||||
ns_key: RecordKey,
|
||||
kad_target_peer_count: usize,
|
||||
kad_fast_discovery_interval: Duration,
|
||||
kad_discovery_interval: Duration,
|
||||
kad_provide_interval: Duration,
|
||||
/// Whether to publish the namespace provider record at all — see
|
||||
/// [`KadConfig::provide_enabled`]. False when this peer isn't publicly
|
||||
/// reachable.
|
||||
kad_provide_enabled: bool,
|
||||
kad_bootstrap_peers: Vec<Multiaddr>,
|
||||
/// Rate-limit floor for reactive kad kicks on `ConnectionClosed`.
|
||||
last_kad_query_at: tokio::time::Instant,
|
||||
/// Auto-redial cadence; zero / empty bootstrap effectively disables.
|
||||
redial_interval: Duration,
|
||||
/// In-flight `bitswap.get(cid)` calls awaiting block delivery.
|
||||
/// Populated by `Command::BitswapGet`, drained by the
|
||||
/// `BehaviourEvent::Bitswap` arm of `on_swarm_event`.
|
||||
bitswap_inflight: BitswapInflight,
|
||||
/// Process-wide HTTP client, for the gateway capability fetch off identify.
|
||||
http: reqwest::Client,
|
||||
/// Timeout + debounce for that fetch.
|
||||
gateway_caps: GatewayCapsConfig,
|
||||
}
|
||||
|
||||
impl SwarmTask {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn new(
|
||||
local_peer_id: PeerId,
|
||||
bootstrap_peers: Vec<Multiaddr>,
|
||||
redial_interval: Duration,
|
||||
kad_config: KadConfig,
|
||||
peer_directory: PeerDirectory,
|
||||
http: reqwest::Client,
|
||||
gateway_caps: GatewayCapsConfig,
|
||||
) -> Self {
|
||||
let bootstrap: Vec<BootstrapEntry> =
|
||||
bootstrap_peers.into_iter().map(BootstrapEntry::new).collect();
|
||||
let ns_key = namespace_key(&kad_config.namespace);
|
||||
// Initialise far enough in the past that the very first reactive kick
|
||||
// can fire (subject to the fast-discovery rate-limit floor).
|
||||
let last_kad_query_at = tokio::time::Instant::now()
|
||||
.checked_sub(kad_config.fast_discovery_interval)
|
||||
.unwrap_or_else(tokio::time::Instant::now);
|
||||
Self {
|
||||
local_peer_id,
|
||||
bootstrap,
|
||||
peer_directory,
|
||||
kad_namespace: kad_config.namespace,
|
||||
ns_key,
|
||||
kad_target_peer_count: kad_config.target_peer_count,
|
||||
kad_fast_discovery_interval: kad_config.fast_discovery_interval,
|
||||
kad_discovery_interval: kad_config.discovery_interval,
|
||||
kad_provide_interval: kad_config.provide_interval,
|
||||
kad_provide_enabled: kad_config.provide_enabled,
|
||||
kad_bootstrap_peers: kad_config.bootstrap_peers,
|
||||
last_kad_query_at,
|
||||
redial_interval,
|
||||
bitswap_inflight: BitswapInflight::new(),
|
||||
http,
|
||||
gateway_caps,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the swarm task to completion. Owns `swarm` and the channel
|
||||
/// receivers as locals — the `tokio::select!` arms borrow these
|
||||
/// directly, and the arm bodies are free to call `&mut self` methods
|
||||
/// because the arm futures have completed (and their borrows
|
||||
/// released) by the time a body runs.
|
||||
pub(super) async fn run(
|
||||
mut self,
|
||||
mut swarm: Swarm<Behaviour>,
|
||||
mut rx: mpsc::Receiver<Command>,
|
||||
) {
|
||||
// Initial bootstrap dials.
|
||||
for entry in &self.bootstrap {
|
||||
match swarm.dial(entry.addr.clone()) {
|
||||
Ok(()) => info!(addr = %entry.addr, "bootstrap dial issued"),
|
||||
Err(e) => warn!(addr = %entry.addr, error = %e, "bootstrap dial failed"),
|
||||
}
|
||||
}
|
||||
|
||||
// Seed kad's routing table with the configured bootstrap multiaddrs.
|
||||
// Each entry must include `/p2p/<peer_id>` so we can register the
|
||||
// peer-id without an extra round-trip; entries without are skipped
|
||||
// with a warning (the kad routing table is keyed by peer-id).
|
||||
let mut kad_seeded = 0usize;
|
||||
for addr in &self.kad_bootstrap_peers {
|
||||
match peer_id_from_multiaddr(addr) {
|
||||
Some(pid) => {
|
||||
swarm.behaviour_mut().kad.add_address(&pid, addr.clone());
|
||||
kad_seeded += 1;
|
||||
debug!(%pid, %addr, "kad: bootstrap address registered");
|
||||
}
|
||||
None => warn!(%addr, "kad: bootstrap multiaddr lacks /p2p/<id>; skipped"),
|
||||
}
|
||||
}
|
||||
if kad_seeded > 0 {
|
||||
match swarm.behaviour_mut().kad.bootstrap() {
|
||||
Ok(qid) => info!(?qid, kad_seeded, "kad: bootstrap query issued"),
|
||||
Err(e) => warn!(error = %e, "kad: bootstrap query failed"),
|
||||
}
|
||||
} else {
|
||||
// Not a fault — it's the local-mode default. An empty bootstrap list
|
||||
// is exactly what keeps an unreachable peer off the public DHT, so
|
||||
// saying it at WARN would train operators to ignore a real warning.
|
||||
info!(
|
||||
"kad: no bootstrap peers configured; public-DHT discovery is off \
|
||||
(local mode — mDNS still works). Set PUBLIC_ADDR to join the public DHT."
|
||||
);
|
||||
}
|
||||
info!(namespace = %self.kad_namespace, "kad: discovery namespace");
|
||||
|
||||
// Auto-redial ticker. `redial_interval == 0` disables; we still need a
|
||||
// ticker (`tokio::select!` requires all branches to compile) so use a
|
||||
// very long interval that effectively never fires.
|
||||
let effective_interval = if self.redial_interval.is_zero() || self.bootstrap.is_empty() {
|
||||
Duration::from_secs(60 * 60 * 24 * 365) // 1y; effectively never
|
||||
} else {
|
||||
self.redial_interval
|
||||
};
|
||||
let mut redial_tick = tokio::time::interval(effective_interval);
|
||||
// Skip the immediate first tick — we just did the initial dials above.
|
||||
redial_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
redial_tick.tick().await;
|
||||
|
||||
// kad provide ticker — fires immediately so we publish on startup,
|
||||
// then every kad_provide_interval. Provider records on remote peers
|
||||
// expire (~24 h default), so this must be lower than that.
|
||||
let mut kad_provide_tick = tokio::time::interval(self.kad_provide_interval);
|
||||
kad_provide_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
// kad discovery ticker — same pattern, fires immediately.
|
||||
let mut kad_discovery_tick = tokio::time::interval(self.kad_discovery_interval);
|
||||
kad_discovery_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = rx.recv() => match cmd {
|
||||
Some(cmd) => self.on_command(&mut swarm, cmd).await,
|
||||
None => {
|
||||
debug!("command channel closed; shutting down swarm task");
|
||||
break;
|
||||
}
|
||||
},
|
||||
_ = redial_tick.tick() => {
|
||||
redial_bootstrap(&mut swarm, &self.bootstrap);
|
||||
},
|
||||
_ = kad_provide_tick.tick() => {
|
||||
// A provider record is a promise that this peer can be
|
||||
// dialed. In local mode it can't be (no public address,
|
||||
// only container-internal listeners), so publishing one
|
||||
// would send every peer that walks the DHT into a dial
|
||||
// that can never connect. Stay quiet.
|
||||
if self.kad_provide_enabled {
|
||||
match swarm.behaviour_mut().kad.start_providing(self.ns_key.clone()) {
|
||||
Ok(qid) => debug!(?qid, namespace = %self.kad_namespace,
|
||||
"kad: re-publishing provider record"),
|
||||
Err(e) => warn!(error = %e, namespace = %self.kad_namespace,
|
||||
"kad: start_providing failed"),
|
||||
}
|
||||
}
|
||||
},
|
||||
_ = kad_discovery_tick.tick() => {
|
||||
self.on_kad_discovery_tick(&mut swarm, &mut kad_discovery_tick);
|
||||
},
|
||||
event = swarm.select_next_some() => {
|
||||
self.on_swarm_event(&mut swarm, event, &mut kad_discovery_tick).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single HTTP-layer command.
|
||||
async fn on_command(&mut self, swarm: &mut Swarm<Behaviour>, cmd: Command) {
|
||||
match cmd {
|
||||
Command::Peers { reply } => {
|
||||
let connected: Vec<String> =
|
||||
swarm.connected_peers().map(|p| p.to_string()).collect();
|
||||
let info = PeersInfo {
|
||||
local_peer_id: self.local_peer_id.to_string(),
|
||||
connected,
|
||||
};
|
||||
let _ = reply.send(info);
|
||||
}
|
||||
Command::BitswapGet { cid, reply } => {
|
||||
// M12: issue a bitswap fetch for one block. The
|
||||
// behaviour fans WANT-HAVE / WANT-BLOCK to connected
|
||||
// peers (gateways + sibling consumers that already
|
||||
// cached the cid). `bitswap.get` returns a QueryId;
|
||||
// we register it with the inflight map and the
|
||||
// `BehaviourEvent::Bitswap` arm fires the reply when
|
||||
// either `GetQueryResponse` (success) or
|
||||
// `GetQueryError` (transport / no-providers /
|
||||
// timeout) arrives.
|
||||
let query_id = swarm.behaviour_mut().bitswap.get(&cid);
|
||||
tracing::debug!(%cid, ?query_id, "bitswap: issued .get()");
|
||||
self.bitswap_inflight.register(query_id, reply);
|
||||
}
|
||||
Command::Provide { cid } => {
|
||||
// Announce on the public IPFS DHT, keyed by the CID's
|
||||
// multihash bytes — that's the kubo-compatible provider key,
|
||||
// so a vanilla IPFS node `get_providers`ing the same content
|
||||
// finds us. The StartProviding result is logged in
|
||||
// `default_log`. Works in `Mode::Client` (same call the
|
||||
// gateway makes).
|
||||
let key = RecordKey::new(&cid.hash().to_bytes());
|
||||
match swarm.behaviour_mut().kad.start_providing(key) {
|
||||
Ok(qid) => debug!(?qid, %cid, "kad: providing seeded cid"),
|
||||
Err(e) => warn!(error = %e, %cid, "kad: start_providing(cid) failed"),
|
||||
}
|
||||
}
|
||||
Command::StopProviding { cid } => {
|
||||
let key = RecordKey::new(&cid.hash().to_bytes());
|
||||
swarm.behaviour_mut().kad.stop_providing(&key);
|
||||
debug!(%cid, "kad: stopped providing seeded cid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fired every `kad_discovery_interval` (steady-state) or
|
||||
/// `kad_fast_discovery_interval` (below floor). Issues a fresh
|
||||
/// `get_providers` query and adapts the next tick's cadence based on
|
||||
/// the current connection count.
|
||||
fn on_kad_discovery_tick(
|
||||
&mut self,
|
||||
swarm: &mut Swarm<Behaviour>,
|
||||
kad_discovery_tick: &mut tokio::time::Interval,
|
||||
) {
|
||||
let connected = swarm.connected_peers().count();
|
||||
let qid = swarm.behaviour_mut().kad.get_providers(self.ns_key.clone());
|
||||
self.last_kad_query_at = tokio::time::Instant::now();
|
||||
// Adapt the next tick's cadence to whether we're below the
|
||||
// connection-floor. `reset_after` only affects the *next* tick —
|
||||
// current behaviour keeps the steady-state config-driven topup rate
|
||||
// when the swarm is full and accelerates discovery when it's not.
|
||||
let next = if connected < self.kad_target_peer_count {
|
||||
self.kad_fast_discovery_interval
|
||||
} else {
|
||||
self.kad_discovery_interval
|
||||
};
|
||||
debug!(
|
||||
?qid, namespace = %self.kad_namespace,
|
||||
connected, target = self.kad_target_peer_count,
|
||||
next_tick_secs = next.as_secs(),
|
||||
"kad: querying for providers"
|
||||
);
|
||||
kad_discovery_tick.reset_after(next);
|
||||
}
|
||||
|
||||
/// Top-level swarm-event dispatch. Each branch inspects `&event` and
|
||||
/// falls through to `default_log` at the end.
|
||||
async fn on_swarm_event(
|
||||
&mut self,
|
||||
swarm: &mut Swarm<Behaviour>,
|
||||
event: SwarmEvent<BehaviourEvent>,
|
||||
kad_discovery_tick: &mut tokio::time::Interval,
|
||||
) {
|
||||
// 0) Soft ceiling for inbound: when we're already at target,
|
||||
// close inbound connections before they negotiate. Pre-handshake
|
||||
// close — peer-id is not known yet, only the source address.
|
||||
// Bootstrap dials and our own outgoing dials are unaffected
|
||||
// (they go through the dial-gates in the mdns/kad branches
|
||||
// below).
|
||||
if let SwarmEvent::IncomingConnection { connection_id, .. } = &event {
|
||||
let cid = *connection_id;
|
||||
let connected = swarm.connected_peers().count();
|
||||
if connected >= self.kad_target_peer_count {
|
||||
debug!(?cid, connected, target = self.kad_target_peer_count,
|
||||
"rejecting inbound connection; at target");
|
||||
let _ = swarm.close_connection(cid);
|
||||
}
|
||||
}
|
||||
// 1) On every outgoing-dial connection, opportunistically populate
|
||||
// bootstrap[].peer_id so future redial ticks can skip
|
||||
// already-connected entries.
|
||||
if let SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } = &event {
|
||||
if let ConnectedPoint::Dialer { address, .. } = endpoint {
|
||||
// Always overwrite the cached peer-id on a fresh outgoing
|
||||
// dial, not just on first contact. Bootstrap targets that
|
||||
// lack a persistent peer-id (today: every meta-share peer
|
||||
// in the POC) rotate their identity on restart, and a
|
||||
// stale cached peer-id makes is_connected() return false
|
||||
// on the next tick — causing a redundant dial that
|
||||
// doubles the connection count.
|
||||
for entry in self.bootstrap.iter_mut() {
|
||||
if addr_targets_match(&entry.addr, address)
|
||||
&& entry.peer_id != Some(*peer_id)
|
||||
{
|
||||
entry.peer_id = Some(*peer_id);
|
||||
debug!(peer_id = %peer_id, addr = %entry.addr,
|
||||
"auto-redial: bootstrap target peer-id learned/refreshed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1b) On disconnect: if we drop below the connection floor, kick
|
||||
// the kad discovery tick to fire immediately so a replacement
|
||||
// peer is found quickly — but only if the last query was at
|
||||
// least `fast_discovery_interval` ago, so a flapping peer
|
||||
// can't trigger DHT queries faster than that floor.
|
||||
// `reset_after(0)` makes the *next* tick fire now; if we're
|
||||
// already mid-query the in-flight one continues.
|
||||
if let SwarmEvent::ConnectionClosed { peer_id, num_established, .. } = &event {
|
||||
// On the *last* connection to a peer closing, drop its gateway
|
||||
// state immediately — the fast complement to the read-side TTL
|
||||
// in PeerDirectory. A peer whose libp2p id rotates on restart
|
||||
// (the dev-stack norm) otherwise keeps advertising upstreams it
|
||||
// no longer serves, and the M7 fan-out burns a slot dialing the
|
||||
// dead id. `num_established` is the count remaining *after* this
|
||||
// close, so 0 means the peer is fully gone.
|
||||
if *num_established == 0 && self.peer_directory.forget_peer(peer_id) {
|
||||
debug!(peer_id = %peer_id, "peer fully disconnected — forgot gateway state");
|
||||
}
|
||||
let connected = swarm.connected_peers().count();
|
||||
if connected < self.kad_target_peer_count
|
||||
&& self.last_kad_query_at.elapsed() >= self.kad_fast_discovery_interval
|
||||
{
|
||||
debug!(connected, target = self.kad_target_peer_count,
|
||||
"kad: below connection floor — kicking discovery");
|
||||
kad_discovery_tick.reset_after(Duration::ZERO);
|
||||
}
|
||||
}
|
||||
// 2) When the identify protocol delivers a peer's listen
|
||||
// addresses, feed them into kad's routing table and parse
|
||||
// `baseUrl=` into the peer directory (the byte-fetch path
|
||||
// resolves a peer's HTTP API URL from here).
|
||||
if let SwarmEvent::Behaviour(BehaviourEvent::Identify(
|
||||
identify::Event::Received { peer_id, info, .. }
|
||||
)) = &event {
|
||||
let pid = *peer_id;
|
||||
let addrs: Vec<Multiaddr> = info.listen_addrs.clone();
|
||||
for addr in addrs {
|
||||
swarm.behaviour_mut().kad.add_address(&pid, addr);
|
||||
}
|
||||
let base_url = parse_base_url(&info.agent_version).map(str::to_string);
|
||||
let update = self.peer_directory.upsert_from_identify(pid, base_url);
|
||||
if let Some(url) = &update.base_url_changed {
|
||||
debug!(peer_id = %pid, base_url = %url,
|
||||
"identify: learned/refreshed peer base URL");
|
||||
}
|
||||
// Gateway discovery. A non-empty `gateways=` token is emitted by
|
||||
// meta-gateway and nobody else, so it's the "this peer is a
|
||||
// gateway" signal. Its *capabilities* aren't on the wire though —
|
||||
// libp2p 0.56 can't mutate agent_version after startup, so a
|
||||
// runtime plugin toggle would never reach us. We fetch them over
|
||||
// plain HTTP off the just-learned baseUrl instead (the reliable
|
||||
// source that replaced the flaky gossipsub heartbeat).
|
||||
//
|
||||
// Fire-and-forget: a slow/dead gateway must not stall the swarm
|
||||
// loop. Debounced by the directory so the identify burst on a
|
||||
// multi-connection peer doesn't fan out into redundant fetches;
|
||||
// identify's ~5min re-announce then keeps the capability fresh
|
||||
// inside its 600s TTL, which is why there's no keep-alive tick.
|
||||
if advertises_gateways(&info.agent_version)
|
||||
&& self
|
||||
.peer_directory
|
||||
.needs_cap_refresh(&pid, self.gateway_caps.refresh_after)
|
||||
{
|
||||
if let Some(base_url) = self.peer_directory.base_url(&pid) {
|
||||
let http = self.http.clone();
|
||||
let dir = self.peer_directory.clone();
|
||||
let timeout = self.gateway_caps.fetch_timeout;
|
||||
tokio::spawn(async move {
|
||||
match fetch_gateway_caps(&http, &base_url, timeout).await {
|
||||
Ok(caps) => {
|
||||
let redeems = caps.redeem_claims();
|
||||
debug!(peer_id = %pid, base_url = %base_url,
|
||||
nzb_fetch = caps.nzb_fetch,
|
||||
redeem_claims = redeems.len(),
|
||||
"gateway caps: fetched");
|
||||
dir.record_gateway_caps(pid, caps.nzb_fetch, redeems);
|
||||
}
|
||||
// Best-effort: keep whatever we held (it ages out on
|
||||
// its own TTL) and retry on the next identify.
|
||||
Err(e) => debug!(peer_id = %pid, base_url = %base_url,
|
||||
error = %e, "gateway caps: fetch failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2b) mDNS: register the address in kad's routing table (so
|
||||
// subsequent lookups dedupe) and dial — unless at target.
|
||||
if let SwarmEvent::Behaviour(BehaviourEvent::Mdns(
|
||||
mdns::Event::Discovered(peers)
|
||||
)) = &event {
|
||||
for (pid, addr) in peers.iter() {
|
||||
if *pid == self.local_peer_id {
|
||||
continue;
|
||||
}
|
||||
swarm.behaviour_mut().kad.add_address(pid, addr.clone());
|
||||
if swarm.is_connected(pid) {
|
||||
trace!(peer_id = %pid, %addr, "mdns: peer already connected");
|
||||
continue;
|
||||
}
|
||||
// LAN-local (mDNS) peers bypass the public-peer connection
|
||||
// ceiling and are always dialed — this is the local mesh (the
|
||||
// gateway + sibling peers) and it must never be starved by a
|
||||
// busy public DHT that has pushed `connected` past `target`.
|
||||
// mDNS is LAN-scoped, so the set stays small (no unbounded-dial
|
||||
// risk); remote/public peers still respect the ceiling below.
|
||||
match swarm.dial(*pid) {
|
||||
Ok(()) => debug!(peer_id = %pid, %addr,
|
||||
"mdns: dialing newly-discovered peer"),
|
||||
Err(e) => debug!(peer_id = %pid, %addr, error = %e,
|
||||
"mdns: dial failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2c.5) Bitswap (M12) — fire the reply oneshot for an outbound
|
||||
// `.get(cid)` when bitswap delivers the block or surfaces
|
||||
// a query error. The same Behaviour also serves inbound
|
||||
// WANT-HAVE/WANT-BLOCK from our local blockstore;
|
||||
// those are intercepted by the Behaviour internally and
|
||||
// never bubble up as `beetswap::Event` variants, so this
|
||||
// arm only handles outbound results.
|
||||
if let SwarmEvent::Behaviour(BehaviourEvent::Bitswap(ev)) = &event {
|
||||
let fired = self.bitswap_inflight.on_event(ev);
|
||||
if fired {
|
||||
trace!("bitswap: inflight query resolved");
|
||||
}
|
||||
}
|
||||
// 3) kad providers — dial each new peer-id (subject to soft ceiling).
|
||||
if let SwarmEvent::Behaviour(BehaviourEvent::Kad(
|
||||
kad::Event::OutboundQueryProgressed {
|
||||
result: QueryResult::GetProviders(Ok(GetProvidersOk::FoundProviders {
|
||||
providers, ..
|
||||
})),
|
||||
..
|
||||
}
|
||||
)) = &event {
|
||||
for pid in providers.iter() {
|
||||
if *pid == self.local_peer_id {
|
||||
continue; // ourselves
|
||||
}
|
||||
if swarm.is_connected(pid) {
|
||||
trace!(peer_id = %pid, "kad: provider already connected");
|
||||
continue;
|
||||
}
|
||||
let connected = swarm.connected_peers().count();
|
||||
if connected >= self.kad_target_peer_count {
|
||||
trace!(peer_id = %pid,
|
||||
connected, target = self.kad_target_peer_count,
|
||||
"kad: at target — skipping dial of provider");
|
||||
continue;
|
||||
}
|
||||
match swarm.dial(*pid) {
|
||||
Ok(()) => debug!(peer_id = %pid,
|
||||
"kad: dialing newly-discovered provider"),
|
||||
Err(e) => debug!(peer_id = %pid, error = %e,
|
||||
"kad: dial of discovered provider failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default_log(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic swarm-event logger — catches everything worth surfacing at
|
||||
/// info/debug/trace level after `on_swarm_event`'s explicit branches.
|
||||
fn default_log(event: SwarmEvent<BehaviourEvent>) {
|
||||
match event {
|
||||
SwarmEvent::NewListenAddr { address, .. } => {
|
||||
info!(%address, "swarm listening");
|
||||
}
|
||||
SwarmEvent::ConnectionEstablished { peer_id, .. } => {
|
||||
trace!(%peer_id, "connection established");
|
||||
}
|
||||
SwarmEvent::ConnectionClosed { peer_id, cause, .. } => {
|
||||
trace!(%peer_id, ?cause, "connection closed");
|
||||
}
|
||||
SwarmEvent::IncomingConnectionError { error, .. } => {
|
||||
warn!(%error, "incoming connection error");
|
||||
}
|
||||
SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
|
||||
warn!(?peer_id, %error, "outgoing connection error");
|
||||
}
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Identify(identify::Event::Received { peer_id, info, .. })) => {
|
||||
trace!(%peer_id, listen_addrs = info.listen_addrs.len(), "identify received");
|
||||
}
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Identify(_)) => {
|
||||
// identify::Event::{Sent,Pushed,Error} — not interesting at info level
|
||||
}
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
|
||||
result: QueryResult::GetProviders(Ok(GetProvidersOk::FoundProviders { providers, .. })),
|
||||
..
|
||||
})) => {
|
||||
debug!(provider_count = providers.len(), "kad: GetProviders ok");
|
||||
}
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
|
||||
result: QueryResult::StartProviding(res), ..
|
||||
})) => match res {
|
||||
Ok(_) => debug!("kad: StartProviding ok"),
|
||||
Err(e) => warn!(error = ?e, "kad: StartProviding error"),
|
||||
},
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
|
||||
result: QueryResult::Bootstrap(res), ..
|
||||
})) => match res {
|
||||
Ok(_) => trace!("kad: Bootstrap step ok"),
|
||||
Err(e) => debug!(error = ?e, "kad: Bootstrap step error"),
|
||||
},
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Kad(_)) => {
|
||||
// RoutingUpdated, InboundRequest (won't happen in client mode),
|
||||
// ModeChanged, etc. — quiet at info.
|
||||
}
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Mdns(mdns::Event::Discovered(peers))) => {
|
||||
trace!(peer_count = peers.len(), "mdns: discovered local peers");
|
||||
}
|
||||
SwarmEvent::Behaviour(BehaviourEvent::Mdns(mdns::Event::Expired(peers))) => {
|
||||
trace!(peer_count = peers.len(), "mdns: expired local peers");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! libp2p `identify` protocol — how meta-share peers learn each other's
|
||||
//! HTTP API URL.
|
||||
//!
|
||||
//! `identify::Behaviour` already exchanges peer-ids, listen addresses,
|
||||
//! and an `agent_version` string. We overload `agent_version` with
|
||||
//! whitespace-separated structured tokens:
|
||||
//!
|
||||
//! - `baseUrl=<url>` — the peer's HTTP API URL, used by cross-peer
|
||||
//! fetches (`/api/peer/:peer_id/file/:cid`) to dial server-to-server.
|
||||
//!
|
||||
//! Free-form-string-with-keys is the convention for `agent_version` in
|
||||
//! libp2p; we'll graduate to a dedicated protocol if the shape starts
|
||||
//! feeling cramped.
|
||||
|
||||
/// Identifier advertised on the identify protocol. Bumping this is harmless;
|
||||
/// peers identify regardless of the protocol-version string.
|
||||
pub const IDENTIFY_PROTOCOL: &str = "/meta-share/identify/1.0.0";
|
||||
|
||||
/// Token prefix in `identify.agent_version` carrying our HTTP API base URL.
|
||||
/// Everything from this token to the next whitespace is the URL. Parsed by
|
||||
/// other peers on `identify::Event::Received` and stored in the peer
|
||||
/// directory so cross-peer fetches (`/api/peer/:peer_id/file/:cid`) can
|
||||
/// dial without going through meta-core's filesystem registry.
|
||||
const AGENT_BASE_URL_TOKEN: &str = "baseUrl=";
|
||||
|
||||
/// Construct the identify-protocol agent_version string. Format:
|
||||
/// `meta-share/<version>` followed by `baseUrl=<url>` if set. Other peers
|
||||
/// parse the URL via [`parse_base_url`].
|
||||
pub fn build_agent_version(base_url: Option<&str>) -> String {
|
||||
let v = env!("CARGO_PKG_VERSION");
|
||||
let mut s = format!("meta-share/{v}");
|
||||
if let Some(url) = base_url {
|
||||
if !url.is_empty() {
|
||||
s.push(' ');
|
||||
s.push_str(AGENT_BASE_URL_TOKEN);
|
||||
s.push_str(url);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Extract the HTTP API base URL from a remote peer's `agent_version`.
|
||||
/// Returns `None` if the field doesn't carry one (consumer-only peer
|
||||
/// with no `BASE_URL` set, or a non-meta-share peer).
|
||||
pub fn parse_base_url(agent_version: &str) -> Option<&str> {
|
||||
agent_version
|
||||
.split_whitespace()
|
||||
.find_map(|tok| tok.strip_prefix(AGENT_BASE_URL_TOKEN))
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_agent_version_includes_base_url() {
|
||||
let s = build_agent_version(Some("http://x:9"));
|
||||
assert!(s.starts_with("meta-share/"));
|
||||
assert!(s.contains("baseUrl=http://x:9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_base_url_extracts_token() {
|
||||
let av = "meta-share/0.1.0 baseUrl=http://x:9";
|
||||
assert_eq!(parse_base_url(av), Some("http://x:9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_base_url_returns_none_when_absent() {
|
||||
let av = "meta-share/0.1.0";
|
||||
assert_eq!(parse_base_url(av), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! libp2p kad-DHT discovery — peer discovery in `Mode::Client` against
|
||||
//! the public IPFS DHT. Two responsibilities:
|
||||
//!
|
||||
//! 1. **Peer discovery** — periodic `start_providing` / `get_providers`
|
||||
//! under a namespace key lets every archivist find every other
|
||||
//! archivist without a designated bootstrap node.
|
||||
//! 2. **CID → peer lookup** for the metadata fetch path (planned;
|
||||
//! not wired through the HTTP layer yet).
|
||||
//!
|
||||
//! See ADR 0001 §"Operational discovery in v0.2" and the package
|
||||
//! `CLAUDE.md` "Load-bearing decisions" for the rationale.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use libp2p::kad::RecordKey;
|
||||
use libp2p::multiaddr::Protocol;
|
||||
use libp2p::{Multiaddr, PeerId};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// kad protocol name. Using `/ipfs/kad/1.0.0` so we ride the same DHT as
|
||||
/// IPFS / kubo and inherit the network-effect of its bootstrap nodes.
|
||||
/// Cohort isolation is achieved via a unique namespace key, not a unique
|
||||
/// protocol.
|
||||
pub const IPFS_KAD_PROTOCOL: &str = "/ipfs/kad/1.0.0";
|
||||
|
||||
/// Default kad bootstrap multiaddrs, copied from kubo. Operators can
|
||||
/// override (or prepend) via the `KAD_BOOTSTRAP_PEERS` env var.
|
||||
pub const DEFAULT_KAD_BOOTSTRAPS: &[&str] = &[
|
||||
"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
|
||||
"/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
|
||||
"/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
|
||||
"/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
|
||||
];
|
||||
|
||||
/// Compute the kad provider key for a given namespace string.
|
||||
/// SHA-256 gives uniformly-distributed 32-byte keys, so multiple cohorts
|
||||
/// don't cluster on the same DHT nodes.
|
||||
pub fn namespace_key(namespace: &str) -> RecordKey {
|
||||
let digest = Sha256::digest(namespace.as_bytes());
|
||||
RecordKey::new(&digest.as_slice().to_vec())
|
||||
}
|
||||
|
||||
/// Configuration for the kad-based peer discovery loop.
|
||||
pub struct KadConfig {
|
||||
/// Multiaddrs to seed kad's routing table. Each entry MUST include a
|
||||
/// trailing `/p2p/<peer_id>` segment so we can register the peer-id with
|
||||
/// the routing table without an extra dial round-trip.
|
||||
pub bootstrap_peers: Vec<Multiaddr>,
|
||||
/// Namespace string. Hashed to a `RecordKey`; cohort isolation knob.
|
||||
pub namespace: String,
|
||||
/// How often to (re)publish ourselves as a provider for the namespace
|
||||
/// key. Provider records expire on remote peers (default ~24 h), so this
|
||||
/// must be lower than that — 6 h is the kubo default.
|
||||
pub provide_interval: Duration,
|
||||
/// How often to query the DHT for other providers of the namespace key
|
||||
/// when the swarm is at or above `target_peer_count`. Steady-state topup
|
||||
/// rate; trades discovery latency for DHT-traffic load.
|
||||
pub discovery_interval: Duration,
|
||||
/// How often to query when the swarm is below `target_peer_count`. Should
|
||||
/// be substantially shorter than `discovery_interval` so a peer that
|
||||
/// joined cold can fan out quickly. Also acts as the rate-limit floor for
|
||||
/// reactive kicks on `ConnectionClosed`.
|
||||
pub fast_discovery_interval: Duration,
|
||||
/// Connection target — used as both a discovery floor and a soft
|
||||
/// ceiling for *new* discovery-driven connections (kadDHT k-bucket
|
||||
/// style):
|
||||
/// - **Below target**: tick at `fast_discovery_interval`; react to
|
||||
/// `ConnectionClosed` by firing the next kad query immediately.
|
||||
/// - **At/above target**: tick at `discovery_interval`; mDNS- and
|
||||
/// kad-discovered peers are added to the kad routing table for
|
||||
/// later lookup but not actively dialled, and inbound
|
||||
/// `IncomingConnection` events are immediately closed.
|
||||
///
|
||||
/// "Soft" because it doesn't apply to bootstrap dials or auto-redial
|
||||
/// (those are intentional connections), and there's a small race
|
||||
/// window where two simultaneous inbounds can both pass the check
|
||||
/// before either is established. Also doesn't kick out existing
|
||||
/// connections that drift over the cap from outside causes — idle
|
||||
/// timeout handles that.
|
||||
pub target_peer_count: usize,
|
||||
/// Whether to publish the namespace provider record at all. False when
|
||||
/// this peer isn't publicly reachable (no `PUBLIC_ADDR`) — a provider
|
||||
/// record for an undialable peer is pure pollution: every peer that
|
||||
/// walks the DHT and finds us burns a dial that can never connect.
|
||||
/// Derived in `config::resolve_network`.
|
||||
pub provide_enabled: bool,
|
||||
}
|
||||
|
||||
/// Extract the peer-id from a multiaddr's trailing `/p2p/<id>` segment.
|
||||
/// Returns `None` if the multiaddr doesn't include one (kad's routing
|
||||
/// table is keyed by peer-id, so address-only entries can't be registered
|
||||
/// without a separate identify round-trip).
|
||||
pub fn peer_id_from_multiaddr(addr: &Multiaddr) -> Option<PeerId> {
|
||||
addr.iter().find_map(|p| match p {
|
||||
Protocol::P2p(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a multiaddr is worth handing to a remote peer as somewhere we
|
||||
/// can be reached.
|
||||
///
|
||||
/// A listener bound to `0.0.0.0` reports itself back as `/ip4/0.0.0.0/...`,
|
||||
/// and an ephemeral bind reports `/tcp/0` — neither is dialable by anyone.
|
||||
/// Loopback is only dialable by a peer already inside our netns. Publishing
|
||||
/// any of them (in a kad provider record, in an identify payload) costs
|
||||
/// every peer that receives it a dial that can never succeed.
|
||||
///
|
||||
/// RFC1918 / link-local addresses deliberately PASS: they're exactly what
|
||||
/// mDNS discovery trades on, and a private cross-host mesh is a legitimate
|
||||
/// deployment. The public/private distinction is the operator's to make via
|
||||
/// `PUBLIC_ADDR` — this function only rejects addresses that are undialable
|
||||
/// by *construction*.
|
||||
///
|
||||
/// Mirrors meta-gateway's `swarm::kad_helpers::is_dialable_addr` and
|
||||
/// meta-search's copy. Duplicated by necessity (independent submodules).
|
||||
pub fn is_dialable_addr(addr: &Multiaddr) -> bool {
|
||||
let mut has_host = false;
|
||||
for proto in addr.iter() {
|
||||
match proto {
|
||||
Protocol::Ip4(ip) => {
|
||||
if ip.is_unspecified() || ip.is_loopback() {
|
||||
return false;
|
||||
}
|
||||
has_host = true;
|
||||
}
|
||||
Protocol::Ip6(ip) => {
|
||||
if ip.is_unspecified() || ip.is_loopback() {
|
||||
return false;
|
||||
}
|
||||
has_host = true;
|
||||
}
|
||||
Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_) | Protocol::Dnsaddr(_) => {
|
||||
has_host = true;
|
||||
}
|
||||
Protocol::Tcp(0) | Protocol::Udp(0) => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
has_host
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ma(s: &str) -> Multiaddr {
|
||||
s.parse().expect("test multiaddr")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unspecified_loopback_and_port_zero() {
|
||||
assert!(!is_dialable_addr(&ma("/ip4/0.0.0.0/tcp/4001")));
|
||||
assert!(!is_dialable_addr(&ma("/ip4/127.0.0.1/tcp/4001")));
|
||||
assert!(!is_dialable_addr(&ma("/ip6/::/tcp/4001")));
|
||||
assert!(!is_dialable_addr(&ma("/ip6/::1/tcp/4001")));
|
||||
assert!(!is_dialable_addr(&ma("/ip4/1.2.3.4/tcp/0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_private_and_public_hosts() {
|
||||
assert!(is_dialable_addr(&ma("/ip4/172.18.0.5/tcp/4001")));
|
||||
assert!(is_dialable_addr(&ma("/ip4/1.2.3.4/tcp/4001")));
|
||||
assert!(is_dialable_addr(&ma("/dns4/peer.example.com/tcp/4001")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_addr_without_a_host_component() {
|
||||
assert!(!is_dialable_addr(&ma(
|
||||
"/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! libp2p swarm — composite `NetworkBehaviour`, command channel, and the
|
||||
//! tokio task that drives them.
|
||||
//!
|
||||
//! The Swarm itself is `!Send` once it owns the libp2p connection state, so
|
||||
//! we follow the standard pattern: the Swarm is owned by a single tokio task
|
||||
//! that loops over events and a command receiver. The HTTP layer talks to
|
||||
//! the swarm by sending [`Command`] messages and awaiting oneshot replies.
|
||||
//!
|
||||
//! The behaviour is a `NetworkBehaviour`-derive composite of:
|
||||
//! - `identify` — exchanges listen addresses on connection. Load-bearing for
|
||||
//! bitswap reachability: without it the listener side of a connection only
|
||||
//! sees the dialer's ephemeral source port, so peers can't be re-dialed for
|
||||
//! block transfer.
|
||||
//! - `kad` — Kademlia DHT in `Mode::Client` against the public IPFS DHT.
|
||||
//! Two responsibilities: peer discovery via namespace-key provider records,
|
||||
//! and cid → peer lookup for the metadata fetch path. See ADR 0001
|
||||
//! §"Operational discovery in v0.2".
|
||||
//! - `mdns` — local-network discovery (`Toggle`'d so it can be cheaply
|
||||
//! disabled when the network blocks multicast).
|
||||
//!
|
||||
//! Submodule layout:
|
||||
//! - [`peer_directory`] — `peer_id → PeerInfo` shared with the HTTP layer.
|
||||
//! - [`identify_agent`] — the `agent_version` overload (baseUrl).
|
||||
//! - [`kad_discovery`] — kad config, namespace-key, DHT helpers.
|
||||
//! - [`bootstrap`] — the auto-redial machine for operator-configured
|
||||
//! multiaddrs (distinct from kad — see the module doc).
|
||||
//! - [`event_loop`] — the `SwarmTask` struct + per-arm handler methods.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use libp2p::connection_limits::{self, ConnectionLimits};
|
||||
use libp2p::kad::{self, store::MemoryStore};
|
||||
use libp2p::mdns::tokio::Behaviour as MdnsBehaviour;
|
||||
use libp2p::mdns::Config as MdnsConfig;
|
||||
use libp2p::swarm::behaviour::toggle::Toggle;
|
||||
use libp2p::swarm::NetworkBehaviour;
|
||||
use libp2p::{identify, identity, Multiaddr, PeerId, StreamProtocol, Swarm};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::blockstore::{GatedBlockstore, IngressRouter, MAX_MULTIHASH_SIZE};
|
||||
use crate::filestore::SharedBlockstore;
|
||||
|
||||
/// Concrete bitswap behaviour alias — fixes the const generic so the
|
||||
/// `#[derive(NetworkBehaviour)]` macro on `Behaviour` doesn't need to
|
||||
/// carry generic params of its own. `MAX_MULTIHASH_SIZE = 64` matches
|
||||
/// the gateway side; both peers must agree because the const flows
|
||||
/// through bitswap's wire-format multihash truncation.
|
||||
///
|
||||
/// The store is three layers, outermost first:
|
||||
///
|
||||
/// 1. [`IngressRouter`](crate::blockstore::IngressRouter) — decides where an
|
||||
/// *arriving* block goes: into the file an in-flight fetch is materialising,
|
||||
/// or (for small unattributed objects) the block table. Phase 4.
|
||||
/// 2. [`GatedBlockstore`](crate::blockstore::GatedBlockstore) — meters blocks
|
||||
/// *leaving* over bitswap, the only place outbound bitswap bytes can be
|
||||
/// throttled while this peer's own viewer is watching something.
|
||||
/// 3. [`SharedBlockstore`](crate::filestore::SharedBlockstore) — the nocopy
|
||||
/// filestore, which resolves a leaf ref (or an in-flight leaf) to real bytes.
|
||||
///
|
||||
/// The order is load-bearing: routing must happen before the gate (a write is
|
||||
/// not egress and must not be metered as one), and the gate must wrap the
|
||||
/// filestore so a ref-resolved or in-flight leaf served to a stranger is
|
||||
/// throttled like any other block. See `crate::ingress`, `crate::filestore`,
|
||||
/// `crate::focus`.
|
||||
pub type Bitswap =
|
||||
beetswap::Behaviour<MAX_MULTIHASH_SIZE, IngressRouter<GatedBlockstore<SharedBlockstore>>>;
|
||||
|
||||
/// Custom `Multihasher` for the meta-hash `midhash256` codec (0x1000).
|
||||
/// Without this, beetswap rejects BLOCK responses keyed by midhash
|
||||
/// with `Unknown multihash code: 4096` and the WANT silently times
|
||||
/// out. The arithmetic is the same as `crate::hash::compute_midhash256`
|
||||
/// but applied to the verbatim block bytes — beetswap doesn't care
|
||||
/// what the bytes are, only that the multihash round-trips.
|
||||
///
|
||||
/// **Important caveat**: beetswap hashes whatever bytes it received
|
||||
/// from the wire. For midhash, the CID's digest is computed over
|
||||
/// `[size:u64-be][middle-1MiB slice]`, **not** the raw record bytes.
|
||||
/// So calling `Sha256(record_bytes)` here would not match the
|
||||
/// midhash digest we keyed the block by. To make midhash blocks
|
||||
/// bitswap-verifiable we'd need beetswap to hash a transformed
|
||||
/// input — which beetswap's `Multihasher` trait does not expose.
|
||||
///
|
||||
/// In practice this isn't a problem today because every meta-core
|
||||
/// record carries a sha-family CID alongside its midhash (the
|
||||
/// `fullhash` plugin runs unconditionally) and
|
||||
/// `canonical_cid_from_metadata` ranks sha-family above midhash. So
|
||||
/// the canonical CID Kamilata announces is sha-family, and bitswap
|
||||
/// only ever needs sha2/sha3 verification — which the
|
||||
/// `multihash-codetable` features unlock cleanly.
|
||||
///
|
||||
/// The midhash multihasher below exists for completeness (so the
|
||||
/// codec is *registered* and beetswap's error mode is "verify
|
||||
/// mismatch", a deliberate signal that the design needs revisiting,
|
||||
/// rather than "unknown code", which looks like a missing dependency).
|
||||
/// If a record ever ends up with midhash as its canonical CID, the
|
||||
/// resulting bitswap fetch will fail with a verify mismatch and the
|
||||
/// `/api/file/{cid}` handler will return 404 — same observable
|
||||
/// behavior as if midhash had been rejected up-front, just clearer
|
||||
/// in the logs.
|
||||
struct MidhashMultihasher;
|
||||
|
||||
impl beetswap::multihasher::Multihasher<MAX_MULTIHASH_SIZE> for MidhashMultihasher {
|
||||
async fn hash(
|
||||
&self,
|
||||
multihash_code: u64,
|
||||
input: &[u8],
|
||||
) -> Result<libp2p::multihash::Multihash<MAX_MULTIHASH_SIZE>, beetswap::multihasher::MultihasherError> {
|
||||
// 0x1000 = the meta-hash midhash256 codec (see hash.rs).
|
||||
if multihash_code != 0x1000 {
|
||||
return Err(beetswap::multihasher::MultihasherError::UnknownMultihashCode);
|
||||
}
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest: [u8; 32] = Sha256::digest(input).into();
|
||||
// CIDv1 multihash field is `[code varint][len][digest]`; the
|
||||
// Multihash<S> type holds just `(code, len, digest_bytes)`.
|
||||
libp2p::multihash::Multihash::<MAX_MULTIHASH_SIZE>::wrap(0x1000, &digest)
|
||||
.map_err(|_| beetswap::multihasher::MultihasherError::InvalidMultihashSize)
|
||||
}
|
||||
}
|
||||
|
||||
mod bitswap_client;
|
||||
mod bootstrap;
|
||||
mod event_loop;
|
||||
mod identify_agent;
|
||||
mod kad_discovery;
|
||||
mod peer_directory;
|
||||
|
||||
pub use bitswap_client::bitswap_get_block;
|
||||
pub use identify_agent::build_agent_version;
|
||||
pub use kad_discovery::{is_dialable_addr, KadConfig, DEFAULT_KAD_BOOTSTRAPS};
|
||||
pub use peer_directory::PeerDirectory;
|
||||
|
||||
use identify_agent::IDENTIFY_PROTOCOL;
|
||||
use kad_discovery::IPFS_KAD_PROTOCOL;
|
||||
|
||||
/// Composite behaviour: identify for address exchange, kad for namespace-key
|
||||
/// based peer discovery on the public IPFS DHT, mdns for local-network
|
||||
/// discovery (`Toggle`'d so it can be cheaply disabled when the network
|
||||
/// blocks multicast), and bitswap for IPFS block transfer. meta-share is
|
||||
/// wire-level pure content transport — the kamilata federated-search routing
|
||||
/// primitive was removed, so there is no search/gateway/gossipsub behaviour.
|
||||
#[derive(NetworkBehaviour)]
|
||||
pub struct Behaviour {
|
||||
pub identify: identify::Behaviour,
|
||||
pub kad: kad::Behaviour<MemoryStore>,
|
||||
pub mdns: Toggle<MdnsBehaviour>,
|
||||
/// Protocol-level connection ceiling. A pure guard behaviour (it emits no
|
||||
/// events — the derived `BehaviourEvent::Conns` variant is uninhabited and
|
||||
/// falls through the `_ => {}` arm in `event_loop::default_log`): it vetoes
|
||||
/// new connections once the swarm is at capacity, so process RSS — which
|
||||
/// scales with noise + yamux + per-substream buffers per connection —
|
||||
/// stays bounded without capping meta-share's HTTP / ingest / seeding work.
|
||||
/// Limits from [`connection_limits_config`] (`META_SHARE_MAX_CONNS`).
|
||||
pub conns: connection_limits::Behaviour,
|
||||
/// M12: IPFS bitswap. Client-side use case: the M13
|
||||
/// `/ipfs/{cid}` HTTP endpoint sends a `BitswapGet` command to
|
||||
/// the swarm task, which calls `bitswap.get(cid)`; bitswap
|
||||
/// negotiates with connected peers (gateway or other consumers
|
||||
/// that already cached the cid) and surfaces the bytes via
|
||||
/// `Event::GetQueryResponse`. The shared
|
||||
/// `Arc<RedbBlockstore>` also makes us a candidate provider for
|
||||
/// every cid we've fetched once — multi-source pulls emerge.
|
||||
pub bitswap: Bitswap,
|
||||
}
|
||||
|
||||
/// Messages from the HTTP layer to the swarm task. Each variant carries a
|
||||
/// oneshot reply channel so the caller can `await` the result.
|
||||
pub enum Command {
|
||||
/// Returns the connected peer count and our own peer-id.
|
||||
Peers {
|
||||
reply: oneshot::Sender<PeersInfo>,
|
||||
},
|
||||
/// M12: fetch a single block via bitswap. The swarm task calls
|
||||
/// `bitswap.get(&cid)`, registers the resulting `QueryId` with
|
||||
/// `BitswapInflight`, and fires `reply` on `Event::GetQueryResponse`
|
||||
/// / `GetQueryError`. The HTTP layer wraps this with a timeout
|
||||
/// (per-block deadline) in [`bitswap_get_block`].
|
||||
BitswapGet {
|
||||
cid: crate::blockstore::MsCid,
|
||||
reply: oneshot::Sender<Result<Vec<u8>, BitswapGetError>>,
|
||||
},
|
||||
/// Announce that this peer provides `cid` on the public IPFS DHT
|
||||
/// (`kad.start_providing`, keyed by the CID's multihash so external
|
||||
/// IPFS nodes resolve it). Fire-and-forget — no reply; the outcome is
|
||||
/// logged by the `StartProviding` arm in `default_log`. Sent from the
|
||||
/// seed-manifest upsert path when `seed_dht_provide` is on.
|
||||
Provide {
|
||||
cid: crate::blockstore::MsCid,
|
||||
},
|
||||
/// Stop announcing `cid` (`kad.stop_providing`). Fire-and-forget. Sent
|
||||
/// from the DELETE /api/seeds/:cid teardown for an IPFS seed.
|
||||
StopProviding {
|
||||
cid: crate::blockstore::MsCid,
|
||||
},
|
||||
}
|
||||
|
||||
/// Failure modes for the consumer-side `BitswapGet` round-trip.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BitswapGetError {
|
||||
/// beetswap's `GetQueryError`. Wraps the original error string —
|
||||
/// the typed enum lives in beetswap and isn't worth re-exporting.
|
||||
#[error("bitswap query error: {0}")]
|
||||
QueryFailed(String),
|
||||
/// The swarm task dropped the reply channel (process shutting
|
||||
/// down).
|
||||
#[error("swarm reply channel closed")]
|
||||
ReplyChannelClosed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PeersInfo {
|
||||
pub local_peer_id: String,
|
||||
pub connected: Vec<String>,
|
||||
}
|
||||
|
||||
/// Default ceiling on total established connections. Each libp2p connection
|
||||
/// carries a noise session + yamux state + per-substream buffers, so process
|
||||
/// RSS grows with the connection count. 128 is generous for a transport /
|
||||
/// seeding node (bootstraps + content peers) while bounding that growth.
|
||||
/// Override with `META_SHARE_MAX_CONNS`.
|
||||
const DEFAULT_MAX_ESTABLISHED_CONNS: u32 = 128;
|
||||
|
||||
/// Build the [`ConnectionLimits`] for the `connection_limits` guard behaviour.
|
||||
///
|
||||
/// - `max_established` (total, across all peers) is the primary RSS lever,
|
||||
/// env-tunable via `META_SHARE_MAX_CONNS`.
|
||||
/// - per-peer is capped at 3: enough for TCP + QUIC to the same peer plus one
|
||||
/// in-flight address migration, but no runaway fan-out from a single peer.
|
||||
/// - pending-incoming is capped so half-open inbound handshakes (each holding
|
||||
/// a noise buffer) can't pile up.
|
||||
fn connection_limits_config() -> ConnectionLimits {
|
||||
let max_established = std::env::var("META_SHARE_MAX_CONNS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.filter(|n| *n > 0)
|
||||
.unwrap_or(DEFAULT_MAX_ESTABLISHED_CONNS);
|
||||
ConnectionLimits::default()
|
||||
.with_max_established(Some(max_established))
|
||||
.with_max_established_per_peer(Some(3))
|
||||
.with_max_pending_incoming(Some(32))
|
||||
}
|
||||
|
||||
/// Build the libp2p Swarm — TCP + QUIC + DNS, noise auth, yamux mux,
|
||||
/// identify + kad + mdns + bitswap.
|
||||
///
|
||||
/// QUIC was added in v0.2 because the public IPFS DHT bootstraps that kad
|
||||
/// rides advertise `/quic-v1` and `/wss` multiaddrs (no plain `/tcp` after
|
||||
/// dnsaddr resolution). Without QUIC the kad layer can't reach the public
|
||||
/// bootstraps.
|
||||
///
|
||||
/// `enable_mdns` controls whether multicast-DNS local discovery is started.
|
||||
/// Default `true` — purely additive, automatically finds peers on the same
|
||||
/// LAN/docker network without any per-cohort bootstrap config. Set false
|
||||
/// only on networks that actively block multicast (some VPNs, some
|
||||
/// mDNS-isolating switches). When false, `Toggle::default()` makes the
|
||||
/// behaviour an inert no-op.
|
||||
///
|
||||
/// `agent_version` becomes the identify-protocol `agent_version` string.
|
||||
/// Build it via [`build_agent_version`] so other peers can parse our HTTP
|
||||
/// API URL out of it for cross-peer file fetches.
|
||||
pub fn build_swarm(
|
||||
keypair: identity::Keypair,
|
||||
listen_addr: &Multiaddr,
|
||||
enable_mdns: bool,
|
||||
agent_version: String,
|
||||
blockstore: Arc<SharedBlockstore>,
|
||||
focus: Arc<meta_feeder_sdk::transport::FocusView>,
|
||||
ingress: Arc<crate::ingress::IngressRegistry>,
|
||||
) -> Result<(Swarm<Behaviour>, PeerId)> {
|
||||
let local_peer_id = PeerId::from(keypair.public());
|
||||
|
||||
let mut swarm = libp2p::SwarmBuilder::with_existing_identity(keypair)
|
||||
.with_tokio()
|
||||
.with_tcp(
|
||||
libp2p::tcp::Config::default(),
|
||||
libp2p::noise::Config::new,
|
||||
libp2p::yamux::Config::default,
|
||||
)
|
||||
.context("tcp transport")?
|
||||
.with_quic()
|
||||
// .with_dns() wraps the transport so `/dns/host/tcp/port` and
|
||||
// `/dnsaddr/.../p2p/<id>` multiaddrs resolve via the system resolver
|
||||
// before dial. Without this, the raw transport sees a /dns/... or
|
||||
// /dnsaddr/... multiaddr as "unsupported" and fails — surfaced when
|
||||
// dialing docker DNS names AND when riding the public IPFS DHT
|
||||
// bootstraps.
|
||||
.with_dns()
|
||||
.context("dns transport")?
|
||||
.with_behaviour(|keypair| {
|
||||
let kad_store = MemoryStore::new(local_peer_id);
|
||||
let kad_config = kad::Config::new(StreamProtocol::new(IPFS_KAD_PROTOCOL));
|
||||
let mut kad_behaviour = kad::Behaviour::with_config(local_peer_id, kad_store, kad_config);
|
||||
// Mode::Client: we use the public DHT to publish/find provider
|
||||
// records but don't serve queries for unrelated IPFS traffic.
|
||||
// Keeps the bandwidth/CPU footprint small and avoids becoming a
|
||||
// routing node for the world. ADR §"Operational discovery in v0.2".
|
||||
kad_behaviour.set_mode(Some(kad::Mode::Client));
|
||||
let mdns_behaviour: Toggle<MdnsBehaviour> = if enable_mdns {
|
||||
match MdnsBehaviour::new(MdnsConfig::default(), local_peer_id) {
|
||||
Ok(b) => Some(b).into(),
|
||||
Err(e) => {
|
||||
warn!(error = %e,
|
||||
"mdns: failed to start (network may not support multicast); continuing without it");
|
||||
None.into()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None.into()
|
||||
};
|
||||
// M12: bitswap. The Behaviour is both client (`.get(cid)`
|
||||
// calls fan out WANT-HAVE to connected peers — gateways +
|
||||
// siblings) AND server (peers we serve `.put_keyed` blocks
|
||||
// to from the shared `RedbBlockstore`).
|
||||
//
|
||||
// The builder registers `MidhashMultihasher` on top of
|
||||
// beetswap's `StandardMultihasher`. `StandardMultihasher`
|
||||
// covers sha2 + sha3 (both unlocked by `multihash-codetable`
|
||||
// features in our Cargo.toml — required because beetswap
|
||||
// pulls multihash-codetable in with no features by default,
|
||||
// which would leave sha3-256 unrecognised and silently drop
|
||||
// every BLOCK response that uses it). `MidhashMultihasher`
|
||||
// is the meta-hash custom codec 0x1000.
|
||||
//
|
||||
// Without these registrations, every Kamilata-discovered
|
||||
// record fetch failed with "Unknown multihash code: N" on
|
||||
// the receive side (root-caused via beetswap=trace logs in
|
||||
// the 2026-05-25 ADR 0002 follow-up session).
|
||||
// Hand bitswap the *gated* view of the store — the throttle point for
|
||||
// blocks this peer serves to others while its own viewer is watching
|
||||
// something. `blockstore` itself (the raw handle) is untouched and
|
||||
// still backs every local read.
|
||||
let bitswap = beetswap::Behaviour::builder(Arc::new(IngressRouter::new(
|
||||
Arc::new(GatedBlockstore::new(
|
||||
Arc::clone(&blockstore),
|
||||
Arc::clone(&focus),
|
||||
)),
|
||||
Arc::clone(&ingress),
|
||||
)))
|
||||
.register_multihasher(MidhashMultihasher)
|
||||
.build();
|
||||
Behaviour {
|
||||
identify: identify::Behaviour::new(
|
||||
identify::Config::new(IDENTIFY_PROTOCOL.to_string(), keypair.public())
|
||||
.with_agent_version(agent_version.clone()),
|
||||
),
|
||||
kad: kad_behaviour,
|
||||
mdns: mdns_behaviour,
|
||||
bitswap,
|
||||
conns: connection_limits::Behaviour::new(connection_limits_config()),
|
||||
}
|
||||
})
|
||||
.map_err(|e| anyhow!("behaviour stage: {e}"))?
|
||||
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(120)))
|
||||
.build();
|
||||
|
||||
swarm
|
||||
.listen_on(listen_addr.clone())
|
||||
.with_context(|| format!("listen on {listen_addr}"))?;
|
||||
|
||||
Ok((swarm, local_peer_id))
|
||||
}
|
||||
|
||||
/// Spawn the swarm event-loop task. Returns the command sender the HTTP
|
||||
/// layer uses to talk to the swarm.
|
||||
///
|
||||
/// `bootstrap_peers` are dialed once on startup, then re-dialed every
|
||||
/// `redial_interval` for any entry whose target peer is not currently
|
||||
/// connected. Pass an empty list (or zero interval) to disable auto-redial.
|
||||
///
|
||||
/// `kad_config` configures the libp2p-kad-based peer discovery loop: kad
|
||||
/// bootstrap addresses are added to the routing table, an initial bootstrap
|
||||
/// query is issued, and the namespace key is periodically (re)provided and
|
||||
/// looked up. Discovered providers are dialed; identify finishes the
|
||||
/// connection handshake.
|
||||
///
|
||||
/// `http` is the process-wide client, cloned into the task for the gateway
|
||||
/// capability fetch (`GET {baseUrl}/api/gateway/plugins`) that runs off an
|
||||
/// identify from a meta-gateway peer. That fetch is plain HTTP by design — it
|
||||
/// adds no libp2p behaviour to the transport-only swarm.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn(
|
||||
swarm: Swarm<Behaviour>,
|
||||
local_peer_id: PeerId,
|
||||
bootstrap_peers: Vec<Multiaddr>,
|
||||
redial_interval: Duration,
|
||||
kad_config: KadConfig,
|
||||
peer_directory: PeerDirectory,
|
||||
http: reqwest::Client,
|
||||
gateway_caps: GatewayCapsConfig,
|
||||
) -> mpsc::Sender<Command> {
|
||||
let (tx, rx) = mpsc::channel::<Command>(64);
|
||||
|
||||
let task = event_loop::SwarmTask::new(
|
||||
local_peer_id,
|
||||
bootstrap_peers,
|
||||
redial_interval,
|
||||
kad_config,
|
||||
peer_directory,
|
||||
http,
|
||||
gateway_caps,
|
||||
);
|
||||
|
||||
tokio::spawn(task.run(swarm, rx));
|
||||
tx
|
||||
}
|
||||
|
||||
/// Knobs for the gateway capability fetch triggered off identify.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GatewayCapsConfig {
|
||||
/// Per-request timeout for `GET {baseUrl}/api/gateway/plugins`.
|
||||
pub fetch_timeout: Duration,
|
||||
/// Don't re-fetch a peer's capabilities more often than this. Sized to
|
||||
/// identify's ~5-minute re-announce cadence, so a connected gateway is
|
||||
/// re-stamped roughly once per identify and stays inside the directory's
|
||||
/// 600s freshness TTL without any extra timer.
|
||||
pub refresh_after: Duration,
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
//! Per-peer state learned from libp2p identify exchanges. The directory
|
||||
//! is shared between the swarm task (writer) and the HTTP layer (readers
|
||||
//! — `/api/peers`, cross-peer file fetches).
|
||||
//!
|
||||
//! Held behind a `std::sync::RwLock` (not `tokio::sync::RwLock`) because
|
||||
//! every access is a brief read/write of a `HashMap<PeerId, _>` —
|
||||
//! contention is essentially nil at our scale and we don't want to await
|
||||
//! across the lock.
|
||||
//!
|
||||
//! Since the gateway tier moved into its own service, the directory's job
|
||||
//! is narrow: map a peer id to its HTTP API URL (parsed from identify's
|
||||
//! `baseUrl=` token) so the cross-peer byte-fetch path can reach it.
|
||||
//! [`PeerDirectory::forget_peer`] drops a peer's state on its last
|
||||
//! `ConnectionClosed`.
|
||||
|
||||
// The freshness queries (`gateways_redeeming`, `nzb_fetch_gateways`, …) are
|
||||
// answered by the hull's replica of this directory (`GET /ipfs-tier/directory`);
|
||||
// they stay here, tested, as the reference the replica mirrors.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use libp2p::PeerId;
|
||||
|
||||
use crate::gateway_discovery::{RedeemClaim, REDEEM_NZB_RELEASE};
|
||||
|
||||
/// How long an identify-sourced fact stays fresh. Identify re-announces on
|
||||
/// libp2p's default 5-minute cadence, so a live peer re-stamps its facts well
|
||||
/// inside this window; a peer that goes quiet ages out rather than lingering as
|
||||
/// a forever-stale routing target. (Disconnected peers are usually dropped
|
||||
/// sooner, by `forget_peer` on the last `ConnectionClosed`.)
|
||||
const IDENTIFY_TTL: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Per-peer state learned from libp2p identify exchanges: the peer's HTTP API
|
||||
/// URL (for cross-peer file fetches) plus, for meta-gateway peers, the
|
||||
/// gateway capabilities fetched off that URL.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PeerInfo {
|
||||
/// HTTP API URL parsed from identify's `baseUrl=` token. `None` when
|
||||
/// the peer didn't advertise one (consumer-only, BASE_URL unset).
|
||||
pub base_url: Option<String>,
|
||||
/// When this peer last advertised `nzbFetch: true` on
|
||||
/// `/api/gateway/plugins` — i.e. it fronts a credentialed meta-share and
|
||||
/// can materialise + serve `nzb-release` bytes. `None` = never, or it
|
||||
/// stopped. Read accessors treat the capability as gone once older than
|
||||
/// [`IDENTIFY_TTL`]. Powers [`PeerDirectory::nzb_fetch_gateways`].
|
||||
nzb_fetch_at: Option<Instant>,
|
||||
/// The locator families this gateway's feeders can redeem, from the same
|
||||
/// capability fetch. Fresh while `caps_fetched_at` is. Powers
|
||||
/// [`PeerDirectory::gateways_redeeming`] and, with `nzb_fetch_at`,
|
||||
/// [`PeerDirectory::gateway_can_redeem`].
|
||||
redeems: Vec<RedeemClaim>,
|
||||
/// When we last *completed* a capability fetch against this peer, whatever
|
||||
/// the answer was.
|
||||
///
|
||||
/// ⚠ This is deliberately separate from `nzb_fetch_at`. Debouncing off the
|
||||
/// capability stamp would mean a gateway answering `nzbFetch: false` (which
|
||||
/// leaves `nzb_fetch_at = None`) looks identical to one never fetched — so
|
||||
/// every single identify would re-fire the HTTP call, forever. The
|
||||
/// attempt stamp is what makes the debounce terminate.
|
||||
caps_fetched_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Shared, read-mostly directory of `peer_id → PeerInfo`. Populated by the
|
||||
/// swarm task on every identify event and read by the HTTP API for
|
||||
/// cross-peer fetches. Not behind tokio::sync::RwLock — locks are always
|
||||
/// brief and identify events are infrequent.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PeerDirectory(Arc<RwLock<HashMap<PeerId, PeerInfo>>>);
|
||||
|
||||
impl PeerDirectory {
|
||||
/// Build a fresh, empty directory. Use this once at startup and clone
|
||||
/// the handle into both `spawn` and the AppState; the `Arc` inside
|
||||
/// shares the underlying map.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Just the peer's API URL. `None` when the peer advertised no URL or
|
||||
/// hasn't been seen yet.
|
||||
pub fn base_url(&self, pid: &PeerId) -> Option<String> {
|
||||
self.0
|
||||
.read()
|
||||
.expect("peer directory poisoned")
|
||||
.get(pid)
|
||||
.and_then(|info| info.base_url.clone())
|
||||
}
|
||||
|
||||
/// Atomically update a peer's info from an identify exchange.
|
||||
///
|
||||
/// `base_url = Some(_)` replaces; `base_url = None` preserves any
|
||||
/// previously-learned URL — identify events from later peer versions
|
||||
/// may legitimately omit the token, and the previous URL is still
|
||||
/// valid until the peer reconnects with a different one.
|
||||
///
|
||||
/// Returns a `PeerInfoUpdate` summarising what changed.
|
||||
pub fn upsert_from_identify(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
base_url: Option<String>,
|
||||
) -> PeerInfoUpdate {
|
||||
let mut map = self.0.write().expect("peer directory poisoned");
|
||||
let entry = map.entry(peer_id).or_default();
|
||||
let mut update = PeerInfoUpdate::default();
|
||||
if let Some(url) = base_url {
|
||||
if entry.base_url.as_deref() != Some(url.as_str()) {
|
||||
entry.base_url = Some(url.clone());
|
||||
update.base_url_changed = Some(url);
|
||||
}
|
||||
}
|
||||
update
|
||||
}
|
||||
|
||||
/// Record the outcome of a `/api/gateway/plugins` capability fetch.
|
||||
/// `capable` stamps the `nzbFetch` liveness; `false` clears it (the gateway
|
||||
/// dropped its credentialed meta-share). `redeems` replaces the gateway's
|
||||
/// redeem claims, which don't depend on `nzbFetch` — redeeming a locator
|
||||
/// needs the gateway's feeder, not an NNTP pool. Either way the *attempt* is
|
||||
/// stamped, which is what [`Self::needs_cap_refresh`] debounces on.
|
||||
///
|
||||
/// Only called after a fetch that actually answered — a failed fetch leaves
|
||||
/// every stamp alone, so the prior value ages out on its own TTL and the
|
||||
/// next identify retries.
|
||||
pub fn record_gateway_caps(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
capable: bool,
|
||||
redeems: Vec<RedeemClaim>,
|
||||
) {
|
||||
self.record_gateway_caps_at(peer_id, capable, redeems, Instant::now());
|
||||
}
|
||||
|
||||
fn record_gateway_caps_at(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
capable: bool,
|
||||
redeems: Vec<RedeemClaim>,
|
||||
now: Instant,
|
||||
) {
|
||||
let mut map = self.0.write().expect("peer directory poisoned");
|
||||
let entry = map.entry(peer_id).or_default();
|
||||
entry.nzb_fetch_at = capable.then_some(now);
|
||||
entry.redeems = redeems;
|
||||
entry.caps_fetched_at = Some(now);
|
||||
}
|
||||
|
||||
/// Base URLs of the gateways whose feeders can redeem `key` for `codec` right
|
||||
/// now — an `nzb-release` indexer host, or a `provider-file` source — from a
|
||||
/// fresh capability fetch, with a `base_url` to call. The targets for
|
||||
/// `crate::redeem`. Deduplicated; order unspecified.
|
||||
pub fn gateways_redeeming(&self, codec: &str, key: &str) -> Vec<String> {
|
||||
self.gateways_redeeming_at(codec, key, Instant::now())
|
||||
}
|
||||
|
||||
fn gateways_redeeming_at(&self, codec: &str, key: &str, now: Instant) -> Vec<String> {
|
||||
let map = self.0.read().expect("peer directory poisoned");
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for info in map.values() {
|
||||
let fresh = info
|
||||
.caps_fetched_at
|
||||
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL);
|
||||
let Some(url) = info.base_url.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if fresh && info.redeems.iter().any(|c| c.covers(codec, key)) {
|
||||
let url = url.trim_end_matches('/').to_string();
|
||||
if !out.contains(&url) {
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Has a capability fetch against *any* reachable gateway completed recently?
|
||||
///
|
||||
/// Tells "no gateway on this swarm redeems that" — a statement worth a `400`
|
||||
/// — apart from "we haven't found a gateway yet", which is a warming state
|
||||
/// worth a retryable `503`.
|
||||
pub fn knows_a_gateway(&self) -> bool {
|
||||
self.knows_a_gateway_at(Instant::now())
|
||||
}
|
||||
|
||||
fn knows_a_gateway_at(&self, now: Instant) -> bool {
|
||||
let map = self.0.read().expect("peer directory poisoned");
|
||||
map.values().any(|info| {
|
||||
info.base_url.is_some()
|
||||
&& info
|
||||
.caps_fetched_at
|
||||
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL)
|
||||
})
|
||||
}
|
||||
|
||||
/// Can any gateway on this swarm redeem a release from `indexer_host` right
|
||||
/// now — a *fresh* `nzbFetch` capability on a gateway whose feeders claim
|
||||
/// that host?
|
||||
///
|
||||
/// The availability predicate behind `/api/file/:cid/probe` for a Usenet cid.
|
||||
/// It costs nothing: the host comes out of the cid's own multihash and the
|
||||
/// claims come from the directory (refreshed on identify, 600 s TTL). Probing
|
||||
/// a Usenet title must never touch NNTP — `bridge::ensure` downloads the
|
||||
/// *whole* posting, so a "free" availability check used to start one full
|
||||
/// download per probed variant.
|
||||
pub fn gateway_can_redeem(&self, indexer_host: &str) -> bool {
|
||||
self.gateway_can_redeem_at(indexer_host, Instant::now())
|
||||
}
|
||||
|
||||
fn gateway_can_redeem_at(&self, indexer_host: &str, now: Instant) -> bool {
|
||||
let map = self.0.read().expect("peer directory poisoned");
|
||||
map.values().any(|info| {
|
||||
let fresh = info
|
||||
.nzb_fetch_at
|
||||
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL);
|
||||
fresh
|
||||
&& info.base_url.is_some()
|
||||
&& info.redeems.iter().any(|c| c.covers(REDEEM_NZB_RELEASE, indexer_host))
|
||||
})
|
||||
}
|
||||
|
||||
/// Can any gateway on this swarm fetch from Usenet **at all**, regardless of
|
||||
/// which indexers it holds keys for?
|
||||
///
|
||||
/// This is [`gateway_can_redeem`](Self::gateway_can_redeem) minus the
|
||||
/// indexer-host match, and it is the right question for a **self-scanned
|
||||
/// `nzb-posting` (`0x1003`)** cid: that cid is a digest over the article
|
||||
/// Message-ID set and names no indexer, so a plain NNTP provider is the only
|
||||
/// thing needed to fetch it. Asking the host-scoped question about a posting
|
||||
/// would answer `false` for every gateway — including ones that can serve it
|
||||
/// perfectly well — and the title would grade `red` and never render.
|
||||
pub fn gateway_can_fetch_nzb(&self) -> bool {
|
||||
self.gateway_can_fetch_nzb_at(Instant::now())
|
||||
}
|
||||
|
||||
fn gateway_can_fetch_nzb_at(&self, now: Instant) -> bool {
|
||||
let map = self.0.read().expect("peer directory poisoned");
|
||||
map.values().any(|info| {
|
||||
info.nzb_fetch_at
|
||||
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL)
|
||||
&& info.base_url.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the swarm task should (re)fetch this peer's gateway capabilities.
|
||||
/// True when we've never completed a fetch, or the last one is older than
|
||||
/// `refresh_after`. Debounces the per-identify fetch so a burst of identify
|
||||
/// events (one per connection) doesn't spawn redundant HTTP calls.
|
||||
pub fn needs_cap_refresh(&self, peer_id: &PeerId, refresh_after: Duration) -> bool {
|
||||
self.needs_cap_refresh_at(peer_id, refresh_after, Instant::now())
|
||||
}
|
||||
|
||||
fn needs_cap_refresh_at(&self, peer_id: &PeerId, refresh_after: Duration, now: Instant) -> bool {
|
||||
let map = self.0.read().expect("peer directory poisoned");
|
||||
match map.get(peer_id).and_then(|info| info.caps_fetched_at) {
|
||||
None => true,
|
||||
Some(at) => now.saturating_duration_since(at) > refresh_after,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gateways that can serve `nzb-release` bytes right now: peers with a
|
||||
/// *fresh* `nzbFetch` capability **and** an HTTP `base_url` to proxy to.
|
||||
/// Returns `(peer_id, base_url)` — the forward-target list for the Usenet
|
||||
/// byte path.
|
||||
pub fn nzb_fetch_gateways(&self) -> Vec<(PeerId, String)> {
|
||||
self.nzb_fetch_gateways_at(Instant::now())
|
||||
}
|
||||
|
||||
fn nzb_fetch_gateways_at(&self, now: Instant) -> Vec<(PeerId, String)> {
|
||||
let map = self.0.read().expect("peer directory poisoned");
|
||||
map.iter()
|
||||
.filter_map(|(pid, info)| {
|
||||
let fresh = info
|
||||
.nzb_fetch_at
|
||||
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL);
|
||||
match (fresh, &info.base_url) {
|
||||
(true, Some(url)) => Some((*pid, url.clone())),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop all state for `peer_id` (base URL + gateway capabilities). Called
|
||||
/// by the swarm task on the *last* `ConnectionClosed` for a peer. Returns
|
||||
/// `true` when an entry was actually present.
|
||||
/// The whole directory as the hull's replica wants it: freshness as ages,
|
||||
/// claims as the JSON the gateway served. `GET /ipfs-tier/directory`.
|
||||
pub fn snapshot(&self) -> Vec<meta_feeder_sdk::transport::ipfs::DirectoryPeer> {
|
||||
let now = Instant::now();
|
||||
let age = |t: Option<Instant>| t.map(|t| now.saturating_duration_since(t).as_millis() as u64);
|
||||
self.0
|
||||
.read()
|
||||
.expect("peer directory poisoned")
|
||||
.iter()
|
||||
.map(|(pid, info)| meta_feeder_sdk::transport::ipfs::DirectoryPeer {
|
||||
peer_id: pid.to_string(),
|
||||
base_url: info.base_url.clone(),
|
||||
nzb_fetch_age_ms: age(info.nzb_fetch_at),
|
||||
caps_age_ms: age(info.caps_fetched_at),
|
||||
redeems: info
|
||||
.redeems
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"codec": c.codec, "field": c.field,
|
||||
"hosts": c.hosts, "sources": c.sources,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn forget_peer(&self, peer_id: &PeerId) -> bool {
|
||||
self.0
|
||||
.write()
|
||||
.expect("peer directory poisoned")
|
||||
.remove(peer_id)
|
||||
.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What changed on a `PeerDirectory::upsert_from_identify` call. `None`
|
||||
/// on a no-op refresh (peer re-identified with the same data). Used by
|
||||
/// the swarm task to emit per-field debug logs only when something
|
||||
/// actually moved.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct PeerInfoUpdate {
|
||||
pub base_url_changed: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pid_n(n: u8) -> PeerId {
|
||||
// Deterministic peer-ids from a seed keypair — we just need
|
||||
// distinct values, not real keypair material.
|
||||
let kp = libp2p::identity::Keypair::ed25519_from_bytes([n; 32]).expect("keypair");
|
||||
PeerId::from_public_key(&kp.public())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gateway_only_redeems_the_indexers_it_advertises() {
|
||||
// The Usenet availability predicate: an nzb-release cid names its indexer
|
||||
// host, and only a gateway holding a key for THAT host can play it. Answered
|
||||
// from the directory — no NNTP, no .nzb grab, no download.
|
||||
let dir = PeerDirectory::new();
|
||||
let gw = PeerId::random();
|
||||
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
|
||||
dir.record_gateway_caps(gw, true, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
|
||||
|
||||
assert!(dir.gateway_can_redeem("api.nzb.life"));
|
||||
// A release from an indexer nobody has a key for cannot play → red.
|
||||
assert!(!dir.gateway_can_redeem("api.other.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gateway_that_lost_its_credentials_redeems_nothing() {
|
||||
// nzbFetch:false withdraws the capability, so a gateway whose meta-share
|
||||
// dropped its NNTP credentials stops grading releases playable — even
|
||||
// though its feeder still claims the host, no pool behind it can fetch
|
||||
// the articles.
|
||||
let dir = PeerDirectory::new();
|
||||
let gw = PeerId::random();
|
||||
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
|
||||
dir.record_gateway_caps(gw, true, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
|
||||
assert!(dir.gateway_can_redeem("api.nzb.life"));
|
||||
|
||||
dir.record_gateway_caps(gw, false, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
|
||||
assert!(!dir.gateway_can_redeem("api.nzb.life"));
|
||||
}
|
||||
|
||||
/// ⚠ THE SELF-SCANNED-POSTING PROBE. `gateway_can_fetch_nzb` must answer
|
||||
/// `true` for a gateway that advertises Usenet fetch capability with **no**
|
||||
/// indexer hosts at all — a self-hosted-scanner gateway has none to
|
||||
/// advertise, since a `0x1003` posting names no indexer. If this regresses
|
||||
/// to requiring an `nzb-release` redeem claim, self-scanned titles grade
|
||||
/// red and never render even though the gateway can serve them fine.
|
||||
#[test]
|
||||
fn any_gateway_with_nzb_capability_answers_the_posting_probe() {
|
||||
let dir = PeerDirectory::new();
|
||||
let gw = PeerId::random();
|
||||
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
|
||||
// Note: no redeem claims — this gateway holds no third-party keys,
|
||||
// exactly the shape a pure self-hosted-scanner gateway has.
|
||||
dir.record_gateway_caps(gw, true, vec![]);
|
||||
|
||||
assert!(dir.gateway_can_fetch_nzb());
|
||||
// But it correctly still cannot redeem a HOST-BOUND locator it has no
|
||||
// key for — the two questions must stay distinct.
|
||||
assert!(!dir.gateway_can_redeem("api.nzb.life"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_gateway_advertising_nzb_capability_fails_the_posting_probe() {
|
||||
let dir = PeerDirectory::new();
|
||||
let gw = PeerId::random();
|
||||
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
|
||||
// Never called record_gateway_caps — no capability advertised at all.
|
||||
assert!(!dir.gateway_can_fetch_nzb());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gateway_that_lost_nzb_capability_fails_the_posting_probe_too() {
|
||||
let dir = PeerDirectory::new();
|
||||
let gw = PeerId::random();
|
||||
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
|
||||
dir.record_gateway_caps(gw, true, vec![]);
|
||||
assert!(dir.gateway_can_fetch_nzb());
|
||||
|
||||
dir.record_gateway_caps(gw, false, vec![]);
|
||||
assert!(!dir.gateway_can_fetch_nzb());
|
||||
}
|
||||
|
||||
fn claim(codec: &str, hosts: &[&str], sources: &[&str]) -> RedeemClaim {
|
||||
RedeemClaim {
|
||||
codec: codec.into(),
|
||||
field: "file".into(),
|
||||
hosts: hosts.iter().map(|s| s.to_string()).collect(),
|
||||
sources: sources.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Redeem targets come from claims, not from `nzbFetch`: redeeming a locator
|
||||
/// needs the gateway's feeder, and a gateway with no NNTP pool behind it can
|
||||
/// still grab a `.nzb` or download a subtitle.
|
||||
#[test]
|
||||
fn redeem_targets_are_the_gateways_whose_claims_cover_the_key() {
|
||||
use crate::gateway_discovery::{REDEEM_NZB_RELEASE, REDEEM_PROVIDER_FILE};
|
||||
let dir = PeerDirectory::new();
|
||||
let subs = pid_n(30);
|
||||
let usenet = pid_n(31);
|
||||
let no_url = pid_n(32);
|
||||
dir.upsert_from_identify(subs, Some("http://subs:3000/".into()));
|
||||
dir.upsert_from_identify(usenet, Some("http://usenet:3000".into()));
|
||||
dir.record_gateway_caps(subs, false, vec![claim(REDEEM_PROVIDER_FILE, &[], &["opensubtitles"])]);
|
||||
dir.record_gateway_caps(usenet, false, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
|
||||
// Claims, but nowhere to POST to.
|
||||
dir.record_gateway_caps(no_url, false, vec![claim(REDEEM_PROVIDER_FILE, &[], &["opensubtitles"])]);
|
||||
|
||||
assert_eq!(
|
||||
dir.gateways_redeeming(REDEEM_PROVIDER_FILE, "opensubtitles"),
|
||||
vec!["http://subs:3000".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
dir.gateways_redeeming(REDEEM_NZB_RELEASE, "api.nzb.life"),
|
||||
vec!["http://usenet:3000".to_string()]
|
||||
);
|
||||
assert!(dir.gateways_redeeming(REDEEM_NZB_RELEASE, "api.other.example").is_empty());
|
||||
assert!(dir.gateways_redeeming(REDEEM_PROVIDER_FILE, "subscene").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redeem_claims_and_gateway_knowledge_expire_with_the_caps_fetch() {
|
||||
use crate::gateway_discovery::REDEEM_PROVIDER_FILE;
|
||||
let dir = PeerDirectory::new();
|
||||
let p = pid_n(33);
|
||||
let t0 = Instant::now();
|
||||
assert!(!dir.knows_a_gateway_at(t0), "empty directory knows nothing");
|
||||
dir.upsert_from_identify(p, Some("http://gw:9".into()));
|
||||
dir.record_gateway_caps_at(p, false, vec![claim(REDEEM_PROVIDER_FILE, &[], &["opensubtitles"])], t0);
|
||||
|
||||
assert!(dir.knows_a_gateway_at(t0 + IDENTIFY_TTL));
|
||||
assert_eq!(dir.gateways_redeeming_at(REDEEM_PROVIDER_FILE, "opensubtitles", t0 + IDENTIFY_TTL).len(), 1);
|
||||
let late = t0 + IDENTIFY_TTL + Duration::from_secs(1);
|
||||
assert!(!dir.knows_a_gateway_at(late));
|
||||
assert!(dir.gateways_redeeming_at(REDEEM_PROVIDER_FILE, "opensubtitles", late).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_records_base_url() {
|
||||
let dir = PeerDirectory::new();
|
||||
let p = pid_n(1);
|
||||
let u = dir.upsert_from_identify(p, Some("http://gateway-1:9".into()));
|
||||
assert_eq!(u.base_url_changed.as_deref(), Some("http://gateway-1:9"));
|
||||
assert_eq!(dir.base_url(&p).as_deref(), Some("http://gateway-1:9"));
|
||||
|
||||
// No-op refresh.
|
||||
let u2 = dir.upsert_from_identify(p, Some("http://gateway-1:9".into()));
|
||||
assert!(u2.base_url_changed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_base_url_on_none() {
|
||||
let dir = PeerDirectory::new();
|
||||
let p = pid_n(2);
|
||||
dir.upsert_from_identify(p, Some("http://x:9".into()));
|
||||
// A later identify without a baseUrl token keeps the old URL.
|
||||
let u = dir.upsert_from_identify(p, None);
|
||||
assert!(u.base_url_changed.is_none());
|
||||
assert_eq!(dir.base_url(&p).as_deref(), Some("http://x:9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_peer_removes_all_state() {
|
||||
let dir = PeerDirectory::new();
|
||||
let p = pid_n(23);
|
||||
dir.upsert_from_identify(p, Some("http://g:9".into()));
|
||||
dir.record_gateway_caps(p, true, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
|
||||
assert_eq!(dir.base_url(&p).as_deref(), Some("http://g:9"));
|
||||
assert_eq!(dir.nzb_fetch_gateways().len(), 1);
|
||||
|
||||
assert!(dir.forget_peer(&p), "peer was present");
|
||||
assert_eq!(dir.base_url(&p), None);
|
||||
assert!(
|
||||
dir.nzb_fetch_gateways().is_empty(),
|
||||
"forget_peer must drop the gateway capability too"
|
||||
);
|
||||
|
||||
assert!(!dir.forget_peer(&p), "second forget is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nzb_gateway_listed_only_with_both_capability_and_base_url() {
|
||||
let dir = PeerDirectory::new();
|
||||
let with_url = pid_n(3);
|
||||
let no_url = pid_n(4);
|
||||
let not_capable = pid_n(5);
|
||||
|
||||
dir.upsert_from_identify(with_url, Some("http://gw:9".into()));
|
||||
dir.record_gateway_caps(with_url, true, vec![]);
|
||||
|
||||
// Capable, but advertised no baseUrl — there's nowhere to proxy to.
|
||||
dir.record_gateway_caps(no_url, true, vec![]);
|
||||
|
||||
// Has a URL but declines nzb bytes (no credentialed meta-share behind it).
|
||||
dir.upsert_from_identify(not_capable, Some("http://plain:9".into()));
|
||||
dir.record_gateway_caps(not_capable, false, vec![]);
|
||||
|
||||
assert_eq!(
|
||||
dir.nzb_fetch_gateways(),
|
||||
vec![(with_url, "http://gw:9".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nzb_capability_expires_after_identify_ttl() {
|
||||
let dir = PeerDirectory::new();
|
||||
let p = pid_n(6);
|
||||
let t0 = Instant::now();
|
||||
dir.upsert_from_identify(p, Some("http://gw:9".into()));
|
||||
dir.record_gateway_caps_at(p, true, vec![], t0);
|
||||
|
||||
// Still fresh at the TTL boundary...
|
||||
assert_eq!(dir.nzb_fetch_gateways_at(t0 + IDENTIFY_TTL).len(), 1);
|
||||
// ...gone just past it. A gateway that stopped identifying must not
|
||||
// stay a forward target forever.
|
||||
assert!(dir
|
||||
.nzb_fetch_gateways_at(t0 + IDENTIFY_TTL + Duration::from_secs(1))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_refresh_debounces_even_when_gateway_reports_not_capable() {
|
||||
// The trap: `nzbFetch: false` leaves no capability stamp. If the
|
||||
// debounce keyed off that, every identify (one per connection, then
|
||||
// every ~5 min) would re-fire the HTTP fetch forever. It must key off
|
||||
// the *attempt* stamp instead.
|
||||
let dir = PeerDirectory::new();
|
||||
let p = pid_n(7);
|
||||
let refresh = Duration::from_secs(300);
|
||||
let t0 = Instant::now();
|
||||
|
||||
assert!(
|
||||
dir.needs_cap_refresh_at(&p, refresh, t0),
|
||||
"never fetched → must fetch"
|
||||
);
|
||||
|
||||
dir.record_gateway_caps_at(p, false, vec![], t0);
|
||||
assert!(
|
||||
!dir.needs_cap_refresh_at(&p, refresh, t0 + Duration::from_secs(1)),
|
||||
"a `false` answer must still debounce the next identify"
|
||||
);
|
||||
assert!(
|
||||
!dir.needs_cap_refresh_at(&p, refresh, t0 + refresh),
|
||||
"still inside the refresh window"
|
||||
);
|
||||
assert!(
|
||||
dir.needs_cap_refresh_at(&p, refresh, t0 + refresh + Duration::from_secs(1)),
|
||||
"past the refresh window → re-fetch"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! WebDAV / meta-core path helpers used by `crate::api`. Two
|
||||
//! responsibilities:
|
||||
//!
|
||||
//! - **WebDAV path construction** — percent-encoding, the canonical
|
||||
//! `plugin/share/<id>[.<ext>]` layout, and PUT URL building.
|
||||
//! - **Meta-core `/urls` resolution** — the one-shot lookup that turns a
|
||||
//! meta-core base URL into the internal WebDAV URL, cached via
|
||||
//! `tokio::sync::OnceCell` for the lifetime of the process.
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
/// Percent-encode unreserved-only per RFC 3986 §2.3. Single ASCII path
|
||||
/// segment in, encoded segment out — callers split on `/` before invoking
|
||||
/// so the separator survives intact.
|
||||
pub fn urlencode_path_segment(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match *b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(*b as char);
|
||||
}
|
||||
other => {
|
||||
out.push('%');
|
||||
out.push_str(&format!("{:02X}", other));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Lazily resolve meta-core's `webdavUrlInternal` from its `/urls` endpoint
|
||||
/// and cache the result. `AppState` (in `crate::api`) holds an
|
||||
/// `Arc<OnceCell<String>>` so a single PUT-time miss costs one round-trip
|
||||
/// per process. Failures keep the cell empty so a transient meta-core blip
|
||||
/// can be retried on the next call.
|
||||
pub async fn resolve_webdav_url(
|
||||
http: &reqwest::Client,
|
||||
meta_core_url: &str,
|
||||
cache: &OnceCell<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
let meta_core_url = meta_core_url.trim_end_matches('/');
|
||||
let url = format!("{meta_core_url}/urls");
|
||||
cache
|
||||
.get_or_try_init(|| async {
|
||||
let body: serde_json::Value = http
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("upstream {url}"))?
|
||||
.json()
|
||||
.await
|
||||
.with_context(|| format!("decode {url}"))?;
|
||||
let internal = body
|
||||
.get("webdavUrlInternal")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("{url} missing webdavUrlInternal"))?;
|
||||
Ok::<_, anyhow::Error>(internal.trim_end_matches('/').to_string())
|
||||
})
|
||||
.await
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn urlencode_keeps_unreserved() {
|
||||
assert_eq!(urlencode_path_segment("Movie-2024_v1.mkv"), "Movie-2024_v1.mkv");
|
||||
assert_eq!(urlencode_path_segment("a b c"), "a%20b%20c");
|
||||
assert_eq!(urlencode_path_segment("[draft]"), "%5Bdraft%5D");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user