meta-transport-ipfs 1.0.0: ipfs tier split out of meta-share
Publish image / publish (push) Failing after 14s

This commit is contained in:
Worph
2026-10-03 15:43:58 +00:00
parent f6dfbc49ce
commit eb21c607d4
35 changed files with 12867 additions and 1 deletions
+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");
}
}