meta-transport-ipfs 1.0.0: ipfs tier split out of meta-share
Publish image / publish (push) Failing after 14s
Publish image / publish (push) Failing after 14s
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
//! The IPFS tier: serve from the local blockstore, fetch raw blocks over bitswap,
|
||||
//! walk dag-pb for ranges, and the hinted-HTTP fallback.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::Response,
|
||||
};
|
||||
use blockstore::Blockstore;
|
||||
use meta_feeder_sdk::transport::dto::{HDR_METER, METER_EGRESS};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::blockstore::parse_record_cid;
|
||||
use crate::focus::Lane;
|
||||
use crate::swarm::bitswap_get_block;
|
||||
|
||||
use crate::api::{ApiError, AppState};
|
||||
use crate::api::files::{bytes_to_response, raw_timeout};
|
||||
|
||||
/// The display name a seed row gets: the cached record's title, else its
|
||||
/// `fileName`, else the cid. Asked of the hull's record cache only — never a
|
||||
/// meta-core round trip on the byte path (same as the monolith).
|
||||
async fn seed_display_name(state: &AppState, cid: &str) -> String {
|
||||
if let Ok(Some(info)) = state.hull.cached_record(cid).await {
|
||||
if let Some(t) = info.title.filter(|t| !t.is_empty()) {
|
||||
return t;
|
||||
}
|
||||
if let Some(n) = info.file_name.filter(|n| !n.is_empty()) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
cid.to_string()
|
||||
}
|
||||
|
||||
/// midhash256 — the other single-block content codec alongside raw IPFS.
|
||||
const MIDHASH256_CODEC: u64 = 0x1000;
|
||||
|
||||
/// Serve `cid` straight out of the local blockstore, or `None`.
|
||||
///
|
||||
/// The block twin of `try_local_material`'s file check, and the same argument:
|
||||
/// when we already hold the bytes, no fetch machinery should run first.
|
||||
///
|
||||
/// It exists because the meta-core/WebDAV tier sits between the request and
|
||||
/// this blockstore read, and for artwork that tier is a **guaranteed miss that
|
||||
/// is not free**. Resolving a record in meta-core costs a read there, and every
|
||||
/// poster paid one before we ever looked at bytes we already had; the record's
|
||||
/// `filePath` then 404s on WebDAV (the artwork is seeded, not on meta-core's
|
||||
/// disk) and the request falls through to exactly this lookup anyway. Measured
|
||||
/// on watch.nsl.sh: ~0.5-3.0s of meta-core round-trip per poster, 220 posters
|
||||
/// on a cold home page.
|
||||
///
|
||||
/// Deliberately narrow, so nothing but that case changes:
|
||||
///
|
||||
/// - **Single-block content codecs only** (raw IPFS `0x55`, midhash256
|
||||
/// `0x1000`) — the two the single-block branch of [`fetch_raw_via_bitswap`]
|
||||
/// handles. dag-pb belongs to the walker, and every locator codec
|
||||
/// (`nzb-release`, `url`, `card`, `btih:`) must keep reaching its own
|
||||
/// dispatch below.
|
||||
|
||||
pub async fn try_local_block(
|
||||
state: &Arc<AppState>,
|
||||
cid: &str,
|
||||
inbound_range: Option<&HeaderValue>,
|
||||
) -> Option<Response> {
|
||||
let mscid = parse_record_cid(cid).ok()?;
|
||||
let codec = mscid.codec();
|
||||
if codec != crate::api::ipfs_walker::RAW_CODEC && codec != MIDHASH256_CODEC {
|
||||
return None;
|
||||
}
|
||||
|
||||
match state.block_store.get(&mscid).await {
|
||||
Ok(Some(bytes)) => {
|
||||
trace!(cid = %cid, source = "blockstore-local-first", bytes = bytes.len(),
|
||||
"served raw bytes without touching meta-core");
|
||||
Some(bytes_to_response(bytes, inbound_range))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!(cid = %cid, error = %e, "local-first blockstore lookup failed; falling through");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `player` marks a request meta-watch's byte proxy sent for a demuxer source —
|
||||
/// i.e. a viewer is blocked on these bytes. It is the *commit* signal for the
|
||||
/// IPFS tier: a title someone genuinely played is worth finishing in the
|
||||
/// background so this peer seeds the whole file rather than the slice that was
|
||||
/// watched (`crate::ingress_commit`). Deliberately not "any successful `/raw`":
|
||||
/// a probe, a `warm` and a poster fetch all reach here and none of them is a
|
||||
/// viewer. No remote peer sends the header.
|
||||
pub async fn fetch_raw_via_bitswap(
|
||||
state: &Arc<AppState>,
|
||||
cid: &str,
|
||||
inbound_range: Option<&HeaderValue>,
|
||||
lane: Lane,
|
||||
player: bool,
|
||||
) -> Result<Response, ApiError> {
|
||||
let mscid = parse_record_cid(cid)
|
||||
.map_err(|e| ApiError::BadRequest(format!("cid: {e:#}")))?;
|
||||
|
||||
let codec = mscid.codec();
|
||||
// Multi-block IPFS files: walk the dag-pb tree. The walker fetches
|
||||
// every block it needs through `ipfs_walker::get_block`, which goes
|
||||
// through the same blockstore-then-bitswap path as the single-block
|
||||
// branch below — no duplication, just a tree on top.
|
||||
if codec == crate::api::ipfs_walker::DAGPB_CODEC {
|
||||
return serve_dagpb_range(state, cid, &mscid, inbound_range, lane, player).await;
|
||||
}
|
||||
|
||||
// Single-block CIDs (midhash256, raw IPFS). Local blockstore →
|
||||
// bitswap WANT, then either slice or serve whole.
|
||||
match state.block_store.get(&mscid).await {
|
||||
Ok(Some(bytes)) => {
|
||||
trace!(cid = %cid, source = "blockstore", bytes = bytes.len(), "served raw bytes");
|
||||
return Ok(bytes_to_response(bytes, inbound_range));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => warn!(cid = %cid, error = %e, "blockstore raw lookup failed; trying bitswap"),
|
||||
}
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
match bitswap_get_block(&state.swarm_tx, mscid, raw_timeout()).await {
|
||||
Ok(bytes) => {
|
||||
trace!(
|
||||
cid = %cid,
|
||||
source = "bitswap",
|
||||
bytes = bytes.len(),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"served raw bytes via bitswap"
|
||||
);
|
||||
// The single-block twin of the meter in `ipfs_walker::get_block` —
|
||||
// this branch reaches bitswap directly and would otherwise be an
|
||||
// un-metered hole in the inbound floor.
|
||||
state.focus.throttle_in(lane, bytes.len() as u64).await;
|
||||
Ok(bytes_to_response(bytes, inbound_range))
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(
|
||||
cid = %cid,
|
||||
error = %e,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"bitswap raw fetch failed"
|
||||
);
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a multi-block dag-pb (IPFS UnixFS file) via the
|
||||
/// [`crate::api::ipfs_walker`] tree walker. Honors inbound `Range:`
|
||||
/// when present; otherwise streams the whole file.
|
||||
pub async fn serve_dagpb_range(
|
||||
state: &Arc<AppState>,
|
||||
cid: &str,
|
||||
mscid: &crate::blockstore::MsCid,
|
||||
inbound_range: Option<&HeaderValue>,
|
||||
lane: Lane,
|
||||
player: bool,
|
||||
) -> Result<Response, ApiError> {
|
||||
use axum::body::Body;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
// 1. Fetch the root block (cache → bitswap). We need it for
|
||||
// blocksizes / filesize.
|
||||
let root_block = crate::api::ipfs_walker::get_block(state, mscid, lane)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
debug!(cid = %cid, error = %e, "dag-pb root fetch failed");
|
||||
ApiError::NotFound
|
||||
})?;
|
||||
|
||||
// 2. Parse the root's PBNode to learn the total file size.
|
||||
let meta = crate::api::ipfs_walker::parse_pbnode(&root_block)
|
||||
.map_err(|e| ApiError::Upstream(format!("parse dag-pb root: {e:#}")))?;
|
||||
// No links: root is itself the data. Fall back to single-block
|
||||
// slicing.
|
||||
if meta.children.is_empty() {
|
||||
return Ok(bytes_to_response(root_block, inbound_range));
|
||||
}
|
||||
let total = meta
|
||||
.filesize
|
||||
.or_else(|| meta.declared_total())
|
||||
.ok_or_else(|| {
|
||||
ApiError::Upstream(
|
||||
"dag-pb root has no filesize and no declared blocksizes — can't serve".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Open (or adopt) the materialisation this fetch fills. From here the leaves
|
||||
// this walk pulls are written into `tmp/<cid>/` and never into `blocks.redb`
|
||||
// — the whole point of Phase 4. `None` for a file small enough that a
|
||||
// container costs more than the copy it saves, which then behaves exactly as
|
||||
// before. The job lookup comes first so the display-name resolution (a record
|
||||
// fetch) happens once per fetch rather than once per range.
|
||||
let job = match state.ingress.job(cid) {
|
||||
Some(j) => {
|
||||
j.touch();
|
||||
Some(j)
|
||||
}
|
||||
None => {
|
||||
let name = seed_display_name(state, cid).await;
|
||||
match state.ingress.begin(cid, cid, &name, total).await {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
// Materialising is an optimisation; serving is not. Fall back
|
||||
// to the pre-Phase-4 path rather than failing the request.
|
||||
warn!(cid = %cid, error = %format!("{e:#}"),
|
||||
"ingress: could not open a materialisation; leaves stay material");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if player {
|
||||
if let Some(j) = job.as_ref() {
|
||||
if j.commit() {
|
||||
debug!(cid = %cid, total,
|
||||
"genuine play — ipfs materialisation committed to a full fill");
|
||||
}
|
||||
}
|
||||
}
|
||||
let ingress_ctx = job
|
||||
.as_ref()
|
||||
.map(|j| crate::ingress::IngressCtx { job: Arc::clone(j), base: 0 });
|
||||
|
||||
// 3. Parse inbound Range (if any) against the file's total size.
|
||||
let ceiling = crate::api::ipfs_walker::max_body_bytes();
|
||||
let (start, end, partial) = match crate::api::range::parse_range_header(inbound_range, total) {
|
||||
crate::api::range::RangeOutcome::Ok { start, end } => (start, end, true),
|
||||
crate::api::range::RangeOutcome::Unsatisfiable => {
|
||||
let mut headers = HeaderMap::new();
|
||||
let cr = format!("bytes */{total}");
|
||||
headers.insert(
|
||||
header::CONTENT_RANGE,
|
||||
HeaderValue::from_str(&cr).expect("ascii content-range"),
|
||||
);
|
||||
return Ok(
|
||||
(StatusCode::RANGE_NOT_SATISFIABLE, headers, Body::empty()).into_response(),
|
||||
);
|
||||
}
|
||||
crate::api::range::RangeOutcome::Absent | crate::api::range::RangeOutcome::Unsupported => {
|
||||
// Whole file. Walker will fetch every leaf but only the
|
||||
// bytes go into the response stream — no extra
|
||||
// concatenation step.
|
||||
(0u64, total.saturating_sub(1), false)
|
||||
}
|
||||
};
|
||||
|
||||
// Reject the request when the requested range (or whole file) is
|
||||
// larger than the in-memory chunking ceiling. Done before any
|
||||
// expensive child fetches.
|
||||
let requested = end - start + 1;
|
||||
if requested as usize > ceiling {
|
||||
return Err(ApiError::Upstream(format!(
|
||||
"dag-pb requested {requested} bytes exceeds {ceiling}-byte ceiling \
|
||||
(META_SHARE_IPFS_MAX_BYTES)"
|
||||
)));
|
||||
}
|
||||
|
||||
// 4. Walk only the leaves intersecting [start, end].
|
||||
let chunks = crate::api::ipfs_walker::walk_range(
|
||||
state,
|
||||
&root_block,
|
||||
start,
|
||||
end,
|
||||
ceiling,
|
||||
lane,
|
||||
ingress_ctx.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
debug!(cid = %cid, error = %e, "dag-pb walk_range failed");
|
||||
ApiError::Upstream(format!("dag-pb walk: {e:#}"))
|
||||
})?;
|
||||
|
||||
// We now hold (and bitswap-serve to cohort peers) the leaf blocks
|
||||
// covering this request — i.e. this peer is seeding the file. Record it
|
||||
// so it appears in Settings → Seeded files. Idempotent; ranged previews
|
||||
// seed the pieces they touched, matching the torrent tier's model. Only
|
||||
// multi-block dag-pb files reach here — single raw blocks (posters,
|
||||
// thumbnails) are deliberately not auto-recorded to keep the list to
|
||||
// real files.
|
||||
//
|
||||
// **Only when the leaves stayed material.** With a job open the bytes are in
|
||||
// a `tmp/` file that no `rename(2)` has published yet: they are servable
|
||||
// (design §2a, through the in-flight set) but they are not a *seed*, and a row
|
||||
// here would bind a container that does not exist and survive a restart that
|
||||
// drops the job — the dangling shape that makes this peer a black hole.
|
||||
// `crate::ingress_commit` records the row at promotion, where the file is real.
|
||||
if job.is_none() {
|
||||
crate::api::record_ipfs_seed(state, cid, &seed_display_name(state, cid).await, total, None).await;
|
||||
}
|
||||
|
||||
// Egress meter. Distinct from the inbound charge in `ipfs_walker::get_block`:
|
||||
// that paid for pulling the blocks off the swarm, this pays for pushing them
|
||||
// back out. Applied by the hull when it relays this body (`HDR_METER`).
|
||||
let body = Body::from_stream(crate::api::ipfs_walker::chunks_to_stream(chunks));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HDR_METER, HeaderValue::from_static(METER_EGRESS));
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCEPT_RANGES,
|
||||
HeaderValue::from_static("bytes"),
|
||||
);
|
||||
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(requested));
|
||||
if partial {
|
||||
let cr = format!("bytes {start}-{end}/{total}");
|
||||
headers.insert(
|
||||
header::CONTENT_RANGE,
|
||||
HeaderValue::from_str(&cr).expect("ascii content-range"),
|
||||
);
|
||||
Ok((StatusCode::PARTIAL_CONTENT, headers, body).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::OK, headers, body).into_response())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The bitswap byte path, plus the few helpers it shared with meta-share's
|
||||
//! other byte tiers.
|
||||
|
||||
pub mod bitswap;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::Response;
|
||||
|
||||
use crate::api::range::{parse_range_header, RangeOutcome};
|
||||
|
||||
/// Marks a request from meta-watch's byte proxy for a demuxer source.
|
||||
pub const PLAYER_HEADER: &str = "x-metamesh-player";
|
||||
|
||||
/// Single bitswap block fetch budget (`META_SHARE_BITSWAP_RAW_TIMEOUT_SECS`).
|
||||
const BITSWAP_RAW_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn raw_timeout() -> Duration {
|
||||
std::env::var("META_SHARE_BITSWAP_RAW_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(BITSWAP_RAW_TIMEOUT)
|
||||
}
|
||||
|
||||
/// An in-memory block as a range response: `206` slice, `416`, or `200`.
|
||||
pub fn bytes_to_response(bytes: Vec<u8>, inbound_range: Option<&HeaderValue>) -> Response {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::response::IntoResponse;
|
||||
let total = bytes.len() as u64;
|
||||
match parse_range_header(inbound_range, total) {
|
||||
RangeOutcome::Ok { start, end } => {
|
||||
let s = start as usize;
|
||||
let e = end as usize;
|
||||
let body = bytes[s..=e].to_vec();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
|
||||
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len() as u64));
|
||||
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
let cr = format!("bytes {start}-{end}/{total}");
|
||||
headers.insert(header::CONTENT_RANGE, HeaderValue::from_str(&cr).expect("ascii content-range"));
|
||||
(StatusCode::PARTIAL_CONTENT, headers, Body::from(body)).into_response()
|
||||
}
|
||||
RangeOutcome::Unsatisfiable => {
|
||||
let mut headers = HeaderMap::new();
|
||||
let cr = format!("bytes */{total}");
|
||||
headers.insert(header::CONTENT_RANGE, HeaderValue::from_str(&cr).expect("ascii content-range"));
|
||||
(StatusCode::RANGE_NOT_SATISFIABLE, headers, Body::empty()).into_response()
|
||||
}
|
||||
RangeOutcome::Absent | RangeOutcome::Unsupported => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
|
||||
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(total));
|
||||
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
(StatusCode::OK, headers, Body::from(bytes)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! `GET /ipfs/{cid}` — IPFS HTTP gateway endpoint (M13).
|
||||
//!
|
||||
//! Resolves an IPFS-compatible cid by:
|
||||
//!
|
||||
//! 1. **Codec dispatch.** Raw cids (codec `0x55`) resolve to a single
|
||||
//! block; dag-pb cids (codec `0x70`) carry a UnixFS file and need
|
||||
//! recursive child fetches.
|
||||
//! 2. **Block fetch via bitswap.** Each block is requested through the
|
||||
//! swarm task via [`crate::swarm::bitswap_get_block`]. The gateway
|
||||
//! tier is the seed; sibling consumers that cached the block on a
|
||||
//! previous fetch also answer.
|
||||
//! 3. **Local cache short-circuit.** Before issuing a bitswap fetch,
|
||||
//! we check `AppState.block_store` directly — a repeat hit on the
|
||||
//! same cid never goes back over the wire.
|
||||
//! 4. **Reassembly.** For dag-pb cids, child cids are extracted from
|
||||
//! the PBNode `Links` field (tag 2) in wire order, fetched in
|
||||
//! parallel (bounded fan-out), and concatenated. Internal nodes
|
||||
//! recurse; raw leaves contribute their body bytes verbatim.
|
||||
//!
|
||||
//! ## Codecs we don't handle
|
||||
//!
|
||||
//! Midhash cids (codec `0x1000`, multibase `bagacb…`) deliberately
|
||||
//! fall through with `400 Bad Request`. They aren't IPFS-spec — the
|
||||
//! caller should use the legacy `/api/peer/{peer}/file/{cid}/raw`
|
||||
//! cross-peer proxy for those instead. The UI's `isIpfsCompatibleCid`
|
||||
//! check already routes correctly; this endpoint enforces the
|
||||
//! invariant server-side.
|
||||
//!
|
||||
//! ## Content-Type
|
||||
//!
|
||||
//! IPFS doesn't carry mime types. The endpoint accepts `?type=<mime>`
|
||||
//! as an explicit hint from the caller (the UI passes the search
|
||||
//! hit's `content_type` field). When the hint is absent or empty we
|
||||
//! fall back to `application/octet-stream` — the browser still
|
||||
//! renders images, audio, and video from that for `<img>` / `<audio>`
|
||||
//! / `<video>` tags, but won't display HTML / PDF inline.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::api::ipfs_walker::{
|
||||
assemble_dagpb_file, get_block, max_body_bytes, DAGPB_CODEC, RAW_CODEC,
|
||||
};
|
||||
use crate::blockstore::MsCid;
|
||||
|
||||
/// Query-string parameters.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct IpfsParams {
|
||||
/// Optional `Content-Type` hint. The UI passes the search hit's
|
||||
/// declared `content_type` here so `<img>` tags render correctly.
|
||||
/// Empty / absent → `application/octet-stream`.
|
||||
#[serde(default, rename = "type")]
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /ipfs/:cid` handler.
|
||||
pub async fn get_ipfs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(cid_str): Path<String>,
|
||||
Query(params): Query<IpfsParams>,
|
||||
) -> Response {
|
||||
let cid: MsCid = match MsCid::from_str(&cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!(cid = %cid_str, error = %e, "ipfs: cid parse failed");
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid cid `{cid_str}`: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Codec gate: only raw + dag-pb. Midhash records have their own
|
||||
// path (`/api/peer/{peer}/file/{cid}/raw`); rejecting them here
|
||||
// makes the UI's codec dispatch authoritative.
|
||||
let codec = cid.codec();
|
||||
match codec {
|
||||
RAW_CODEC | DAGPB_CODEC => {}
|
||||
other => {
|
||||
debug!(cid = %cid_str, codec = format!("0x{:x}", other), "ipfs: unsupported codec");
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("cid codec 0x{other:x} not supported by /ipfs (raw=0x55, dag-pb=0x70 only)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The public IPFS gateway is, by definition, someone else pulling bytes out
|
||||
// of this peer. It gets the focus lane of whatever title the cid belongs to —
|
||||
// which, while our own viewer is watching something else, means the background
|
||||
// floor. Serving a *focused* cid stays full-speed (the viewer may be the one
|
||||
// asking).
|
||||
let lane = state.focus.lane(&cid_str);
|
||||
|
||||
let max_bytes = max_body_bytes();
|
||||
let body = match fetch_ipfs_file(&state, &cid, max_bytes, lane).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(cid = %cid_str, error = %e, "ipfs: fetch failed");
|
||||
return error_response(StatusCode::BAD_GATEWAY, format!("fetch `{cid_str}`: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let content_type = params
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
debug!(
|
||||
cid = %cid_str,
|
||||
codec = format!("0x{:x}", codec),
|
||||
bytes = body.len(),
|
||||
content_type = %content_type,
|
||||
"ipfs: served"
|
||||
);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_LENGTH, body.len().to_string())
|
||||
// Immutable cache: cids are content-addressed, so the bytes
|
||||
// for a given cid never change. Browsers cache aggressively.
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400, immutable")
|
||||
.body(Body::from(body))
|
||||
.expect("static response builder")
|
||||
}
|
||||
|
||||
/// Resolve `cid` to file bytes. For raw codec, one bitswap fetch.
|
||||
/// For dag-pb, recursive UnixFS traversal.
|
||||
async fn fetch_ipfs_file(
|
||||
state: &AppState,
|
||||
cid: &MsCid,
|
||||
max_bytes: usize,
|
||||
lane: crate::focus::Lane,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let codec = cid.codec();
|
||||
let block = get_block(state, cid, lane).await?;
|
||||
match codec {
|
||||
RAW_CODEC => {
|
||||
if block.len() > max_bytes {
|
||||
anyhow::bail!(
|
||||
"raw block exceeds {max_bytes}-byte ceiling ({} bytes)",
|
||||
block.len()
|
||||
);
|
||||
}
|
||||
Ok(block)
|
||||
}
|
||||
DAGPB_CODEC => assemble_dagpb_file(state, &block, max_bytes, lane).await,
|
||||
other => anyhow::bail!("unsupported codec 0x{other:x}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, msg: String) -> Response {
|
||||
(status, axum::Json(serde_json::json!({ "error": msg }))).into_response()
|
||||
}
|
||||
|
||||
|
||||
// PBNode-parsing tests live in `super::ipfs_walker::tests` — that's where
|
||||
// the parser actually lives. This handler is just a codec-dispatch shell.
|
||||
@@ -0,0 +1,636 @@
|
||||
//! Shared dag-pb / UnixFS walker.
|
||||
//!
|
||||
//! Originally lived inside `super::ipfs_gateway` next to the
|
||||
//! `/ipfs/{cid}` handler. Lifted out here so the canonical byte-fetch
|
||||
//! endpoint (`super::files::get_file_raw`) can also assemble (and
|
||||
//! partial-fetch) multi-block dag-pb files — needed for HTTP `Range:`
|
||||
//! support on gateway-seeded videos. The `/ipfs/{cid}` route still
|
||||
//! delegates here for back-compat (see [`assemble_dagpb_file`]).
|
||||
//!
|
||||
//! ## Capabilities exposed
|
||||
//!
|
||||
//! - [`parse_pbnode`] — given a PBNode block, return the link cids
|
||||
//! *with their blocksizes* plus the UnixFS `filesize`. Newer surface
|
||||
//! than the original `parse_pbnode_links`: that one only returned the
|
||||
//! cids, which was enough for whole-file assembly but not for range
|
||||
//! slicing (we need offsets to know which children to fetch).
|
||||
//! - [`walk_range`] — yield a stream of `Bytes` chunks that together
|
||||
//! cover `[start, end]` of the underlying UnixFS file. Fetches only
|
||||
//! the leaf blocks that intersect the range; slices the boundary
|
||||
//! leaves. Recurses into internal dag-pb children for balanced-tree
|
||||
//! files.
|
||||
//! - [`assemble_dagpb_file`] — back-compat shim. Equivalent to
|
||||
//! `walk_range(0, filesize-1)` collected into one `Vec<u8>`. Used by
|
||||
//! the legacy `/ipfs/{cid}` handler.
|
||||
//! - [`get_block`] — single-block fetch (cache hit → bitswap).
|
||||
//! - Codec constants [`RAW_CODEC`] / [`DAGPB_CODEC`] and the byte
|
||||
//! ceiling helper [`max_body_bytes`].
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use blockstore::Blockstore;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{self, Stream, StreamExt, TryStreamExt};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::blockstore::MsCid;
|
||||
use crate::focus::Lane;
|
||||
use crate::swarm::bitswap_get_block;
|
||||
|
||||
pub const RAW_CODEC: u64 = 0x55;
|
||||
pub const DAGPB_CODEC: u64 = 0x70;
|
||||
|
||||
/// Per-block timeout for a single bitswap fetch during dag-pb walks.
|
||||
pub const BITSWAP_BLOCK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Concurrency cap for parallel child-block fetches.
|
||||
pub const PARALLEL_FETCH: usize = 8;
|
||||
|
||||
/// Hard ceiling on the assembled body size for one walked file.
|
||||
/// Aborts the response rather than streaming an unbounded payload —
|
||||
/// bitswap doesn't know the final UnixFS file's size until it walks the
|
||||
/// tree, so a malicious cid could otherwise exhaust memory. Default
|
||||
/// 256 MiB; override via `META_SHARE_IPFS_MAX_BYTES`.
|
||||
pub fn max_body_bytes() -> usize {
|
||||
std::env::var("META_SHARE_IPFS_MAX_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(256 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// Parsed metadata for a dag-pb PBNode that wraps a UnixFS File.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PbNodeMeta {
|
||||
/// Link cids paired with their declared blocksizes (UnixFS Data
|
||||
/// tag 4, one per link in link order). When the encoder doesn't
|
||||
/// write blocksizes, the per-link size is `None` — the walker
|
||||
/// can't range-slice in that case and the caller has to fall back
|
||||
/// to full assembly.
|
||||
pub children: Vec<(MsCid, Option<u64>)>,
|
||||
/// UnixFS Data tag 3 — the file's total payload size. Optional
|
||||
/// because some PBNode shapes (directories, headerless raw wraps)
|
||||
/// don't carry it.
|
||||
pub filesize: Option<u64>,
|
||||
}
|
||||
|
||||
impl PbNodeMeta {
|
||||
/// Sum of declared blocksizes when every link has one declared.
|
||||
/// Used when `filesize` is absent to derive the same number.
|
||||
pub fn declared_total(&self) -> Option<u64> {
|
||||
let mut total: u64 = 0;
|
||||
for (_, sz) in &self.children {
|
||||
let s = (*sz)?;
|
||||
total = total.checked_add(s)?;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PBNode block. Generalises the original `parse_pbnode_links`
|
||||
/// to also pull blocksizes (UnixFS Data tag 4) and filesize (Data tag
|
||||
/// 3) out of the inner Data field, zipped against the link order.
|
||||
pub fn parse_pbnode(bytes: &[u8]) -> anyhow::Result<PbNodeMeta> {
|
||||
let mut links: Vec<MsCid> = Vec::new();
|
||||
let mut blocksizes: Vec<u64> = Vec::new();
|
||||
let mut filesize: Option<u64> = None;
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let (tag_wire, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let field = (tag_wire >> 3) as u32;
|
||||
let wire_type = (tag_wire & 0x7) as u8;
|
||||
match (field, wire_type) {
|
||||
// Outer tag 2 = Links (length-delimited PBLink).
|
||||
(2, 2) => {
|
||||
let (link_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let link_end = cursor
|
||||
.checked_add(link_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBLink length overflow"))?;
|
||||
if link_end > bytes.len() {
|
||||
anyhow::bail!("PBLink length runs off end of buffer");
|
||||
}
|
||||
let link_bytes = &bytes[cursor..link_end];
|
||||
cursor = link_end;
|
||||
if let Some(hash) = parse_pblink_hash(link_bytes)? {
|
||||
let cid = MsCid::try_from(hash.as_slice()).map_err(|e| {
|
||||
anyhow::anyhow!("PBLink.Hash isn't a valid cid: {e}")
|
||||
})?;
|
||||
links.push(cid);
|
||||
}
|
||||
}
|
||||
// Outer tag 1 = Data (length-delimited). The Data field
|
||||
// itself encodes a UnixFS protobuf; recurse to pluck out
|
||||
// filesize (tag 3) and blocksizes (tag 4 repeated).
|
||||
(1, 2) => {
|
||||
let (data_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let data_end = cursor
|
||||
.checked_add(data_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBNode Data length overflow"))?;
|
||||
if data_end > bytes.len() {
|
||||
anyhow::bail!("PBNode Data length runs off end of buffer");
|
||||
}
|
||||
let (fsz, sizes) = parse_unixfs_data(&bytes[cursor..data_end])?;
|
||||
if fsz.is_some() {
|
||||
filesize = fsz;
|
||||
}
|
||||
blocksizes = sizes;
|
||||
cursor = data_end;
|
||||
}
|
||||
// Skip unknown / unrecognised fields per proto3 rules.
|
||||
(_, 0) => {
|
||||
let (_, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
}
|
||||
(_, 2) => {
|
||||
let (skip_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
cursor = cursor
|
||||
.checked_add(skip_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown field skip overflow"))?;
|
||||
if cursor > bytes.len() {
|
||||
anyhow::bail!("unknown field length runs off end of buffer");
|
||||
}
|
||||
}
|
||||
(_, 1) => {
|
||||
cursor = cursor
|
||||
.checked_add(8)
|
||||
.ok_or_else(|| anyhow::anyhow!("fixed64 skip overflow"))?;
|
||||
}
|
||||
(_, 5) => {
|
||||
cursor = cursor
|
||||
.checked_add(4)
|
||||
.ok_or_else(|| anyhow::anyhow!("fixed32 skip overflow"))?;
|
||||
}
|
||||
_ => anyhow::bail!("unsupported wire type {wire_type} on field {field}"),
|
||||
}
|
||||
}
|
||||
// Zip links with blocksizes. If counts don't match, we deliberately
|
||||
// pair as many as we can and leave the rest with `None` — the
|
||||
// walker handles that by falling back to whole-block fetches for
|
||||
// those leaves (it can't slice without a known size).
|
||||
let mut children: Vec<(MsCid, Option<u64>)> = Vec::with_capacity(links.len());
|
||||
for (i, cid) in links.into_iter().enumerate() {
|
||||
let sz = blocksizes.get(i).copied();
|
||||
children.push((cid, sz));
|
||||
}
|
||||
Ok(PbNodeMeta { children, filesize })
|
||||
}
|
||||
|
||||
/// Parse the inner UnixFS Data protobuf message. Returns `(filesize,
|
||||
/// blocksizes)`. Both can be empty / `None` for headerless or
|
||||
/// directory-shaped Data.
|
||||
fn parse_unixfs_data(bytes: &[u8]) -> anyhow::Result<(Option<u64>, Vec<u64>)> {
|
||||
let mut filesize: Option<u64> = None;
|
||||
let mut blocksizes: Vec<u64> = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let (tag_wire, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let field = (tag_wire >> 3) as u32;
|
||||
let wire_type = (tag_wire & 0x7) as u8;
|
||||
match (field, wire_type) {
|
||||
// UnixFS tag 3 = filesize (varint).
|
||||
(3, 0) => {
|
||||
let (v, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
filesize = Some(v);
|
||||
}
|
||||
// UnixFS tag 4 = blocksizes (varint, repeated).
|
||||
(4, 0) => {
|
||||
let (v, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
blocksizes.push(v);
|
||||
}
|
||||
// Everything else (Type, inline Data, hashType, fanout) is
|
||||
// either irrelevant to slicing or a future-proofing tag we
|
||||
// skip.
|
||||
(_, 0) => {
|
||||
let (_, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
}
|
||||
(_, 2) => {
|
||||
let (skip_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
cursor = cursor
|
||||
.checked_add(skip_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("UnixFS field skip overflow"))?;
|
||||
if cursor > bytes.len() {
|
||||
anyhow::bail!("UnixFS field length runs off end of buffer");
|
||||
}
|
||||
}
|
||||
(_, 1) => cursor = cursor.checked_add(8).unwrap_or(usize::MAX),
|
||||
(_, 5) => cursor = cursor.checked_add(4).unwrap_or(usize::MAX),
|
||||
_ => anyhow::bail!("unsupported UnixFS Data wire type {wire_type} on field {field}"),
|
||||
}
|
||||
}
|
||||
Ok((filesize, blocksizes))
|
||||
}
|
||||
|
||||
/// Inside a PBLink, extract `Hash` (tag 1). Returns `Ok(None)` if the
|
||||
/// link is malformed in a tolerable way (no Hash field) — the link is
|
||||
/// then skipped at the caller.
|
||||
fn parse_pblink_hash(bytes: &[u8]) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
let mut cursor = 0usize;
|
||||
while cursor < bytes.len() {
|
||||
let (tag_wire, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let field = (tag_wire >> 3) as u32;
|
||||
let wire_type = (tag_wire & 0x7) as u8;
|
||||
match (field, wire_type) {
|
||||
(1, 2) => {
|
||||
let (len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
let end = cursor
|
||||
.checked_add(len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBLink.Hash length overflow"))?;
|
||||
if end > bytes.len() {
|
||||
anyhow::bail!("PBLink.Hash runs off end of buffer");
|
||||
}
|
||||
return Ok(Some(bytes[cursor..end].to_vec()));
|
||||
}
|
||||
(_, 0) => {
|
||||
let (_, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
}
|
||||
(_, 2) => {
|
||||
let (skip_len, n) = read_varint(&bytes[cursor..])?;
|
||||
cursor += n;
|
||||
cursor = cursor
|
||||
.checked_add(skip_len as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("PBLink field skip overflow"))?;
|
||||
if cursor > bytes.len() {
|
||||
anyhow::bail!("PBLink field length runs off end of buffer");
|
||||
}
|
||||
}
|
||||
(_, 1) => cursor = cursor.checked_add(8).unwrap_or(usize::MAX),
|
||||
(_, 5) => cursor = cursor.checked_add(4).unwrap_or(usize::MAX),
|
||||
_ => anyhow::bail!("unsupported PBLink wire type {wire_type} on field {field}"),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Decode one LEB128 unsigned varint. Returns `(value, bytes_consumed)`.
|
||||
fn read_varint(bytes: &[u8]) -> anyhow::Result<(u64, usize)> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift: u32 = 0;
|
||||
let mut n: usize = 0;
|
||||
for &b in bytes {
|
||||
n += 1;
|
||||
value |= ((b & 0x7F) as u64) << shift;
|
||||
if b & 0x80 == 0 {
|
||||
return Ok((value, n));
|
||||
}
|
||||
shift += 7;
|
||||
if shift >= 64 {
|
||||
anyhow::bail!("varint overflow (more than 10 bytes)");
|
||||
}
|
||||
}
|
||||
anyhow::bail!("varint truncated")
|
||||
}
|
||||
|
||||
/// Fetch one block by cid. Cache short-circuit first; on miss, issues
|
||||
/// a bitswap fetch and (because the bitswap behaviour shares the same
|
||||
/// store) implicitly caches the result for future requests.
|
||||
///
|
||||
/// This is the single point at which an IPFS file byte enters the process, so it
|
||||
/// is where the playback focus meters **inbound** bitswap: a block fetched for a
|
||||
/// title nobody is watching is charged against the background rate
|
||||
/// ([`FocusGate::throttle_in`](crate::focus::FocusGate::throttle_in)) while a
|
||||
/// title is playing. `lane` is resolved once per request from the *requested* cid
|
||||
/// and threaded down — never re-derived here, because a dag-pb **leaf** cid isn't
|
||||
/// a sibling of the record and wouldn't resolve.
|
||||
///
|
||||
/// A blockstore hit is free: it moved no bytes over the wire, so it isn't charged.
|
||||
pub async fn get_block(state: &AppState, cid: &MsCid, lane: Lane) -> anyhow::Result<Vec<u8>> {
|
||||
if let Some(bytes) = blockstore_get(state.block_store.as_ref(), cid).await? {
|
||||
debug!(%cid, "ipfs walker: blockstore cache hit");
|
||||
return Ok(bytes);
|
||||
}
|
||||
debug!(%cid, "ipfs walker: cache miss, issuing bitswap fetch");
|
||||
let bytes = bitswap_get_block(&state.swarm_tx, cid.clone(), BITSWAP_BLOCK_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
debug!(%cid, bytes = bytes.len(), "ipfs walker: bitswap fetch returned");
|
||||
state.focus.throttle_in(lane, bytes.len() as u64).await;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn blockstore_get<B: Blockstore>(
|
||||
store: &B,
|
||||
cid: &MsCid,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
store
|
||||
.get(cid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blockstore get: {e}"))
|
||||
}
|
||||
|
||||
/// Back-compat: assemble the whole UnixFS file by walking every leaf in
|
||||
/// link order. Equivalent to `walk_range(0, filesize-1)` collected into
|
||||
/// a single `Vec<u8>`. Used by the legacy `/ipfs/{cid}` handler.
|
||||
///
|
||||
/// Note: this still builds a single Vec in memory. The streaming entry
|
||||
/// point for callers who can consume chunks is [`walk_range`].
|
||||
pub fn assemble_dagpb_file<'a>(
|
||||
state: &'a AppState,
|
||||
root_block: &'a [u8],
|
||||
max_bytes: usize,
|
||||
lane: Lane,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<Vec<u8>>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let meta = parse_pbnode(root_block)?;
|
||||
if meta.children.is_empty() {
|
||||
// No links — return the raw block bytes; caller decides how
|
||||
// to render application/octet-stream.
|
||||
return Ok(root_block.to_vec());
|
||||
}
|
||||
// Parallel-fetch every child whole (legacy semantics).
|
||||
let children = stream::iter(meta.children.into_iter().map(|(child_cid, _sz)| async move {
|
||||
let codec = child_cid.codec();
|
||||
let block = get_block(state, &child_cid, lane).await?;
|
||||
match codec {
|
||||
RAW_CODEC => Ok::<Vec<u8>, anyhow::Error>(block),
|
||||
DAGPB_CODEC => assemble_dagpb_file(state, &block, max_bytes, lane).await,
|
||||
other => anyhow::bail!("unsupported child codec 0x{other:x}"),
|
||||
}
|
||||
}))
|
||||
.buffered(PARALLEL_FETCH)
|
||||
.try_collect::<Vec<Vec<u8>>>()
|
||||
.await?;
|
||||
let total: usize = children.iter().map(Vec::len).sum();
|
||||
if total > max_bytes {
|
||||
anyhow::bail!(
|
||||
"assembled UnixFS file would be {total} bytes — exceeds {max_bytes}-byte ceiling"
|
||||
);
|
||||
}
|
||||
let mut out = Vec::with_capacity(total);
|
||||
for chunk in children {
|
||||
out.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk a dag-pb tree yielding only the bytes inside `[start, end]`
|
||||
/// (inclusive). Returns the chunks as a `Vec<Bytes>` so the caller can
|
||||
/// hand them to `axum::body::Body::from_stream(stream::iter(...))`
|
||||
/// without re-concatenating into a single buffer.
|
||||
///
|
||||
/// Skips leaves wholly outside `[start, end]` — no bitswap fetch for
|
||||
/// them — and slices the boundary leaves to align. Recurses into
|
||||
/// internal dag-pb children with the sub-range translated into the
|
||||
/// child's offset space.
|
||||
///
|
||||
/// `max_bytes` is the cap on the resulting body size — i.e. the
|
||||
/// requested range size, NOT the whole file. So a 4 GiB file with
|
||||
/// `start=0, end=65535` succeeds even when the whole file would have
|
||||
/// hit the ceiling.
|
||||
///
|
||||
/// A leaf without a declared blocksize forces a fall-back fetch + size
|
||||
/// inspection. Errors when a child codec is neither raw nor dag-pb.
|
||||
/// `ingress` carries the in-flight materialisation this walk is filling, if any
|
||||
/// (`crate::ingress`). It is what turns a walk into a *download*: every raw leaf
|
||||
/// is registered with its **file-absolute** offset before its WANT goes out, so
|
||||
/// the block that comes back can be written straight into the file instead of
|
||||
/// into `blocks.redb`. `None` walks behave exactly as they did before Phase 4.
|
||||
pub fn walk_range<'a>(
|
||||
state: &'a Arc<AppState>,
|
||||
root_block: &'a [u8],
|
||||
start: u64,
|
||||
end: u64,
|
||||
max_bytes: usize,
|
||||
lane: Lane,
|
||||
ingress: Option<&'a crate::ingress::IngressCtx>,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = anyhow::Result<Vec<Bytes>>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
if start > end {
|
||||
anyhow::bail!("walk_range: empty range start={start} end={end}");
|
||||
}
|
||||
let requested = end - start + 1;
|
||||
if requested as usize > max_bytes {
|
||||
anyhow::bail!(
|
||||
"walk_range: requested {requested} bytes exceeds {max_bytes}-byte ceiling"
|
||||
);
|
||||
}
|
||||
let meta = parse_pbnode(root_block)?;
|
||||
if meta.children.is_empty() {
|
||||
// No links — root IS the data. Slice it.
|
||||
let total = root_block.len() as u64;
|
||||
if end >= total {
|
||||
anyhow::bail!("walk_range: end={end} past root block size={total}");
|
||||
}
|
||||
let s = start as usize;
|
||||
let e = end as usize;
|
||||
return Ok(vec![Bytes::copy_from_slice(&root_block[s..=e])]);
|
||||
}
|
||||
|
||||
let mut out: Vec<Bytes> = Vec::new();
|
||||
// Cumulative offset of the current child in the file's byte space.
|
||||
let mut cursor: u64 = 0;
|
||||
for (child_cid, child_size_opt) in meta.children {
|
||||
// Without a declared blocksize we have to fetch the child
|
||||
// to learn its size — that defeats the purpose of range
|
||||
// walking but is correct for headerless blocks. We bound
|
||||
// the cost with the recursion + max_bytes ceiling.
|
||||
let (child_block, child_size) = match child_size_opt {
|
||||
Some(sz) => (None, sz),
|
||||
None => {
|
||||
let b = get_block(state, &child_cid, lane).await?;
|
||||
let sz = b.len() as u64;
|
||||
(Some(b), sz)
|
||||
}
|
||||
};
|
||||
let child_start = cursor;
|
||||
let child_end = cursor.saturating_add(child_size);
|
||||
cursor = child_end;
|
||||
// Skip children wholly before `[start, end]`.
|
||||
if child_end <= start {
|
||||
continue;
|
||||
}
|
||||
// Stop once we're past the requested range.
|
||||
if child_start > end {
|
||||
break;
|
||||
}
|
||||
// Range inside the child's local byte space:
|
||||
// overlap of [start, end] with [child_start, child_end-1].
|
||||
let overlap_lo = start.max(child_start);
|
||||
let overlap_hi = end.min(child_end - 1);
|
||||
let local_lo = overlap_lo - child_start;
|
||||
let local_hi = overlap_hi - child_start;
|
||||
|
||||
let codec = child_cid.codec();
|
||||
match codec {
|
||||
RAW_CODEC => {
|
||||
// Claim the leaf *before* the fetch: the block may come back
|
||||
// on another task, and an unclaimed arrival is indistinguishable
|
||||
// from an unsolicited one (which we drop). `base` makes the
|
||||
// offset file-absolute through however many dag levels deep
|
||||
// this leaf sits.
|
||||
if let Some(ctx) = ingress {
|
||||
state.ingress.expect_leaf(
|
||||
ctx,
|
||||
child_cid.to_bytes(),
|
||||
ctx.base.saturating_add(child_start),
|
||||
child_size,
|
||||
);
|
||||
}
|
||||
let block = match child_block {
|
||||
Some(b) => b,
|
||||
None => get_block(state, &child_cid, lane).await?,
|
||||
};
|
||||
if (block.len() as u64) < child_size {
|
||||
anyhow::bail!(
|
||||
"leaf block shorter than declared blocksize ({} < {})",
|
||||
block.len(),
|
||||
child_size
|
||||
);
|
||||
}
|
||||
let s = local_lo as usize;
|
||||
let e = local_hi as usize;
|
||||
out.push(Bytes::copy_from_slice(&block[s..=e]));
|
||||
}
|
||||
DAGPB_CODEC => {
|
||||
let block = match child_block {
|
||||
Some(b) => b,
|
||||
None => get_block(state, &child_cid, lane).await?,
|
||||
};
|
||||
// Recurse with the sub-range translated into the
|
||||
// child's own offset space — and the ingress base translated
|
||||
// the opposite way, so leaves keep reporting where they land
|
||||
// in the file rather than in this subtree.
|
||||
let nested_ctx = ingress.map(|c| c.nested(child_start));
|
||||
let nested = walk_range(
|
||||
state,
|
||||
&block,
|
||||
local_lo,
|
||||
local_hi,
|
||||
max_bytes,
|
||||
lane,
|
||||
nested_ctx.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
out.extend(nested);
|
||||
}
|
||||
other => anyhow::bail!("unsupported child codec 0x{other:x}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Adapter: convert a `Vec<Bytes>` chunk list into a `Stream` that
|
||||
/// `axum::body::Body::from_stream` accepts. The stream yields
|
||||
/// `Result<Bytes, std::io::Error>` per axum's body-stream bound.
|
||||
pub fn chunks_to_stream(
|
||||
chunks: Vec<Bytes>,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
|
||||
stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn cid_wire(codec: u8, digest: &[u8; 32]) -> Vec<u8> {
|
||||
let mut w = Vec::with_capacity(36);
|
||||
w.push(0x01);
|
||||
w.push(codec);
|
||||
w.push(0x12);
|
||||
w.push(0x20);
|
||||
w.extend_from_slice(digest);
|
||||
w
|
||||
}
|
||||
fn write_varint(value: u64, out: &mut Vec<u8>) {
|
||||
let mut v = value;
|
||||
while v >= 0x80 {
|
||||
out.push((v as u8) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.push(v as u8);
|
||||
}
|
||||
fn write_varint_field(field: u32, value: u64, out: &mut Vec<u8>) {
|
||||
write_varint((field as u64) << 3, out);
|
||||
write_varint(value, out);
|
||||
}
|
||||
fn write_bytes_field(field: u32, value: &[u8], out: &mut Vec<u8>) {
|
||||
write_varint(((field as u64) << 3) | 2, out);
|
||||
write_varint(value.len() as u64, out);
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
|
||||
/// Build a 2-leaf dag-pb root encoding `chunk_a || chunk_b` exactly
|
||||
/// the way the meta-gateway encoder does.
|
||||
fn make_root(chunk_a: &[u8], chunk_b: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let cid_a = cid_wire(0x55, &Sha256::digest(chunk_a).into());
|
||||
let cid_b = cid_wire(0x55, &Sha256::digest(chunk_b).into());
|
||||
|
||||
let mut link_a = Vec::new();
|
||||
write_bytes_field(1, &cid_a, &mut link_a);
|
||||
write_varint_field(3, chunk_a.len() as u64, &mut link_a);
|
||||
let mut link_b = Vec::new();
|
||||
write_bytes_field(1, &cid_b, &mut link_b);
|
||||
write_varint_field(3, chunk_b.len() as u64, &mut link_b);
|
||||
|
||||
let mut unixfs_data = Vec::new();
|
||||
write_varint_field(1, 2, &mut unixfs_data); // Type=File
|
||||
write_varint_field(3, (chunk_a.len() + chunk_b.len()) as u64, &mut unixfs_data);
|
||||
write_varint_field(4, chunk_a.len() as u64, &mut unixfs_data);
|
||||
write_varint_field(4, chunk_b.len() as u64, &mut unixfs_data);
|
||||
|
||||
let mut pbnode = Vec::new();
|
||||
write_bytes_field(2, &link_a, &mut pbnode);
|
||||
write_bytes_field(2, &link_b, &mut pbnode);
|
||||
write_bytes_field(1, &unixfs_data, &mut pbnode);
|
||||
(pbnode, cid_a, cid_b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pbnode_emits_links_with_blocksizes_and_filesize() {
|
||||
let chunk_a: &[u8] = b"AAAA";
|
||||
let chunk_b: &[u8] = b"BBBBB";
|
||||
let (root, _ca, _cb) = make_root(chunk_a, chunk_b);
|
||||
let meta = parse_pbnode(&root).expect("parse");
|
||||
assert_eq!(meta.filesize, Some(9));
|
||||
assert_eq!(meta.children.len(), 2);
|
||||
assert_eq!(meta.children[0].1, Some(4));
|
||||
assert_eq!(meta.children[1].1, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pbnode_links_empty_when_no_links() {
|
||||
let mut data = Vec::new();
|
||||
write_varint_field(1, 2, &mut data); // Type=File
|
||||
let mut pbnode = Vec::new();
|
||||
write_bytes_field(1, &data, &mut pbnode);
|
||||
let meta = parse_pbnode(&pbnode).expect("parse");
|
||||
assert!(meta.children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pbnode_truncated_returns_err() {
|
||||
let mut bytes = Vec::new();
|
||||
// tag 2 wire-type 2, length 100, but only 5 bytes follow.
|
||||
bytes.push((2u8 << 3) | 2);
|
||||
bytes.push(100);
|
||||
bytes.extend_from_slice(b"short");
|
||||
assert!(parse_pbnode(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_total_matches_filesize() {
|
||||
let chunk_a: &[u8] = b"AAAA";
|
||||
let chunk_b: &[u8] = b"BBBBB";
|
||||
let (root, _, _) = make_root(chunk_a, chunk_b);
|
||||
let meta = parse_pbnode(&root).expect("parse");
|
||||
assert_eq!(meta.declared_total(), Some(9));
|
||||
assert_eq!(meta.declared_total(), meta.filesize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The plugin's slice of meta-share's former `api` module: the state the moved
|
||||
//! IPFS code reads (kept under the same names, so it moved unedited), the dag
|
||||
//! walker, the `/ipfs/:cid` gateway handler and the bitswap byte path.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use meta_feeder_sdk::transport::{Event, FocusView, HullClient};
|
||||
use tokio::sync::{mpsc, OnceCell};
|
||||
use tracing::debug;
|
||||
|
||||
pub mod files;
|
||||
pub mod ipfs_gateway;
|
||||
pub mod ipfs_walker;
|
||||
|
||||
pub use meta_feeder_sdk::transport::ApiError;
|
||||
pub mod range {
|
||||
pub use meta_feeder_sdk::transport::range::*;
|
||||
}
|
||||
|
||||
/// What the IPFS tier reaches for — the subset of meta-share's `AppState` it
|
||||
/// used, under the same field names.
|
||||
pub struct AppState {
|
||||
pub block_store: Arc<crate::filestore::SharedBlockstore>,
|
||||
pub ingress: Arc<crate::ingress::IngressRegistry>,
|
||||
pub swarm_tx: mpsc::Sender<crate::swarm::Command>,
|
||||
pub peer_directory: crate::swarm::PeerDirectory,
|
||||
/// The playback focus the hull pushes.
|
||||
pub focus: Arc<FocusView>,
|
||||
pub http: reqwest::Client,
|
||||
/// Callbacks into the hull (seed rows, promoted containers, display names).
|
||||
pub hull: HullClient,
|
||||
pub data_dir: PathBuf,
|
||||
/// For reading library bytes (refs into meta-core files) — byte reads only.
|
||||
pub meta_core_url: Option<String>,
|
||||
pub webdav_url_cache: Arc<OnceCell<String>>,
|
||||
/// Announce seeded cids on the public DHT (`META_SHARE_SEED_DHT_PROVIDE`,
|
||||
/// default = public mode). Decided here, where `PUBLIC_ADDR` is known; the
|
||||
/// hull always asks and this gates.
|
||||
pub seed_dht_provide: bool,
|
||||
}
|
||||
|
||||
/// "This peer now seeds `cid`" — the hull writes the seed row (and decides on
|
||||
/// the DHT announce). Every seed this plugin reports on its own is cache-origin;
|
||||
/// library seeds are recorded by the hull, which drives them.
|
||||
pub async fn record_ipfs_seed(state: &AppState, cid: &str, name: &str, size: u64, container: Option<&str>) {
|
||||
let ev = Event::Seed {
|
||||
cid: cid.to_string(),
|
||||
kind: "ipfs".into(),
|
||||
name: name.to_string(),
|
||||
size_bytes: size,
|
||||
container: container.map(str::to_string),
|
||||
};
|
||||
if let Err(e) = state.hull.emit(&ev).await {
|
||||
debug!(cid, error = %e, "seed notification not delivered");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user