meta-transport-ipfs 1.0.0: ipfs tier split out of meta-share

This commit is contained in:
Worph
2026-10-03 15:43:58 +00:00
parent f6dfbc49ce
commit 9b6d96d8b0
35 changed files with 12867 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
target
.git
+55
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/target
+25
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -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
View File
@@ -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"]
+37 -1
View File
@@ -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 .
```
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.89"
components = ["rustfmt", "clippy"]
+321
View File
@@ -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())
}
}
+60
View File
@@ -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()
}
}
}
+170
View File
@@ -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.
+636
View File
@@ -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);
}
}
+57
View File
@@ -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");
}
}
+517
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+373
View File
@@ -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
View File
@@ -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");
}
}
+283
View File
@@ -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);
}
+619
View File
@@ -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
View File
@@ -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
}
+60
View File
@@ -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
View File
@@ -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)
}
+84
View File
@@ -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
View File
@@ -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)))
}
+92
View File
@@ -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)
}
}
+150
View File
@@ -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:?}"),
}
}
}
+78
View File
@@ -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,
})
}
+558
View File
@@ -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");
}
_ => {}
}
}
+74
View File
@@ -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);
}
}
+173
View File
@@ -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"
)));
}
}
+421
View File
@@ -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,
}
+585
View File
@@ -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"
);
}
}
+81
View File
@@ -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");
}
}