Files
seaweedfs/seaweed-volume/src/server/handlers.rs
T
985706cc32 volume: check a needle's CRC the first time a range of it is read (#12467)
* volume: check a needle's CRC the first time a range of it is read

A ranged read never checked the CRC, so a replica with a damaged needle
served the damaged bytes with 206 and the reader never tried another
copy. The first ranged read of a needle now streams the whole needle
through the existing CRC check, with the usual pooled page buffers,
before writing any byte. A mismatch fails the read, so the reader falls
back to another replica. Needles that pass go into a bounded LRU, keyed
by volume, compaction revision, id, append time and checksum, so later
ranges of the same needle stream as before.

Fixes #5959 for ranged reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: key verified ranges by volume load, share concurrent checks

From review: the cache key read v.SuperBlock.CompactionRevision without
the data file lock, racing a compaction commit that reloads the super
block, and a remounted copy of a volume inherited the old copy's
verified entries. Key entries by a load generation instead, an atomic
counter that changes on every load, including the reload in
CommitCompact, and cache a result only if no reload happened during
the check. Concurrent first ranges of a needle now share one check
through singleflight instead of each reading the whole needle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: keep hot needles verified, leave the io error streak to the range read

From review: Contains does not refresh an entry's place in the LRU, so
even a constantly read needle was evicted after 64k other needles were
verified; look it up with Get instead. The verification pass also
cleared the io error streak before the range read ran, so a range read
failing with EIO after a passing check never built up a streak; the
pass into io.Discard now leaves the streak to the range read, like the
meta read before a streamed GET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume (Rust): check a needle's CRC the first time a range of it is read

Mirror of the Go change: a range served from the data file now checks the
whole needle's CRC before any byte of it is sent, so a damaged replica
answers 500 and the reader tries another copy. Needles that pass go into a
bounded LRU keyed by the volume's load generation, needle id, append time
and checksum; concurrent first ranges of a needle share one check, and a
check that passes leaves the io error streak to the range read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume (Rust): a range over the whole needle checks its CRC

Go reads a range that covers the whole needle through the checked full-read
path. The Rust volume server read it from the data file unchecked, so
"bytes=0-" served a damaged needle with 206. Check the slice's CRC before
answering, as the full read does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: check the covered blocks of every range against CRCs recorded at the first check

The first ranged read of a needle now records the CRC of each block
(64 KiB to 1 MiB, at most 64 blocks below 64 MiB) while it checks the
whole needle. Later ranges read the block-aligned span around them and
write a block's bytes only once it matches, so damage that appears
after the first check is not served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume (Rust): check the covered blocks of every range against CRCs recorded at the first check

verify_checksum now records a CRC per block (same sizes as Go's
rangeCheckBlockSize) and returns them from the cache; read_checked_range
reads the block-aligned span of a range and answers 500 if a block no
longer matches, so damage after the first check is not served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-11-07 10:53:07 -08:00

9384 lines
349 KiB
Rust

//! HTTP handlers for volume server operations.
//!
//! Implements GET/HEAD (read), POST/PUT (write), DELETE, /status, /healthz.
//! Matches Go's volume_server_handlers_read.go, volume_server_handlers_write.go,
//! volume_server_handlers_admin.go.
use std::collections::HashMap;
use std::future::Future;
use std::ops::ControlFlow;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, Method, Request, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use super::absolute_display_path;
use super::grpc_client::{GrpcDialOptions, connect_channel, volume_server_client};
use super::volume_server::{VolumeServerState, normalize_outgoing_http_url, to_http_address};
use crate::config::ReadMode;
use crate::metrics;
use crate::pb::volume_server_pb;
use crate::storage::needle::CRC;
use crate::storage::needle::needle::Needle;
use crate::storage::types::*;
/// Slack added over the configured file-size limit when bounding the raw
/// request body, to allow for multipart/form-data framing overhead. The exact
/// per-file limit is still enforced on the parsed data after multipart parsing.
const UPLOAD_BODY_OVERHEAD: usize = 16 * 1024 * 1024; // 16 MiB
/// Upper bound on bytes we will materialize in memory for a single request when
/// expanding stored content (gzip decompression or chunk-manifest assembly).
/// Guards against gzip bombs and crafted/oversized manifest sizes OOM-killing
/// the server; legitimate large objects are read via client-side chunk fetches.
const MAX_EXPANSION_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB
/// Why `maybe_decompress_gzip` failed, so callers can distinguish a recoverable
/// "not valid gzip, use the raw bytes" from "the bomb cap was hit, reject it".
#[derive(Debug)]
enum GunzipError {
/// Input was not valid gzip / decode failed — callers may fall back to raw.
Decode,
/// Decompressed output would exceed `MAX_EXPANSION_BYTES` — must be rejected.
TooLarge,
}
// ============================================================================
// Inflight Throttle Guard
// ============================================================================
/// RAII guard that subtracts bytes from an atomic counter and notifies waiters on drop.
struct InflightGuard<'a> {
counter: &'a std::sync::atomic::AtomicI64,
bytes: i64,
notify: &'a tokio::sync::Notify,
metric: &'a prometheus::IntGauge,
}
impl<'a> Drop for InflightGuard<'a> {
fn drop(&mut self) {
let new_val = self.counter.fetch_sub(self.bytes, Ordering::Relaxed) - self.bytes;
self.metric.set(new_val);
self.notify.notify_waiters();
}
}
/// Body wrapper that tracks download inflight bytes and releases them when dropped.
struct TrackedBody {
data: Vec<u8>,
state: Arc<VolumeServerState>,
bytes: i64,
}
impl http_body::Body for TrackedBody {
type Data = bytes::Bytes;
type Error = std::convert::Infallible;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
if self.data.is_empty() {
return std::task::Poll::Ready(None);
}
let data = std::mem::take(&mut self.data);
std::task::Poll::Ready(Some(Ok(http_body::Frame::data(bytes::Bytes::from(data)))))
}
fn size_hint(&self) -> http_body::SizeHint {
http_body::SizeHint::with_exact(self.data.len() as u64)
}
}
impl Drop for TrackedBody {
fn drop(&mut self) {
let new_val = self
.state
.inflight_download_bytes
.fetch_sub(self.bytes, Ordering::Relaxed)
- self.bytes;
metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val);
self.state.download_notify.notify_waiters();
}
}
fn finalize_bytes_response(
status: StatusCode,
headers: HeaderMap,
data: Vec<u8>,
state: Option<Arc<VolumeServerState>>,
) -> Response {
if let Some(state) = state {
let data_len = data.len() as i64;
let new_val = state
.inflight_download_bytes
.fetch_add(data_len, Ordering::Relaxed)
+ data_len;
metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val);
let tracked_body = TrackedBody {
data,
state,
bytes: data_len,
};
let body = Body::new(tracked_body);
let mut resp = Response::new(body);
*resp.status_mut() = status;
*resp.headers_mut() = headers;
resp
} else {
(status, headers, data).into_response()
}
}
// ============================================================================
// Streaming Body for Large Files
// ============================================================================
/// Threshold in bytes above which we stream needle data instead of buffering.
const STREAMING_THRESHOLD: u32 = 1024 * 1024; // 1 MB
/// Default chunk size for streaming reads from the dat file.
const DEFAULT_STREAMING_CHUNK_SIZE: usize = 64 * 1024; // 64 KB
/// Whether a GET/HEAD can be answered from the needle meta and the data file.
#[derive(Clone, Copy)]
struct SourceReadRequest {
is_head: bool,
has_range: bool,
has_image_ops: bool,
/// No URL extension, but the stored name may still make it a transform.
may_have_image_ops: bool,
bypass_cm: bool,
}
impl SourceReadRequest {
/// The payload can be served as stored.
fn direct(&self, n: &Needle) -> bool {
!n.is_compressed() && !(n.is_chunk_manifest() && !self.bypass_cm) && !self.has_image_ops
}
/// HEAD, a range, or a stream.
fn served_from_source(&self, n: &Needle) -> bool {
if self.is_head {
// HEAD is meta-only unless the payload is needed to expand a
// manifest or to answer an image transform's length.
return !self.has_image_ops && !(n.is_chunk_manifest() && !self.bypass_cm);
}
self.direct(n) && (self.has_range || n.data_size > STREAMING_THRESHOLD)
}
/// Meta first only if the needle could be served from source; its data
/// size is below its index size.
fn try_meta_first(&self, size: Size) -> bool {
if self.has_image_ops {
return false;
}
// A range that may need a transform reads a small needle whole, like Go.
self.is_head
|| (self.has_range && !self.may_have_image_ops)
|| size.0 > STREAMING_THRESHOLD as i32
}
}
/// Blocking part of a GET/HEAD: the store guard is dropped before any needle
/// I/O, so a slow or tiered read cannot park store writers. `Ok(None)` is a
/// cookie mismatch.
fn read_needle_for_get(
state: &VolumeServerState,
vid: VolumeId,
needle_id: NeedleId,
cookie: Cookie,
read_deleted: bool,
request: SourceReadRequest,
) -> Result<
Option<(Needle, Option<crate::storage::volume::NeedleStreamInfo>)>,
crate::storage::volume::VolumeError,
> {
let mut plan =
crate::storage::store::Store::needle_read_plan(&state.store, vid, needle_id, read_deleted)?;
let blank = || Needle {
id: needle_id,
cookie,
..Needle::default()
};
let mut n = blank();
if request.try_meta_first(plan.size()) {
plan.read_meta(&mut n)?;
if n.cookie != cookie {
// The meta read answered the request.
plan.note_meta_only_success();
return Ok(None);
}
if request.served_from_source(&n) {
if request.is_head {
// HEAD ends here; no payload read will record the success.
plan.note_meta_only_success();
}
let info = plan.into_stream_info(&n);
return Ok(Some((n, Some(info))));
}
// Compressed or a chunk manifest: the payload is needed after all.
n = blank();
}
plan.read_full(&mut n)?;
if n.cookie != cookie {
return Ok(None);
}
Ok(Some((n, None)))
}
/// A body that streams needle data from the dat file in chunks using pread,
/// avoiding loading the entire payload into memory at once.
struct StreamingBody {
/// Opened at plan time: it pins the inode the offset was resolved against,
/// so a vacuum commit mid-stream moves nothing under it.
source: Arc<crate::storage::volume::NeedleStreamSource>,
data_offset: u64,
data_size: u32,
pos: usize,
chunk_size: usize,
data_file_access_control: Arc<crate::storage::volume::DataFileAccessControl>,
hold_read_lock_for_stream: bool,
_held_read_lease: Option<crate::storage::volume::DataFileReadLease>,
io_unavailable: Arc<crate::storage::volume::IoUnavailable>,
/// Chunk read failures feed the volume's io-error streak, and finishing
/// the stream clears it — Go readNeedleDataInto parity.
io_errors: Arc<crate::storage::io_error::IoErrorTracker>,
/// Pending chunk read; it hands `buf` back with the result.
pending: Option<tokio::task::JoinHandle<(bytes::BytesMut, std::io::Result<bytes::Bytes>)>>,
/// Chunk buffer, reclaimed once the previous frame has been written out.
buf: bytes::BytesMut,
/// For download throttling — released on drop.
state: Option<Arc<VolumeServerState>>,
tracked_bytes: i64,
needle_id: crate::storage::types::NeedleId,
/// Checksum stored in the needle tail.
expected_checksum: u32,
/// Running checksum over the emitted data.
crc: CRC,
}
impl http_body::Body for StreamingBody {
type Data = bytes::Bytes;
type Error = std::io::Error;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
loop {
// If we have a pending read, poll it
if let Some(ref mut handle) = self.pending {
match std::pin::Pin::new(handle).poll(cx) {
std::task::Poll::Pending => return std::task::Poll::Pending,
std::task::Poll::Ready(result) => {
self.pending = None;
let result = result.map(|(buf, read)| {
self.buf = buf;
read
});
match result {
Ok(Ok(chunk)) => {
let len = chunk.len();
self.crc = self.crc.update(&chunk);
self.pos += len;
// The last frame is held back until the checksum verifies
// (Go parity: readNeedleDataInto). On a mismatch it is
// never emitted, so the response ends short of the declared
// Content-Length and the reader sees a failed transfer.
if self.pos >= self.data_size as usize {
let ok = self.expected_checksum == self.crc.0
|| self.expected_checksum == self.crc.legacy_value();
if !ok {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_CRC])
.inc();
return std::task::Poll::Ready(Some(Err(
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"needle data checksum {} expected {} for needle {}",
self.crc.0,
self.expected_checksum,
self.needle_id
),
),
)));
}
// Hyper may not poll again once Content-Length is
// satisfied, so the read's success is recorded here.
self.io_errors.check_read_write_error(None);
}
return std::task::Poll::Ready(Some(Ok(http_body::Frame::data(
chunk,
))));
}
Ok(Err(e)) => {
self.io_errors.check_read_write_error(Some(&e));
return std::task::Poll::Ready(Some(Err(e)));
}
Err(e) => {
return std::task::Poll::Ready(Some(Err(std::io::Error::other(e))));
}
}
}
}
}
let total = self.data_size as usize;
if self.pos >= total {
self.io_errors.check_read_write_error(None);
return std::task::Poll::Ready(None);
}
let chunk_len = std::cmp::min(self.chunk_size, total - self.pos);
let file_offset = self.data_offset + self.pos as u64;
let source = self.source.clone();
let data_file_access_control = self.data_file_access_control.clone();
let hold_read_lock_for_stream = self.hold_read_lock_for_stream;
let io_unavailable = self.io_unavailable.clone();
let mut buf = std::mem::take(&mut self.buf);
let handle = tokio::task::spawn_blocking(move || {
let _lease = if hold_read_lock_for_stream {
None
} else {
Some(data_file_access_control.read_lock())
};
// Under the lease: a writer marks the volume holding the write lock.
if let Some(e) = io_unavailable.error() {
return (buf, Err(std::io::Error::other(e)));
}
buf.resize(chunk_len, 0);
let read = source
.read_exact_at(&mut buf, file_offset)
.map(|()| buf.split().freeze());
(buf, read)
});
self.pending = Some(handle);
// Loop back to poll the newly created future
}
}
}
impl Drop for StreamingBody {
fn drop(&mut self) {
if let Some(ref st) = self.state {
let new_val = st
.inflight_download_bytes
.fetch_sub(self.tracked_bytes, Ordering::Relaxed)
- self.tracked_bytes;
metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val);
st.download_notify.notify_waiters();
}
}
}
// ============================================================================
// URL Parsing
// ============================================================================
/// The pieces a needle URL carries, as Go's `parseURLPath` splits them
/// (`weed/server/common.go:218-249`). Borrowed from the path so the callers
/// that only need one field do not allocate.
///
/// `fid` keeps any `_delta` suffix, exactly like Go: applying the delta is
/// `parse_needle_id_cookie`'s job (Go's `needle.ParsePath`), and the JWT check
/// strips it separately.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct NeedlePath<'a> {
pub vid: &'a str,
pub fid: &'a str,
/// Extension including the dot (`.jpg`), or "" when the URL has none.
pub ext: &'a str,
/// The trailing display name of the `vid/fid/filename.ext` form only.
pub filename: Option<&'a str>,
}
/// Split a needle URL path into volume id, file id, extension and filename.
///
/// Dispatches on the slash count like Go's `parseURLPath`, which is what
/// decides where the extension is taken from:
/// - `/vid/fid/filename.ext` (Go case 3): the filename carries the extension
/// and the fid is left intact.
/// - `/vid/fid.ext` (Go case 2): the extension is split off the fid.
/// - `/vid,fid.ext` (Go's default): the last path segment is split on its last
/// comma, then on its last dot.
///
/// The leading slash is optional, so chunk manifest entries ("3,01637037d6")
/// parse through the same function. Returns `None` for a path with no file id
/// at all — Go's `isVolumeIdOnly` case — since every caller here needs one.
pub(crate) fn parse_needle_path(path: &str) -> Option<NeedlePath<'_>> {
// Go counts one more slash than this because it keeps the leading one.
let trimmed = path.trim_start_matches('/');
match trimmed.matches('/').count() {
2 => {
let mut parts = trimmed.splitn(3, '/');
let vid = parts.next()?;
let fid = parts.next()?;
let filename = parts.next()?;
// Go uses filepath.Ext here, which has no "dot at index 0" guard.
let ext = filename.rfind('.').map_or("", |dot| &filename[dot..]);
Some(NeedlePath {
vid,
fid,
ext,
filename: Some(filename),
})
}
1 => {
let (vid, fid) = trimmed.split_once('/')?;
let (fid, ext) = split_extension(fid);
Some(NeedlePath {
vid,
fid,
ext,
filename: None,
})
}
_ => {
// Go looks only after the last slash, so a deeper path has no
// comma to find and falls out as invalid.
let segment = trimmed.rsplit_once('/').map_or(trimmed, |(_, last)| last);
let comma = segment.rfind(',')?;
let (fid, ext) = split_extension(&segment[comma + 1..]);
Some(NeedlePath {
vid: &segment[..comma],
fid,
ext,
filename: None,
})
}
}
}
/// Split a trailing `.ext` off a file id. Go guards this with `dotIndex > 0`,
/// so a file id that is nothing but an extension keeps it and fails to parse
/// later instead of becoming empty here.
fn split_extension(fid: &str) -> (&str, &str) {
match fid.rfind('.') {
Some(dot) if dot > 0 => (&fid[..dot], &fid[dot..]),
_ => (fid, ""),
}
}
/// Extract the file_id string (e.g., "3,01637037d6") from a URL path for JWT validation.
///
/// Go compares the token's `fid` claim against `vid + "," + fid` for every URL
/// form, after dropping an `_suffix` (`volume_server_handlers.go:361-364`), so
/// the comma form is emitted here even when the request used slashes.
fn extract_file_id(path: &str) -> String {
let Some(parsed) = parse_needle_path(path) else {
return path.trim_start_matches('/').to_string();
};
let fid = match parsed.fid.rfind('_') {
Some(sep) if sep > 0 => &parsed.fid[..sep],
_ => parsed.fid,
};
format!("{},{}", parsed.vid, fid)
}
fn streaming_chunk_size(read_buffer_size_bytes: usize, data_size: usize) -> usize {
std::cmp::min(
read_buffer_size_bytes.max(DEFAULT_STREAMING_CHUNK_SIZE),
data_size.max(1),
)
}
fn parse_url_path(path: &str) -> Option<(VolumeId, NeedleId, Cookie)> {
let parsed = parse_needle_path(path)?;
let vid = VolumeId::parse(parsed.vid).ok()?;
// parse_needle_id_cookie applies an `_delta` suffix itself (Go ParsePath).
let (needle_id, cookie) =
crate::storage::needle::needle::parse_needle_id_cookie(parsed.fid).ok()?;
Some((vid, needle_id, cookie))
}
// ============================================================================
// Volume Lookup + Proxy/Redirect
// ============================================================================
/// A volume location returned by master lookup.
#[derive(Clone, Debug, Deserialize)]
struct VolumeLocation {
url: String,
#[serde(rename = "readOnly", default)]
read_only: bool,
#[serde(rename = "readOnlyCanDelete", default)]
read_only_can_delete: bool,
// Master often omits publicUrl when it matches url (Go json omitempty).
#[serde(rename = "publicUrl", default)]
public_url: String,
#[serde(rename = "grpcPort", default)]
grpc_port: u32,
}
impl VolumeLocation {
fn public_or_url(&self) -> &str {
if self.public_url.is_empty() {
&self.url
} else {
&self.public_url
}
}
}
/// Master /dir/lookup response.
#[derive(Debug, Deserialize)]
struct LookupResult {
#[serde(default)]
locations: Option<Vec<VolumeLocation>>,
#[serde(default)]
error: Option<String>,
}
/// Look up volume locations from the master via HTTP /dir/lookup.
async fn lookup_volume(
client: &reqwest::Client,
scheme: &str,
master_url: &str,
volume_id: u32,
) -> Result<Vec<VolumeLocation>, String> {
// master_url may be in SeaweedFS's "host:httpPort.grpcPort" form (mirrors
// Go's pb.ServerAddress); strip the gRPC port before building the HTTP URL.
let master_http = to_http_address(master_url);
let url = normalize_outgoing_http_url(
scheme,
&format!("{}/dir/lookup?volumeId={}", master_http, volume_id),
)?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("lookup request failed: {}", e))?;
let status = resp.status();
let result: LookupResult = resp
.json()
.await
.map_err(|e| format!("lookup parse failed: {}", e))?;
if let Some(err) = result.error
&& !err.is_empty()
{
// Mirror Go: a missing volume arrives as a per-volume error in an
// answered lookup (HTTP 404), meaning "no locations" — a real master
// failure (e.g. 503 while warming up) stays an error so proxy mode
// can tell missing data (404) from a failed lookup (500).
if status != StatusCode::NOT_FOUND {
return Err(err);
}
tracing::debug!("volume {} lookup: {}", volume_id, err);
} else if !status.is_success() && status != StatusCode::NOT_FOUND {
// A non-success answer without an error body (e.g. an intermediary's
// 503 as `{}`) is a failed lookup, not a missing volume.
return Err(format!("lookup failed: {}", status));
}
Ok(result.locations.unwrap_or_default())
}
fn grpc_address_for_location(location: &VolumeLocation) -> Result<String, String> {
let raw = location
.url
.trim_start_matches("http://")
.trim_start_matches("https://");
if location.grpc_port > 0 {
let (host, _) = raw
.rsplit_once(':')
.ok_or_else(|| format!("cannot parse address: {}", location.url))?;
return Ok(format!("{}:{}", host, location.grpc_port));
}
if let Some(colon_idx) = raw.rfind(':') {
let port_part = &raw[colon_idx + 1..];
if let Some(dot_idx) = port_part.rfind('.') {
let host = &raw[..colon_idx];
let grpc_port = &port_part[dot_idx + 1..];
grpc_port
.parse::<u16>()
.map_err(|e| format!("invalid grpc port: {}", e))?;
return Ok(format!("{}:{}", host, grpc_port));
}
let port: u16 = port_part
.parse()
.map_err(|e| format!("invalid port: {}", e))?;
let host = &raw[..colon_idx];
return Ok(format!("{}:{}", host, port as u32 + 10000));
}
Err(format!("cannot parse address: {}", location.url))
}
async fn batch_delete_file_ids(
state: &VolumeServerState,
file_ids: &[String],
) -> Result<(), String> {
let mut lookup_cache: HashMap<u32, Vec<VolumeLocation>> = HashMap::new();
let mut server_to_file_ids: HashMap<String, Vec<String>> = HashMap::new();
for file_id in file_ids {
let parsed = crate::storage::needle::needle::FileId::parse(file_id)
.map_err(|e| format!("chunk delete {}: {}", file_id, e))?;
let volume_id = parsed.volume_id.0;
let locations = if let Some(locations) = lookup_cache.get(&volume_id) {
locations.clone()
} else {
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
volume_id,
)
.await
.map_err(|e| format!("chunk delete {}: {}", file_id, e))?;
if locations.is_empty() {
return Err(format!("chunk delete {}: file not found", file_id));
}
lookup_cache.insert(volume_id, locations.clone());
locations
};
for location in locations {
let grpc_addr = grpc_address_for_location(&location)
.map_err(|e| format!("chunk delete {}: {}", file_id, e))?;
server_to_file_ids
.entry(grpc_addr)
.or_default()
.push(file_id.clone());
}
}
for (grpc_addr, batch) in server_to_file_ids {
// BatchDelete is unary, but `stream()` is still right: this fan-out has
// no per-request deadline today, and `unary()` would add a 10 s one.
let channel = connect_channel(
&grpc_addr,
state.outgoing_grpc_tls.as_ref(),
GrpcDialOptions::stream(),
)
.await
.map_err(|e| format!("batch delete {}: {}", grpc_addr, e))?;
let mut client = volume_server_client(channel);
let response = client
.batch_delete(volume_server_pb::BatchDeleteRequest {
file_ids: batch.clone(),
skip_cookie_check: true,
})
.await
.map_err(|e| format!("batch delete {}: {}", grpc_addr, e))?
.into_inner();
for result in response.results {
if !result.error.is_empty() {
return Err(format!("chunk delete {}: {}", result.file_id, result.error));
}
if result.status >= 400 {
return Err(format!(
"chunk delete {}: status {}",
result.file_id, result.status
));
}
}
}
Ok(())
}
/// `query` with the fields named in `overrides` replaced by their values.
fn override_query_fields(query: &str, overrides: &[(&str, String)]) -> Result<String, String> {
let mut fields = Vec::new();
for field in query.split('&').filter(|field| !field.is_empty()) {
let decoded: Vec<(String, String)> = serde_urlencoded::from_str(field)
.map_err(|e| format!("parse replication query: {e}"))?;
if !decoded
.iter()
.any(|(name, _)| overrides.iter().any(|(o, _)| o == name))
{
fields.push(field);
}
}
let encoded = serde_urlencoded::to_string(overrides)
.map_err(|e| format!("encode replication query: {e}"))?;
if !encoded.is_empty() {
fields.push(&encoded);
}
Ok(fields.join("&"))
}
/// Bound on one replica forward, from connecting until its response arrives.
/// Without it a silently partitioned peer (packets dropped, no reset) holds the
/// write until TCP gives up, minutes later; with it the forward fails and the
/// writer gets "replication failed" (500). It bounds each forward after the
/// master lookup, not the whole write: the lookup has only the client's connect
/// bound. A forward with a body gets more time, see replica_request_timeout.
pub(crate) const REPLICA_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// The slowest link a replica forward's body may cross within its bound, so a
/// large write is not cut off on a link that is merely slower than a LAN.
const REPLICA_MIN_BYTES_PER_SEC: u64 = 1 << 20;
fn replica_request_timeout(body_len: usize) -> std::time::Duration {
REPLICA_REQUEST_TIMEOUT
+ std::time::Duration::from_millis(
(body_len as u64).saturating_mul(1000) / REPLICA_MIN_BYTES_PER_SEC,
)
}
fn replica_request(
client: &reqwest::Client,
method: axum::http::Method,
url: &str,
body_len: usize,
) -> reqwest::RequestBuilder {
client
.request(method, url)
.timeout(replica_request_timeout(body_len))
}
/// Helper to synchronously replicate a request to peer volume servers.
async fn do_replicated_request(
state: &VolumeServerState,
vid: u32,
method: axum::http::Method,
path: &str,
query: &str,
headers: &axum::http::HeaderMap,
body: Option<bytes::Bytes>,
) -> Result<(), String> {
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
vid,
)
.await
.map_err(|e| format!("lookup volume failed: {}", e))?;
let copy_count = {
let store = state.store.read().unwrap();
store.find_volume(VolumeId(vid)).map_or(1, |(_, v)| {
v.super_block().replica_placement.get_copy_count()
})
};
let allow_delete = method == axum::http::Method::DELETE;
let eligible_locations: Vec<_> = locations
.into_iter()
.filter(|loc| {
(!loc.read_only && allow_delete)
|| (!loc.read_only && !allow_delete)
|| (allow_delete && loc.read_only_can_delete)
})
.collect();
if eligible_locations.len() < copy_count as usize {
return Err(format!(
"replicating operations [{}] is less than volume {} replication copy count [{}]",
eligible_locations.len(),
vid,
copy_count
));
}
let self_http = to_http_address(&state.self_url);
let remote_locations: Vec<_> = eligible_locations
.into_iter()
.filter(|loc| {
if (!allow_delete && loc.read_only)
|| (allow_delete && loc.read_only && !loc.read_only_can_delete)
{
return false;
}
to_http_address(&loc.url) != self_http
&& to_http_address(loc.public_or_url()) != self_http
})
.collect();
if remote_locations.is_empty() {
return Ok(());
}
let new_query = override_query_fields(query, &[("type", "replicate".to_string())])?;
let mut futures = Vec::new();
for loc in remote_locations {
// Defensively strip a `.grpcPort` suffix in case loc.url carries it
// (e.g. from an older master); see lookup_volume for the same fix.
let peer_http = to_http_address(&loc.url);
let url = normalize_outgoing_http_url(
&state.outgoing_http_scheme,
&format!("{}{}?{}", peer_http, path, new_query),
)?;
let client = state.http_client.clone();
let body_len = body.as_ref().map_or(0, |b| b.len());
let mut req_builder = replica_request(&client, method.clone(), &url, body_len);
// Forward relevant headers
if let Some(ct) = headers.get(axum::http::header::CONTENT_TYPE) {
req_builder = req_builder.header(axum::http::header::CONTENT_TYPE, ct);
}
if let Some(ce) = headers.get(axum::http::header::CONTENT_ENCODING) {
req_builder = req_builder.header(axum::http::header::CONTENT_ENCODING, ce);
}
if let Some(md5) = headers.get("Content-MD5") {
req_builder = req_builder.header("Content-MD5", md5);
}
if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) {
req_builder = req_builder.header(axum::http::header::AUTHORIZATION, auth);
}
for (name, value) in headers
.iter()
.filter(|(name, _)| name.as_str().starts_with("seaweed-"))
{
req_builder = req_builder.header(name, value);
}
if let Some(ref b) = body {
req_builder = req_builder.body(b.clone());
}
futures.push(async move {
match req_builder.send().await {
Ok(r) if r.status().is_success() => Ok(()),
Ok(r) => {
crate::metrics::UPLOAD_ERROR_COUNTER
.with_label_values(&[&r.status().as_u16().to_string()])
.inc();
Err(format!("{} returned status {}", url, r.status()))
}
Err(e) => {
crate::metrics::UPLOAD_ERROR_COUNTER
.with_label_values(&["0"])
.inc();
Err(format!("{} failed: {}", url, e))
}
}
});
}
let results = futures::future::join_all(futures).await;
let mut errors = Vec::new();
for res in results {
if let Err(e) = res {
errors.push(e);
}
}
if !errors.is_empty() {
return Err(errors.join(", "));
}
Ok(())
}
/// Extracted request info needed for proxy/redirect (avoids borrowing Request across await).
struct ProxyRequestInfo {
method: Method,
original_headers: HeaderMap,
original_query: String,
path: String,
vid_str: String,
fid_str: String,
}
fn build_proxy_request_info(
path: &str,
headers: &HeaderMap,
query_string: &str,
method: &Method,
) -> Option<ProxyRequestInfo> {
// Go redirects to "vid,fid" built from parseURLPath, so the extension must
// already be off the fid here (volume_server_handlers_read.go:128-137).
let parsed = parse_needle_path(path)?;
Some(ProxyRequestInfo {
method: method.clone(),
original_headers: headers.clone(),
original_query: query_string.to_string(),
path: path.to_string(),
vid_str: parsed.vid.to_string(),
fid_str: parsed.fid.to_string(),
})
}
/// Handle proxy or redirect for a non-local volume read.
async fn proxy_or_redirect_to_target(
state: &VolumeServerState,
info: ProxyRequestInfo,
vid: VolumeId,
allow_local_redirect: bool,
) -> Response {
// Look up volume locations from master
let locations = match lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
vid.0,
)
.await
{
Ok(locs) => locs,
Err(e) => {
tracing::warn!("volume lookup failed for {}: {}", vid.0, e);
// Go: master lookup failure is 500 in proxy mode, 404 for redirect
return match state.read_mode {
ReadMode::Proxy => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
};
}
};
if locations.is_empty() {
return StatusCode::NOT_FOUND.into_response();
}
// Filter out self, then shuffle remaining
let mut candidates: Vec<&VolumeLocation> = locations
.iter()
.filter(|loc| !loc.url.contains(&state.self_url))
.collect();
if candidates.is_empty() {
return StatusCode::NOT_FOUND.into_response();
}
// Shuffle for load balancing
if candidates.len() >= 2 {
use rand::seq::SliceRandom;
let mut rng = rand::rng();
candidates.shuffle(&mut rng);
}
let target = candidates[0];
match state.read_mode {
ReadMode::Proxy => proxy_request(state, &info, target).await,
ReadMode::Redirect => redirect_request(&info, target, &state.outgoing_http_scheme),
ReadMode::Local if allow_local_redirect => {
redirect_request(&info, target, &state.outgoing_http_scheme)
}
ReadMode::Local => unreachable!(),
}
}
/// Hop-by-hop field names that must not be relayed: the fixed set plus every
/// field the header's own Connection values nominate (RFC 9110 7.6.1).
fn hop_by_hop_names(headers: &HeaderMap, extra: &[&str]) -> std::collections::HashSet<String> {
let mut names: std::collections::HashSet<String> = headers
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.map(|token| token.trim().to_ascii_lowercase())
.filter(|token| !token.is_empty())
.collect();
for fixed in [
"connection",
"keep-alive",
"te",
"trailer",
"upgrade",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
] {
names.insert(fixed.to_string());
}
for name in extra {
names.insert(name.to_string());
}
names
}
/// Proxy the request to the target volume server.
async fn proxy_request(
state: &VolumeServerState,
info: &ProxyRequestInfo,
target: &VolumeLocation,
) -> Response {
// Build target URL, adding proxied=true query param. Go's query.Set
// replaces any proxied params the client sent, so a stale proxied=false
// cannot outrank the marker this hop adds.
let path = info.path.trim_start_matches('/');
// Defensively strip a `.grpcPort` suffix in case target.url carries it.
let target_http = to_http_address(&target.url);
let forwarded_query = info
.original_query
.split('&')
.filter(|kv| kv.split('=').next() != Some("proxied"))
.collect::<Vec<_>>()
.join("&");
let raw_target = if forwarded_query.is_empty() {
format!("{}/{}?proxied=true", target_http, path)
} else {
format!("{}/{}?{}&proxied=true", target_http, path, forwarded_query)
};
let target_url = match normalize_outgoing_http_url(&state.outgoing_http_scheme, &raw_target) {
Ok(url) => url,
Err(e) => {
tracing::warn!("proxy target url {} invalid: {}", raw_target, e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
// Build the proxy request, preserving the inbound method (Go parity:
// a client HEAD must not turn into a GET download at the replica).
let mut req_builder = state.http_client.request(info.method.clone(), &target_url);
// Forward the original headers, as Go does — minus hop-by-hop fields.
// The proxied request carries no body, so this hop's Host and framing
// must not reach the replica either.
let hop_by_hop = hop_by_hop_names(
&info.original_headers,
&["host", "content-length", "transfer-encoding"],
);
for (name, value) in &info.original_headers {
if hop_by_hop.contains(name.as_str()) {
continue;
}
req_builder = req_builder.header(name.clone(), value.clone());
}
let resp = match req_builder.send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("proxy request to {} failed: {}", target_url, e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
// Build response, copying headers and body from remote. Hop-by-hop
// response fields describe the replica hop, not ours — Content-Length
// stays because it still describes the relayed body.
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let resp_hop_by_hop = hop_by_hop_names(resp.headers(), &[]);
let mut response_headers = HeaderMap::new();
for (name, value) in resp.headers() {
if name.as_str().eq_ignore_ascii_case("server") || resp_hop_by_hop.contains(name.as_str()) {
continue;
}
response_headers.append(name.clone(), value.clone());
}
// Stream the proxy response body instead of buffering it entirely
let byte_stream = resp.bytes_stream();
let body = Body::from_stream(byte_stream);
let mut response = Response::new(body);
*response.status_mut() = status;
*response.headers_mut() = response_headers;
response
}
/// Return a redirect response to the target volume server.
fn redirect_request(info: &ProxyRequestInfo, target: &VolumeLocation, scheme: &str) -> Response {
// Build query string: preserve collection, add proxied=true, drop readDeleted (Go parity)
let mut query_params = Vec::new();
if !info.original_query.is_empty() {
for param in info.original_query.split('&') {
if let Some((key, value)) = param.split_once('=')
&& key == "collection"
{
query_params.push(format!("collection={}", value));
}
// Intentionally drop readDeleted and other params (Go parity)
}
}
query_params.push("proxied=true".to_string());
let query = query_params.join("&");
// Defensively strip a `.grpcPort` suffix in case target.url carries it.
let target_http = to_http_address(&target.url);
let raw_target = format!(
"{}/{},{}?{}",
target_http, info.vid_str, info.fid_str, query
);
let location = match normalize_outgoing_http_url(scheme, &raw_target) {
Ok(url) => url,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header("Location", &location)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(format!(
"<a href=\"{}\">Moved Permanently</a>.\n\n",
location
)))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
// ============================================================================
// Query parameters
// ============================================================================
#[derive(Deserialize, Default)]
pub struct ReadQueryParams {
#[serde(rename = "response-content-type")]
pub response_content_type: Option<String>,
#[serde(rename = "response-cache-control")]
pub response_cache_control: Option<String>,
pub dl: Option<String>,
#[serde(rename = "readDeleted")]
pub read_deleted: Option<String>,
/// cm=false disables chunk manifest expansion (returns raw manifest JSON).
pub cm: Option<String>,
/// Image resize width
pub width: Option<u32>,
/// Image resize height
pub height: Option<u32>,
/// Image resize mode: "fit" or "fill"
pub mode: Option<String>,
/// Image crop parameters
pub crop_x1: Option<u32>,
pub crop_y1: Option<u32>,
pub crop_x2: Option<u32>,
pub crop_y2: Option<u32>,
/// S3 response passthrough headers
#[serde(rename = "response-content-encoding")]
pub response_content_encoding: Option<String>,
#[serde(rename = "response-expires")]
pub response_expires: Option<String>,
#[serde(rename = "response-content-language")]
pub response_content_language: Option<String>,
#[serde(rename = "response-content-disposition")]
pub response_content_disposition: Option<String>,
/// Pretty print JSON response
pub pretty: Option<String>,
}
// ============================================================================
// Read Handler (GET/HEAD)
// ============================================================================
/// Called from the method-dispatching store handler with a full Request.
pub async fn get_or_head_handler_from_request(
State(state): State<Arc<VolumeServerState>>,
request: Request<Body>,
) -> Response {
let uri = request.uri().clone();
let headers = request.headers().clone();
// Parse query params manually from URI
let query_params: ReadQueryParams = uri
.query()
.and_then(|q| serde_urlencoded::from_str(q).ok())
.unwrap_or_default();
get_or_head_handler_inner(state, headers, query_params, request).await
}
pub async fn get_or_head_handler(
State(state): State<Arc<VolumeServerState>>,
headers: HeaderMap,
query: Query<ReadQueryParams>,
request: Request<Body>,
) -> Response {
get_or_head_handler_inner(state, headers, query.0, request).await
}
async fn get_or_head_handler_inner(
state: Arc<VolumeServerState>,
headers: HeaderMap,
query: ReadQueryParams,
request: Request<Body>,
) -> Response {
let path = request.uri().path().to_string();
let method = request.method().clone();
if let Some(resp) = reject_read_jwt(&state, &headers, request.uri(), &path) {
return resp;
}
let (vid, needle_id, cookie) = match parse_url_path(&path) {
Some(parsed) => parsed,
None => return StatusCode::BAD_REQUEST.into_response(),
};
// The RwLockReadGuard must drop before any .await to keep the future Send.
let has_volume = state.store.read().unwrap().has_volume(vid);
let has_ec_volume = state.store.read().unwrap().has_ec_volume(vid);
if !has_volume && !has_ec_volume {
return proxy_missing_volume(
&state,
request.uri(),
request.headers(),
&path,
vid,
&method,
)
.await;
}
let track_download = match check_download_limit(
&state,
request.uri(),
request.headers(),
&path,
vid,
&method,
)
.await
{
ControlFlow::Continue(track_download) => track_download,
ControlFlow::Break(resp) => return resp,
};
let read_deleted = query.read_deleted.as_deref() == Some("true");
// Go reads Range once and ignores an empty value; a non-UTF-8 byte fails its parse.
let range = headers
.get(header::RANGE)
.map(|v| String::from_utf8_lossy(v.as_bytes()))
.filter(|r| !r.is_empty());
let (ext, request_kind) = parse_read_request(&path, range.is_some(), &query, &method);
// EC volumes always do a full read (no streaming/meta-only).
let plan = if has_ec_volume && !has_volume {
read_ec_shard_needle(&state, vid, needle_id, cookie).await
} else {
read_volume_needle(&state, vid, needle_id, cookie, read_deleted, request_kind).await
};
let ReadPlan {
needle: n,
strategy,
} = match plan {
ControlFlow::Continue(plan) => plan,
ControlFlow::Break(resp) => return resp,
};
let (etag, last_modified_str) = etag_and_last_modified(&n);
// A Range whose If-Range no longer matches is ignored and the whole
// content is sent, so a resumed download restarts instead of mixing
// two versions.
let range = range.filter(|_| if_range_matches(&headers, &etag, &last_modified_str));
if let Some(resp) = not_modified_response(&n, &headers, &etag, &last_modified_str) {
// A request that ends here was answered by the meta read alone.
if let Some(io_errors) = strategy.io_errors() {
io_errors.check_read_write_error(None);
}
return resp;
}
// Chunk manifest expansion needs the full data.
if n.is_chunk_manifest()
&& !request_kind.bypass_cm
&& let Some(expanded) = try_expand_chunk_manifest(
&state,
&n,
&path,
&query,
range.as_deref().filter(|_| method == Method::GET),
&method,
)
.await
{
// HEAD, and a Range the manifest path did not answer, apply to the assembled object.
return match expanded {
ControlFlow::Continue((data, response_headers)) => buffered_response(
&state,
range.as_deref(),
&method,
data,
response_headers,
track_download,
),
ControlFlow::Break(resp) => resp,
};
}
// If manifest expansion fails (invalid JSON etc.), fall through to raw data
let (mut response_headers, ext) =
read_response_headers(&n, &path, &query, &etag, &last_modified_str, ext);
match strategy {
ReadStrategy::Stream(info) => {
return stream_response(&state, info, response_headers, track_download);
}
ReadStrategy::HeadFromMeta(info) => {
return head_from_meta_response(&info, response_headers);
}
ReadStrategy::RangeFromSource(info) => {
return match range.as_deref() {
Some(range) => {
range_from_source_response(
&state,
range,
info,
response_headers,
track_download,
)
.await
}
// If-Range dropped the range; serve the whole content.
None => stream_response(&state, info, response_headers, track_download),
};
}
ReadStrategy::Buffered => {}
}
let data = match buffered_payload(
n,
&headers,
&query,
&ext,
request_kind.has_image_ops,
&mut response_headers,
) {
ControlFlow::Continue(data) => data,
ControlFlow::Break(resp) => return resp,
};
buffered_response(
&state,
range.as_deref(),
&method,
data,
response_headers,
track_download,
)
}
/// How a GET/HEAD reply is produced once the needle is read.
enum ReadStrategy {
/// Large, served as stored, no range: streamed from the data file.
Stream(crate::storage::volume::NeedleStreamInfo),
/// HEAD: answered from the needle meta.
HeadFromMeta(crate::storage::volume::NeedleStreamInfo),
/// A range over a payload served as stored: read from the data file.
RangeFromSource(crate::storage::volume::NeedleStreamInfo),
/// Everything else, from the needle data in memory.
Buffered,
}
/// The needle a GET/HEAD resolved to, and how its reply is produced.
struct ReadPlan {
needle: Needle,
strategy: ReadStrategy,
}
impl ReadStrategy {
/// The tracker a terminal meta read reports success to, when the reply
/// is served from the data file.
fn io_errors(&self) -> Option<&Arc<crate::storage::io_error::IoErrorTracker>> {
match self {
ReadStrategy::Stream(info)
| ReadStrategy::HeadFromMeta(info)
| ReadStrategy::RangeFromSource(info) => Some(&info.io_errors),
ReadStrategy::Buffered => None,
}
}
}
/// The 401 reply for a bad read JWT; Go checks it before NewVolumeId, so a
/// bad path with JWT enabled is a 401, not a 400.
fn reject_read_jwt(
state: &VolumeServerState,
headers: &HeaderMap,
uri: &axum::http::Uri,
path: &str,
) -> Option<Response> {
let file_id = extract_file_id(path);
let token = extract_jwt(headers, uri);
if state
.guard
.read()
.unwrap()
.check_jwt_for_file(token.as_deref(), &file_id, false)
.is_err()
{
let body = serde_json::json!({"error": "wrong jwt"});
return Some(
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
);
}
None
}
/// The volume is not here: 404, or proxy/redirect to a holder per read_mode.
async fn proxy_missing_volume(
state: &Arc<VolumeServerState>,
uri: &axum::http::Uri,
request_headers: &HeaderMap,
path: &str,
vid: VolumeId,
method: &Method,
) -> Response {
let query_string = uri.query().unwrap_or("").to_string();
if is_proxied_query(&query_string)
|| state.read_mode == ReadMode::Local
|| state.current_master().await.is_empty()
{
return StatusCode::NOT_FOUND.into_response();
}
// For redirect, fid must be stripped of extension (Go parity: parseURLPath returns raw fid).
let info = match build_proxy_request_info(path, request_headers, &query_string, method) {
Some(info) => info,
None => return StatusCode::NOT_FOUND.into_response(),
};
proxy_or_redirect_to_target(state, info, vid, false).await
}
/// Go's `proxied` query param check (loop prevention).
fn is_proxied_query(query_string: &str) -> bool {
query_string.split('&').any(|kv| kv == "proxied=true")
}
/// Download throttling — Go's checkDownloadLimit + waitForDownloadSlot.
/// `Continue(true)` when the reply must count toward the inflight download bytes.
async fn check_download_limit(
state: &Arc<VolumeServerState>,
uri: &axum::http::Uri,
request_headers: &HeaderMap,
path: &str,
vid: VolumeId,
method: &Method,
) -> ControlFlow<Response, bool> {
if state.concurrent_download_limit <= 0 {
return ControlFlow::Continue(false);
}
let timeout = state.inflight_download_data_timeout;
let deadline = tokio::time::Instant::now() + timeout;
let query_string = uri.query().unwrap_or("").to_string();
let current = state.inflight_download_bytes.load(Ordering::Relaxed);
if current > state.concurrent_download_limit {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::DOWNLOAD_LIMIT_COND])
.inc();
// Go tries proxy to replica once before the wait loop
// (checkDownloadLimit); it does not retry on each wakeup.
let should_try_replica =
!is_proxied_query(&query_string) && !state.current_master().await.is_empty() && {
let store = state.store.read().unwrap();
store
.find_volume(vid)
.is_some_and(|(_, vol)| vol.super_block().replica_placement.has_replication())
};
if should_try_replica
&& let Some(info) =
build_proxy_request_info(path, request_headers, &query_string, method)
{
return ControlFlow::Break(proxy_or_redirect_to_target(state, info, vid, true).await);
}
// Blocking wait loop (Go's waitForDownloadSlot)
loop {
if tokio::time::timeout_at(deadline, state.download_notify.notified())
.await
.is_err()
{
return ControlFlow::Break(json_error_with_query(
StatusCode::TOO_MANY_REQUESTS,
"download limit exceeded",
uri.query(),
));
}
let current = state.inflight_download_bytes.load(Ordering::Relaxed);
if current <= state.concurrent_download_limit {
break;
}
}
}
ControlFlow::Continue(true)
}
/// The URL extension, and how the reply may be served.
fn parse_read_request(
path: &str,
has_range: bool,
query: &ReadQueryParams,
method: &Method,
) -> (String, SourceReadRequest) {
let ext = extract_extension_from_path(path);
// Go checks resize and crop extensions separately: resize supports .webp, crop does not.
let request_kind = SourceReadRequest {
is_head: method == Method::HEAD,
has_range,
has_image_ops: has_image_ops(&ext, query),
may_have_image_ops: ext.is_empty() && has_image_ops(".jpg", query),
bypass_cm: query.cm.as_deref() == Some("false"),
};
(ext, request_kind)
}
/// Full read from an EC volume; there is no streaming for EC.
async fn read_ec_shard_needle(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
cookie: Cookie,
) -> ControlFlow<Response, ReadPlan> {
// The distributed read does a local-first pass in its Snapshot phase, so
// calling it directly covers both the all-local fast case and the
// peer-fetch + reconstruct case without reading local intervals twice.
let n = match crate::server::store_ec::read_ec_shard_needle_distributed(state, vid, needle_id)
.await
{
Ok(Some(ec_needle)) => ec_needle,
Ok(None) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_NOT_FOUND])
.inc();
return ControlFlow::Break(StatusCode::NOT_FOUND.into_response());
}
Err(e) => {
let kind = if e.kind() == std::io::ErrorKind::NotFound {
metrics::ERROR_GET_NOT_FOUND
} else {
metrics::ERROR_GET_INTERNAL
};
metrics::HANDLER_COUNTER.with_label_values(&[kind]).inc();
if e.kind() == std::io::ErrorKind::NotFound {
return ControlFlow::Break(StatusCode::NOT_FOUND.into_response());
}
return ControlFlow::Break(
(StatusCode::INTERNAL_SERVER_ERROR, format!("ec read: {}", e)).into_response(),
);
}
};
// Validate cookie (matches Go behavior after ReadEcShardNeedle)
if n.cookie != cookie {
return ControlFlow::Break(StatusCode::NOT_FOUND.into_response());
}
ControlFlow::Continue(ReadPlan {
needle: n,
strategy: ReadStrategy::Buffered,
})
}
/// Reads a regular volume's needle off the store lock, meta-only when the
/// reply can be served from the data file.
async fn read_volume_needle(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
cookie: Cookie,
read_deleted: bool,
request_kind: SourceReadRequest,
) -> ControlFlow<Response, ReadPlan> {
let has_range = request_kind.has_range;
let is_head = request_kind.is_head;
let read_state = state.clone();
let read = tokio::task::spawn_blocking(move || {
read_needle_for_get(
&read_state,
vid,
needle_id,
cookie,
read_deleted,
request_kind,
)
})
.await;
let (n, stream_info) = match read {
Ok(Ok(Some(found))) => found,
// Cookie mismatch
Ok(Ok(None)) => return ControlFlow::Break(StatusCode::NOT_FOUND.into_response()),
Ok(Err(
crate::storage::volume::VolumeError::NotFound
| crate::storage::volume::VolumeError::Deleted,
)) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_NOT_FOUND])
.inc();
return ControlFlow::Break(StatusCode::NOT_FOUND.into_response());
}
Ok(Err(e)) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_INTERNAL])
.inc();
return ControlFlow::Break(
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("read error: {}", e),
)
.into_response(),
);
}
Err(e) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_INTERNAL])
.inc();
return ControlFlow::Break(
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("read error: {}", e),
)
.into_response(),
);
}
};
// Stream info is only returned for a reply served from the data file.
let can_direct_source_read = stream_info.is_some() && request_kind.direct(&n);
let can_stream =
can_direct_source_read && n.data_size > STREAMING_THRESHOLD && !has_range && !is_head;
// HEAD is meta-only when served_from_source loaded no payload. A chunk
// manifest without cm=false is read in full so expansion can run.
let can_handle_head_from_meta = stream_info.is_some() && is_head;
let can_handle_range_from_source = can_direct_source_read && has_range;
let strategy = match stream_info {
Some(info) if can_stream => ReadStrategy::Stream(info),
Some(info) if can_handle_head_from_meta => ReadStrategy::HeadFromMeta(info),
Some(info) if can_handle_range_from_source => ReadStrategy::RangeFromSource(info),
_ => ReadStrategy::Buffered,
};
ControlFlow::Continue(ReadPlan {
needle: n,
strategy,
})
}
/// The ETag, and Last-Modified in RFC 1123 format.
fn etag_and_last_modified(n: &Needle) -> (String, Option<String>) {
let etag = format!("\"{}\"", n.etag());
let last_modified_str = if n.last_modified > 0 {
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(n.last_modified as i64, 0)
.single()
.map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
} else {
None
};
(etag, last_modified_str)
}
/// The 304 reply, if a conditional header matches.
fn not_modified_response(
n: &Needle,
headers: &HeaderMap,
etag: &str,
last_modified_str: &Option<String>,
) -> Option<Response> {
let not_modified = || {
let mut resp = StatusCode::NOT_MODIFIED.into_response();
resp.headers_mut()
.insert(header::ETAG, etag.parse().unwrap());
if let Some(lm) = last_modified_str {
resp.headers_mut()
.insert(header::LAST_MODIFIED, lm.parse().unwrap());
}
resp
};
// If-Modified-Since applies only when there is no If-None-Match (RFC 9110 13.2.2).
if headers.contains_key(header::IF_NONE_MATCH) {
let inm = headers
.get_all(header::IF_NONE_MATCH)
.iter()
.map(|v| v.to_str().unwrap_or(""))
.collect::<Vec<_>>()
.join(",");
return etag_list_matches(&inm, etag).then(not_modified);
}
if n.last_modified > 0
&& let Some(ims_str) = headers
.get(header::IF_MODIFIED_SINCE)
.and_then(|v| v.to_str().ok())
{
// Parse HTTP date format: "Mon, 02 Jan 2006 15:04:05 GMT"
if let Ok(ims_time) =
chrono::NaiveDateTime::parse_from_str(ims_str, "%a, %d %b %Y %H:%M:%S GMT")
&& (n.last_modified as i64) <= ims_time.and_utc().timestamp()
{
return Some(not_modified());
}
}
None
}
/// If-Range (RFC 9110 13.1.5): an entity-tag by strong comparison, or a date
/// equal to Last-Modified. The response etag here is always a strong tag.
fn if_range_matches(headers: &HeaderMap, etag: &str, last_modified_str: &Option<String>) -> bool {
let Some(raw) = headers.get(header::IF_RANGE) else {
return true;
};
let Ok(if_range) = raw.to_str() else {
return false;
};
let if_range = if_range.trim();
if if_range.is_empty() {
return true;
}
if if_range.starts_with('"') || if_range.starts_with("W/") {
return if_range == etag;
}
let Ok(date) = httpdate::parse_http_date(if_range) else {
return false;
};
last_modified_str
.as_deref()
.and_then(|lm| httpdate::parse_http_date(lm).ok())
.is_some_and(|lm| date == lm)
}
/// Weak comparison against "*" or a comma-separated entity-tag list (RFC 9110 13.1.2).
fn etag_list_matches(header: &str, etag: &str) -> bool {
if header.trim() == "*" {
return true;
}
let etag = etag.trim_matches('"');
split_etag_list(header).into_iter().any(|tag| {
let tag = tag.trim();
let tag = tag.strip_prefix("W/").unwrap_or(tag);
// Empty list elements are ignored, so padding like a trailing
// comma cannot match an empty opaque tag.
!tag.is_empty() && tag.trim_matches('"') == etag
})
}
/// Splits an entity-tag list on the commas between tags; a comma inside a
/// quoted opaque tag does not split.
fn split_etag_list(header: &str) -> Vec<&str> {
let mut tags = Vec::new();
let (mut start, mut quoted) = (0, false);
for (i, b) in header.bytes().enumerate() {
match b {
b'"' => quoted = !quoted,
b',' if !quoted => {
tags.push(&header[start..i]);
start = i + 1;
}
_ => {}
}
}
tags.push(&header[start..]);
tags
}
/// The 200 reply headers, and the extension, which falls back to the stored name's.
fn read_response_headers(
n: &Needle,
path: &str,
query: &ReadQueryParams,
etag: &str,
last_modified_str: &Option<String>,
ext: String,
) -> (HeaderMap, String) {
let mut response_headers = HeaderMap::new();
response_headers.insert(header::ETAG, etag.parse().unwrap());
// H1: Emit pairs as response headers
if n.has_pairs()
&& !n.pairs.is_empty()
&& let Ok(pair_map) =
serde_json::from_slice::<std::collections::HashMap<String, String>>(&n.pairs)
{
for (k, v) in &pair_map {
if let (Ok(hname), Ok(hval)) = (
axum::http::HeaderName::from_bytes(k.as_bytes()),
axum::http::HeaderValue::from_str(v),
) {
response_headers.insert(hname, hval);
}
}
}
// H8: Use needle stored name when URL path has no filename (only vid,fid)
let mut filename = extract_filename_from_path(path);
let mut ext = ext;
if n.name_size > 0 && filename.is_empty() {
filename = String::from_utf8_lossy(&n.name).to_string();
if ext.is_empty()
&& let Some(dot_pos) = filename.rfind('.')
{
ext = filename[dot_pos..].to_lowercase();
}
}
// H6: Determine Content-Type: filter application/octet-stream, use mime_guess
// For chunk manifests, skip extension-based MIME override — use stored MIME as-is (Go parity)
let content_type = if n.is_chunk_manifest() {
// Chunk manifests: use stored MIME but filter application/octet-stream (Go L334)
if !n.mime.is_empty() {
let mt = String::from_utf8_lossy(&n.mime).to_string();
if mt.starts_with("application/octet-stream") {
None
} else {
Some(mt)
}
} else {
None
}
} else {
// Get MIME from needle, but filter out application/octet-stream
let needle_mime = if !n.mime.is_empty() {
let mt = String::from_utf8_lossy(&n.mime).to_string();
if mt.starts_with("application/octet-stream") {
String::new()
} else {
mt
}
} else {
String::new()
};
if !needle_mime.is_empty() {
Some(needle_mime)
} else {
// Fall through to extension-based detection
let detect_ext = if !ext.is_empty() {
ext.clone()
} else if !filename.is_empty() {
if let Some(dot_pos) = filename.rfind('.') {
filename[dot_pos..].to_lowercase()
} else {
String::new()
}
} else {
String::new()
};
if !detect_ext.is_empty() {
mime_guess::from_ext(detect_ext.trim_start_matches('.'))
.first()
.map(|m| m.to_string())
} else {
None // Omit Content-Type entirely
}
}
};
// Every value below can come straight from the query string, so none of
// them may be unwrapped: `?response-cache-control=%0Aevil` decodes to a
// value with a newline, `HeaderValue::from_str` rejects it, and the unwrap
// would panic the connection task. An invalid `response-content-type`
// falls back to the needle MIME rather than dropping Content-Type.
if let Some(hval) = query
.response_content_type
.as_ref()
.and_then(|ct| ct.parse::<header::HeaderValue>().ok())
{
response_headers.insert(header::CONTENT_TYPE, hval);
} else if let Some(ref ct) = content_type
&& let Ok(hval) = ct.parse()
{
response_headers.insert(header::CONTENT_TYPE, hval);
}
// Cache-Control override from query param
if let Some(ref cc) = query.response_cache_control
&& let Ok(hval) = cc.parse()
{
response_headers.insert(header::CACHE_CONTROL, hval);
}
// S3 response passthrough headers
if let Some(ref ce) = query.response_content_encoding
&& let Ok(hval) = ce.parse()
{
response_headers.insert(header::CONTENT_ENCODING, hval);
}
if let Some(ref exp) = query.response_expires
&& let Ok(hval) = exp.parse()
{
response_headers.insert(header::EXPIRES, hval);
}
if let Some(ref cl) = query.response_content_language
&& let Ok(hval) = cl.parse()
{
response_headers.insert("Content-Language", hval);
}
if let Some(ref cd) = query.response_content_disposition
&& let Ok(hval) = cd.parse()
{
response_headers.insert(header::CONTENT_DISPOSITION, hval);
}
// Last-Modified
if let Some(lm) = last_modified_str {
response_headers.insert(header::LAST_MODIFIED, lm.parse().unwrap());
}
// H7: Content-Disposition — inline by default, attachment only when dl is truthy
// Only set if not already set by response-content-disposition query param
if !response_headers.contains_key(header::CONTENT_DISPOSITION) && !filename.is_empty() {
let disposition_type = if let Some(ref dl_val) = query.dl {
if parse_go_bool(dl_val).unwrap_or(false) {
"attachment"
} else {
"inline"
}
} else {
"inline"
};
let disposition = format_content_disposition(disposition_type, &filename);
if let Ok(hval) = disposition.parse() {
response_headers.insert(header::CONTENT_DISPOSITION, hval);
}
}
(response_headers, ext)
}
/// Streaming path: large uncompressed files.
fn stream_response(
state: &Arc<VolumeServerState>,
info: crate::storage::volume::NeedleStreamInfo,
mut response_headers: HeaderMap,
track_download: bool,
) -> Response {
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
response_headers.insert(
header::CONTENT_LENGTH,
info.data_size.to_string().parse().unwrap(),
);
let tracked_bytes = info.data_size as i64;
let tracking_state = if track_download {
let new_val = state
.inflight_download_bytes
.fetch_add(tracked_bytes, Ordering::Relaxed)
+ tracked_bytes;
metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val);
Some(state.clone())
} else {
None
};
let streaming = StreamingBody {
source: Arc::new(info.source),
data_offset: info.data_file_offset,
data_size: info.data_size,
pos: 0,
chunk_size: streaming_chunk_size(state.read_buffer_size_bytes, info.data_size as usize),
_held_read_lease: if state.has_slow_read {
None
} else {
Some(info.data_file_access_control.read_lock())
},
data_file_access_control: info.data_file_access_control,
hold_read_lock_for_stream: !state.has_slow_read,
io_unavailable: info.io_unavailable,
io_errors: info.io_errors,
pending: None,
buf: bytes::BytesMut::new(),
state: tracking_state,
tracked_bytes,
needle_id: info.needle_id,
expected_checksum: info.checksum,
crc: CRC(0),
};
let body = Body::new(streaming);
let mut resp = Response::new(body);
*resp.status_mut() = StatusCode::OK;
*resp.headers_mut() = response_headers;
resp
}
/// HEAD from the needle meta: Content-Length is the stored data size.
fn head_from_meta_response(
info: &crate::storage::volume::NeedleStreamInfo,
mut response_headers: HeaderMap,
) -> Response {
response_headers.insert(
header::CONTENT_LENGTH,
info.data_size.to_string().parse().unwrap(),
);
(StatusCode::OK, response_headers).into_response()
}
/// A range over a payload served as stored, read from the data file.
async fn range_from_source_response(
state: &Arc<VolumeServerState>,
range_str: &str,
info: crate::storage::volume::NeedleStreamInfo,
response_headers: HeaderMap,
track_download: bool,
) -> Response {
let range_str = range_str.to_string();
let tracking = track_download.then(|| state.clone());
let chunk_size = streaming_chunk_size(state.read_buffer_size_bytes, info.data_size as usize);
tokio::task::spawn_blocking(move || {
handle_range_request_from_source(&range_str, info, response_headers, tracking, chunk_size)
})
.await
.unwrap_or_else(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("range read error: {}", e),
)
.into_response()
})
}
/// The needle data, decompressed and image-processed as the request needs.
fn buffered_payload(
n: Needle,
headers: &HeaderMap,
query: &ReadQueryParams,
ext: &str,
needs_image_ops: bool,
response_headers: &mut HeaderMap,
) -> ControlFlow<Response, Vec<u8>> {
// Handle compressed data: if needle is compressed, either pass through or decompress
let is_compressed = n.is_compressed();
let mut data = n.data;
// Image operations must decompress first regardless of Accept-Encoding.
if is_compressed {
if needs_image_ops {
// Always decompress for image operations (Go decompresses before resize/crop)
match maybe_decompress_gzip(&data) {
Ok(decompressed) => data = decompressed,
Err(GunzipError::TooLarge) => {
return ControlFlow::Break(
(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
)
.into_response(),
);
}
Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes
}
} else {
let accept_encoding = headers
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept_encoding.contains("gzip")
&& data.len() >= 2
&& data[0] == 0x1f
&& data[1] == 0x8b
{
// Go checks IsGzippedContent (magic bytes 0x1f 0x8b) before
// setting Content-Encoding: gzip
response_headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
} else {
// Decompress for client
match maybe_decompress_gzip(&data) {
Ok(decompressed) => data = decompressed,
Err(GunzipError::TooLarge) => {
return ControlFlow::Break(
(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
)
.into_response(),
);
}
Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes
}
}
}
}
// Image crop and resize — Go checks extensions separately per operation.
// Crop: .png .jpg .jpeg .gif (no .webp). Resize: .png .jpg .jpeg .gif .webp.
if is_image_crop_ext(ext) {
data = maybe_crop_image(&data, ext, query);
}
if is_image_resize_ext(ext) {
data = maybe_resize_image(&data, ext, query);
}
ControlFlow::Continue(data)
}
/// The buffered reply over the payload, whole or a range.
fn buffered_response(
state: &Arc<VolumeServerState>,
range: Option<&str>,
method: &Method,
data: Vec<u8>,
mut response_headers: HeaderMap,
track_download: bool,
) -> Response {
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
// HEAD before Range (Go's writeResponseContent order).
if method == Method::HEAD {
response_headers.insert(
header::CONTENT_LENGTH,
data.len().to_string().parse().unwrap(),
);
return (StatusCode::OK, response_headers).into_response();
}
if let Some(range) = range {
return handle_range_request(
range,
&data,
response_headers,
track_download.then(|| state.clone()),
);
}
finalize_bytes_response(
StatusCode::OK,
response_headers,
data,
track_download.then(|| state.clone()),
)
}
/// Handle HTTP Range requests. Returns 206 Partial Content or 416 Range Not Satisfiable.
#[derive(Clone, Copy, Debug)]
struct HttpRange {
start: i64,
length: i64,
}
// Returned when the first-byte-pos of every byte-range-spec is at or past the content size.
const RANGE_NO_OVERLAP: &str = "invalid range: failed to overlap";
fn parse_range_header(s: &str, size: i64) -> Result<Vec<HttpRange>, &'static str> {
if s.is_empty() {
return Ok(Vec::new());
}
const PREFIX: &str = "bytes=";
if !s.starts_with(PREFIX) {
return Err("invalid range");
}
let mut ranges = Vec::new();
let mut no_overlap = false;
for part in s[PREFIX.len()..].split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let Some(pos) = part.find('-') else {
return Err("invalid range");
};
let start_str = part[..pos].trim();
let end_str = part[pos + 1..].trim();
let mut r = HttpRange {
start: 0,
length: 0,
};
if start_str.is_empty() {
// suffix-length must be a non-negative integer (RFC 7233 Section 2.1)
if end_str.is_empty() || end_str.starts_with('-') {
return Err("invalid range");
}
let mut i = end_str.parse::<i64>().map_err(|_| "invalid range")?;
if i < 0 {
return Err("invalid range");
}
if i > size {
i = size;
}
r.start = size - i;
if r.start >= size {
// a zero-length suffix selects no bytes, so it does not overlap
no_overlap = true;
continue;
}
r.length = size - r.start;
} else {
let i = start_str.parse::<i64>().map_err(|_| "invalid range")?;
if i < 0 {
return Err("invalid range");
}
if i >= size {
no_overlap = true;
continue;
}
r.start = i;
if end_str.is_empty() {
r.length = size - r.start;
} else {
let mut i = end_str.parse::<i64>().map_err(|_| "invalid range")?;
if r.start > i {
return Err("invalid range");
}
if i >= size {
i = size - 1;
}
r.length = i - r.start + 1;
}
}
ranges.push(r);
}
if no_overlap && ranges.is_empty() {
return Err(RANGE_NO_OVERLAP);
}
Ok(ranges)
}
fn sum_ranges_size(ranges: &[HttpRange]) -> i64 {
ranges.iter().fold(0i64, |acc, r| {
if r.length > 0 {
acc.saturating_add(r.length)
} else {
acc
}
})
}
fn range_content_range(r: HttpRange, total: i64) -> String {
format!("bytes {}-{}/{}", r.start, r.start + r.length - 1, total)
}
fn range_error_response(mut headers: HeaderMap, msg: &str) -> Response {
// net/http.Error resets Content-Type and sets nosniff even when a
// caller already set the object's MIME type.
headers.insert(
header::CONTENT_TYPE,
"text/plain; charset=utf-8".parse().unwrap(),
);
headers.insert(header::X_CONTENT_TYPE_OPTIONS, "nosniff".parse().unwrap());
let mut response = Response::new(Body::from(msg.to_string()));
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
*response.headers_mut() = headers;
response
}
fn handle_range_request(
range_str: &str,
data: &[u8],
headers: HeaderMap,
state: Option<Arc<VolumeServerState>>,
) -> Response {
handle_range_request_with(
range_str,
data.len() as i64,
|buf, start, end| buf.extend_from_slice(&data[start..end]),
headers,
state,
)
}
/// Range reply over an object of `total` bytes; `read` appends `[start, end)`
/// of it and is called only for the ranges actually served.
fn handle_range_request_with(
range_str: &str,
total: i64,
read: impl Fn(&mut Vec<u8>, usize, usize),
mut headers: HeaderMap,
state: Option<Arc<VolumeServerState>>,
) -> Response {
let ranges = match parse_range_header(range_str, total) {
Ok(r) => r,
Err(msg) => {
if msg == RANGE_NO_OVERLAP {
headers.insert(
"Content-Range",
format!("bytes */{}", total).parse().unwrap(),
);
}
return range_error_response(headers, msg);
}
};
// Go's ProcessRangeRequest returns nil (empty body) for empty or oversized ranges
if ranges.is_empty() {
return (StatusCode::OK, headers).into_response();
}
if sum_ranges_size(&ranges) > total {
return (StatusCode::OK, headers).into_response();
}
if ranges.len() == 1 {
let r = ranges[0];
headers.insert(
"Content-Range",
range_content_range(r, total).parse().unwrap(),
);
headers.insert(
header::CONTENT_LENGTH,
r.length.max(0).to_string().parse().unwrap(),
);
if r.length <= 0 {
return (StatusCode::PARTIAL_CONTENT, headers).into_response();
}
let mut slice = Vec::with_capacity(r.length as usize);
read(&mut slice, r.start as usize, (r.start + r.length) as usize);
finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, slice, state)
} else {
// Multi-range: build multipart/byteranges response
let boundary = "SeaweedFSBoundary";
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut body = Vec::new();
for (i, r) in ranges.iter().enumerate() {
// First boundary has no leading CRLF per RFC 2046
if i == 0 {
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
} else {
body.extend_from_slice(format!("\r\n--{}\r\n", boundary).as_bytes());
}
body.extend_from_slice(format!("Content-Type: {}\r\n", content_type).as_bytes());
body.extend_from_slice(
format!("Content-Range: {}\r\n\r\n", range_content_range(*r, total)).as_bytes(),
);
if r.length > 0 {
read(&mut body, r.start as usize, (r.start + r.length) as usize);
}
}
body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
headers.insert(
header::CONTENT_TYPE,
format!("multipart/byteranges; boundary={}", boundary)
.parse()
.unwrap(),
);
if !headers.contains_key(header::CONTENT_ENCODING) {
headers.insert(
header::CONTENT_LENGTH,
body.len().to_string().parse().unwrap(),
);
}
finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, body, state)
}
}
fn handle_range_request_from_source(
range_str: &str,
info: crate::storage::volume::NeedleStreamInfo,
mut headers: HeaderMap,
state: Option<Arc<VolumeServerState>>,
chunk_size: usize,
) -> Response {
let total = info.data_size as i64;
let ranges = match parse_range_header(range_str, total) {
Ok(r) => r,
Err(msg) => {
if msg == RANGE_NO_OVERLAP {
headers.insert(
"Content-Range",
format!("bytes */{}", total).parse().unwrap(),
);
}
return range_error_response(headers, msg);
}
};
if ranges.is_empty() {
return (StatusCode::OK, headers).into_response();
}
if sum_ranges_size(&ranges) > total {
return (StatusCode::OK, headers).into_response();
}
let block_crcs = if ranges.iter().any(|r| r.start != 0 || r.length != total) {
match info.verify_checksum(chunk_size) {
Ok(crcs) => Some(crcs),
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("range read error: {}", err),
)
.into_response();
}
}
} else {
None
};
let read_slice = |start: i64, length: i64| -> Result<Vec<u8>, std::io::Error> {
if length <= 0 {
return Ok(Vec::new());
}
let read = match &block_crcs {
Some(crcs) if length != total => {
info.read_checked_range(crcs, start as u64, length as u64)
}
_ => {
let mut buf = vec![0u8; length as usize];
info.source
.read_exact_at(&mut buf, info.data_file_offset + start as u64)
.map(|()| buf)
}
};
match read {
Ok(buf) if length == total && !info.checksum_matches(&buf) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_CRC])
.inc();
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"needle data checksum mismatch for needle {}",
info.needle_id
),
))
}
// A finished range read clears the streak a failed meta read
// counted, like readNeedleDataInto's terminal report.
Ok(buf) => {
info.io_errors.check_read_write_error(None);
Ok(buf)
}
// A block that fails its CRC leaves the streak alone, as Go's.
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => Err(e),
Err(e) => {
info.io_errors.check_read_write_error(Some(&e));
Err(e)
}
}
};
if ranges.len() == 1 {
let r = ranges[0];
let slice = match read_slice(r.start, r.length) {
Ok(slice) => slice,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("range read error: {}", err),
)
.into_response();
}
};
headers.insert(
"Content-Range",
range_content_range(r, total).parse().unwrap(),
);
headers.insert(
header::CONTENT_LENGTH,
slice.len().to_string().parse().unwrap(),
);
return finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, slice, state);
}
let boundary = "SeaweedFSBoundary";
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut body = Vec::new();
for (i, r) in ranges.iter().enumerate() {
let slice = match read_slice(r.start, r.length) {
Ok(slice) => slice,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("range read error: {}", err),
)
.into_response();
}
};
if i == 0 {
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
} else {
body.extend_from_slice(format!("\r\n--{}\r\n", boundary).as_bytes());
}
body.extend_from_slice(format!("Content-Type: {}\r\n", content_type).as_bytes());
body.extend_from_slice(
format!("Content-Range: {}\r\n\r\n", range_content_range(*r, total)).as_bytes(),
);
body.extend_from_slice(&slice);
}
body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
headers.insert(
header::CONTENT_TYPE,
format!("multipart/byteranges; boundary={}", boundary)
.parse()
.unwrap(),
);
if !headers.contains_key(header::CONTENT_ENCODING) {
headers.insert(
header::CONTENT_LENGTH,
body.len().to_string().parse().unwrap(),
);
}
finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, body, state)
}
/// Extract filename from URL path like "/vid/fid/filename.ext"
fn extract_filename_from_path(path: &str) -> String {
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
if parts.len() >= 3 {
parts[2].to_string()
} else {
String::new()
}
}
fn has_image_ops(ext: &str, query: &ReadQueryParams) -> bool {
let resize =
is_image_resize_ext(ext) && (query.width.unwrap_or(0) > 0 || query.height.unwrap_or(0) > 0);
let crop = is_image_crop_ext(ext) && {
let x1 = query.crop_x1.unwrap_or(0);
let y1 = query.crop_y1.unwrap_or(0);
let x2 = query.crop_x2.unwrap_or(0);
let y2 = query.crop_y2.unwrap_or(0);
x2 > x1 && y2 > y1
};
resize || crop
}
fn path_base(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
trimmed
.rsplit('/')
.find(|s| !s.is_empty())
.unwrap_or("")
.to_string()
}
fn parse_go_bool(value: &str) -> Option<bool> {
match value {
"1" | "t" | "T" | "TRUE" | "True" | "true" => Some(true),
"0" | "f" | "F" | "FALSE" | "False" | "false" => Some(false),
_ => None,
}
}
/// Format Content-Disposition header value per RFC 6266.
///
/// Matches Go's `mime.FormatMediaType(dispositionType, map[string]string{"filename": filename})`:
/// - Simple ASCII names (alphanumeric, hyphen, underscore, dot): `attachment; filename=file.txt`
/// - ASCII names with spaces/special chars: `attachment; filename="my file.txt"`
/// - Non-ASCII names: `attachment; filename*=utf-8''percent-encoded-name`
fn format_content_disposition(disposition_type: &str, filename: &str) -> String {
let is_ascii = filename.bytes().all(|b| b.is_ascii());
if is_ascii {
// Check if the filename is a simple "token" (no quoting needed).
// RFC 2616 token chars: any CHAR except CTLs or separators.
// Go's mime.FormatMediaType uses needsQuoting which checks for non-token chars.
let is_token = !filename.is_empty()
&& filename.bytes().all(|b| {
b > 0x20
&& b < 0x7f
&& !matches!(
b,
b'(' | b')'
| b'<'
| b'>'
| b'@'
| b','
| b';'
| b':'
| b'\\'
| b'"'
| b'/'
| b'['
| b']'
| b'?'
| b'='
| b' '
)
});
if is_token {
format!("{}; filename={}", disposition_type, filename)
} else {
// Quote the filename, escaping backslashes and quotes
let escaped = filename.replace('\\', "\\\\").replace('"', "\\\"");
format!("{}; filename=\"{}\"", disposition_type, escaped)
}
} else {
// Non-ASCII: use RFC 2231 encoding with filename* parameter
let encoded = percent_encode_rfc2231(filename);
format!("{}; filename*=utf-8''{}", disposition_type, encoded)
}
}
/// Percent-encode a string for RFC 2231 filename* parameter.
/// Encodes all bytes except unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~").
fn percent_encode_rfc2231(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(byte as char);
} else {
out.push('%');
out.push(char::from(HEX_UPPER[byte as usize >> 4]));
out.push(char::from(HEX_UPPER[byte as usize & 0x0f]));
}
}
out
}
const HEX_UPPER: [u8; 16] = *b"0123456789ABCDEF";
// ============================================================================
// Image processing helpers
// ============================================================================
fn is_image_resize_ext(ext: &str) -> bool {
matches!(ext, ".png" | ".jpg" | ".jpeg" | ".gif" | ".webp")
}
/// Go's shouldCropImages only supports these four formats (no .webp).
fn is_image_crop_ext(ext: &str) -> bool {
matches!(ext, ".png" | ".jpg" | ".jpeg" | ".gif")
}
fn extract_extension_from_path(path: &str) -> String {
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
if parts.len() >= 3 {
// 3-segment path: /vid/fid/filename.ext
let filename = parts[2];
if let Some(dot_pos) = filename.rfind('.') {
return filename[dot_pos..].to_lowercase();
}
} else if !parts.is_empty() {
// 2-segment path: /vid,fid.ext or /vid/fid.ext
// Go's parseURLPath extracts ext from the full path for all formats
let last = parts[parts.len() - 1];
if let Some(dot_pos) = last.rfind('.') {
return last[dot_pos..].to_lowercase();
}
}
String::new()
}
fn maybe_resize_image(data: &[u8], ext: &str, query: &ReadQueryParams) -> Vec<u8> {
let width = query.width.unwrap_or(0);
let height = query.height.unwrap_or(0);
if width == 0 && height == 0 {
return data.to_vec();
}
let img = match image::load_from_memory(data) {
Ok(img) => img,
Err(_) => return data.to_vec(),
};
let (src_w, src_h) = (img.width(), img.height());
// Only resize if source is larger than target
if (width == 0 || src_w <= width) && (height == 0 || src_h <= height) {
return data.to_vec();
}
let mode = query.mode.as_deref().unwrap_or("");
let resized = match mode {
"fit" => img.resize(width, height, image::imageops::FilterType::Lanczos3),
"fill" => img.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3),
_ => {
if width > 0 && height > 0 && width == height && src_w != src_h {
img.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3)
} else {
img.resize(width, height, image::imageops::FilterType::Lanczos3)
}
}
};
encode_image(&resized, ext).unwrap_or_else(|| data.to_vec())
}
fn maybe_crop_image(data: &[u8], ext: &str, query: &ReadQueryParams) -> Vec<u8> {
let (x1, y1, x2, y2) = match (query.crop_x2, query.crop_y2) {
(Some(x2), Some(y2)) => {
let x1 = query.crop_x1.unwrap_or(0);
let y1 = query.crop_y1.unwrap_or(0);
if x2 > x1 && y2 > y1 {
(x1, y1, x2, y2)
} else {
return data.to_vec();
}
}
_ => return data.to_vec(),
};
let img = match image::load_from_memory(data) {
Ok(img) => img,
Err(_) => return data.to_vec(),
};
let (src_w, src_h) = (img.width(), img.height());
if x2 > src_w || y2 > src_h {
return data.to_vec();
}
let cropped = img.crop_imm(x1, y1, x2 - x1, y2 - y1);
encode_image(&cropped, ext).unwrap_or_else(|| data.to_vec())
}
fn encode_image(img: &image::DynamicImage, ext: &str) -> Option<Vec<u8>> {
use std::io::Cursor;
let mut buf = Cursor::new(Vec::new());
let format = match ext {
".png" => image::ImageFormat::Png,
".jpg" | ".jpeg" => image::ImageFormat::Jpeg,
".gif" => image::ImageFormat::Gif,
".webp" => image::ImageFormat::WebP,
_ => return None,
};
img.write_to(&mut buf, format).ok()?;
Some(buf.into_inner())
}
// ============================================================================
// Write Handler (POST/PUT)
// ============================================================================
#[derive(Serialize)]
struct UploadResult {
#[serde(skip_serializing_if = "String::is_empty")]
name: String,
#[serde(skip_serializing_if = "is_zero_u32")]
size: u32,
#[serde(rename = "eTag", skip_serializing_if = "String::is_empty")]
etag: String,
#[serde(skip_serializing_if = "String::is_empty")]
mime: String,
#[serde(rename = "contentMd5", skip_serializing_if = "Option::is_none")]
content_md5: Option<String>,
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
/// Store an uploaded needle on the local volume — durable (fsync) uploads
/// through the per-volume write queue, the rest synchronously — and then
/// replicate it if the volume's placement calls for more copies.
pub async fn post_handler(
State(state): State<Arc<VolumeServerState>>,
request: Request<Body>,
) -> Response {
let path = request.uri().path().to_string();
let query = request.uri().query().unwrap_or("").to_string();
let method = request.method().clone();
let headers = request.headers().clone();
let query_fields: Vec<(String, String)> = match serde_urlencoded::from_str(&query) {
Ok(fields) => fields,
Err(e) => {
// Go's r.ParseForm() returns 400 on malformed query strings
return json_error_with_query(
StatusCode::BAD_REQUEST,
format!("form parse error: {}", e),
Some(&query),
);
}
};
let (vid, needle_id, cookie) = match parse_url_path(&path) {
Some(parsed) => parsed,
None => {
return json_error_with_query(
StatusCode::BAD_REQUEST,
"invalid URL path",
Some(&query),
);
}
};
// JWT check for writes
let file_id = extract_file_id(&path);
let token = extract_jwt(&headers, request.uri());
if state
.guard
.read()
.unwrap()
.check_jwt_for_file(token.as_deref(), &file_id, true)
.is_err()
{
return json_error_with_query(StatusCode::UNAUTHORIZED, "wrong jwt", Some(&query));
}
// Upload throttling: check inflight bytes against limit
let is_replicate = query_fields
.iter()
.find(|(name, _)| name == "type")
.is_some_and(|(_, value)| value == "replicate");
let content_length = headers
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
if !is_replicate && state.concurrent_upload_limit > 0 {
// Wait for inflight bytes to drop below limit, or timeout
let timeout = if state.inflight_upload_data_timeout.is_zero() {
std::time::Duration::from_secs(2)
} else {
state.inflight_upload_data_timeout
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
let current = state.inflight_upload_bytes.load(Ordering::Relaxed);
if current <= state.concurrent_upload_limit {
break;
}
// Go increments UploadLimitCond on every loop iteration (L184),
// not just on timeout.
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::UPLOAD_LIMIT_COND])
.inc();
// Wait for notification or timeout
if tokio::time::timeout_at(deadline, state.upload_notify.notified())
.await
.is_err()
{
return json_error_with_query(
StatusCode::TOO_MANY_REQUESTS,
"upload limit exceeded",
Some(&query),
);
}
}
let new_val = state
.inflight_upload_bytes
.fetch_add(content_length, Ordering::Relaxed)
+ content_length;
metrics::INFLIGHT_UPLOAD_SIZE.set(new_val);
}
// RAII guard to release upload throttle on any exit path
let _upload_guard = if !is_replicate && state.concurrent_upload_limit > 0 {
Some(InflightGuard {
counter: &state.inflight_upload_bytes,
bytes: content_length,
notify: &state.upload_notify,
metric: &metrics::INFLIGHT_UPLOAD_SIZE,
})
} else {
None
};
let content_type_str = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
// Go only parses multipart form-data for POST requests with form-data content type.
let should_parse_multipart = method == Method::POST && content_type_str.contains("form-data");
// Validate multipart/form-data has a boundary
if should_parse_multipart && !content_type_str.contains("boundary=") {
return json_error_with_query(
StatusCode::BAD_REQUEST,
"no multipart boundary param in Content-Type",
Some(&query),
);
}
let content_md5 = headers
.get("Content-MD5")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Read body, bounded by the configured file-size limit so a single upload
// cannot buffer unbounded memory and OOM-kill the server (mirrors Go's
// io.LimitReader(r.Body, sizeLimit+1)). A margin covers multipart framing;
// the exact per-file limit is still enforced on the parsed data below.
let body_limit = if state.file_size_limit_bytes > 0 {
// try_from (not `as usize`) so a >usize::MAX limit on 32-bit caps at
// usize::MAX instead of silently truncating/wrapping to a tiny value.
usize::try_from(state.file_size_limit_bytes)
.unwrap_or(usize::MAX)
.saturating_add(UPLOAD_BODY_OVERHEAD)
} else {
usize::MAX
};
let body = match axum::body::to_bytes(request.into_body(), body_limit).await {
Ok(b) => b,
Err(e) => {
// With a limit configured, an error here means the body exceeded it
// before we buffered the whole thing; report it like the size check.
let msg = if state.file_size_limit_bytes > 0 {
format!(
"file over the limited {} bytes",
state.file_size_limit_bytes
)
} else {
format!("read body: {}", e)
};
return json_error_with_query(StatusCode::BAD_REQUEST, msg, Some(&query));
}
};
// H5: Multipart form-data parsing
let (
body_data_raw,
parsed_filename,
parsed_content_type,
parsed_content_encoding,
parsed_content_md5,
multipart_form_fields,
) = if should_parse_multipart {
// Extract boundary from Content-Type
let boundary = content_type_str
.split(';')
.find_map(|part| {
let part = part.trim();
part.strip_prefix("boundary=")
.map(|val| val.trim_matches('"').to_string())
})
.unwrap_or_default();
let mut multipart = multer::Multipart::new(
futures::stream::once(async { Ok::<_, std::io::Error>(body.clone()) }),
boundary,
);
let mut file_data: Option<Vec<u8>> = None;
let mut first_part_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None;
let mut file_content_type: Option<String> = None;
let mut file_content_encoding: Option<String> = None;
let mut file_content_md5: Option<String> = None;
let mut form_fields = std::collections::HashMap::new();
while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().map(|s| s.to_string());
let fname = field.file_name().map(clean_windows_path_base);
let field_headers = field.headers().clone();
let fct = field_headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let fce = field_headers
.get(header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let fmd5 = field_headers
.get("Content-MD5")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if let Ok(data) = field.bytes().await {
// Go reads the first part's data unconditionally, then looks for
// a part with a filename. If no part has a filename, Go uses the
// first part's data (with empty filename).
if first_part_data.is_none() {
first_part_data = Some(data.to_vec());
}
if file_data.is_none() && fname.is_some() {
// Found a file field — use this part's data
file_data = Some(data.to_vec());
file_name = fname;
file_content_type = fct;
file_content_encoding = fce;
file_content_md5 = fmd5;
} else if let Some(name) = field_name {
form_fields
.entry(name)
.or_insert_with(|| String::from_utf8_lossy(&data).to_string());
}
}
}
if let Some(data) = file_data {
(
data,
file_name.unwrap_or_default(),
file_content_type,
file_content_encoding,
file_content_md5,
form_fields,
)
} else if let Some(data) = first_part_data {
// No file field found, use first part's data (matching Go behavior)
(data, String::new(), None, None, None, form_fields)
} else {
// No parts at all
(Vec::new(), String::new(), None, None, None, form_fields)
}
} else {
(
body.to_vec(),
String::new(),
None,
None,
None,
std::collections::HashMap::new(),
)
};
let form_value = |name: &str| {
query_fields
.iter()
.find_map(|(k, v)| if k == name { Some(v.clone()) } else { None })
.or_else(|| multipart_form_fields.get(name).cloned())
};
let is_replicate = form_value("type").as_deref() == Some("replicate");
// Check for chunk manifest flag.
// Go uses r.FormValue("cm"), which falls back to multipart fields when present.
let is_chunk_manifest = matches!(
form_value("cm").as_deref(),
Some("1" | "t" | "T" | "TRUE" | "True" | "true")
);
// Check file size limit (matches Go: "file over the limited %d bytes")
if state.file_size_limit_bytes > 0 && body_data_raw.len() as i64 > state.file_size_limit_bytes {
return json_error_with_query(
StatusCode::BAD_REQUEST,
format!(
"file over the limited {} bytes",
state.file_size_limit_bytes
),
Some(&query),
);
}
// Check if upload is pre-compressed
let is_gzipped = if should_parse_multipart {
parsed_content_encoding.as_deref() == Some("gzip")
} else {
headers
.get(header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|s| s == "gzip")
.unwrap_or(false)
};
let uncompressed_data = if is_gzipped {
match maybe_decompress_gzip(&body_data_raw) {
Ok(d) => d,
Err(GunzipError::TooLarge) => {
return json_error_with_query(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
Some(&query),
);
}
Err(GunzipError::Decode) => body_data_raw.clone(),
}
} else {
body_data_raw.clone()
};
let original_data_size = uncompressed_data.len() as u32;
// Only compute and validate Content-MD5 when the client provided one
// (Go only computes MD5 when Content-MD5 header/field is present)
let content_md5 = content_md5.or(parsed_content_md5);
let original_content_md5 = if content_md5.is_some() {
Some(compute_md5_base64(&uncompressed_data))
} else {
None
};
if let (Some(expected_md5), Some(actual_md5)) = (&content_md5, &original_content_md5)
&& expected_md5 != actual_md5
{
return json_error_with_query(
StatusCode::BAD_REQUEST,
format!(
"Content-MD5 did not match md5 of file data expected [{}] received [{}] size {}",
expected_md5, actual_md5, original_data_size
),
Some(&query),
);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Parse custom timestamp from query param
let ts_str = form_value("ts").unwrap_or_default();
let last_modified = if !ts_str.is_empty() {
ts_str.parse::<u64>().unwrap_or(now)
} else {
now
};
// Prefer the multipart filename before deriving MIME and other metadata.
let filename = if !parsed_filename.is_empty() {
parsed_filename
} else if !should_parse_multipart {
headers
.get(header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(parse_content_disposition_filename)
.unwrap_or_else(|| path_base(&path))
} else {
extract_filename_from_path(&path)
};
// Extract MIME type: prefer multipart-parsed content type, else from Content-Type header
let mime_type = if let Some(ref pct) = parsed_content_type {
pct.clone()
} else {
let multipart_fallback = if should_parse_multipart
&& !filename.is_empty()
&& !is_chunk_manifest
&& !is_replicate
{
mime_guess::from_path(&filename)
.first()
.map(|m| m.to_string())
.unwrap_or_default()
} else {
String::new()
};
headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| {
if should_parse_multipart && ct.starts_with("multipart/") {
multipart_fallback.clone()
} else {
ct.to_string()
}
})
.unwrap_or(multipart_fallback)
};
// Parse TTL from query param (matches Go's r.FormValue("ttl"))
let ttl_str = form_value("ttl").unwrap_or_default();
let ttl = if !ttl_str.is_empty() {
crate::storage::needle::TTL::read(&ttl_str).ok()
} else {
None
};
// Extract Seaweed-* custom metadata headers (pairs)
// Go's net/http canonicalizes header names to Title-Case, so after stripping
// the "Seaweed-" prefix, keys are Title-Case (e.g., "Foo-Bar"). Rust's http
// crate lowercases all header names, so we must convert the stripped key to
// Title-Case to match Go's behavior.
fn to_title_case(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut capitalize_next = true;
for c in s.chars() {
if c == '-' {
result.push('-');
capitalize_next = true;
} else if capitalize_next {
for uc in c.to_uppercase() {
result.push(uc);
}
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
let pair_map: std::collections::HashMap<String, String> = headers
.iter()
.filter_map(|(k, v)| {
let key = k.as_str();
if key.len() > 8 && key[..8].eq_ignore_ascii_case("seaweed-") {
if let Ok(val) = v.to_str() {
// Store with the prefix stripped and Title-Cased (matching Go's trimmedPairMap)
Some((to_title_case(&key[8..]), val.to_string()))
} else {
None
}
} else {
None
}
})
.collect();
// Fix JPEG orientation from EXIF data before storing (matches Go behavior).
let (body_data, body_rewritten) =
if state.fix_jpg_orientation && crate::images::is_jpeg(&mime_type, &filename) {
let fixed = crate::images::fix_jpg_orientation(&body_data_raw);
let rewritten = fixed != body_data_raw;
(fixed, rewritten)
} else {
(body_data_raw, false)
};
// Auto-compress compressible file types (matches Go's IsCompressableFileType).
// Only compress if not already gzipped and compression saves >10%.
// A replica keeps the compression state the primary stored.
let (final_data, final_is_gzipped) = if !is_gzipped && !is_chunk_manifest && !is_replicate {
let ext = filename
.rfind('.')
.map(|p| &filename[p..])
.unwrap_or_default();
if is_compressible_file_type(ext, &mime_type) {
if let Some(compressed) = try_gzip_data(&body_data) {
if compressed.len() * 10 < body_data.len() * 9 {
(compressed, true)
} else {
(body_data, false)
}
} else {
(body_data, false)
}
} else {
(body_data, false)
}
} else {
(body_data, is_gzipped)
};
let mut n = Needle {
id: needle_id,
cookie,
data_size: final_data.len() as u32,
data: final_data,
last_modified,
..Needle::default()
};
n.set_has_last_modified_date();
if is_chunk_manifest {
n.set_is_chunk_manifest();
}
if final_is_gzipped {
n.set_is_compressed();
}
// Go sets HasMime even for empty MIME types: if len(pu.MimeType) < 256
if mime_type.len() < 256 {
n.mime = mime_type.as_bytes().to_vec();
n.set_has_mime();
}
// Set TTL on needle
if let Some(ref t) = ttl
&& !t.is_empty()
{
n.ttl = Some(*t);
n.set_has_ttl();
}
// Set pairs on needle
if !pair_map.is_empty()
&& let Ok(pairs_json) = serde_json::to_vec(&pair_map)
&& pairs_json.len() < 65536
{
n.pairs_size = pairs_json.len() as u16;
n.pairs = pairs_json;
n.set_has_pairs();
}
// Set filename on needle (matches Go: if len(pu.FileName) < 256)
// Go sets HasName even for empty filenames
if filename.len() < 256 {
n.name = filename.as_bytes().to_vec();
n.name_size = filename.len() as u8;
n.set_has_name();
}
// A durable write flushes before it is acked. Read it the way Go's
// r.FormValue does, off the decoded fields, so a percent-encoded value is
// honored here too. ReplicatedWrite forwards the parameter, so a replica
// sees it the same way the primary did.
let fsync = form_value("fsync").as_deref() == Some("true");
// Go computes the checksum while building the needle (CreateNeedleFromRequest).
// The write queue takes the needle away, so the response fields that come
// from it are read here, before the write, rather than from a copy.
n.checksum = crate::storage::needle::crc::CRC::new(&n.data);
let needle_etag = n.etag();
let needle_has_name = n.has_name();
// Replicate to remote volume servers if this volume has replicas.
// Matches Go's GetWritableRemoteReplications: skip if copy_count == 1.
let needs_replication = !is_replicate && {
let store = state.store.read().unwrap();
store
.find_volume(vid)
.is_some_and(|(_, v)| v.super_block().replica_placement.get_copy_count() > 1)
};
// The fan-out runs concurrently with the local needle write, like Go's
// ReplicatedWrite: replicas get the stored bytes with their compression
// state, so they keep it instead of re-deriving it. The ack waits for both.
let replication = if needs_replication {
let state_clone = state.clone();
let path_clone = path.clone();
let query_clone = query.clone();
let mut overrides = vec![("ts", n.last_modified.to_string())];
if let Some(t) = ttl.filter(|t| !t.is_empty()) {
overrides.push(("ttl", t.to_string()));
}
if is_chunk_manifest {
overrides.push(("cm", "true".to_string()));
}
if fsync {
overrides.push(("fsync", "true".to_string()));
}
let mut replica_headers = HeaderMap::new();
if let Some(auth) = headers.get(header::AUTHORIZATION) {
replica_headers.insert(header::AUTHORIZATION, auth.clone());
}
if n.has_pairs() {
for (key, value) in &pair_map {
if let (Ok(name), Ok(value)) = (
header::HeaderName::try_from(format!("seaweed-{key}")),
header::HeaderValue::from_str(value),
) {
replica_headers.insert(name, value);
}
}
}
let (content_type, replica_body) = replica_upload_body(
&filename,
&mime_type,
n.is_compressed().then_some("gzip"),
original_content_md5.as_deref().filter(|_| !body_rewritten),
&n.data,
);
if let Ok(value) = header::HeaderValue::from_str(&content_type) {
replica_headers.insert(header::CONTENT_TYPE, value);
}
Some(tokio::spawn(async move {
do_replicated_request(
&state_clone,
vid.0,
Method::POST,
&path_clone,
&override_query_fields(&query_clone, &overrides)?,
&replica_headers,
Some(replica_body),
)
.await
}))
} else {
None
};
let (write_result, refused_before_append) =
match write_upload_needle(&state, vid, n, fsync).await {
Ok(Ok(written)) => (Ok(written), false),
Ok(Err(refused)) => (Err(refused), true),
Err(error) => (Err(error), false),
};
// No leader-known gate: when the lookup cannot run, the write must fail
// rather than be acknowledged on fewer copies than required. The local
// write result takes precedence, matching Go's error ordering.
let replication_result = match replication {
Some(task) => Some(
task.await
.map_err(|e| format!("replication task failed: {}", e))
.flatten(),
),
None => None,
};
if write_result.is_ok()
&& let Some(Err(e)) = replication_result
{
tracing::error!("replicated write failed: {}", e);
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("replication failed: {}", e),
Some(&query),
);
}
let resp = match write_result {
Ok((_offset, _size, is_unchanged)) => {
if is_unchanged {
let etag = format!("\"{}\"", needle_etag);
(StatusCode::NO_CONTENT, [(header::ETAG, etag)]).into_response()
} else {
// Go only includes contentMd5 when the client provided Content-MD5
let result = UploadResult {
name: if needle_has_name {
filename.clone()
} else {
String::new()
},
size: original_data_size, // H3: use original size, not compressed
etag: needle_etag.clone(),
mime: mime_type.clone(),
content_md5: original_content_md5.clone(),
};
let etag = needle_etag;
let etag_header = if etag.starts_with('"') {
etag.clone()
} else {
format!("\"{}\"", etag)
};
let mut resp = json_result_with_query(StatusCode::CREATED, &result, &query);
resp.headers_mut()
.insert(header::ETAG, etag_header.parse().unwrap());
if let Some(ref md5_value) = original_content_md5 {
resp.headers_mut()
.insert("Content-MD5", md5_value.parse().unwrap());
}
resp
}
}
Err(e) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_WRITE_TO_LOCAL_DISK])
.inc();
let read_only = matches!(e, crate::storage::volume::VolumeError::ReadOnly(_));
let status = if read_only {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
let mut response = json_error_with_query(status, format!("{}", e), Some(&query));
// Replication or a failed append can leave another copy written.
if read_only && refused_before_append && !fsync && !needs_replication && !is_replicate {
response.headers_mut().insert(
"x-seaweed-write-outcome",
header::HeaderValue::from_static("refused-before-append"),
);
}
response
}
};
// _upload_guard drops here, releasing inflight bytes
resp
}
/// Durable uploads use the batching worker unless the server is stopping.
/// Only an inner error proves refusal before append.
async fn write_upload_needle(
state: &Arc<VolumeServerState>,
vid: VolumeId,
mut n: Needle,
fsync: bool,
) -> Result<
Result<(u64, Size, bool), crate::storage::volume::VolumeError>,
crate::storage::volume::VolumeError,
> {
let queued = fsync && !*state.is_stopping.read().unwrap();
if queued && let Some(wq) = state.write_queue.get() {
return wq.submit(vid, n, fsync).await.map(Ok);
}
let vol = {
let store = state.store.read().unwrap();
store.writable_volume(vid)
};
let vol = match vol {
Ok(vol) => vol,
Err(refused) => return Ok(Err(refused)),
};
let pending = vol.track_pending_write(&n);
if fsync {
vol.write_pending_needles(&mut [(n, true)], &[Some(pending)])
.pop()
.unwrap_or(Err(crate::storage::volume::VolumeError::NotFound))
.map(Ok)
} else {
vol.write_pending_needle_unsynced(&mut n, Some(&pending))
}
}
// ============================================================================
// Delete Handler
// ============================================================================
#[derive(Serialize)]
struct DeleteResult {
size: i64,
}
/// Read option for deletes: read only the needle's header and trailer.
///
/// A delete needs to know that the needle exists, what its cookie is, and
/// whether it is a chunk manifest. None of that requires the payload. If the
/// needle turns out to be a chunk manifest, the read loads its data as well,
/// because the delete needs the manifest to delete the chunks.
pub(crate) fn delete_read_option() -> ReadOption {
ReadOption {
attempt_meta_only: true,
must_meta_only: true,
..ReadOption::default()
}
}
pub async fn delete_handler(
State(state): State<Arc<VolumeServerState>>,
request: Request<Body>,
) -> Response {
let path = request.uri().path().to_string();
let del_query = request.uri().query().unwrap_or("").to_string();
let del_params: ReadQueryParams = serde_urlencoded::from_str(&del_query).unwrap_or_default();
let headers = request.headers().clone();
let (vid, needle_id, cookie) = match parse_url_path(&path) {
Some(parsed) => parsed,
None => {
return json_error_with_query(
StatusCode::BAD_REQUEST,
"invalid URL path",
Some(&del_query),
);
}
};
// JWT check for writes (deletes use write key)
let file_id = extract_file_id(&path);
let token = extract_jwt(&headers, request.uri());
if state
.guard
.read()
.unwrap()
.check_jwt_for_file(token.as_deref(), &file_id, true)
.is_err()
{
return json_error_with_query(StatusCode::UNAUTHORIZED, "wrong jwt", Some(&del_query));
}
// Check for EC volume first (Go checks hasEcVolume before regular volume in DeleteHandler).
// Go's flow: FindEcVolume -> DeleteEcShardNeedle(ecVolume, n, cookie) -> writeDeleteResult
// DeleteEcShardNeedle: reads needle (for size + cookie validation), validates cookie, journals delete.
{
let has_ec = state.store.read().unwrap().has_ec_volume(vid);
if has_ec {
// Step 1: Read the EC needle to get its size and validate cookie.
//
// This must go through the *distributed* reader, as the GET path
// does. The local-only `EcVolume::read_ec_shard_needle` errors
// "ec shard N not available locally" for any interval that lives on
// a peer, so on a standard 10+4 spread every HTTP DELETE of an EC
// needle failed 500 and never appended to `.ecj`. The distributed
// reader does a local-first pass in its snapshot phase, so the
// all-shards-local case costs the same as before.
//
// No store guard is held across this call: the reader takes and
// releases its own, and `RwLockReadGuard` is `!Send`.
let ec_read_result =
crate::server::store_ec::read_ec_shard_needle_distributed(&state, vid, needle_id)
.await;
match ec_read_result {
Ok(Some(ec_needle)) => {
// Step 2: Validate cookie (Go: cookie != 0 && cookie != n.Cookie)
if cookie.0 != 0 && ec_needle.cookie != cookie {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: unexpected cookie {:x}", cookie.0),
Some(&del_query),
);
}
let count = ec_needle.data_size as i64;
// Step 3: Journal the delete on a holder of the needle's
// primary data shard — Go's DeleteEcShardNeedle forwards a
// VolumeEcBlobDelete there rather than journaling locally,
// so exactly one node carries the tombstone and
// delete_count stays consistent across replicas.
match crate::server::store_ec::delete_ec_shard_needle_distributed(
&state, vid, needle_id,
)
.await
{
Ok(()) => {
let result = DeleteResult { size: count };
return json_response_with_params(
StatusCode::ACCEPTED,
&result,
Some(&del_params),
);
}
// Unmounted between the read and the append, or the
// needle went away in the same window: nothing was
// journalled, so answering 202 would lose the delete
// while reporting success.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
&result,
Some(&del_params),
);
}
Err(e) => {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
);
}
}
}
Ok(None) => {
// Needle not in the EC index, or the volume disappeared
// between the `has_ec` check and the snapshot — the
// distributed reader reports both as `Ok(None)`, and both
// mean the same thing to a deleter.
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
&result,
Some(&del_params),
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// A shard or interval the read needed is gone rather than
// merely remote. The GET path answers 404 here; answering
// 500 (as this handler did for every error) told callers to
// retry a delete that can never succeed.
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
&result,
Some(&del_params),
);
}
Err(e) => {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
);
}
}
}
}
// H9: Parse custom timestamp from query param; default to now (not 0)
let del_ts_str = del_query
.split('&')
.find_map(|p| p.strip_prefix("ts="))
.unwrap_or("");
let del_last_modified = if !del_ts_str.is_empty() {
del_ts_str.parse::<u64>().unwrap_or_else(|_| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
} else {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
};
let mut n = Needle {
id: needle_id,
cookie,
..Needle::default()
};
// Read needle first to validate cookie (matching Go behavior)
let original_cookie = cookie;
let is_replicate = serde_urlencoded::from_str::<Vec<(String, String)>>(&del_query)
.unwrap_or_default()
.into_iter()
.find(|(name, _)| name == "type")
.is_some_and(|(_, value)| value == "replicate");
{
let vol = {
let store = state.store.read().unwrap();
store.find_volume(vid).map(|(_, vol)| vol)
};
let mut found = vol
.as_ref()
.ok_or(crate::storage::volume::VolumeError::NotFound)
.and_then(|vol| vol.read_needle_with_option(&mut n, &mut delete_read_option()));
if found.is_err()
&& is_replicate
&& let Some(vol) = &vol
{
// the write this delete undoes can still be queued here
vol.cancel_pending_writes(needle_id, original_cookie);
found = vol.read_needle_with_option(&mut n, &mut delete_read_option());
}
match found {
Ok(_) => {}
Err(_) => {
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
&result,
Some(&del_params),
);
}
}
}
if n.cookie != original_cookie {
return json_error_with_query(
StatusCode::BAD_REQUEST,
"File Random Cookie does not match.",
Some(&del_query),
);
}
// Apply custom timestamp (always set — defaults to now per H9)
n.last_modified = del_last_modified;
n.set_has_last_modified_date();
let mut delete_size_override = None;
// If this is a chunk manifest, delete child chunks first
if n.is_chunk_manifest() {
let manifest_data = if n.is_compressed() {
match maybe_decompress_gzip(&n.data) {
Ok(d) => d,
Err(GunzipError::TooLarge) => {
return json_error_with_query(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed manifest exceeds decompression limit",
Some(&del_query),
);
}
Err(GunzipError::Decode) => n.data.clone(),
}
} else {
n.data.clone()
};
let manifest = match serde_json::from_slice::<ChunkManifest>(&manifest_data) {
Ok(manifest) => manifest,
Err(e) => {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Load chunks manifest error: {}", e),
Some(&del_query),
);
}
};
let child_fids: Vec<String> = manifest
.chunks
.iter()
.map(|chunk| chunk.fid.clone())
.collect();
if let Err(e) = batch_delete_file_ids(&state, &child_fids).await {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Delete chunks error: {}", e),
Some(&del_query),
);
}
delete_size_override = Some(manifest.size as i64);
}
let delete_result = {
let vol = {
let store = state.store.read().unwrap();
store
.find_volume(vid)
.map(|(_, vol)| vol)
.ok_or(crate::storage::volume::VolumeError::NotFound)
};
match vol {
Err(e) => Err(e),
// Matches Store::delete_volume_needle: noWriteOrDelete rejects first.
Ok(vol) if vol.is_no_write_or_delete() => {
Err(crate::storage::volume::VolumeError::ReadOnly(vid))
}
Ok(vol) => vol.delete_needle(&mut n),
}
};
if !is_replicate && delete_result.is_ok() {
let needs_replication = {
let store = state.store.read().unwrap();
store
.find_volume(vid)
.is_some_and(|(_, v)| v.super_block().replica_placement.get_copy_count() > 1)
};
if needs_replication
&& let Err(e) = do_replicated_request(
&state,
vid.0,
Method::DELETE,
&path,
&del_query,
&headers,
None,
)
.await
{
tracing::error!("replicated delete failed: {}", e);
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("replication failed: {}", e),
Some(&del_query),
);
}
}
match delete_result {
Ok(size) => {
let result = DeleteResult {
size: delete_size_override.unwrap_or(size.0 as i64),
};
json_response_with_params(StatusCode::ACCEPTED, &result, Some(&del_params))
}
Err(crate::storage::volume::VolumeError::NotFound) => {
let result = DeleteResult { size: 0 };
json_response_with_params(StatusCode::NOT_FOUND, &result, Some(&del_params))
}
Err(e) => json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
),
}
}
// ============================================================================
// Status Handler
// ============================================================================
pub async fn status_handler(
Query(params): Query<ReadQueryParams>,
State(state): State<Arc<VolumeServerState>>,
) -> Response {
let store = state.store.read().unwrap();
let mut volumes = Vec::new();
for loc in &store.locations {
for (_vid, vol) in loc.volumes() {
let mut vol_info = serde_json::Map::new();
vol_info.insert("Id".to_string(), serde_json::Value::from(vol.id.0));
vol_info.insert(
"Collection".to_string(),
serde_json::Value::from(vol.collection.clone()),
);
vol_info.insert(
"Size".to_string(),
serde_json::Value::from(vol.content_size()),
);
vol_info.insert(
"FileCount".to_string(),
serde_json::Value::from(vol.file_count()),
);
vol_info.insert(
"DeleteCount".to_string(),
serde_json::Value::from(vol.deleted_count()),
);
vol_info.insert(
"DeletedByteCount".to_string(),
serde_json::Value::from(vol.deleted_size()),
);
vol_info.insert(
"ReadOnly".to_string(),
serde_json::Value::from(vol.is_read_only()),
);
vol_info.insert(
"Version".to_string(),
serde_json::Value::from(vol.version().0),
);
vol_info.insert(
"CompactRevision".to_string(),
serde_json::Value::from(vol.super_block().compaction_revision),
);
vol_info.insert(
"ModifiedAtSecond".to_string(),
serde_json::Value::from(vol.last_modified_ts()),
);
vol_info.insert(
"DiskType".to_string(),
serde_json::Value::from(loc.disk_type.to_string()),
);
let replica = &vol.super_block().replica_placement;
let mut replica_value = serde_json::Map::new();
if replica.diff_data_center_count > 0 {
replica_value.insert(
"dc".to_string(),
serde_json::Value::from(replica.diff_data_center_count),
);
}
if replica.diff_rack_count > 0 {
replica_value.insert(
"rack".to_string(),
serde_json::Value::from(replica.diff_rack_count),
);
}
if replica.same_rack_count > 0 {
replica_value.insert(
"node".to_string(),
serde_json::Value::from(replica.same_rack_count),
);
}
vol_info.insert(
"ReplicaPlacement".to_string(),
serde_json::Value::Object(replica_value),
);
let ttl = vol.super_block().ttl;
let mut ttl_value = serde_json::Map::new();
if ttl.count > 0 {
ttl_value.insert("Count".to_string(), serde_json::Value::from(ttl.count));
}
if ttl.unit > 0 {
ttl_value.insert("Unit".to_string(), serde_json::Value::from(ttl.unit));
}
vol_info.insert("Ttl".to_string(), serde_json::Value::Object(ttl_value));
let (remote_storage_name, remote_storage_key) = vol.remote_storage_name_key();
vol_info.insert(
"RemoteStorageName".to_string(),
serde_json::Value::from(remote_storage_name),
);
vol_info.insert(
"RemoteStorageKey".to_string(),
serde_json::Value::from(remote_storage_key),
);
volumes.push(serde_json::Value::Object(vol_info));
}
}
volumes.sort_by(|a, b| {
let left = a.get("Id").and_then(|v| v.as_u64()).unwrap_or_default();
let right = b.get("Id").and_then(|v| v.as_u64()).unwrap_or_default();
left.cmp(&right)
});
let mut m = serde_json::Map::new();
m.insert(
"Version".to_string(),
serde_json::Value::from(crate::version::version()),
);
m.insert("Volumes".to_string(), serde_json::Value::Array(volumes));
m.insert(
"DiskStatuses".to_string(),
serde_json::Value::Array(build_disk_statuses(&store)),
);
json_response_with_params(StatusCode::OK, &serde_json::Value::Object(m), Some(&params))
}
// ============================================================================
// Health Check Handler
// ============================================================================
pub async fn healthz_handler(State(state): State<Arc<VolumeServerState>>) -> Response {
// Go's healthzHandler returns only status codes with no body text.
let is_stopping = *state.is_stopping.read().unwrap();
if is_stopping {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
// If not heartbeating, return 503 (matches Go health check behavior)
if !state.is_heartbeating.load(Ordering::Relaxed) {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
// A server with quarantined local replicas has faulty storage media;
// report degraded so a load balancer can drain it.
if state.store.read().unwrap().has_io_quarantine() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
StatusCode::OK.into_response()
}
// ============================================================================
// Metrics Handler
// ============================================================================
pub async fn metrics_handler() -> Response {
let body = metrics::gather_metrics();
(
StatusCode::OK,
[(
header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
body,
)
.into_response()
}
// ============================================================================
// Stats Handlers
// ============================================================================
pub async fn stats_counter_handler(Query(params): Query<ReadQueryParams>) -> Response {
let payload = serde_json::json!({
"Version": crate::version::version(),
"Counters": super::server_stats::snapshot(),
});
json_response_with_params(StatusCode::OK, &payload, Some(&params))
}
pub async fn stats_memory_handler(Query(params): Query<ReadQueryParams>) -> Response {
let mem = super::memory_status::collect_mem_status();
let payload = serde_json::json!({
"Version": crate::version::version(),
"Memory": {
"goroutines": mem.goroutines,
"all": mem.all,
"used": mem.used,
"free": mem.free,
"self": mem.self_,
"heap": mem.heap,
"stack": mem.stack,
},
});
json_response_with_params(StatusCode::OK, &payload, Some(&params))
}
pub async fn stats_disk_handler(
Query(params): Query<ReadQueryParams>,
State(state): State<Arc<VolumeServerState>>,
) -> Response {
let store = state.store.read().unwrap();
let payload = serde_json::json!({
"Version": crate::version::version(),
"DiskStatuses": build_disk_statuses(&store),
});
json_response_with_params(StatusCode::OK, &payload, Some(&params))
}
// ============================================================================
// Static Asset Handlers
// ============================================================================
pub async fn favicon_handler() -> Response {
let asset = super::ui::favicon_asset();
(
StatusCode::OK,
[(header::CONTENT_TYPE, asset.content_type)],
asset.bytes,
)
.into_response()
}
pub async fn static_asset_handler(Path(path): Path<String>) -> Response {
match super::ui::lookup_static_asset(&path) {
Some(asset) => (
StatusCode::OK,
[(header::CONTENT_TYPE, asset.content_type)],
asset.bytes,
)
.into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
pub async fn ui_handler(State(state): State<Arc<VolumeServerState>>) -> Response {
let html = super::ui::render_volume_server_html(&state);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response()
}
// ============================================================================
// Chunk Manifest
// ============================================================================
#[derive(Deserialize)]
struct ChunkManifest {
#[serde(default)]
name: String,
#[serde(default)]
mime: String,
#[serde(default)]
size: i64,
#[serde(default)]
chunks: Vec<ChunkInfo>,
}
#[derive(Deserialize)]
struct ChunkInfo {
fid: String,
offset: i64,
size: i64,
}
fn manifest_filename(path: &str, manifest_name: &str) -> String {
let mut filename = extract_filename_from_path(path);
if filename.is_empty() && !manifest_name.is_empty() {
filename = manifest_name.to_string();
}
filename
}
fn extension_of(filename: &str) -> String {
filename
.rfind('.')
.map(|dot| filename[dot..].to_lowercase())
.unwrap_or_default()
}
fn reject_bad_chunks(chunks: &[ChunkInfo]) -> Option<ControlFlow<Response, (Vec<u8>, HeaderMap)>> {
for chunk in chunks {
if chunk.offset < 0 || chunk.size < 0 {
return Some(ControlFlow::Break(
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("invalid negative chunk offset/size in {}", chunk.fid),
)
.into_response(),
));
}
}
None
}
/// `None` is a u64 overflow or a sum that does not fit in i64.
fn sum_chunk_sizes(chunks: &[ChunkInfo]) -> Option<i64> {
let mut sum: u64 = 0;
for chunk in chunks {
if chunk.size < 0 {
return None;
}
sum = sum.checked_add(chunk.size as u64)?;
}
if sum > i64::MAX as u64 {
return None;
}
Some(sum as i64)
}
fn exceeds_expansion_limit(buffered: u64) -> bool {
buffered > MAX_EXPANSION_BYTES
}
fn manifest_too_large() -> ControlFlow<Response, (Vec<u8>, HeaderMap)> {
ControlFlow::Break(
(
StatusCode::PAYLOAD_TOO_LARGE,
"chunk manifest exceeds expansion limit",
)
.into_response(),
)
}
fn chunk_manifest_response_headers(
n: &Needle,
query: &ReadQueryParams,
etag: &str,
last_modified_str: &Option<String>,
filename: &str,
manifest_mime: &str,
) -> HeaderMap {
let content_type = {
if !manifest_mime.is_empty() && !manifest_mime.starts_with("application/octet-stream") {
manifest_mime.to_string()
} else {
let ext = extension_of(filename);
if !ext.is_empty() {
mime_guess::from_ext(ext.trim_start_matches('.'))
.first()
.map(|m| m.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string())
} else if !manifest_mime.is_empty() {
manifest_mime.to_string()
} else {
"application/octet-stream".to_string()
}
}
};
let mut response_headers = HeaderMap::new();
if let Ok(etag_val) = etag.parse() {
response_headers.insert(header::ETAG, etag_val);
}
// `mime` is stored JSON; fall back when it is not a legal header value.
let content_type_value = content_type
.parse()
.unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));
response_headers.insert(header::CONTENT_TYPE, content_type_value);
response_headers.insert("X-File-Store", "chunked".parse().unwrap());
if let Some(lm) = last_modified_str
&& let Ok(hval) = lm.parse()
{
response_headers.insert(header::LAST_MODIFIED, hval);
}
if n.has_pairs()
&& !n.pairs.is_empty()
&& let Ok(pair_map) =
serde_json::from_slice::<std::collections::HashMap<String, String>>(&n.pairs)
{
for (k, v) in &pair_map {
if let (Ok(hname), Ok(hval)) = (
axum::http::HeaderName::from_bytes(k.as_bytes()),
axum::http::HeaderValue::from_str(v),
) {
response_headers.insert(hname, hval);
}
}
}
if let Some(ref cc) = query.response_cache_control
&& let Ok(hval) = cc.parse()
{
response_headers.insert(header::CACHE_CONTROL, hval);
}
if let Some(ref ce) = query.response_content_encoding
&& let Ok(hval) = ce.parse()
{
response_headers.insert(header::CONTENT_ENCODING, hval);
}
if let Some(ref exp) = query.response_expires
&& let Ok(hval) = exp.parse()
{
response_headers.insert(header::EXPIRES, hval);
}
if let Some(ref cl) = query.response_content_language
&& let Ok(hval) = cl.parse()
{
response_headers.insert("Content-Language", hval);
}
if let Some(ref cd) = query.response_content_disposition
&& let Ok(hval) = cd.parse()
{
response_headers.insert(header::CONTENT_DISPOSITION, hval);
}
if !filename.is_empty() {
let disposition_type = if let Some(ref dl_val) = query.dl {
if parse_go_bool(dl_val).unwrap_or(false) {
"attachment"
} else {
"inline"
}
} else {
"inline"
};
let disposition = format_content_disposition(disposition_type, filename);
if let Ok(hval) = disposition.parse() {
response_headers.insert(header::CONTENT_DISPOSITION, hval);
}
}
response_headers
}
/// Try to expand a chunk manifest needle into its assembled body and reply
/// headers. The Range of a GET (`get_range`) is answered here from only the
/// chunks it overlaps. Returns None if manifest can't be parsed.
async fn try_expand_chunk_manifest(
state: &Arc<VolumeServerState>,
n: &Needle,
path: &str,
query: &ReadQueryParams,
get_range: Option<&str>,
method: &Method,
) -> Option<ControlFlow<Response, (Vec<u8>, HeaderMap)>> {
let data = if n.is_compressed() {
match maybe_decompress_gzip(&n.data) {
Ok(d) => d,
Err(GunzipError::TooLarge) => {
return Some(ControlFlow::Break(
(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed manifest exceeds decompression limit",
)
.into_response(),
));
}
Err(GunzipError::Decode) => return None,
}
} else {
n.data.clone()
};
let manifest: ChunkManifest = match serde_json::from_slice(&data) {
Ok(m) => m,
Err(_) => return None,
};
if let Some(flow) = reject_bad_chunks(&manifest.chunks) {
return Some(flow);
}
let Some(sum) = sum_chunk_sizes(&manifest.chunks) else {
return Some(manifest_too_large());
};
// Go's tryHandleChunkedFile: ext comes from the URL (including a fid
// suffix like /vid,fid.png) and falls back to the resolved filename's.
let filename = manifest_filename(path, &manifest.name);
let url_ext = extract_extension_from_path(path);
let cm_ext = if url_ext.is_empty() {
extension_of(&filename)
} else {
url_ext
};
let transforms_image = has_image_ops(&cm_ext, query);
let (etag, last_modified_str) = etag_and_last_modified(n);
let mut response_headers = chunk_manifest_response_headers(
n,
query,
&etag,
&last_modified_str,
&filename,
&manifest.mime,
);
if *method == Method::HEAD && !transforms_image {
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
response_headers.insert(header::CONTENT_LENGTH, sum.to_string().parse().unwrap());
return Some(ControlFlow::Break(
(StatusCode::OK, response_headers).into_response(),
));
}
let mut wanted: Option<(&str, Vec<HttpRange>)> = None;
if transforms_image {
if exceeds_expansion_limit(sum as u64) {
return Some(manifest_too_large());
}
} else if let Some(range) = get_range {
let parsed = parse_range_header(range, sum);
let fetch = match &parsed {
Err(_) => false,
Ok(ranges) if ranges.is_empty() || sum_ranges_size(ranges) > sum => false,
Ok(ranges) => {
let covered = sum_ranges_size(ranges);
if covered > 0 && exceeds_expansion_limit(covered as u64) {
return Some(manifest_too_large());
}
if buffered_span_exceeds_limit(&manifest.chunks, sum, ranges) {
return Some(manifest_too_large());
}
true
}
};
if !fetch {
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
return Some(ControlFlow::Break(handle_range_request_with(
range,
sum,
|_buf: &mut Vec<u8>, _, _| {},
response_headers,
None,
)));
}
wanted = Some((range, parsed.unwrap()));
} else if exceeds_expansion_limit(sum as u64) {
return Some(manifest_too_large());
}
let size = sum as usize;
type Parts = HashMap<(usize, usize), Vec<(usize, Vec<u8>)>>;
let mut parts: Parts = HashMap::new();
let mut result = if wanted.is_some() {
Vec::new()
} else {
vec![0u8; size]
};
for chunk in &manifest.chunks {
let Some(window) = chunk_window(chunk.offset, chunk.size, sum) else {
continue;
};
let ranges = wanted.as_ref().map(|(_, ranges)| ranges.as_slice());
let Some(how) = remote_read_for(chunk.offset, window, ranges) else {
continue;
};
let fetched = match read_chunk_needle(state, &chunk.fid, how).await {
Ok(d) => d,
Err(e) => {
return Some(ControlFlow::Break(
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("read chunk {}: {}", chunk.fid, e),
)
.into_response(),
));
}
};
let offset = chunk.offset as usize;
let file_lo = offset + fetched.origin;
if file_lo >= size {
continue;
}
let window_end = offset + window as usize;
let copy_len = fetched
.data
.len()
.min(window_end.saturating_sub(file_lo))
.min(size - file_lo);
if let Some((_, ranges)) = &wanted {
let file_hi = file_lo + copy_len;
let key = |r: &HttpRange| (r.start as usize, (r.start + r.length) as usize);
for r in ranges {
if r.length <= 0 {
continue;
}
let rs = r.start as usize;
let re = rs + r.length as usize;
let lo = rs.max(file_lo);
let hi = re.min(file_hi);
if lo < hi {
parts
.entry(key(r))
.or_default()
.push((lo, fetched.data[lo - file_lo..hi - file_lo].to_vec()));
}
}
} else {
result[file_lo..file_lo + copy_len].copy_from_slice(&fetched.data[..copy_len]);
}
}
if let Some((range, _)) = wanted {
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
let read = |buf: &mut Vec<u8>, start: usize, end: usize| {
let base = buf.len();
buf.resize(base + end - start, 0);
if let Some(range_parts) = parts.get(&(start, end)) {
for (offset, data) in range_parts {
let (lo, hi) = (start.max(*offset), end.min(offset + data.len()));
if lo < hi {
buf[base + lo - start..base + hi - start]
.copy_from_slice(&data[lo - offset..hi - offset]);
}
}
}
};
return Some(ControlFlow::Break(handle_range_request_with(
range,
sum,
read,
response_headers,
None,
)));
}
if is_image_crop_ext(&cm_ext) {
result = maybe_crop_image(&result, &cm_ext, query);
}
if is_image_resize_ext(&cm_ext) {
result = maybe_resize_image(&result, &cm_ext, query);
}
Some(ControlFlow::Continue((result, response_headers)))
}
#[derive(Clone, Copy)]
enum RemoteRead {
/// GET with no Range. Keep at most `cap` bytes. Origin 0.
Whole { cap: u64 },
/// `Range: bytes={lo}-{hi-1}`. The returned bytes start at chunk offset `lo`.
Range { lo: u64, hi: u64 },
}
struct ChunkBytes {
/// Chunk offset of `data[0]`.
origin: usize,
data: Vec<u8>,
}
fn chunk_window(offset: i64, size: i64, sum: i64) -> Option<i64> {
if size <= 0 || offset >= sum {
return None;
}
Some(size.min(sum - offset))
}
/// Overlap of `[offset, offset+window)` with `ranges`, as chunk-relative `[lo, hi)`.
fn overlap_span(offset: i64, window: i64, ranges: &[HttpRange]) -> Option<(i64, i64)> {
let file_hi = offset + window;
let mut lo = i64::MAX;
let mut hi = i64::MIN;
for r in ranges {
if r.length <= 0 {
continue;
}
let start = r.start.max(offset);
let end = (r.start + r.length).min(file_hi);
if start < end {
lo = lo.min(start);
hi = hi.max(end);
}
}
if lo < hi {
Some((lo - offset, hi - offset))
} else {
None
}
}
/// One fetched span over `MAX_EXPANSION_BYTES`. The covered-length check does
/// not see the gap between two ranges on the same chunk.
fn buffered_span_exceeds_limit(chunks: &[ChunkInfo], sum: i64, ranges: &[HttpRange]) -> bool {
chunks.iter().any(|chunk| {
let Some(window) = chunk_window(chunk.offset, chunk.size, sum) else {
return false;
};
let Some((lo, hi)) = overlap_span(chunk.offset, window, ranges) else {
return false;
};
exceeds_expansion_limit((hi - lo) as u64)
})
}
fn remote_read_for(offset: i64, window: i64, ranges: Option<&[HttpRange]>) -> Option<RemoteRead> {
let (lo, hi) = match ranges {
None => (0, window),
Some(ranges) => overlap_span(offset, window, ranges)?,
};
if lo == 0 && hi == window {
Some(RemoteRead::Whole { cap: window as u64 })
} else {
Some(RemoteRead::Range {
lo: lo as u64,
hi: hi as u64,
})
}
}
fn content_range_first_byte(headers: &HeaderMap) -> Option<u64> {
let values: Vec<_> = headers.get_all(header::CONTENT_RANGE).iter().collect();
if values.len() != 1 {
return None;
}
let value = values[0].to_str().ok()?;
let rest = value.trim().strip_prefix("bytes ")?;
let (start_end, _) = rest.split_once('/')?;
let (start, end) = start_end.split_once('-')?;
if start.trim().is_empty() || start.trim() == "*" || end.trim().is_empty() {
return None;
}
start.trim().parse().ok()
}
async fn read_limited_body(resp: &mut reqwest::Response, cap: usize) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
while out.len() < cap {
match resp.chunk().await {
Ok(Some(chunk)) => {
let room = cap - out.len();
if chunk.len() <= room {
out.extend_from_slice(&chunk);
} else {
out.extend_from_slice(&chunk[..room]);
break;
}
}
Ok(None) => break,
Err(e) => return Err(format!("read body: {e}")),
}
}
Ok(out)
}
/// Read one chunk-manifest chunk's final (decompressed) content bytes from
/// wherever it lives: a local regular volume, a local EC volume
/// (reconstruct-on-read from surviving shards), or a peer resolved via the
/// master. Mirrors Go's ChunkedFileReader, which looks every chunk up through
/// the master instead of assuming a local regular needle.
async fn read_chunk_needle(
state: &Arc<VolumeServerState>,
fid: &str,
how: RemoteRead,
) -> Result<ChunkBytes, String> {
let (vid, nid, cookie) =
parse_url_path(fid).ok_or_else(|| format!("invalid chunk fid: {}", fid))?;
// Decide where the chunk lives under one store read lock; drop it before any
// await (the EC and remote paths are async).
enum Placement {
Ec,
Remote,
Local(std::sync::Arc<crate::storage::volume::Volume>),
}
let placement = {
let store = state.store.read().unwrap();
if let Some((_, vol)) = store.find_volume(vid) {
Placement::Local(vol)
} else if store.find_ec_volume(vid).is_some() {
Placement::Ec
} else {
Placement::Remote
}
};
match placement {
Placement::Local(vol) => {
let mut n = Needle {
id: nid,
cookie,
..Needle::default()
};
vol.read_needle(&mut n)
.map_err(|e| format!("{}", e))
.and_then(|_| cookie_checked_chunk(n, cookie))
.map(|data| ChunkBytes { origin: 0, data })
}
Placement::Ec => {
match crate::server::store_ec::read_ec_shard_needle_distributed(state, vid, nid).await {
Ok(Some(n)) => {
cookie_checked_chunk(n, cookie).map(|data| ChunkBytes { origin: 0, data })
}
Ok(None) => Err("not found".to_string()),
Err(e) => Err(format!("{}", e)),
}
}
// The peer serves through its own GET handler, which validates the cookie.
Placement::Remote => read_remote_chunk_needle(state, vid, fid, how).await,
}
}
/// Validate a locally-read chunk's cookie against the one in its fid, then return
/// its content bytes. The main GET paths check the cookie after a read; a chunk
/// read must do the same so a stale/guessed id can't serve another needle's data.
fn cookie_checked_chunk(n: Needle, cookie: Cookie) -> Result<Vec<u8>, String> {
if n.cookie != cookie {
return Err("not found".to_string());
}
decompress_chunk(n)
}
/// Return a needle's content bytes, decompressing gzip payloads the way the read
/// handler does for a client that did not ask for gzip.
fn decompress_chunk(n: Needle) -> Result<Vec<u8>, String> {
if n.is_compressed() {
match maybe_decompress_gzip(&n.data) {
Ok(d) => Ok(d),
Err(GunzipError::TooLarge) => {
Err("compressed chunk exceeds decompression limit".to_string())
}
// Not valid gzip; keep the raw bytes, matching the prior fallback.
Err(GunzipError::Decode) => Ok(n.data),
}
} else {
Ok(n.data)
}
}
/// Fetch a chunk that is not hosted locally from a peer volume server. The peer
/// serves the final (decompressed) bytes whether the chunk is on a regular or EC
/// volume, so a chunk whose EC shards live elsewhere is still reconstructed on
/// the holder's side. Mirrors Go's ChunkedFileReader.readChunkNeedle.
async fn read_remote_chunk_needle(
state: &Arc<VolumeServerState>,
vid: VolumeId,
fid: &str,
how: RemoteRead,
) -> Result<ChunkBytes, String> {
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.current_master().await,
vid.0,
)
.await?;
if locations.is_empty() {
return Err("not found".to_string());
}
let mut last_err = String::new();
for loc in &locations {
// Skip self: the local paths already ruled this server out.
if loc.url.contains(&state.self_url) {
continue;
}
let target_http = to_http_address(&loc.url);
let url = match normalize_outgoing_http_url(
&state.outgoing_http_scheme,
&format!("{}/{}?proxied=true", target_http, fid),
) {
Ok(u) => u,
Err(e) => {
last_err = e;
continue;
}
};
let mut req = state.http_client.get(&url);
let (origin, cap) = match how {
RemoteRead::Whole { cap } => (0usize, cap as usize),
RemoteRead::Range { lo, hi } => {
req = req.header(header::RANGE, format!("bytes={lo}-{}", hi - 1));
(lo as usize, (hi - lo) as usize)
}
};
match req.send().await {
Ok(mut resp) => {
let status = resp.status();
let err = match how {
RemoteRead::Whole { .. } if status.is_success() => None,
RemoteRead::Range { lo, .. }
if status.as_u16() == StatusCode::PARTIAL_CONTENT.as_u16() =>
{
match content_range_first_byte(resp.headers()) {
Some(start) if start == lo => None,
_ => Some(format!("{url} Content-Range does not start at {lo}")),
}
}
_ => Some(format!("{url} returned {status}")),
};
if let Some(err) = err {
last_err = err;
continue;
}
match read_limited_body(&mut resp, cap).await {
Ok(data) => return Ok(ChunkBytes { origin, data }),
Err(e) => last_err = format!("read body from {url}: {e}"),
}
}
Err(e) => last_err = format!("request to {url} failed: {e}"),
}
}
Err(if last_err.is_empty() {
"not found".to_string()
} else {
last_err
})
}
// ============================================================================
// Helpers
// ============================================================================
fn build_disk_statuses(store: &crate::storage::store::Store) -> Vec<serde_json::Value> {
let mut disk_statuses = Vec::new();
for loc in &store.locations {
let resolved_dir = absolute_display_path(&loc.directory);
let (all, free) = crate::storage::disk_location::get_disk_stats(&resolved_dir);
let used = all.saturating_sub(free);
let percent_free = if all > 0 {
(free as f64 / all as f64) * 100.0
} else {
0.0
};
let percent_used = if all > 0 {
(used as f64 / all as f64) * 100.0
} else {
0.0
};
// Match Go encoding/json on protobuf struct (snake_case json tags)
disk_statuses.push(serde_json::json!({
"dir": resolved_dir,
"all": all,
"used": used,
"free": free,
"percent_free": percent_free,
"percent_used": percent_used,
"disk_type": loc.disk_type.to_string(),
}));
}
disk_statuses
}
/// Serialize to JSON with 1-space indent (matches Go's `json.MarshalIndent(obj, "", " ")`).
fn to_pretty_json<T: Serialize>(value: &T) -> String {
let mut buf = Vec::new();
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
value.serialize(&mut ser).unwrap();
String::from_utf8(buf).unwrap()
}
fn json_response_with_params<T: Serialize>(
status: StatusCode,
body: &T,
params: Option<&ReadQueryParams>,
) -> Response {
let is_pretty = params
.and_then(|params| params.pretty.as_ref())
.is_some_and(|value| !value.is_empty());
let json_body = if is_pretty {
to_pretty_json(body)
} else {
serde_json::to_string(body).unwrap()
};
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from(json_body))
.unwrap()
}
/// Return a JSON error response, honoring `?pretty=<any non-empty value>` for pretty-printed JSON.
pub(super) fn json_error_with_query(
status: StatusCode,
msg: impl Into<String>,
query: Option<&str>,
) -> Response {
let body = serde_json::json!({"error": msg.into()});
let is_pretty = query.is_some_and(|q| {
q.split('&')
.any(|p| p.starts_with("pretty=") && p.len() > "pretty=".len())
});
let json_body = if is_pretty {
to_pretty_json(&body)
} else {
serde_json::to_string(&body).unwrap()
};
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from(json_body))
.unwrap()
}
/// Return a JSON response honoring `?pretty=<any non-empty value>` from a raw query string.
fn json_result_with_query<T: Serialize>(status: StatusCode, body: &T, query: &str) -> Response {
let is_pretty = query
.split('&')
.any(|p| p.starts_with("pretty=") && p.len() > "pretty=".len());
let json_body = if is_pretty {
to_pretty_json(body)
} else {
serde_json::to_string(body).unwrap()
};
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from(json_body))
.unwrap()
}
/// Extract JWT token from query param, Authorization header, or Cookie.
/// Query param takes precedence over header, header over cookie.
fn extract_jwt(headers: &HeaderMap, uri: &axum::http::Uri) -> Option<String> {
// 1. Check ?jwt= query parameter
if let Some(query) = uri.query() {
for pair in query.split('&') {
if let Some(value) = pair.strip_prefix("jwt=")
&& !value.is_empty()
{
return Some(value.to_string());
}
}
}
// 2. Check Authorization: Bearer <token> (case-insensitive prefix)
if let Some(auth) = headers.get(header::AUTHORIZATION)
&& let Ok(auth_str) = auth.to_str()
&& auth_str.len() > 7
&& auth_str[..7].eq_ignore_ascii_case("bearer ")
{
return Some(auth_str[7..].to_string());
}
// 3. Check Cookie
if let Some(cookie_header) = headers.get(header::COOKIE)
&& let Ok(cookie_str) = cookie_header.to_str()
{
for cookie in cookie_str.split(';') {
let cookie = cookie.trim();
if let Some(value) = cookie.strip_prefix("AT=")
&& !value.is_empty()
{
return Some(value.to_string());
}
}
}
None
}
// ============================================================================
// Auto-compression helpers (matches Go's util.IsCompressableFileType)
// ============================================================================
/// Check if a file type should be compressed based on extension and MIME type.
/// Returns true only when we are sure the type is compressible.
fn is_compressible_file_type(ext: &str, mtype: &str) -> bool {
let ext = ext.to_ascii_lowercase();
let ext = ext.as_str();
let mtype = mtype
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let mtype = mtype.as_str();
// text/*
if mtype.starts_with("text/") {
return true;
}
// Compressible image/audio formats
match ext {
".svg" | ".bmp" | ".wav" => return true,
_ => {}
}
// Most image/* formats are already compressed
if mtype.starts_with("image/") {
return false;
}
// By file extension
match ext {
".zip" | ".rar" | ".gz" | ".bz2" | ".xz" | ".zst" | ".br" => return false,
".pdf" | ".txt" | ".html" | ".htm" | ".css" | ".js" | ".json" => return true,
".php" | ".java" | ".go" | ".rb" | ".c" | ".cpp" | ".h" | ".hpp" => return true,
".png" | ".jpg" | ".jpeg" => return false,
_ => {}
}
// By MIME type
if mtype.starts_with("application/") {
if mtype.ends_with("zstd") {
return false;
}
if mtype.ends_with("xml") {
return true;
}
if mtype.ends_with("script") {
return true;
}
if mtype.ends_with("json") || mtype.ends_with("json-seq") {
return true;
}
if mtype.ends_with("vnd.rar") {
return false;
}
}
if mtype.starts_with("audio/") {
let sub = mtype.strip_prefix("audio/").unwrap_or("");
if matches!(sub, "wave" | "wav" | "x-wav" | "x-pn-wav") {
return true;
}
}
false
}
/// One-part multipart upload of a stored needle, the form Go's upload_content
/// sends: the bytes as stored, labeled with their content encoding.
fn replica_upload_body(
filename: &str,
mime: &str,
content_encoding: Option<&str>,
content_md5: Option<&str>,
data: &[u8],
) -> (String, bytes::Bytes) {
let boundary = uuid::Uuid::new_v4().simple().to_string();
let mut head = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
filename.replace('\\', "\\\\").replace('"', "\\\"")
);
if !mime.is_empty() {
head.push_str(&format!("Content-Type: {mime}\r\n"));
}
if let Some(encoding) = content_encoding {
head.push_str(&format!("Content-Encoding: {encoding}\r\n"));
}
if let Some(md5) = content_md5 {
head.push_str(&format!("Content-MD5: {md5}\r\n"));
}
head.push_str("\r\n");
let tail = format!("\r\n--{boundary}--\r\n");
let mut body = Vec::with_capacity(head.len() + data.len() + tail.len());
body.extend_from_slice(head.as_bytes());
body.extend_from_slice(data);
body.extend_from_slice(tail.as_bytes());
(
format!("multipart/form-data; boundary={boundary}"),
body.into(),
)
}
/// Try to gzip data. Returns None on error.
fn try_gzip_data(data: &[u8]) -> Option<Vec<u8>> {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).ok()?;
encoder.finish().ok()
}
fn maybe_decompress_gzip(data: &[u8]) -> Result<Vec<u8>, GunzipError> {
use flate2::read::GzDecoder;
use std::io::Read;
// Cap the output so a crafted, highly-compressible (gzip-bomb) needle cannot
// OOM the server when decompressed on read or upload. take(limit+1) lets us
// tell "exactly at the limit" apart from "over it", which we reject as
// TooLarge so callers fail the request instead of silently using raw bytes.
let mut decoder = GzDecoder::new(data).take(MAX_EXPANSION_BYTES + 1);
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.map_err(|_| GunzipError::Decode)?;
if decompressed.len() as u64 > MAX_EXPANSION_BYTES {
return Err(GunzipError::TooLarge);
}
Ok(decompressed)
}
fn compute_md5_base64(data: &[u8]) -> String {
use base64::Engine;
use md5::{Digest, Md5};
let mut hasher = Md5::new();
hasher.update(data);
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
}
fn clean_windows_path_base(value: &str) -> String {
let cleaned = value.replace('\\', "/");
cleaned.rsplit('/').next().unwrap_or(&cleaned).to_string()
}
fn parse_content_disposition_filename(value: &str) -> Option<String> {
let mut filename: Option<String> = None;
let mut name: Option<String> = None;
for segment in value.split(';') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let lower = segment.to_ascii_lowercase();
if lower.starts_with("filename=") {
let raw = segment[9..].trim();
let trimmed = raw
.strip_prefix('\"')
.and_then(|s| s.strip_suffix('\"'))
.unwrap_or(raw);
filename = Some(clean_windows_path_base(trimmed));
} else if lower.starts_with("name=") {
let raw = segment[5..].trim();
let trimmed = raw
.strip_prefix('\"')
.and_then(|s| s.strip_suffix('\"'))
.unwrap_or(raw);
name = Some(clean_windows_path_base(trimmed));
}
}
let candidate = filename.or(name);
candidate.filter(|s| !s.is_empty())
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
/// A replica that reads the forward and never answers. Each request's
/// first line is sent on the returned channel.
async fn spawn_silent_replica() -> (
std::net::SocketAddr,
tokio::sync::mpsc::UnboundedReceiver<String>,
) {
use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (received, receipts) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let received = received.clone();
tokio::spawn(async move {
let mut request = vec![0u8; 4096];
let n = socket.read(&mut request).await.unwrap_or(0);
let text = String::from_utf8_lossy(&request[..n]).into_owned();
let _ = received.send(text.lines().next().unwrap_or("").to_string());
std::future::pending::<()>().await;
});
}
});
(addr, receipts)
}
/// The whole replicated write path, not only the request builder: the
/// replica receives the forward, never answers, and the write fails at
/// the bound for its body with an error naming that replica.
#[tokio::test]
async fn test_replicated_write_fails_at_the_bound_when_a_peer_never_answers() {
let tmp = tempfile::TempDir::new().unwrap();
let (peer, mut receipts) = spawn_silent_replica().await;
let state = remote_manifest_state(&tmp, spawn_lookup(peer).await);
let body = bytes::Bytes::from(vec![0u8; REPLICA_MIN_BYTES_PER_SEC as usize]);
let bound = replica_request_timeout(body.len());
let started = std::time::Instant::now();
let sent = do_replicated_request(
&state,
1,
axum::http::Method::POST,
"/1,01ab",
"",
&axum::http::HeaderMap::new(),
Some(body),
)
.await;
let took = started.elapsed();
let forwarded = receipts
.try_recv()
.expect("the replica never received the forward");
assert!(
forwarded.starts_with("POST /1,01ab?") && forwarded.contains("type=replicate"),
"{forwarded}"
);
let error = sent.expect_err("a silent replica cannot ack the write");
assert!(error.contains(&peer.to_string()), "{error}");
assert!(
took >= bound && took < bound + std::time::Duration::from_secs(5),
"{took:?}"
);
}
/// A peer that accepts the connection but never answers (a silent
/// partition after connect) must fail the forward at the bound, not hold
/// the write until TCP gives up.
#[tokio::test(start_paused = true)]
async fn test_replica_request_times_out_on_a_silent_peer() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let _held = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
std::future::pending::<()>().await;
drop(socket);
});
let client = reqwest::Client::new();
let url = format!("http://{}/3,01ab?type=replicate", addr);
let body = vec![0u8; 1 << 20];
let bound = replica_request_timeout(body.len());
let started = tokio::time::Instant::now();
let sent = tokio::time::timeout(
std::time::Duration::from_secs(600),
replica_request(&client, axum::http::Method::POST, &url, body.len())
.body(body)
.send(),
)
.await
.expect("replica forward must not outlive its bound");
let err = sent.expect_err("a silent peer cannot ack the replica");
assert!(err.is_timeout(), "{err}");
assert!(started.elapsed() >= bound && started.elapsed() < bound * 2);
}
/// A forward's bound grows with its body at REPLICA_MIN_BYTES_PER_SEC, so
/// a write near the file size limit is not cut off at the bodiless bound.
#[test]
fn test_replica_request_bound_grows_with_the_body() {
assert_eq!(replica_request_timeout(0), REPLICA_REQUEST_TIMEOUT);
let mib = REPLICA_MIN_BYTES_PER_SEC as usize;
assert_eq!(
replica_request_timeout(256 * mib),
REPLICA_REQUEST_TIMEOUT + std::time::Duration::from_secs(256)
);
assert_eq!(
replica_request_timeout(mib / 2),
REPLICA_REQUEST_TIMEOUT + std::time::Duration::from_millis(500)
);
let request = replica_request(
&reqwest::Client::new(),
axum::http::Method::POST,
"http://127.0.0.1:1/3,01ab?type=replicate",
64 * mib,
)
.build()
.unwrap();
assert_eq!(
request.timeout(),
Some(&(REPLICA_REQUEST_TIMEOUT + std::time::Duration::from_secs(64)))
);
}
/// The upload handler reads fsync off the decoded query fields rather than
/// matching the raw string, because Go's r.FormValue decodes and a raw
/// match would silently drop a percent-encoded value.
#[test]
fn test_encoded_query_field_decodes() {
let raw = "fsync=%74rue";
assert!(
!raw.split('&').any(|p| p == "fsync=true"),
"a raw match is exactly what misses this"
);
let fields: Vec<(String, String)> = serde_urlencoded::from_str(raw).unwrap();
assert_eq!(
fields
.iter()
.find(|(k, _)| k == "fsync")
.map(|(_, v)| v.as_str()),
Some("true")
);
}
/// Every URL form the volume server accepts, against what Go's
/// `parseURLPath` returns for it (`weed/server/common.go:218-249`):
/// the `vid/fid/filename` form leaves the fid alone and takes the
/// extension off the filename, the other two take it off the fid, and
/// none of them touch an `_delta` suffix.
#[test]
fn test_parse_needle_path_every_form() {
let cases: &[(&str, &str, &str, &str, Option<&str>)] = &[
// "vid,fid" — Go's default branch.
("/3,01637037d6", "3", "01637037d6", "", None),
("/3,01637037d6.jpg", "3", "01637037d6", ".jpg", None),
("/3,01637037d6_1", "3", "01637037d6_1", "", None),
("/3,01637037d6_1.jpg", "3", "01637037d6_1", ".jpg", None),
// "vid/fid" — Go's case 2.
("/3/01637037d6", "3", "01637037d6", "", None),
("/3/01637037d6.jpg", "3", "01637037d6", ".jpg", None),
("/3/01637037d6_1", "3", "01637037d6_1", "", None),
("/3/01637037d6_1.jpg", "3", "01637037d6_1", ".jpg", None),
// "vid/fid/filename" — Go's case 3: the fid stays whole.
(
"/3/01637037d6/report",
"3",
"01637037d6",
"",
Some("report"),
),
(
"/3/01637037d6/report.txt",
"3",
"01637037d6",
".txt",
Some("report.txt"),
),
(
"/3/01637037d6_1/report",
"3",
"01637037d6_1",
"",
Some("report"),
),
(
"/3/01637037d6_1/report.txt",
"3",
"01637037d6_1",
".txt",
Some("report.txt"),
),
// Mixed forms: the slash count decides, so a comma with a trailing
// segment is read as "vid/fid" (Go case 2, and the volume id then
// fails to parse) and a comma inside a filename is just part of
// the filename (Go case 3).
(
"/3,01637037d6/name.txt",
"3,01637037d6",
"name",
".txt",
None,
),
(
"/3/01637037d6/my,file.jpg",
"3",
"01637037d6",
".jpg",
Some("my,file.jpg"),
),
// Go's case 2 guards the extension split with `dotIndex > 0`, so a
// file id that is nothing but an extension keeps it and fails to
// parse later; filepath.Ext in case 3 has no such guard.
("/3/.jpg", "3", ".jpg", "", None),
(
"/3/01637037d6/.jpg",
"3",
"01637037d6",
".jpg",
Some(".jpg"),
),
];
for &(path, vid, fid, ext, filename) in cases {
assert_eq!(
parse_needle_path(path),
Some(NeedlePath {
vid,
fid,
ext,
filename
}),
"path {}",
path
);
// Chunk manifest entries arrive without the leading slash.
let unrooted = path.trim_start_matches('/');
assert_eq!(
parse_needle_path(unrooted),
parse_needle_path(path),
"path {}",
unrooted
);
}
}
/// A path with no file id at all is Go's `isVolumeIdOnly` case; every
/// caller here needs a file id, so it has to come back as `None`.
#[test]
fn test_parse_needle_path_rejects_paths_without_a_file_id() {
for path in ["", "/", "/3", "/invalid", "/not/a/valid/volume/path"] {
assert_eq!(parse_needle_path(path), None, "path {}", path);
}
}
/// Go's `maybeCheckJwtAuthorization` compares the token's `fid` claim
/// against `vid + "," + fid` whatever form the URL used
/// (volume_server_handlers.go:361-364), so the slash form has to produce
/// the comma form too or a JWT-protected read of `/3/01637037d6` can never
/// match its own token.
#[test]
fn test_extract_file_id_normalizes_every_url_form() {
for path in [
"/3,01637037d6",
"/3,01637037d6.jpg",
"/3,01637037d6_1",
"/3/01637037d6",
"/3/01637037d6.jpg",
"/3/01637037d6_1.jpg",
"/3/01637037d6/report.txt",
"/3/01637037d6_1/report.txt",
] {
assert_eq!(extract_file_id(path), "3,01637037d6", "path {}", path);
}
}
/// Go only drops the `_suffix` when `strings.LastIndex(fid, "_") > 0`
/// (volume_server_handlers.go:361-364), so a file id that starts with the
/// separator is compared whole rather than becoming empty.
#[test]
fn test_extract_file_id_keeps_a_leading_underscore() {
assert_eq!(extract_file_id("/3,_5"), "3,_5");
assert_eq!(extract_file_id("/3/_5"), "3,_5");
}
/// Go checks the JWT before it parses the volume id
/// (volume_server_handlers_read.go:142-156), and so does
/// get_or_head_handler_inner, which is why an unparsable path still has to
/// produce a comparison string: it decides 401 before parse_url_path gets
/// to decide 400.
#[test]
fn test_extract_file_id_falls_back_for_unparsable_paths() {
assert_eq!(extract_file_id("/invalid"), "invalid");
assert_eq!(extract_file_id("/3"), "3");
assert_eq!(
extract_file_id("/not/a/valid/volume/path"),
"not/a/valid/volume/path"
);
}
#[test]
fn test_parse_url_path_comma() {
let (vid, nid, cookie) = parse_url_path("/3,01637037d6").unwrap();
assert_eq!(vid, VolumeId(3));
assert_eq!(nid, NeedleId(0x01));
assert_eq!(cookie, Cookie(0x637037d6));
}
#[test]
fn test_parse_url_path_with_ext() {
let (vid, _, _) = parse_url_path("/3,01637037d6.jpg").unwrap();
assert_eq!(vid, VolumeId(3));
}
#[test]
fn test_parse_url_path_slash() {
let result = parse_url_path("3/01637037d6");
assert!(result.is_some());
}
#[test]
fn test_parse_url_path_slash_with_filename() {
// A comma in the filename is part of the filename: the slash count
// already decided the form, as in Go's case 3.
for path in ["3/01637037d6/report.txt", "3/01637037d6/my,file.jpg"] {
let (vid, nid, cookie) = parse_url_path(path).unwrap();
assert_eq!(vid, VolumeId(3), "path {}", path);
assert_eq!(nid, NeedleId(0x01), "path {}", path);
assert_eq!(cookie, Cookie(0x637037d6), "path {}", path);
}
}
/// The `_delta` suffix stays on the fid through the path split and is
/// applied by parse_needle_id_cookie, as Go's ParsePath does.
#[test]
fn test_parse_url_path_applies_delta_suffix() {
for path in [
"/3,01637037d6_1",
"/3/01637037d6_1",
"/3/01637037d6_1/a.txt",
] {
let (vid, nid, cookie) = parse_url_path(path).unwrap();
assert_eq!(vid, VolumeId(3), "path {}", path);
assert_eq!(nid, NeedleId(0x02), "path {}", path);
assert_eq!(cookie, Cookie(0x637037d6), "path {}", path);
}
}
#[test]
fn test_parse_url_path_invalid() {
assert!(parse_url_path("/invalid").is_none());
assert!(parse_url_path("").is_none());
// Go reads this as case 2 with vid = "3,01637037d6", which NewVolumeId
// rejects, so the comma-with-a-trailing-segment form is a 400 here too.
assert!(parse_url_path("/3,01637037d6/name.txt").is_none());
// Go's `dotIndex > 0` guard leaves ".jpg" as the file id, which then
// fails to parse as a needle id.
assert!(parse_url_path("/3/.jpg").is_none());
}
#[test]
fn test_parse_range_header_no_overlap() {
assert_eq!(
parse_range_header("bytes=10-", 10).unwrap_err(),
RANGE_NO_OVERLAP
);
assert_eq!(
parse_range_header("bytes=100-", 10).unwrap_err(),
RANGE_NO_OVERLAP
);
// a zero-length suffix selects no bytes; same unsatisfiable class
assert_eq!(
parse_range_header("bytes=-0", 10).unwrap_err(),
RANGE_NO_OVERLAP
);
// 416 only when every range fails to overlap
let ranges = parse_range_header("bytes=10-,0-1", 10).unwrap();
assert_eq!(ranges.len(), 1);
assert_eq!((ranges[0].start, ranges[0].length), (0, 2));
let ranges = parse_range_header("bytes=-0,0-1", 10).unwrap();
assert_eq!(ranges.len(), 1);
assert_eq!((ranges[0].start, ranges[0].length), (0, 2));
// an end past the size is clamped, still satisfiable
let ranges = parse_range_header("bytes=5-100", 10).unwrap();
assert_eq!((ranges[0].start, ranges[0].length), (5, 5));
}
#[test]
fn test_parse_range_header_rejects_negative_suffix() {
// a suffix-length is a non-negative integer; "--5" must not become
// the range {start: 15, length: -5}
for spec in ["bytes=--5", "bytes=--0", "bytes=-"] {
assert_eq!(parse_range_header(spec, 10).unwrap_err(), "invalid range");
}
let ranges = parse_range_header("bytes=-3", 10).unwrap();
assert_eq!((ranges[0].start, ranges[0].length), (7, 3));
}
#[test]
fn test_extract_jwt_bearer() {
let mut headers = HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap());
let uri: axum::http::Uri = "/test".parse().unwrap();
assert_eq!(extract_jwt(&headers, &uri), Some("abc123".to_string()));
}
#[test]
fn test_extract_jwt_query_param() {
let headers = HeaderMap::new();
let uri: axum::http::Uri = "/test?jwt=mytoken".parse().unwrap();
assert_eq!(extract_jwt(&headers, &uri), Some("mytoken".to_string()));
}
#[test]
fn test_extract_jwt_query_over_header() {
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
"Bearer header_token".parse().unwrap(),
);
let uri: axum::http::Uri = "/test?jwt=query_token".parse().unwrap();
assert_eq!(extract_jwt(&headers, &uri), Some("query_token".to_string()));
}
#[test]
fn test_extract_jwt_none() {
let headers = HeaderMap::new();
let uri: axum::http::Uri = "/test".parse().unwrap();
assert_eq!(extract_jwt(&headers, &uri), None);
}
#[test]
fn test_handle_range_single() {
let data = b"hello world";
let headers = HeaderMap::new();
let resp = handle_range_request("bytes=0-4", data, headers, None);
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
}
#[test]
fn test_handle_range_invalid() {
let data = b"hello";
let headers = HeaderMap::new();
let resp = handle_range_request("bytes=999-1000", data, headers, None);
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
let resp = handle_range_request("bytes=-0", data, HeaderMap::new(), None);
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
}
#[tokio::test]
async fn test_stats_memory_handler_matches_go_memstatus_shape() {
let response = stats_memory_handler(Query(ReadQueryParams::default())).await;
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let memory = payload.get("Memory").unwrap();
for key in ["goroutines", "all", "used", "free", "self", "heap", "stack"] {
assert!(memory.get(key).is_some(), "missing key {}", key);
}
}
#[tokio::test]
async fn test_stats_counter_handler_matches_go_json_shape() {
super::super::server_stats::reset_for_tests();
super::super::server_stats::record_read_request();
let response = stats_counter_handler(Query(ReadQueryParams::default())).await;
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
payload.get("Version").and_then(|value| value.as_str()),
Some(crate::version::version())
);
let counters = payload.get("Counters").unwrap();
assert!(counters.get("ReadRequests").is_some());
assert!(counters.get("Requests").is_some());
}
#[test]
fn test_is_compressible_file_type() {
// Text types
assert!(is_compressible_file_type("", "text/html"));
assert!(is_compressible_file_type("", "text/plain"));
assert!(is_compressible_file_type("", "text/css"));
// Compressible by extension
assert!(is_compressible_file_type(".svg", ""));
assert!(is_compressible_file_type(".bmp", ""));
assert!(is_compressible_file_type(".js", ""));
assert!(is_compressible_file_type(".json", ""));
assert!(is_compressible_file_type(".html", ""));
assert!(is_compressible_file_type(".css", ""));
assert!(is_compressible_file_type(".c", ""));
assert!(is_compressible_file_type(".go", ""));
// Already compressed — should NOT compress
assert!(!is_compressible_file_type(".zip", ""));
assert!(!is_compressible_file_type(".gz", ""));
assert!(!is_compressible_file_type(".jpg", ""));
assert!(!is_compressible_file_type(".png", ""));
assert!(!is_compressible_file_type("", "image/jpeg"));
assert!(!is_compressible_file_type("", "image/png"));
// Application subtypes
assert!(is_compressible_file_type("", "application/xml"));
assert!(is_compressible_file_type("", "application/javascript"));
assert!(!is_compressible_file_type("", "application/zstd"));
assert!(!is_compressible_file_type("", "application/vnd.rar"));
assert!(is_compressible_file_type("", "application/json"));
assert!(is_compressible_file_type(
"",
"application/json; charset=utf-8"
));
assert!(is_compressible_file_type("", "Application/JSON"));
assert!(is_compressible_file_type("", "application/ld+json"));
assert!(is_compressible_file_type("", "application/x-ndjson"));
assert!(is_compressible_file_type("", "application/json-seq"));
assert!(is_compressible_file_type("", "text/plain; charset=utf-8"));
assert!(is_compressible_file_type(".JSON", ""));
assert!(!is_compressible_file_type(".gz", "application/json"));
// Audio
assert!(is_compressible_file_type(".wav", "audio/wav"));
assert!(!is_compressible_file_type("", "audio/mpeg"));
// Unknown
assert!(!is_compressible_file_type(
".xyz",
"application/octet-stream"
));
}
#[test]
fn test_try_gzip_data() {
let data = b"hello world hello world hello world";
let compressed = try_gzip_data(data);
assert!(compressed.is_some());
let compressed = compressed.unwrap();
// Compressed data should be different from original
assert!(!compressed.is_empty());
// Verify we can decompress it
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(&compressed[..]);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed).unwrap();
assert_eq!(decompressed, data);
}
#[test]
fn test_maybe_decompress_gzip() {
let data = b"gzip me";
let compressed = try_gzip_data(data).unwrap();
let decompressed = maybe_decompress_gzip(&compressed).unwrap();
assert_eq!(decompressed, data);
// Non-gzip input is reported as a decode error (callers fall back to raw).
assert!(matches!(
maybe_decompress_gzip(data),
Err(GunzipError::Decode)
));
}
#[test]
fn test_parse_content_disposition_filename() {
assert_eq!(
parse_content_disposition_filename("attachment; filename=\"report.txt\""),
Some("report.txt".to_string())
);
assert_eq!(
parse_content_disposition_filename("inline; name=\"hello.txt\""),
Some("hello.txt".to_string())
);
assert_eq!(
parse_content_disposition_filename("name=foo.txt"),
Some("foo.txt".to_string())
);
assert_eq!(
parse_content_disposition_filename("attachment; filename=\"C:\\\\path\\\\file.jpg\""),
Some("file.jpg".to_string())
);
assert_eq!(parse_content_disposition_filename("inline"), None);
}
#[test]
fn test_streaming_chunk_size_respects_configured_read_buffer() {
assert_eq!(
streaming_chunk_size(4 * 1024 * 1024, 8 * 1024 * 1024),
4 * 1024 * 1024
);
assert_eq!(
streaming_chunk_size(32 * 1024, 512 * 1024),
DEFAULT_STREAMING_CHUNK_SIZE
);
assert_eq!(
streaming_chunk_size(8 * 1024 * 1024, 128 * 1024),
128 * 1024
);
}
#[test]
fn test_normalize_outgoing_http_url_rewrites_scheme() {
let url = normalize_outgoing_http_url(
"https",
"http://master.example.com:9333/dir/lookup?volumeId=7",
)
.unwrap();
assert_eq!(url, "https://master.example.com:9333/dir/lookup?volumeId=7");
}
#[test]
fn test_redirect_request_uses_outgoing_http_scheme() {
let info = ProxyRequestInfo {
method: Method::GET,
original_headers: HeaderMap::new(),
original_query: "collection=photos&readDeleted=true".to_string(),
path: "/3,01637037d6".to_string(),
vid_str: "3".to_string(),
fid_str: "01637037d6".to_string(),
};
let target = VolumeLocation {
url: "volume.internal:8080".to_string(),
public_url: "volume.public:8080".to_string(),
grpc_port: 18080,
read_only: false,
read_only_can_delete: false,
};
let response = redirect_request(&info, &target, "https");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
response.headers().get(header::LOCATION).unwrap(),
"https://volume.internal:8080/3,01637037d6?collection=photos&proxied=true"
);
}
/// Defensive coverage for peer-to-peer redirects: if a master ever returns
/// `Location.url` already encoded in `host:httpPort.grpcPort` form,
/// `redirect_request` must still emit a valid HTTP Location header.
#[test]
fn test_redirect_request_strips_grpc_port_from_target_url() {
let info = ProxyRequestInfo {
method: Method::GET,
original_headers: HeaderMap::new(),
original_query: String::new(),
path: "/3,01637037d6".to_string(),
vid_str: "3".to_string(),
fid_str: "01637037d6".to_string(),
};
let target = VolumeLocation {
url: "volume.internal:8080.18080".to_string(),
public_url: "volume.public:8080.18080".to_string(),
grpc_port: 18080,
read_only: false,
read_only_can_delete: false,
};
let response = redirect_request(&info, &target, "http");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
response.headers().get(header::LOCATION).unwrap(),
"http://volume.internal:8080/3,01637037d6?proxied=true"
);
}
/// Go redirects to `vid,fid` with the extension already stripped: its
/// `parseURLPath` case 2 splits `.jpg` off the fid and
/// `proxyReqToTargetServer` formats `"%s/%s,%s"` from what is left
/// (common.go:224-233, volume_server_handlers_read.go:128-137). Built from
/// the real `build_proxy_request_info` so the parse and the Location header
/// are covered together.
#[test]
fn test_redirect_location_drops_extension_for_slash_form() {
let info =
build_proxy_request_info("/3/01637037d6.jpg", &HeaderMap::new(), "", &Method::GET)
.expect("parses");
assert_eq!(info.vid_str, "3");
assert_eq!(info.fid_str, "01637037d6");
let target = VolumeLocation {
url: "volume.internal:8080".to_string(),
public_url: "volume.public:8080".to_string(),
grpc_port: 18080,
read_only: false,
read_only_can_delete: false,
};
let response = redirect_request(&info, &target, "http");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
response.headers().get(header::LOCATION).unwrap(),
"http://volume.internal:8080/3,01637037d6?proxied=true"
);
}
/// Master /dir/lookup often omits publicUrl when empty (Go `json:"publicUrl,omitempty"`).
/// Replication must still parse locations or every cross-DC write fails.
#[test]
fn test_lookup_result_deserializes_without_public_url() {
let body = r#"{
"volumeOrFileId": "9",
"locations": [
{"url": "volume-a.example:8080", "dataCenter": "dc-a", "grpcPort": 18080},
{"url": "volume-b.example:8080", "dataCenter": "dc-b", "grpcPort": 18080}
]
}"#;
let result: LookupResult = serde_json::from_str(body).expect("parse lookup JSON");
let locations = result.locations.expect("locations");
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].url, "volume-a.example:8080");
assert!(locations[0].public_url.is_empty());
assert_eq!(locations[0].public_or_url(), "volume-a.example:8080");
assert_eq!(locations[0].grpc_port, 18080);
}
/// Regression test for issue #9274.
///
/// SeaweedFS encodes a server's gRPC port by appending `.grpcPort` to
/// the HTTP port: e.g. `master.local:9333.19333` (mirrors Go's
/// `pb.ServerAddress`). When the volume server's `--mserver` flag is
/// configured this way, replication writes look up the peer locations
/// from the master over HTTP. Prior to the fix, the lookup URL became
/// `http://host:9333.19333/dir/lookup?volumeId=...` — an invalid port
/// that reqwest rejects with a "builder error", causing every
/// replicated write to fail.
#[tokio::test]
async fn test_lookup_volume_strips_grpc_port_from_master_url() {
use axum::{Router, routing::get};
let app = Router::new().route(
"/dir/lookup",
get(
|axum::extract::Query(params): axum::extract::Query<
std::collections::HashMap<String, String>,
>| async move {
assert_eq!(params.get("volumeId").map(String::as_str), Some("31"));
axum::Json(serde_json::json!({
"volumeOrFileId": "31",
"locations": [
{"url": "10.0.0.2:5301", "publicUrl": "10.0.0.2:5301", "grpcPort": 5311}
]
}))
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
// Mirrors how main.rs receives masters: a `host:httpPort.grpcPort` token.
// The grpc-port part is fictitious here (the master is HTTP-only in
// this test) — what matters is that the volume server must not pass
// it through to reqwest.
let master_url = format!("127.0.0.1:{}.{}", addr.port(), 19999);
let client = reqwest::Client::new();
let locations = super::lookup_volume(&client, "http", &master_url, 31)
.await
.expect("lookup_volume should succeed even when master_url carries a .grpcPort suffix");
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].url, "10.0.0.2:5301");
assert_eq!(locations[0].grpc_port, 5311);
server.abort();
}
/// The master answers a missing volume with 404 and a per-volume `error`
/// in the lookup body. Go's LookupResult.Error never becomes the Go error
/// return — the volume server treats it as empty locations and replies
/// 404. Surfacing it as Err would make proxy-mode reads answer 500.
/// A lookup answered with any other failure status (e.g. 503 while the
/// master warms up) stays an error, like Go's transport-level failure.
#[tokio::test]
async fn test_lookup_volume_missing_volume_returns_empty() {
use axum::{Router, routing::get};
let app = Router::new().route(
"/dir/lookup",
get(|axum::extract::Query(params): axum::extract::Query<
std::collections::HashMap<String, String>,
>| async move {
let (status, error) = match params.get("volumeId").map(String::as_str) {
Some("7") => (StatusCode::NOT_FOUND, Some("volume id 7 not found")),
Some("9") => (StatusCode::SERVICE_UNAVAILABLE, None),
_ => (
StatusCode::SERVICE_UNAVAILABLE,
Some("service warming up, please retry"),
),
};
let mut body = serde_json::json!({
"volumeOrFileId": params.get("volumeId")
});
if let Some(error) = error {
body["error"] = serde_json::json!(error);
}
(status, axum::Json(body))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let master_url = format!("127.0.0.1:{}", addr.port());
let client = reqwest::Client::new();
let locations = super::lookup_volume(&client, "http", &master_url, 7)
.await
.expect("missing volume is not a lookup failure");
assert!(locations.is_empty());
let err = super::lookup_volume(&client, "http", &master_url, 8)
.await
.expect_err("master failure is a lookup failure");
assert!(err.contains("warming up"));
let err = super::lookup_volume(&client, "http", &master_url, 9)
.await
.expect_err("a failed lookup without an error body is still a failure");
assert!(err.contains("503"));
server.abort();
}
/// A body streaming `data_size` bytes of `file` from offset 0.
fn file_streaming_body(
file: &std::fs::File,
data_size: usize,
chunk_size: usize,
expected_checksum: u32,
) -> StreamingBody {
StreamingBody {
source: Arc::new(crate::storage::volume::NeedleStreamSource::Local(
file.try_clone().unwrap(),
)),
data_offset: 0,
data_size: data_size as u32,
pos: 0,
chunk_size,
data_file_access_control: Arc::new(
crate::storage::volume::DataFileAccessControl::default(),
),
hold_read_lock_for_stream: true,
_held_read_lease: None,
io_unavailable: Arc::default(),
io_errors: Arc::default(),
pending: None,
buf: bytes::BytesMut::new(),
state: None,
tracked_bytes: 0,
needle_id: NeedleId(7),
expected_checksum,
crc: CRC(0),
}
}
/// Repeated target response headers (e.g. Set-Cookie) must all reach the
/// client, as Go's `w.Header().Add` does; only `Server` is dropped.
#[tokio::test]
async fn test_proxy_request_keeps_repeated_response_headers() {
use axum::{Router, routing::get};
let app = Router::new().route(
"/3,01637037d6",
get(|| async {
(
axum::response::AppendHeaders([
(header::SET_COOKIE, "a=1"),
(header::SET_COOKIE, "b=2"),
(header::SERVER, "target"),
]),
"payload",
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let info = ProxyRequestInfo {
method: Method::GET,
original_headers: HeaderMap::new(),
original_query: String::new(),
path: "/3,01637037d6".to_string(),
vid_str: "3".to_string(),
fid_str: "01637037d6".to_string(),
};
let target = VolumeLocation {
url: addr.to_string(),
public_url: String::new(),
grpc_port: 0,
read_only: false,
read_only_can_delete: false,
};
let response = proxy_request(&streaming_test_state(), &info, &target).await;
assert_eq!(response.status(), StatusCode::OK);
let cookies: Vec<_> = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect();
assert_eq!(cookies, vec!["a=1", "b=2"]);
assert!(response.headers().get(header::SERVER).is_none());
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"payload");
server.abort();
}
/// Go now forwards the inbound method (volume_server_handlers_read.go), so
/// a client HEAD reaches the replica as HEAD, not GET.
#[tokio::test]
async fn test_proxy_request_preserves_method() {
use axum::{Router, routing::get};
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
let head_count = StdArc::new(AtomicUsize::new(0));
let get_count = StdArc::new(AtomicUsize::new(0));
let app = {
let head_count = head_count.clone();
let get_count = get_count.clone();
Router::new().route(
"/{*path}",
get(move |request: axum::extract::Request| {
let head_count = head_count.clone();
let get_count = get_count.clone();
async move {
if *request.method() == Method::HEAD {
head_count.fetch_add(1, AtomicOrdering::SeqCst);
} else {
get_count.fetch_add(1, AtomicOrdering::SeqCst);
}
StatusCode::OK
}
}),
)
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let target = VolumeLocation {
url: addr.to_string(),
public_url: String::new(),
grpc_port: 0,
read_only: false,
read_only_can_delete: false,
};
let state = streaming_test_state();
for method in [Method::HEAD, Method::GET] {
let info = ProxyRequestInfo {
method,
original_headers: HeaderMap::new(),
original_query: String::new(),
path: "/3,01637037d6".to_string(),
vid_str: "3".to_string(),
fid_str: "01637037d6".to_string(),
};
let response = proxy_request(&state, &info, &target).await;
assert_eq!(response.status(), StatusCode::OK);
}
assert_eq!(head_count.load(AtomicOrdering::SeqCst), 1);
assert_eq!(get_count.load(AtomicOrdering::SeqCst), 1);
server.abort();
}
/// Go's query.Set replaces any proxied params the client sent, so the
/// forwarded query carries a single proxied=true marker — a stale
/// proxied=false cannot outrank it on the replica's first-value lookup.
#[tokio::test]
async fn test_proxy_request_replaces_stale_proxied_param() {
use axum::{Router, routing::get};
use std::sync::Arc as StdArc;
use std::sync::Mutex;
let seen = StdArc::new(Mutex::new(String::new()));
let app = {
let seen = seen.clone();
Router::new().route(
"/{*path}",
get(move |request: axum::extract::Request| {
let seen = seen.clone();
async move {
*seen.lock().unwrap() =
request.uri().query().unwrap_or("").to_string();
StatusCode::OK
}
}),
)
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let target = VolumeLocation {
url: addr.to_string(),
public_url: String::new(),
grpc_port: 0,
read_only: false,
read_only_can_delete: false,
};
let state = streaming_test_state();
let info = ProxyRequestInfo {
method: Method::GET,
original_headers: HeaderMap::new(),
original_query: "proxied=false&collection=photos&proxied".to_string(),
path: "/3,01637037d6".to_string(),
vid_str: "3".to_string(),
fid_str: "01637037d6".to_string(),
};
let response = proxy_request(&state, &info, &target).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
seen.lock().unwrap().as_str(),
"collection=photos&proxied=true"
);
server.abort();
}
/// End-to-end headers like Range reach the replica; hop-by-hop headers do
/// not — a forwarded Content-Length or Host describes this hop, not the
/// replica's, and a lying Content-Length stalls the peer's body read.
#[tokio::test]
async fn test_proxy_request_drops_hop_by_hop_headers() {
use axum::{Router, routing::get};
use std::sync::Arc as StdArc;
use std::sync::Mutex;
let seen = StdArc::new(Mutex::new(Vec::new()));
let app = {
let seen = seen.clone();
Router::new().route(
"/{*path}",
get(move |request: axum::extract::Request| {
let seen = seen.clone();
async move {
let headers: Vec<(String, String)> = request
.headers()
.iter()
.map(|(k, v)| {
(k.as_str().to_string(), v.to_str().unwrap_or("").to_string())
})
.collect();
seen.lock().unwrap().push(headers);
(
[
(header::CONNECTION, "keep-alive, x-resp-marker"),
(header::HeaderName::from_static("keep-alive"), "timeout=5"),
(header::HeaderName::from_static("x-resp-marker"), "private"),
(header::ETAG, "\"peer-etag\""),
],
request
.headers()
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string(),
)
}
}),
)
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let target = VolumeLocation {
url: addr.to_string(),
public_url: String::new(),
grpc_port: 0,
read_only: false,
read_only_can_delete: false,
};
let mut original_headers = HeaderMap::new();
original_headers.insert(header::RANGE, header::HeaderValue::from_static("bytes=0-3"));
original_headers.insert(
header::HOST,
header::HeaderValue::from_static("client-facing:8080"),
);
original_headers.insert(
header::CONTENT_LENGTH,
header::HeaderValue::from_static("999"),
);
original_headers.insert(
header::TRANSFER_ENCODING,
header::HeaderValue::from_static("chunked"),
);
original_headers.insert(
header::CONNECTION,
header::HeaderValue::from_static("keep-alive, x-hop-marker"),
);
original_headers.insert(
"x-hop-marker",
header::HeaderValue::from_static("private"),
);
original_headers.insert(
"proxy-authorization",
header::HeaderValue::from_static("Basic cHJveHk6c2VjcmV0"),
);
let info = ProxyRequestInfo {
method: Method::GET,
original_headers,
original_query: String::new(),
path: "/3,01637037d6".to_string(),
vid_str: "3".to_string(),
fid_str: "01637037d6".to_string(),
};
let response = proxy_request(&streaming_test_state(), &info, &target).await;
assert_eq!(response.status(), StatusCode::OK);
let seen = seen.lock().unwrap();
let headers = seen.first().expect("peer saw a request");
assert!(
headers
.iter()
.any(|(n, v)| n == "range" && v == "bytes=0-3"),
"Range was not forwarded: {headers:?}"
);
for dropped in [
"content-length",
"transfer-encoding",
"connection",
"x-hop-marker",
"proxy-authorization",
] {
assert!(
!headers.iter().any(|(n, _)| n == dropped),
"hop-by-hop header {dropped} reached the replica: {headers:?}"
);
}
// The peer must see its own host, not the client's.
let host = headers
.iter()
.find(|(n, _)| n == "host")
.map(|(_, v)| v.as_str())
.expect("replica request has a host header");
assert_eq!(host, addr.to_string());
// End-to-end response fields reach the client; the replica's
// hop-by-hop fields and Connection-nominated markers do not.
assert_eq!(
response.headers().get(header::ETAG).unwrap(),
"\"peer-etag\""
);
for dropped in ["connection", "keep-alive", "x-resp-marker"] {
assert!(
response.headers().get(dropped).is_none(),
"hop-by-hop response header {dropped} reached the client"
);
}
server.abort();
}
fn streaming_test_state() -> Arc<VolumeServerState> {
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::store::Store;
test_state_with_store(Store::new(NeedleMapKind::InMemory))
}
fn test_state_with_store(store: crate::storage::store::Store) -> Arc<VolumeServerState> {
use crate::security::{Guard, SigningKey};
use crate::server::volume_server::RuntimeMetricsConfig;
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32};
Arc::new(VolumeServerState {
store: RwLock::new(store),
guard: RwLock::new(Guard::new(
&[],
SigningKey(vec![]),
0,
SigningKey(vec![]),
0,
)),
is_stopping: RwLock::new(false),
maintenance: AtomicBool::new(false),
state_version: AtomicU32::new(0),
concurrent_upload_limit: 0,
concurrent_download_limit: 0,
inflight_upload_data_timeout: std::time::Duration::ZERO,
inflight_download_data_timeout: std::time::Duration::ZERO,
inflight_upload_bytes: AtomicI64::new(0),
inflight_download_bytes: AtomicI64::new(0),
upload_notify: tokio::sync::Notify::new(),
download_notify: tokio::sync::Notify::new(),
data_center: String::new(),
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: AtomicBool::new(false),
has_master: false,
pre_stop_seconds: 0,
volume_state_notify: tokio::sync::Notify::new(),
write_queue: std::sync::OnceLock::new(),
read_mode: crate::config::ReadMode::Local,
allow_untrusted_remote_endpoints: false,
master_url: String::new(),
master_urls: Vec::new(),
seed_master_set: std::collections::HashSet::new(),
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
outgoing_grpc_tls: None,
metrics_runtime: RwLock::new(RuntimeMetricsConfig::default()),
metrics_notify: tokio::sync::Notify::new(),
fix_jpg_orientation: false,
has_slow_read: false,
read_buffer_size_bytes: 4 * 1024 * 1024,
security_file: String::new(),
cli_white_list: vec![],
state_file_path: String::new(),
ec_decodes_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
volume_copies_in_flight: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
ec_decode_tail: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail_notify: tokio::sync::Notify::new(),
})
}
/// Go's readNeedleDataInto holds back the last chunk of a whole-needle read
/// until the checksum verifies; a streamed needle that fails its CRC must
/// likewise end short of the declared length instead of looking complete.
#[tokio::test]
async fn test_streaming_body_checksum_failure_drops_last_chunk() {
use futures::StreamExt;
use std::io::Write;
let data: Vec<u8> = (0..10_000u32).map(|i| (i % 251) as u8).collect();
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&data).unwrap();
let file = tmp.reopen().unwrap();
let mk_body = |expected_checksum: u32, file: &std::fs::File| {
file_streaming_body(file, data.len(), 4096, expected_checksum)
};
// Healthy needle: every byte is delivered.
let body = Body::new(mk_body(CRC::new(&data).0, &file));
let mut stream = body.into_data_stream();
let mut got = 0usize;
while let Some(item) = stream.next().await {
got += item.expect("healthy stream errored").len();
}
assert_eq!(got, data.len());
// Corrupted needle: the last chunk fails the checksum and is never
// emitted — the body ends short of the data size.
let body = Body::new(mk_body(CRC::new(&data).0 ^ 0xff, &file));
let mut stream = body.into_data_stream();
let mut got = 0usize;
let mut saw_err = false;
while let Some(item) = stream.next().await {
match item {
Ok(bytes) => got += bytes.len(),
Err(e) => {
assert!(
e.to_string().contains("checksum"),
"unexpected stream error: {e}"
);
saw_err = true;
}
}
}
assert!(saw_err, "corrupted stream must fail");
assert!(got < data.len(), "delivered {got} of {} bytes", data.len());
}
const TEST_COOKIE: u32 = 0x1234_5678;
/// State whose store holds volume 1 in `tmp`.
fn volume_test_state(tmp: &tempfile::TempDir) -> Arc<VolumeServerState> {
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::store::Store;
use crate::storage::volume::VolumeSpec;
let dir = tmp.path().to_str().unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
store
.add_location(
dir,
dir,
10,
DiskType::HardDrive,
crate::config::MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
store
.add_volume(VolumeId(1), DiskType::HardDrive, &VolumeSpec::default())
.unwrap();
test_state_with_store(store)
}
/// Write a needle to volume 1 and return its URL path.
fn put_test_needle(state: &VolumeServerState, id: u64, data: &[u8]) -> String {
put_test_needle_with(state, id, data, |_| {})
}
fn put_test_needle_with(
state: &VolumeServerState,
id: u64,
data: &[u8],
prepare: impl FnOnce(&mut Needle),
) -> String {
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
data: data.to_vec(),
data_size: data.len() as u32,
..Needle::default()
};
prepare(&mut n);
state
.store
.write()
.unwrap()
.write_volume_needle(VolumeId(1), &mut n, false)
.unwrap();
format!("/1,{:x}{:08x}", id, TEST_COOKIE)
}
fn watch_reads(
ids: &[u64],
) -> (
Vec<crate::storage::volume::needle_read_hook::Registration>,
std::sync::Arc<[std::sync::atomic::AtomicUsize]>,
) {
use crate::storage::volume::needle_read_hook;
use std::sync::atomic::AtomicUsize;
let reads: std::sync::Arc<[AtomicUsize]> = std::sync::Arc::from(
std::iter::repeat_with(|| AtomicUsize::new(0))
.take(ids.len())
.collect::<Vec<_>>()
.into_boxed_slice(),
);
let regs = ids
.iter()
.enumerate()
.map(|(i, &id)| {
let reads = reads.clone();
needle_read_hook::register(NeedleId(id), move |_| {
reads[i].fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
})
.collect();
(regs, reads)
}
fn taken(reads: &std::sync::Arc<[std::sync::atomic::AtomicUsize]>) -> Vec<usize> {
use std::sync::atomic::Ordering;
reads.iter().map(|c| c.swap(0, Ordering::SeqCst)).collect()
}
fn put_manifest(state: &VolumeServerState, id: u64, json: &str) -> String {
put_test_needle_with(state, id, json.as_bytes(), |n| n.set_is_chunk_manifest())
}
fn put_gzip_manifest(state: &VolumeServerState, id: u64, json: &str) -> String {
let gz = try_gzip_data(json.as_bytes()).unwrap();
put_test_needle_with(state, id, &gz, |n| {
n.set_is_chunk_manifest();
n.set_is_compressed();
})
}
/// EC reconstructed-interval counter: one fresh volume per case, the needle (on
/// shard 0) EC-encoded under a known generation, the `missing` shards
/// unmounted and the shards in `corrupt` overwritten where the needle lies.
/// Returns the read's bytes and the marker lines logged at debug.
async fn read_ec_reconstructed(
vid: u32,
missing: &[crate::storage::erasure_coding::ec_shard::ShardId],
corrupt: &[crate::storage::erasure_coding::ec_shard::ShardId],
) -> (Option<Vec<u8>>, Vec<String>) {
use crate::storage::erasure_coding::ec_encoder::write_ec_files;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::{VifEcShardConfig, VifVolumeInfo, Volume, VolumeSpec};
use std::io::{Seek, SeekFrom, Write};
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let dir = tmp.path().to_str().unwrap();
let v = Volume::new(
dir,
dir,
VolumeId(vid),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(7),
cookie: Cookie(TEST_COOKIE),
data: b"ec degraded read".to_vec(),
data_size: 16,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
v.close();
let block_size = write_ec_files(dir, dir, "", VolumeId(vid), 10, 4).unwrap();
let vif = format!("{dir}/{vid}.vif");
let mut info: VifVolumeInfo =
serde_json::from_str(&std::fs::read_to_string(&vif).unwrap()).unwrap();
info.ec_shard_config = Some(VifEcShardConfig {
data_shards: 10,
parity_shards: 4,
encode_ts_ns: EC_GENERATION,
block_size,
});
crate::storage::store::save_vif_volume_info(&vif, &info).unwrap();
for shard in corrupt {
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(format!("{dir}/{vid}.ec{shard:02}"))
.unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&[0xA5; 256]).unwrap();
}
let mounted: Vec<_> = (0..14).filter(|s| !missing.contains(s)).collect();
state
.store
.write()
.unwrap()
.mount_ec_shards(VolumeId(vid), "", &mounted)
.unwrap();
let out = Captured::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_ansi(false)
.with_writer(out.clone())
.finish();
let guard = tracing::subscriber::set_default(subscriber);
// Another test may have reached the marker's callsite first, with no
// subscriber, and cached it as never enabled.
tracing::callsite::rebuild_interest_cache();
let read = crate::server::store_ec::read_ec_shard_needle_or_miss(
&state,
VolumeId(vid),
NeedleId(7),
)
.await;
drop(guard);
let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
let lines = text
.lines()
.filter(|l| l.contains("ec reconstructed interval:"))
.map(|l| l.trim_end().to_string())
.collect();
(read.ok().and_then(Result::ok).map(|n| n.data), lines)
}
const EC_GENERATION: i64 = 1_700_000_000_123_456_789;
#[derive(Clone, Default)]
struct Captured(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Captured {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Captured {
type Writer = Captured;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
/// What the counter and line mean, case by case. A direct read logs nothing;
/// a read that rebuilds shard 0 logs and counts one success; one with 5
/// shards left fails and logs one failure; a corrupt interval read directly
/// goes to the repair path and isn't marked. `success` is not needle
/// validation: rebuilt from corrupt parity, the interval logs success while
/// the read fails.
#[tokio::test]
async fn test_ec_reconstructed_interval() {
use crate::metrics::EC_RECONSTRUCTED_INTERVAL_COUNTER as C;
let prefix = |vid: u32| {
format!(
"ec reconstructed interval: volume={vid} generation={EC_GENERATION} needle=7 shard=0 "
)
};
let data = Some(b"ec degraded read".to_vec());
assert_eq!(
read_ec_reconstructed(61, &[], &[]).await,
(data.clone(), vec![])
);
let success = C.with_label_values(&["success"]).get();
let (read, lines) = read_ec_reconstructed(62, &[0], &[]).await;
assert_eq!(read, data);
assert!(
matches!(&lines[..], [l] if l.ends_with(&(prefix(62) + "result=success error=<nil>"))),
"{lines:?}"
);
assert!(C.with_label_values(&["success"]).get() > success);
let failure = C.with_label_values(&["failure"]).get();
let (read, lines) = read_ec_reconstructed(63, &[0, 6, 7, 8, 9, 10, 11, 12, 13], &[]).await;
assert_eq!(read, None);
assert!(
matches!(&lines[..], [l] if l.contains(&(prefix(63) + "result=failure error=")) && !l.ends_with("error=")),
"{lines:?}"
);
assert!(C.with_label_values(&["failure"]).get() > failure);
let (read, lines) = read_ec_reconstructed(64, &[], &[0, 10, 11, 12, 13]).await;
assert_eq!(
(read, lines),
(None, vec![]),
"an unrepairable corrupt read was marked"
);
let success = C.with_label_values(&["success"]).get();
let (read, lines) = read_ec_reconstructed(65, &[0], &[10, 11, 12, 13]).await;
assert_eq!(read, None, "a read rebuilt from corrupt parity was served");
assert!(
matches!(&lines[..], [l] if l.ends_with(&(prefix(65) + "result=success error=<nil>"))),
"{lines:?}"
);
assert!(C.with_label_values(&["success"]).get() > success);
}
/// Write `data` as a chunk manifest on a fresh EC volume 2. Chunks stay on volume 1.
fn put_ec_manifest(
state: &VolumeServerState,
tmp: &tempfile::TempDir,
id: u64,
data: &[u8],
compressed: bool,
) -> String {
use crate::storage::erasure_coding::ec_encoder::write_ec_files;
use crate::storage::erasure_coding::ec_shard::ShardId;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::{Volume, VolumeSpec};
let dir = tmp.path().to_str().unwrap();
let v = Volume::new(
dir,
dir,
VolumeId(2),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
data: data.to_vec(),
data_size: data.len() as u32,
..Needle::default()
};
n.set_is_chunk_manifest();
if compressed {
n.set_is_compressed();
}
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
v.close();
write_ec_files(dir, dir, "", VolumeId(2), 10, 4).unwrap();
let shard_ids: Vec<ShardId> = (0..14).collect();
state
.store
.write()
.unwrap()
.mount_ec_shards(VolumeId(2), "", &shard_ids)
.unwrap();
format!("/2,{:x}{:08x}", id, TEST_COOKIE)
}
fn slash_name(stored: &str, name: &str) -> String {
let fid = stored.trim_start_matches('/');
let (vid, needle) = fid.split_once(',').unwrap();
format!("/{vid}/{needle}/{name}")
}
async fn send_read(
state: &Arc<VolumeServerState>,
method: Method,
path: &str,
range: Option<&[u8]>,
) -> (StatusCode, HeaderMap, Vec<u8>) {
use tower::ServiceExt;
let mut req = Request::builder().method(method).uri(path);
if let Some(range) = range {
req = req.header(
header::RANGE,
header::HeaderValue::from_bytes(range).unwrap(),
);
}
let resp = super::super::volume_server::build_public_router(state.clone())
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, headers, body)
}
/// The public router does not accept DELETE requests, so this calls the
/// handler directly, the same way the admin router does.
async fn send_delete(state: &Arc<VolumeServerState>, path: &str) -> StatusCode {
let request = Request::builder()
.method(Method::DELETE)
.uri(path)
.body(Body::empty())
.unwrap();
delete_handler(State(state.clone()), request).await.status()
}
#[test]
fn test_not_modified_checks_if_none_match_first() {
let n = Needle {
last_modified: 1700000000,
..Needle::default()
};
let (etag, last_modified_str) = etag_and_last_modified(&n);
let bare = n.etag();
let before = "Tue, 14 Nov 2023 22:12:20 GMT".to_string();
let after = "Tue, 14 Nov 2023 22:14:20 GMT".to_string();
let cases: Vec<(Vec<(axum::http::HeaderName, String)>, bool)> = vec![
(vec![(header::IF_NONE_MATCH, format!("\"{bare}\""))], true),
(vec![(header::IF_NONE_MATCH, format!("W/\"{bare}\""))], true),
(
vec![(header::IF_NONE_MATCH, format!("\"x\", \"{bare}\""))],
true,
),
(vec![(header::IF_NONE_MATCH, "*".into())], true),
(vec![(header::IF_NONE_MATCH, "\"x\"".into())], false),
(
vec![
(header::IF_NONE_MATCH, "\"x\"".into()),
(header::IF_NONE_MATCH, format!("\"{bare}\"")),
],
true,
),
(
vec![(header::IF_NONE_MATCH, format!("\"{bare},old\""))],
false,
),
(
vec![(header::IF_NONE_MATCH, format!("\"x\",,\"{bare}\""))],
true,
),
(
vec![
(header::IF_NONE_MATCH, "\"x\"".into()),
(header::IF_MODIFIED_SINCE, after.clone()),
],
false,
),
(
vec![
(header::IF_NONE_MATCH, "".into()),
(header::IF_MODIFIED_SINCE, after.clone()),
],
false,
),
(vec![(header::IF_MODIFIED_SINCE, after)], true),
(vec![(header::IF_MODIFIED_SINCE, before)], false),
];
for (pairs, want) in cases {
let mut headers = HeaderMap::new();
for (name, value) in &pairs {
headers.append(name, value.parse().unwrap());
}
assert_eq!(
not_modified_response(&n, &headers, &etag, &last_modified_str).is_some(),
want,
"{pairs:?}"
);
}
let mut headers = HeaderMap::new();
headers.insert(
header::IF_NONE_MATCH,
format!("\"{bare}\"").parse().unwrap(),
);
let resp = not_modified_response(&n, &headers, &etag, &last_modified_str).unwrap();
assert!(resp.headers().contains_key(header::ETAG));
assert!(resp.headers().contains_key(header::LAST_MODIFIED));
}
#[test]
fn test_if_range_matches() {
let etag = "\"v2\"".to_string();
let last_modified_str = Some("Sat, 24 Oct 2026 22:01:04 GMT".to_string());
let cases: Vec<(Option<&str>, bool)> = vec![
(None, true),
(Some("\"v2\""), true),
(Some("Sat, 24 Oct 2026 22:01:04 GMT"), true),
(Some("Saturday, 24-Oct-26 22:01:04 GMT"), true),
(Some("Sat Oct 24 22:01:04 2026"), true),
(Some("\"v1\""), false),
(Some("W/\"v2\""), false),
(Some("Sat, 24 Oct 2026 22:01:03 GMT"), false),
(Some("not a date"), false),
];
for (if_range, want) in cases {
let mut headers = HeaderMap::new();
if let Some(v) = if_range {
headers.insert(header::IF_RANGE, v.parse().unwrap());
}
assert_eq!(
if_range_matches(&headers, &etag, &last_modified_str),
want,
"{if_range:?}"
);
}
// A present value that is not valid UTF-8 fails the condition.
let mut headers = HeaderMap::new();
headers.insert(
header::IF_RANGE,
header::HeaderValue::from_bytes(b"\"\xff\xfev2\"").unwrap(),
);
assert!(!if_range_matches(&headers, &etag, &last_modified_str));
// No Last-Modified on the representation: a date validator fails,
// an entity-tag still applies.
let mut headers = HeaderMap::new();
headers.insert(header::IF_RANGE, "\"v2\"".parse().unwrap());
assert!(if_range_matches(&headers, &etag, &None));
headers.insert(
header::IF_RANGE,
"Sat, 24 Oct 2026 22:01:04 GMT".parse().unwrap(),
);
assert!(!if_range_matches(&headers, &etag, &None));
}
/// A GET whose needle read is parked must not hold the store lock: a
/// writer, here a real append to the same volume, must get through.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_needle_read_runs_off_the_store_lock() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
use std::sync::mpsc;
use std::time::Duration;
const ID: u64 = 0x6e7a_0001;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data = b"parked needle".to_vec();
let path = put_test_needle(&state, ID, &data);
let (entered_tx, entered_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Mutex::new(release_rx);
let _hook = needle_read_hook::register(NeedleId(ID), move |_| {
let _ = entered_tx.send(());
// Returns once released, or at once when the sender is gone.
let _ = release_rx.lock().unwrap().recv();
});
let get = tokio::spawn({
let state = state.clone();
async move { send_read(&state, Method::GET, &path, None).await }
});
tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10)))
.await
.unwrap()
.expect("the GET never reached its needle read");
let try_write_ok = state.store.try_write().is_ok();
let (wrote_tx, wrote_rx) = mpsc::channel();
std::thread::spawn({
let state = state.clone();
move || {
put_test_needle(&state, ID + 1, b"written meanwhile");
let _ = wrote_tx.send(());
}
});
let write_done = tokio::task::spawn_blocking(move || {
wrote_rx.recv_timeout(Duration::from_secs(5)).is_ok()
})
.await
.unwrap();
drop(release_tx);
assert!(
try_write_ok,
"a parked GET read must not hold the store lock"
);
assert!(write_done, "an append must not wait for a parked GET read");
let (status, _, body) = get.await.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body, data);
}
/// A small needle is read from disk once, not once for its meta and
/// again in full; a large one never reads its payload before streaming.
#[tokio::test]
async fn test_get_reads_small_needle_once_and_large_needle_meta_only() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
const SMALL: u64 = 0x6e7a_0101;
const LARGE: u64 = 0x6e7a_0102;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let small = b"small needle".to_vec();
let large: Vec<u8> = (0..(STREAMING_THRESHOLD as usize + 4096))
.map(|i| (i % 251) as u8)
.collect();
let small_path = put_test_needle(&state, SMALL, &small);
let large_path = put_test_needle(&state, LARGE, &large);
let small_reads = Arc::new(Mutex::new(Vec::new()));
let large_reads = Arc::new(Mutex::new(Vec::new()));
let _small_hook = needle_read_hook::register(NeedleId(SMALL), {
let reads = small_reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let _large_hook = needle_read_hook::register(NeedleId(LARGE), {
let reads = large_reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let (status, _, body) = send_read(&state, Method::GET, &small_path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, small);
let reads = small_reads.lock().unwrap().clone();
assert_eq!(reads.len(), 1, "reads of the small needle: {reads:?}");
let (status, _, body) = send_read(&state, Method::GET, &large_path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, large);
let reads = large_reads.lock().unwrap().clone();
assert!(
!reads.is_empty() && reads.iter().all(|&len| len < 64 * 1024),
"the large needle's payload must not be read before streaming: {reads:?}"
);
}
/// A delete needs the needle's cookie and its chunk-manifest flag. Both
/// are stored in the header and trailer, so the delete must not read the
/// payload.
#[tokio::test]
async fn test_delete_reads_needle_meta_only() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
const ID: u64 = 0x6e7a_0a01;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data: Vec<u8> = (0..512 * 1024).map(|i| (i % 251) as u8).collect();
let path = put_test_needle(&state, ID, &data);
let reads = Arc::new(Mutex::new(Vec::new()));
let _hook = needle_read_hook::register(NeedleId(ID), {
let reads = reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let wrong_cookie = format!("/1,{:x}{:08x}", ID, TEST_COOKIE + 1);
assert_eq!(
send_delete(&state, &wrong_cookie).await,
StatusCode::BAD_REQUEST
);
assert_eq!(send_delete(&state, &path).await, StatusCode::ACCEPTED);
let reads = reads.lock().unwrap().clone();
assert!(
!reads.is_empty() && reads.iter().all(|&len| len < 4096),
"a delete must not read the needle's payload ({} bytes): {reads:?}",
data.len()
);
let (status, _, _) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// A range streams a small needle from the data file, unless it has no
/// URL extension and asks for a resize or crop the stored name may call
/// for: then the needle is read whole, like Go.
#[test]
fn test_range_reads_meta_first_unless_it_may_need_a_transform() {
let small = Size(4096);
let width = ReadQueryParams {
width: Some(100),
..Default::default()
};
let crop = ReadQueryParams {
crop_x2: Some(10),
crop_y2: Some(10),
..Default::default()
};
for (path, query, want) in [
("/1,abc", ReadQueryParams::default(), true),
("/1,abc", width, false),
("/1,abc", crop, false),
(
"/1,abc.bin",
ReadQueryParams {
width: Some(100),
..Default::default()
},
true,
),
] {
let (_, request) = parse_read_request(path, true, &query, &Method::GET);
assert_eq!(request.try_meta_first(small), want, "{path}");
let (_, request) = parse_read_request(path, false, &query, &Method::GET);
assert!(!request.try_meta_first(small), "{path} without a range");
}
}
/// HEAD and ranged reads keep answering from the needle meta and the
/// data file, and a missing or wrong-cookie needle is still a 404.
#[tokio::test]
async fn test_get_head_range_and_not_found_after_single_read() {
const ID: u64 = 0x6e7a_0201;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let path = put_test_needle(&state, ID, &data);
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], data.len().to_string());
assert!(headers.contains_key(header::ETAG));
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=10-19")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &data[10..20]);
assert_eq!(
headers["Content-Range"],
format!("bytes 10-19/{}", data.len())
);
let wrong_cookie = format!("/1,{:x}{:08x}", ID, TEST_COOKIE ^ 1);
let (status, _, _) = send_read(&state, Method::GET, &wrong_cookie, None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let missing = format!("/1,{:x}{:08x}", ID + 1, TEST_COOKIE);
let (status, _, _) = send_read(&state, Method::GET, &missing, None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// A ranged read served from the data file reports its outcome to the
/// io-error streak, like readNeedleDataInto: a finished range clears the
/// count a failed meta read left.
#[test]
fn test_range_from_source_reports_to_io_error_streak() {
use std::io::Write;
let tmp = tempfile::TempDir::new().unwrap();
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(tmp.path().join("1.dat"))
.unwrap();
let data: Vec<u8> = (0..256u32).map(|i| i as u8).collect();
(&file).write_all(&data).unwrap();
let io_errors = Arc::new(crate::storage::io_error::IoErrorTracker::default());
let eio = crate::storage::volume::media_error_for_test();
io_errors.check_read_write_error(Some(&eio));
io_errors.check_read_write_error(Some(&eio));
assert_eq!(io_errors.get_io_error_state().1, 2);
let info = crate::storage::volume::NeedleStreamInfo {
source: crate::storage::volume::NeedleStreamSource::Local(file),
data_file_offset: 0,
data_size: data.len() as u32,
data_file_access_control: Arc::default(),
io_unavailable: Arc::default(),
io_errors: io_errors.clone(),
needle_id: NeedleId(1),
checksum: CRC::new(&data).0,
append_at_ns: 0,
load_generation: u64::MAX,
};
// The checksum pass leaves the streak to the range read.
info.verify_checksum(64).unwrap();
assert_eq!(io_errors.get_io_error_state().1, 2);
let resp =
handle_range_request_from_source("bytes=10-19", info, HeaderMap::new(), None, 64);
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(io_errors.get_io_error_state().1, 0);
}
/// A range carries no checksum of its own, so the first ranged read of a
/// needle checks the whole needle's CRC and sends none of the range on a
/// mismatch; the reader can then fall back to another replica. A verified
/// needle is checked again once the volume reloads.
#[tokio::test]
async fn test_range_read_checks_the_needle_checksum() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let payload =
|seed: u32| -> Vec<u8> { (0..3000u32).map(|i| ((i * 7 + seed) % 251) as u8).collect() };
let damage = |data: &[u8]| {
let dat = tmp.path().join("1.dat");
let mut bytes = std::fs::read(&dat).unwrap();
let at = bytes.windows(data.len()).position(|w| w == data).unwrap();
bytes[at + 10] ^= 0xff;
std::fs::write(&dat, bytes).unwrap();
};
let intact = payload(1);
let path = put_test_needle(&state, 0x6e7a_0c01, &intact);
let (status, _, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=1000-1499")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &intact[1000..1500]);
// Damage a byte outside the range: the range itself is intact, but
// the needle is not, so this replica must not serve it.
let damaged = payload(2);
let path = put_test_needle(&state, 0x6e7a_0c02, &damaged);
damage(&damaged);
for range in [&b"bytes=1000-1499"[..], b"bytes=0-", b"bytes=0-1,1000-1001"] {
let (status, _, body) = send_read(&state, Method::GET, &path, Some(range)).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{range:?}");
assert!(
!body.windows(2).any(|w| w == &damaged[1000..1002]),
"{range:?}"
);
}
// Concurrent first ranges of a needle all see the outcome of the check.
for (seed, damage_it) in [(3, false), (4, true)] {
let data = payload(seed);
let path = put_test_needle(&state, 0x6e7a_0c00 + seed as u64, &data);
if damage_it {
damage(&data);
}
let reads = (0..8).map(|_| send_read(&state, Method::GET, &path, Some(b"bytes=10-19")));
for (status, _, _) in futures::future::join_all(reads).await {
assert_eq!(status == StatusCode::PARTIAL_CONTENT, !damage_it, "{seed}");
}
}
// A verified needle is checked again after a reload, as a compaction
// commit does; until then later ranges skip the check.
let data = payload(5);
let path = put_test_needle(&state, 0x6e7a_0c05, &data);
let (status, _, _) = send_read(&state, Method::GET, &path, Some(b"bytes=10-19")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
let (_, v) = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap();
v.compact_by_index(0, 0, |_| true).unwrap();
v.commit_compact().unwrap();
damage(&data);
let (status, _, _) = send_read(&state, Method::GET, &path, Some(b"bytes=10-19")).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
// Damage that appears after a needle passed its check is caught by
// the block it falls in, while ranges over intact blocks still read.
let block = 64 * 1024;
let data: Vec<u8> = (0..5 * block - 100)
.map(|i| (i * 7 + i / 251) as u8)
.collect();
let path = put_test_needle(&state, 0x6e7a_0c06, &data);
let range = |from: usize, to: usize| format!("bytes={}-{}", from, to - 1).into_bytes();
let damaged_ranges = [
(3 * block + 10, 3 * block + 110),
(block - 50, 4 * block - 50),
];
for (from, to) in damaged_ranges {
let (status, _, body) =
send_read(&state, Method::GET, &path, Some(&range(from, to))).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &data[from..to]);
}
let dat = tmp.path().join("1.dat");
let mut bytes = std::fs::read(&dat).unwrap();
let at = bytes.windows(64).position(|w| w == &data[..64]).unwrap();
bytes[at + 3 * block + 500] ^= 0xff;
std::fs::write(&dat, bytes).unwrap();
for (from, to) in damaged_ranges {
let (status, _, _) =
send_read(&state, Method::GET, &path, Some(&range(from, to))).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{from}-{to}");
}
for (from, to) in [
(10, 110),
(2 * block + 1, 3 * block),
(4 * block + 5, data.len()),
] {
let (status, _, body) =
send_read(&state, Method::GET, &path, Some(&range(from, to))).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT, "{from}-{to}");
assert_eq!(body, &data[from..to]);
}
}
#[test]
fn test_range_check_block_size() {
use crate::storage::volume::range_check_block_size;
let block = 64 * 1024;
let mib = 1024 * 1024;
for (size, want) in [
(0, block),
(64 * block, block),
(64 * block + 1, 2 * block),
(64 * mib, mib),
(1 << 34, mib),
] {
assert_eq!(range_check_block_size(size), want, "{size}");
}
}
/// Range is read as Go reads it: a byte its parseRange cannot parse is a
/// 416, Unicode whitespace around a range is trimmed, an empty value is no
/// range, and HEAD ignores it.
#[tokio::test]
async fn test_get_range_header_is_read_as_go_reads_it() {
const PLAIN: u64 = 0x6e7a_0501;
const GZIPPED: u64 = 0x6e7a_0502;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data = b"range over a needle".to_vec();
let plain_path = put_test_needle(&state, PLAIN, &data);
let gz = try_gzip_data(&data).unwrap();
let gz_path = put_test_needle_with(&state, GZIPPED, &gz, |n| n.set_is_compressed());
for path in [&plain_path, &gz_path] {
let (status, _, body) =
send_read(&state, Method::GET, path, Some(b"bytes=0-1\xff")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE, "{path}");
assert_eq!(body, b"invalid range", "{path}");
let (status, _, _) =
send_read(&state, Method::HEAD, path, Some(b"bytes=0-1\xff")).await;
assert_eq!(status, StatusCode::OK, "{path}");
for range in ["bytes=0-1\u{a0}", "bytes=\u{85}0-1"] {
let (status, headers, body) =
send_read(&state, Method::GET, path, Some(range.as_bytes())).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT, "{path} {range:?}");
assert_eq!(body, &data[..2], "{path} {range:?}");
assert_eq!(
headers["Content-Range"],
format!("bytes 0-1/{}", data.len())
);
}
let (status, _, body) = send_read(&state, Method::GET, path, Some(b"")).await;
assert_eq!(status, StatusCode::OK, "{path}");
assert_eq!(body, data, "{path}");
}
}
/// An EC needle is served from memory: its GET parses Range like any
/// other, and its HEAD ignores it as Go's writeResponseContent does.
#[tokio::test]
async fn test_ec_needle_range_and_head() {
use crate::storage::erasure_coding::ec_encoder::write_ec_files;
use crate::storage::erasure_coding::ec_shard::ShardId;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::{Volume, VolumeSpec};
const ID: u64 = 0x6e7a_0601;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let dir = tmp.path().to_str().unwrap();
let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let v = Volume::new(
dir,
dir,
VolumeId(2),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(ID),
cookie: Cookie(TEST_COOKIE),
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
v.close();
write_ec_files(dir, dir, "", VolumeId(2), 10, 4).unwrap();
let shard_ids: Vec<ShardId> = (0..14).collect();
state
.store
.write()
.unwrap()
.mount_ec_shards(VolumeId(2), "", &shard_ids)
.unwrap();
let path = format!("/2,{:x}{:08x}", ID, TEST_COOKIE);
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=0-1\xff")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(body, b"invalid range");
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=10-19")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &data[10..20]);
for range in [&b"bytes=10-19"[..], b"bytes=0-1\xff"] {
let (status, headers, _) = send_read(&state, Method::HEAD, &path, Some(range)).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::CONTENT_LENGTH], data.len().to_string());
}
}
/// A chunk manifest applies Range to the assembled object. HEAD reports the
/// sum of chunk sizes, including when a Range header is present.
#[tokio::test]
async fn test_chunk_manifest_range() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data = b"0123456789abcdef";
let a = put_test_needle(&state, 0x6e7a_0701, &data[..8]);
let b = put_test_needle(&state, 0x6e7a_0702, &data[8..]);
let manifest = format!(
r#"{{"name":"obj.txt","size":16,"chunks":[{{"fid":"{}","offset":0,"size":8}},{{"fid":"{}","offset":8,"size":8}}]}}"#,
&a[1..],
&b[1..]
);
let path = put_test_needle_with(&state, 0x6e7a_0703, manifest.as_bytes(), |n| {
n.set_is_chunk_manifest()
});
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, data);
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=5-12")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &data[5..13]);
assert_eq!(headers["Content-Range"], "bytes 5-12/16");
assert_eq!(headers["X-File-Store"], "chunked");
assert!(headers.contains_key(header::ETAG));
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=0-1,14-15")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert!(
headers[header::CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("multipart/byteranges")
);
let body = String::from_utf8(body).unwrap();
assert!(body.contains("Content-Range: bytes 0-1/16\r\n\r\n01"));
assert!(body.contains("Content-Range: bytes 14-15/16\r\n\r\nef"));
let (status, headers, _) = send_read(&state, Method::GET, &path, Some(b"bytes=16-")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(headers["Content-Range"], "bytes */16");
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=0-1\xff")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(body, b"invalid range");
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], "16");
assert_eq!(headers["X-File-Store"], "chunked");
assert_eq!(headers[header::ACCEPT_RANGES], "bytes");
let (range_status, range_headers, range_body) =
send_read(&state, Method::HEAD, &path, Some(b"bytes=0-9")).await;
assert_eq!(range_status, StatusCode::OK);
assert!(range_body.is_empty());
assert_eq!(range_headers[header::CONTENT_LENGTH], "16");
assert_eq!(range_headers["X-File-Store"], "chunked");
}
/// A ranged GET of a chunk manifest fetches only the chunks its ranges
/// overlap, as Go's ChunkedFileReader does.
#[tokio::test]
async fn test_chunk_manifest_range_fetches_only_overlapping_chunks() {
use crate::storage::volume::needle_read_hook;
use std::sync::atomic::{AtomicUsize, Ordering};
const IDS: [u64; 3] = [0x6e7a_0901, 0x6e7a_0902, 0x6e7a_0903];
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data = b"chunk-1:chunk-2:chunk-3:";
let fids: Vec<String> = IDS
.iter()
.zip(data.chunks(8))
.map(|(&id, part)| put_test_needle(&state, id, part)[1..].to_string())
.collect();
let manifest = format!(
r#"{{"name":"obj.txt","size":24,"chunks":[{{"fid":"{}","offset":0,"size":8}},{{"fid":"{}","offset":8,"size":8}},{{"fid":"{}","offset":16,"size":8}}]}}"#,
fids[0], fids[1], fids[2]
);
let path = put_test_needle_with(&state, 0x6e7a_0904, manifest.as_bytes(), |n| {
n.set_is_chunk_manifest()
});
let reads: Arc<[AtomicUsize; 3]> = Arc::new(Default::default());
let _hooks: Vec<_> = IDS
.iter()
.enumerate()
.map(|(i, &id)| {
let reads = reads.clone();
needle_read_hook::register(NeedleId(id), move |_| {
reads[i].fetch_add(1, Ordering::SeqCst);
})
})
.collect();
let fetched =
|| -> [usize; 3] { std::array::from_fn(|i| reads[i].swap(0, Ordering::SeqCst)) };
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, data);
assert_eq!(fetched(), [1, 1, 1]);
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=10-13")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &data[10..14]);
assert_eq!(headers["Content-Range"], "bytes 10-13/24");
assert_eq!(headers[header::CONTENT_LENGTH], "4");
assert_eq!(headers[header::ACCEPT_RANGES], "bytes");
assert_eq!(headers["X-File-Store"], "chunked");
assert!(headers.contains_key(header::ETAG));
assert_eq!(fetched(), [0, 1, 0]);
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=4-11")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &data[4..12]);
assert_eq!(fetched(), [1, 1, 0]);
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=0-1,20-23")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert!(
headers[header::CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("multipart/byteranges")
);
let body = String::from_utf8(body).unwrap();
assert!(body.contains("Content-Range: bytes 0-1/24\r\n\r\nch"));
assert!(body.contains("Content-Range: bytes 20-23/24\r\n\r\nk-3:\r\n"));
assert_eq!(fetched(), [1, 0, 1]);
let (status, headers, _) = send_read(&state, Method::GET, &path, Some(b"bytes=24-")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(headers["Content-Range"], "bytes */24");
assert_eq!(fetched(), [0, 0, 0]);
}
/// Ranges served from fetched chunks read what assembly would: clamped
/// chunks, later ones overwriting earlier ones, and zero-filled gaps.
#[tokio::test]
async fn test_chunk_manifest_range_matches_assembly() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let a = put_test_needle(&state, 0x6e7a_0905, b"AAAAAAAA");
let b = put_test_needle(&state, 0x6e7a_0906, b"BBBB");
let c = put_test_needle(&state, 0x6e7a_0907, b"CCCC");
let manifest = format!(
r#"{{"size":12,"chunks":[{{"fid":"{}","offset":0,"size":6}},{{"fid":"{}","offset":4,"size":4}},{{"fid":"{}","offset":10,"size":4}}]}}"#,
&a[1..],
&b[1..],
&c[1..]
);
let path = put_test_needle_with(&state, 0x6e7a_0908, manifest.as_bytes(), |n| {
n.set_is_chunk_manifest()
});
let (status, _, full) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(full, b"AAAABBBB\0\0CCCC");
for start in 0..14 {
for end in start..14 {
let range = format!("bytes={start}-{end}");
let (status, _, body) =
send_read(&state, Method::GET, &path, Some(range.as_bytes())).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT, "{range}");
assert_eq!(body, &full[start..=end], "{range}");
}
}
}
/// Regular and EC HEAD of a chunk manifest, plain or gzipped, report the
/// sum and do not read chunks. The sum is not the stored needle length.
#[tokio::test]
async fn test_chunk_manifest_head_sum() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let a = put_test_needle(&state, 0x6e7a_0a01, b"AAAAAAAA");
let b = put_test_needle(&state, 0x6e7a_0a02, b"BBBBBBBB");
let json = format!(
r#"{{"name":"obj.txt","size":100,"chunks":[{{"fid":"{}","offset":0,"size":8}},{{"fid":"{}","offset":8,"size":8}}]}}"#,
&a[1..],
&b[1..]
);
assert_ne!(json.len(), 16);
let path = put_manifest(&state, 0x6e7a_0a03, &json);
let (_regs, reads) = watch_reads(&[0x6e7a_0a01, 0x6e7a_0a02]);
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], "16");
assert_eq!(headers["X-File-Store"], "chunked");
assert_eq!(headers[header::ACCEPT_RANGES], "bytes");
assert_eq!(taken(&reads), vec![0, 0]);
let gz_path = put_gzip_manifest(&state, 0x6e7a_0a04, &json);
let (status, headers, _) = send_read(&state, Method::HEAD, &gz_path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::CONTENT_LENGTH], "16");
assert_eq!(headers["X-File-Store"], "chunked");
assert_eq!(taken(&reads), vec![0, 0]);
let cached = format!("{path}?response-cache-control=max-age%3D9");
let (status, headers, _) = send_read(&state, Method::HEAD, &cached, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::CACHE_CONTROL], "max-age=9");
assert_eq!(headers[header::CONTENT_LENGTH], "16");
assert_eq!(taken(&reads), vec![0, 0]);
// Regression pins: expansion does not claim these.
let raw = put_manifest(&state, 0x6e7a_0a05, "not-json");
let (status, headers, body) = send_read(&state, Method::HEAD, &raw, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], "8");
assert!(!headers.contains_key("X-File-Store"));
let bypass = format!("{path}?cm=false");
let (status, headers, _) = send_read(&state, Method::HEAD, &bypass, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::CONTENT_LENGTH], json.len().to_string());
assert!(!headers.contains_key("X-File-Store"));
}
/// An EC manifest used to fetch every chunk on HEAD. It now reports the sum.
#[tokio::test]
async fn test_chunk_manifest_head_ec() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let a = put_test_needle(&state, 0x6e7a_0a11, b"AAAAAAAA");
let b = put_test_needle(&state, 0x6e7a_0a12, b"BBBBBBBB");
let json = format!(
r#"{{"name":"obj.txt","size":100,"chunks":[{{"fid":"{}","offset":0,"size":8}},{{"fid":"{}","offset":8,"size":8}}]}}"#,
&a[1..],
&b[1..]
);
let path = put_ec_manifest(&state, &tmp, 0x6e7a_0a13, json.as_bytes(), false);
let (_regs, reads) = watch_reads(&[0x6e7a_0a11, 0x6e7a_0a12]);
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], "16");
assert_eq!(headers["X-File-Store"], "chunked");
assert_eq!(taken(&reads), vec![0, 0]);
drop(_regs);
// A second mount of volume 2 on the same store fails. Gzip gets its own.
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let a = put_test_needle(&state, 0x6e7a_0a15, b"AAAAAAAA");
let b = put_test_needle(&state, 0x6e7a_0a16, b"BBBBBBBB");
let json = format!(
r#"{{"name":"obj.txt","size":100,"chunks":[{{"fid":"{}","offset":0,"size":8}},{{"fid":"{}","offset":8,"size":8}}]}}"#,
&a[1..],
&b[1..]
);
let gz = try_gzip_data(json.as_bytes()).unwrap();
assert_ne!(gz.len(), 16);
let path = put_ec_manifest(&state, &tmp, 0x6e7a_0a17, &gz, true);
let (_regs, reads) = watch_reads(&[0x6e7a_0a15, 0x6e7a_0a16]);
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], "16");
assert_eq!(headers["X-File-Store"], "chunked");
assert_eq!(taken(&reads), vec![0, 0]);
}
fn solid_png(width: u32, height: u32) -> Vec<u8> {
let mut img = image::RgbImage::new(width, height);
for px in img.pixels_mut() {
*px = image::Rgb([10, 20, 30]);
}
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
/// A zero crop is not a transform. A real crop of one PNG still fetches,
/// and Range applies to the transformed bytes.
#[tokio::test]
async fn test_chunk_manifest_head_image() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let first = put_test_needle(&state, 0x6e7a_0a21, b"AAAA");
let second = put_test_needle(&state, 0x6e7a_0a22, b"BBBB");
let raw = format!(
r#"{{"name":"obj.txt","size":8,"chunks":[{{"fid":"{}","offset":0,"size":4}},{{"fid":"{}","offset":4,"size":4}}]}}"#,
&first[1..],
&second[1..]
);
let stored = put_manifest(&state, 0x6e7a_0a23, &raw);
let (_regs, reads) = watch_reads(&[0x6e7a_0a21, 0x6e7a_0a22]);
for query in ["crop_x2=0&crop_y2=0", "width=0&height=0"] {
let url = format!("{}?{query}", slash_name(&stored, "obj.jpg"));
let (status, headers, _) = send_read(&state, Method::HEAD, &url, None).await;
assert_eq!(status, StatusCode::OK, "{query}");
assert_eq!(headers[header::CONTENT_LENGTH], "8", "{query}");
assert_eq!(headers["X-File-Store"], "chunked", "{query}");
assert_eq!(taken(&reads), vec![0, 0], "{query}");
}
let partial = format!("{}?crop_x2=0&crop_y2=0", slash_name(&stored, "obj.jpg"));
let (status, _, body) = send_read(&state, Method::GET, &partial, Some(b"bytes=0-3")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"AAAA");
assert_eq!(taken(&reads), vec![1, 0]);
let png = solid_png(8, 8);
let chunk = put_test_needle(&state, 0x6e7a_0a24, &png);
let image = format!(
r#"{{"name":"obj.txt","size":{},"chunks":[{{"fid":"{}","offset":0,"size":{}}}]}}"#,
png.len(),
&chunk[1..],
png.len()
);
let stored = put_manifest(&state, 0x6e7a_0a25, &image);
let (_regs, reads) = watch_reads(&[0x6e7a_0a24]);
let crop = format!(
"{}?crop_x1=0&crop_y1=0&crop_x2=2&crop_y2=2",
slash_name(&stored, "obj.jpg")
);
let (get_status, _, cropped) = send_read(&state, Method::GET, &crop, None).await;
assert_eq!(get_status, StatusCode::OK);
assert_ne!(cropped.len(), png.len(), "the crop must change the length");
assert_eq!(taken(&reads), vec![1]);
let (status, headers, body) =
send_read(&state, Method::HEAD, &crop, Some(b"bytes=0-1")).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], cropped.len().to_string());
assert_eq!(taken(&reads), vec![1]);
let past = format!("bytes={}-", cropped.len());
let (status, _, _) = send_read(&state, Method::GET, &crop, Some(past.as_bytes())).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(taken(&reads), vec![1]);
}
/// A HEAD asking for an image transform needs the payload too: a
/// meta-only read could not produce the transformed length.
#[tokio::test]
async fn test_head_with_image_op_reads_full() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let png = solid_png(8, 8);
let stored = put_test_needle(&state, 0x6e7a_0a30, &png);
let crop = format!(
"{}.png?crop_x1=0&crop_y1=0&crop_x2=2&crop_y2=2",
stored
);
let (get_status, _, cropped) = send_read(&state, Method::GET, &crop, None).await;
assert_eq!(get_status, StatusCode::OK);
assert_ne!(cropped.len(), png.len(), "the crop must change the length");
let (status, headers, body) = send_read(&state, Method::HEAD, &crop, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], cropped.len().to_string());
}
/// Go takes the transform extension from the URL first — including a fid
/// suffix like /vid,fid.png — before the resolved filename's.
#[tokio::test]
async fn test_chunk_manifest_fid_extension_drives_transform() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let png = solid_png(8, 8);
let chunk = put_test_needle(&state, 0x6e7a_0f01, &png);
let (_regs, reads) = watch_reads(&[0x6e7a_0f01]);
let json = format!(
r#"{{"name":"obj","size":{},"chunks":[{{"fid":"{}","offset":0,"size":{}}}]}}"#,
png.len(),
&chunk[1..],
png.len()
);
let stored = put_manifest(&state, 0x6e7a_0f02, &json);
let url = format!("{stored}.png?width=2&height=2");
let (status, _, body) = send_read(&state, Method::GET, &url, None).await;
assert_eq!(status, StatusCode::OK);
assert_ne!(body.len(), png.len(), "resize must run on the fid suffix");
assert_eq!(taken(&reads), vec![1]);
let (status, headers, head_body) = send_read(&state, Method::HEAD, &url, None).await;
assert_eq!(status, StatusCode::OK);
assert!(head_body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], body.len().to_string());
assert_eq!(taken(&reads), vec![1]);
}
#[test]
fn test_expansion_limit_allows_exactly_two_gib() {
assert!(!exceeds_expansion_limit(MAX_EXPANSION_BYTES));
assert!(exceeds_expansion_limit(MAX_EXPANSION_BYTES + 1));
assert!(!exceeds_expansion_limit(0));
}
#[test]
fn test_remote_whole_window_caps_at_visible_window() {
let ranges = [HttpRange {
start: 100,
length: 4,
}];
let how = remote_read_for(100, 4, Some(&ranges)).unwrap();
assert!(matches!(how, RemoteRead::Whole { cap: 4 }));
let full = remote_read_for(0, 8, None).unwrap();
assert!(matches!(full, RemoteRead::Whole { cap: 8 }));
let prefix = remote_read_for(
0,
8,
Some(&[HttpRange {
start: 0,
length: 4,
}]),
)
.unwrap();
assert!(matches!(prefix, RemoteRead::Range { lo: 0, hi: 4 }));
}
#[test]
fn test_sum_ranges_size_ignores_non_positive() {
let ranges = [
HttpRange {
start: 0,
length: 8,
},
HttpRange {
start: 0,
length: 8,
},
HttpRange {
start: 0,
length: -1_073_741_824,
},
];
assert_eq!(sum_ranges_size(&ranges), 16);
let over = [
HttpRange {
start: 0,
length: MAX_EXPANSION_BYTES as i64 + 1,
},
HttpRange {
start: 0,
length: -1,
},
];
assert_eq!(sum_ranges_size(&over), MAX_EXPANSION_BYTES as i64 + 1);
assert!(exceeds_expansion_limit(sum_ranges_size(&over) as u64));
}
/// `"size"` is not the object length. Holes stay zero. Chunks at or past
/// the sum are not read.
#[tokio::test]
async fn test_chunk_manifest_length_is_sum() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let a = put_test_needle(&state, 0x6e7a_0c01, b"AAAA");
let b = put_test_needle(&state, 0x6e7a_0c02, b"BBBB");
let omitted = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":4}},{{"fid":"{}","offset":4,"size":4}}]}}"#,
&a[1..],
&b[1..]
);
let path = put_manifest(&state, 0x6e7a_0c03, &omitted);
let (status, headers, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"AAAABBBB");
assert_eq!(headers[header::CONTENT_LENGTH], "8");
let padded = format!(
r#"{{"size":100,"chunks":[{{"fid":"{}","offset":0,"size":4}}]}}"#,
&a[1..]
);
let path = put_manifest(&state, 0x6e7a_0c04, &padded);
let (status, headers, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"AAAA");
assert_eq!(headers[header::CONTENT_LENGTH], "4");
let gap_b = put_test_needle(&state, 0x6e7a_0c05, b"BBBB");
let (_regs, reads) = watch_reads(&[0x6e7a_0c01, 0x6e7a_0c05]);
let gap = format!(
r#"{{"size":100,"chunks":[{{"fid":"{}","offset":0,"size":4}},{{"fid":"{}","offset":8,"size":4}}]}}"#,
&a[1..],
&gap_b[1..]
);
let path = put_manifest(&state, 0x6e7a_0c06, &gap);
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"AAAA\0\0\0\0");
assert_eq!(taken(&reads), vec![1, 0]);
let zero = format!(
r#"{{"chunks":[{{"fid":"9,0000000a12345678","offset":0,"size":0}},{{"fid":"{}","offset":0,"size":4}}]}}"#,
&a[1..]
);
let path = put_manifest(&state, 0x6e7a_0c07, &zero);
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"AAAA");
let empty = put_manifest(&state, 0x6e7a_0c08, r#"{"size":5,"chunks":[]}"#);
let (status, headers, body) = send_read(&state, Method::HEAD, &empty, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::CONTENT_LENGTH], "0");
assert!(body.is_empty());
let (status, headers, body) = send_read(&state, Method::GET, &empty, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::CONTENT_LENGTH], "0");
assert!(body.is_empty());
let (status, headers, _) = send_read(&state, Method::GET, &empty, Some(b"bytes=0-0")).await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(headers["Content-Range"], "bytes */0");
let ten = put_test_needle(&state, 0x6e7a_0c09, b"0123456789");
let (_regs, reads) = watch_reads(&[0x6e7a_0c09]);
let dup = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":10}}]}}"#,
&ten[1..]
);
let path = put_manifest(&state, 0x6e7a_0c0a, &dup);
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=0-7,0-7")).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::ACCEPT_RANGES], "bytes");
assert_eq!(taken(&reads), vec![0]);
}
/// A negative suffix makes the whole header invalid, so the read is
/// rejected before any chunk is fetched.
#[tokio::test]
async fn test_chunk_manifest_negative_suffix_is_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let ten = put_test_needle(&state, 0x6e7a_0c41, b"0123456789");
let (_regs, reads) = watch_reads(&[0x6e7a_0c41]);
let json = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":10}}]}}"#,
&ten[1..]
);
let path = put_manifest(&state, 0x6e7a_0c42, &json);
let (status, _, _) = send_read(
&state,
Method::GET,
&path,
Some(b"bytes=0-7,0-7,--1073741824"),
)
.await;
assert_eq!(status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(taken(&reads), vec![0]);
}
/// A negative field is rejected before any read, including a chunk the
/// range does not overlap.
#[tokio::test]
async fn test_chunk_manifest_negative_reads_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let inside = put_test_needle(&state, 0x6e7a_0c11, b"AAAA");
let outside = put_test_needle(&state, 0x6e7a_0c12, b"BBBB");
let (_regs, reads) = watch_reads(&[0x6e7a_0c11, 0x6e7a_0c12]);
let json = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":4}},{{"fid":"{}","offset":100,"size":4}},{{"fid":"9,0000000b12345678","offset":0,"size":-1}}]}}"#,
&inside[1..],
&outside[1..]
);
let path = put_manifest(&state, 0x6e7a_0c13, &json);
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=0-1")).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
let body = String::from_utf8(body).unwrap();
assert!(
body.contains("invalid negative chunk offset/size"),
"{body}"
);
assert_eq!(taken(&reads), vec![0, 0]);
let (status, _, _) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(taken(&reads), vec![0, 0]);
}
/// The cap is the bytes that would be buffered. Exactly 2 GiB is allowed
/// by the predicate. This test does not allocate that buffer.
#[tokio::test]
async fn test_chunk_manifest_cap_is_buffered_bytes() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let one = put_test_needle(&state, 0x6e7a_0c21, b"Z");
let (_regs, reads) = watch_reads(&[0x6e7a_0c21]);
let big = MAX_EXPANSION_BYTES + 1;
let json = format!(
r#"{{"size":1,"chunks":[{{"fid":"{}","offset":0,"size":{big}}}]}}"#,
&one[1..]
);
let path = put_manifest(&state, 0x6e7a_0c22, &json);
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[header::CONTENT_LENGTH], big.to_string());
assert!(body.is_empty());
assert_eq!(taken(&reads), vec![0]);
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(body, b"chunk manifest exceeds expansion limit");
assert_eq!(taken(&reads), vec![0]);
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=0-3")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"Z\0\0\0");
assert_eq!(taken(&reads), vec![1]);
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=0-")).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(body, b"chunk manifest exceeds expansion limit");
assert_eq!(taken(&reads), vec![0]);
let overflow = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":1}},{{"fid":"9,0000000c12345678","offset":0,"size":9223372036854775807}},{{"fid":"9,0000000d12345678","offset":0,"size":9223372036854775807}},{{"fid":"9,0000000e12345678","offset":0,"size":9223372036854775807}}]}}"#,
&one[1..]
);
let path = put_manifest(&state, 0x6e7a_0c23, &overflow);
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(body, b"chunk manifest exceeds expansion limit");
assert_eq!(taken(&reads), vec![0]);
}
/// Two open ranges on an `i64::MAX` object must not wrap the covered
/// length. The saturated sum equals the total, which is over the buffer
/// cap, so this is 413 and reads nothing.
#[tokio::test]
async fn test_chunk_manifest_open_ranges_do_not_wrap() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let one = put_test_needle(&state, 0x6e7a_0c31, b"Z");
let (_regs, reads) = watch_reads(&[0x6e7a_0c31]);
let json = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":{}}}]}}"#,
&one[1..],
i64::MAX
);
let path = put_manifest(&state, 0x6e7a_0c32, &json);
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=0-,0-")).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(body, b"chunk manifest exceeds expansion limit");
assert_eq!(taken(&reads), vec![0]);
}
#[derive(Clone)]
enum PeerMode {
/// Honor a single `bytes=lo-hi` range. No Range header returns `chunk`.
Honor {
chunk: Vec<u8>,
},
Always200 {
body: Vec<u8>,
},
Status(u16),
/// 206 whose Content-Range starts at 0, whatever Range was asked.
WrongStart {
body: Vec<u8>,
},
/// 206, correct start, body shorter than the asked span.
Short {
body: Vec<u8>,
},
/// 206, correct start, body longer than the asked span.
Long {
body: Vec<u8>,
},
/// 206 with no Content-Range header.
NoContentRange {
body: Vec<u8>,
},
}
struct PeerHit {
range: Option<String>,
}
async fn spawn_chunk_peer(
mode: std::sync::Arc<std::sync::Mutex<PeerMode>>,
hits: std::sync::Arc<std::sync::Mutex<Vec<PeerHit>>>,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
use axum::routing::get;
let app = axum::Router::new().route(
"/{*rest}",
get(move |headers: HeaderMap| {
let mode = mode.clone();
let hits = hits.clone();
async move {
let range = headers
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
hits.lock().unwrap().push(PeerHit {
range: range.clone(),
});
let mode = mode.lock().unwrap().clone();
match mode {
PeerMode::Honor { chunk } => match range {
None => (StatusCode::OK, chunk).into_response(),
Some(spec) => {
let nums = spec.trim_start_matches("bytes=");
let (lo, hi) = nums.split_once('-').unwrap();
let lo: usize = lo.parse().unwrap();
let hi: usize = hi.parse().unwrap();
let body = chunk[lo..=hi].to_vec();
let cr = format!("bytes {lo}-{hi}/{}", chunk.len());
(
StatusCode::PARTIAL_CONTENT,
[(header::CONTENT_RANGE, cr)],
body,
)
.into_response()
}
},
PeerMode::Always200 { body } => (StatusCode::OK, body).into_response(),
PeerMode::Status(code) => {
StatusCode::from_u16(code).unwrap().into_response()
}
PeerMode::WrongStart { body } => (
StatusCode::PARTIAL_CONTENT,
[(
header::CONTENT_RANGE,
format!("bytes 0-{}/8", body.len().saturating_sub(1)),
)],
body,
)
.into_response(),
PeerMode::Short { body } => {
let spec = range.unwrap();
let nums = spec.trim_start_matches("bytes=");
let (lo, hi) = nums.split_once('-').unwrap();
(
StatusCode::PARTIAL_CONTENT,
[(header::CONTENT_RANGE, format!("bytes {lo}-{hi}/8"))],
body,
)
.into_response()
}
PeerMode::Long { body } => {
let spec = range.unwrap();
let nums = spec.trim_start_matches("bytes=");
let (lo, hi) = nums.split_once('-').unwrap();
(
StatusCode::PARTIAL_CONTENT,
[(header::CONTENT_RANGE, format!("bytes {lo}-{hi}/8"))],
body,
)
.into_response()
}
PeerMode::NoContentRange { body } => {
(StatusCode::PARTIAL_CONTENT, body).into_response()
}
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(addr, handle)
}
async fn spawn_lookup(peer: std::net::SocketAddr) -> String {
use axum::routing::get;
let app = axum::Router::new().route(
"/dir/lookup",
get(move || async move {
axum::Json(serde_json::json!({
"volumeOrFileId": "9",
"locations": [{ "url": peer.to_string() }]
}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("127.0.0.1:{}", addr.port())
}
fn remote_manifest_state(
tmp: &tempfile::TempDir,
master_url: String,
) -> Arc<VolumeServerState> {
let mut state = volume_test_state(tmp);
let s = Arc::get_mut(&mut state).unwrap();
// Empty self_url makes `url.contains(self_url)` true for every peer.
s.self_url = "198.51.100.8:8080".to_string();
s.master_url = master_url.clone();
s.current_master_url = tokio::sync::RwLock::new(master_url);
state
}
/// Remote overlap is one bounded Range, indexed from `lo`. Local short
/// needles still zero-fill.
#[tokio::test]
async fn test_chunk_manifest_remote_range() {
let tmp = tempfile::TempDir::new().unwrap();
let mode = std::sync::Arc::new(std::sync::Mutex::new(PeerMode::Honor {
chunk: b"ABCDEFGH".to_vec(),
}));
let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let (peer, peer_handle) = spawn_chunk_peer(mode.clone(), hits.clone()).await;
let master = spawn_lookup(peer).await;
let state = remote_manifest_state(&tmp, master);
let local = put_test_needle(&state, 0x6e7a_0d01, b"0123456789");
let remote_fid = "9,0000000a12345678";
let json = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":10}},{{"fid":"{remote_fid}","offset":10,"size":8}}]}}"#,
&local[1..]
);
let path = put_manifest(&state, 0x6e7a_0d02, &json);
hits.lock().unwrap().clear();
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=12-15")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"CDEF");
assert_eq!(headers["Content-Range"], "bytes 12-15/18");
let seen = hits.lock().unwrap().drain(..).collect::<Vec<_>>();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].range.as_deref(), Some("bytes=2-5"));
hits.lock().unwrap().clear();
let (status, _, body) =
send_read(&state, Method::GET, &path, Some(b"bytes=11-12,15-16")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
let text = String::from_utf8(body).unwrap();
assert!(
text.contains("Content-Range: bytes 11-12/18\r\n\r\nBC"),
"{text}"
);
assert!(
text.contains("Content-Range: bytes 15-16/18\r\n\r\nFG"),
"{text}"
);
let seen = hits.lock().unwrap().drain(..).collect::<Vec<_>>();
assert_eq!(seen.len(), 1, "disjoint ranges share one span");
assert_eq!(seen[0].range.as_deref(), Some("bytes=1-6"));
hits.lock().unwrap().clear();
let whole = put_manifest(
&state,
0x6e7a_0d03,
&format!(r#"{{"chunks":[{{"fid":"{remote_fid}","offset":0,"size":8}}]}}"#),
);
let (status, _, body) = send_read(&state, Method::GET, &whole, Some(b"bytes=0-7")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"ABCDEFGH");
let seen = hits.lock().unwrap().drain(..).collect::<Vec<_>>();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].range, None);
*mode.lock().unwrap() = PeerMode::Short {
body: b"AB".to_vec(),
};
hits.lock().unwrap().clear();
let (status, _, body) = send_read(&state, Method::GET, &whole, Some(b"bytes=0-3")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"AB\0\0");
*mode.lock().unwrap() = PeerMode::Long {
body: b"ABCDEFGH".to_vec(),
};
let (status, _, body) = send_read(&state, Method::GET, &whole, Some(b"bytes=0-3")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"ABCD");
*mode.lock().unwrap() = PeerMode::WrongStart {
body: b"ABCD".to_vec(),
};
let (status, _, body) = send_read(&state, Method::GET, &whole, Some(b"bytes=2-5")).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(String::from_utf8_lossy(&body).contains("read chunk"));
*mode.lock().unwrap() = PeerMode::NoContentRange {
body: b"CDEF".to_vec(),
};
let (status, _, body) = send_read(&state, Method::GET, &whole, Some(b"bytes=2-5")).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(String::from_utf8_lossy(&body).contains("read chunk"));
*mode.lock().unwrap() = PeerMode::Always200 {
body: b"ABCDEFGH".to_vec(),
};
let (status, _, body) = send_read(&state, Method::GET, &whole, Some(b"bytes=2-5")).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(String::from_utf8_lossy(&body).contains("read chunk"));
*mode.lock().unwrap() = PeerMode::Status(416);
let (status, _, body) = send_read(&state, Method::GET, &whole, Some(b"bytes=1-2")).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(String::from_utf8_lossy(&body).contains("read chunk"));
peer_handle.abort();
}
/// A local needle shorter than declared zero-fills. The remote 416 above
/// is the intentional divergence.
#[tokio::test]
async fn test_chunk_manifest_local_short_needle_zero_fills() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let short = put_test_needle(&state, 0x6e7a_0d11, b"AB");
let json = format!(
r#"{{"chunks":[{{"fid":"{}","offset":0,"size":4}}]}}"#,
&short[1..]
);
let path = put_manifest(&state, 0x6e7a_0d12, &json);
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=1-2")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"B\0");
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"AB\0\0");
}
/// A short range of a huge remote chunk is fetched. The span between two
/// distant ranges is not, once it would buffer more than 2 GiB.
#[tokio::test]
async fn test_chunk_manifest_remote_span_over_cap_is_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let mode = std::sync::Arc::new(std::sync::Mutex::new(PeerMode::Honor {
chunk: b"ABCDEFGH".to_vec(),
}));
let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let (peer, peer_handle) = spawn_chunk_peer(mode, hits.clone()).await;
let master = spawn_lookup(peer).await;
let state = remote_manifest_state(&tmp, master);
let big = MAX_EXPANSION_BYTES + 1;
let path = put_manifest(
&state,
0x6e7a_0e01,
&format!(r#"{{"chunks":[{{"fid":"9,0000000a12345678","offset":0,"size":{big}}}]}}"#),
);
hits.lock().unwrap().clear();
let (status, _, body) = send_read(&state, Method::GET, &path, Some(b"bytes=0-3")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, b"ABCD");
assert_eq!(hits.lock().unwrap().len(), 1);
hits.lock().unwrap().clear();
let last = big - 1;
let wide = format!("bytes=0-0,{last}-{last}");
let (status, _, body) = send_read(&state, Method::GET, &path, Some(wide.as_bytes())).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(body, b"chunk manifest exceeds expansion limit");
assert!(hits.lock().unwrap().is_empty());
peer_handle.abort();
}
/// An illegal manifest MIME is not a legal header value. HEAD reaches the
/// header builder, so both HEAD and GET fall back instead of panicking.
#[tokio::test]
async fn test_chunk_manifest_illegal_mime_falls_back() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let chunk = put_test_needle(&state, 0x6e7a_0e11, b"AAAA");
let (_regs, reads) = watch_reads(&[0x6e7a_0e11]);
let json = format!(
r#"{{"name":"obj.txt","mime":"text/plain\n","size":4,"chunks":[{{"fid":"{}","offset":0,"size":4}}]}}"#,
&chunk[1..]
);
let path = put_manifest(&state, 0x6e7a_0e12, &json);
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_TYPE], "application/octet-stream");
assert_eq!(headers[header::CONTENT_LENGTH], "4");
assert_eq!(taken(&reads), vec![0]);
let (status, headers, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"AAAA");
assert_eq!(headers[header::CONTENT_TYPE], "application/octet-stream");
assert_eq!(taken(&reads), vec![1]);
}
/// A proxied read forwards request headers as raw bytes, as Go does: a
/// Range the target cannot parse must reach it and come back as its 416.
#[tokio::test]
async fn test_proxy_forwards_non_ascii_range() {
let tmp = tempfile::TempDir::new().unwrap();
let target_state = volume_test_state(&tmp);
let path = put_test_needle(&target_state, 0x6e7a_0801, b"proxied payload");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = super::super::volume_server::build_public_router(target_state);
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let proxy_state = test_state_with_store(crate::storage::store::Store::new(
crate::storage::needle_map::NeedleMapKind::InMemory,
));
let target = VolumeLocation {
url: addr.to_string(),
public_url: addr.to_string(),
grpc_port: 0,
read_only: false,
read_only_can_delete: false,
};
let mut headers = HeaderMap::new();
headers.insert(
header::RANGE,
header::HeaderValue::from_bytes(b"bytes=0-1\xff").unwrap(),
);
let info = build_proxy_request_info(&path, &headers, "", &Method::GET).unwrap();
let resp = proxy_request(&proxy_state, &info, &target).await;
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"invalid range");
server.abort();
}
/// A large compressed needle cannot be streamed as stored: its meta is
/// read first, then the payload exactly once.
#[tokio::test]
async fn test_get_large_compressed_needle_reads_payload_once() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
const ID: u64 = 0x6e7a_0301;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
// Incompressible, so the stored gzip stays above the stream threshold.
let mut x = 0x2545_f491_u32;
let plain: Vec<u8> = (0..(STREAMING_THRESHOLD as usize + 64 * 1024))
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x as u8
})
.collect();
let gz = try_gzip_data(&plain).unwrap();
assert!(gz.len() > STREAMING_THRESHOLD as usize);
let path = put_test_needle_with(&state, ID, &gz, |n| n.set_is_compressed());
let reads = Arc::new(Mutex::new(Vec::new()));
let _hook = needle_read_hook::register(NeedleId(ID), {
let reads = reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, plain);
let reads = reads.lock().unwrap().clone();
let full = reads.iter().filter(|&&len| len >= gz.len()).count();
assert_eq!(full, 1, "reads: {reads:?}");
}
/// The single full read still verifies the needle checksum.
#[tokio::test]
async fn test_get_small_needle_with_bad_checksum_fails() {
const ID: u64 = 0x6e7a_0401;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data = b"checksummed payload, to be corrupted".to_vec();
let path = put_test_needle(&state, ID, &data);
let dat = tmp.path().join("1.dat");
let mut bytes = std::fs::read(&dat).unwrap();
let at = bytes
.windows(data.len())
.position(|w| w == data.as_slice())
.unwrap();
bytes[at] ^= 0xff;
std::fs::write(&dat, bytes).unwrap();
let (status, _, _) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
/// Each chunk reuses the buffer of the previous frame once that frame has
/// been dropped, instead of allocating a new one.
#[tokio::test]
async fn test_streaming_body_reuses_its_chunk_buffer() {
use std::io::Write;
const CHUNK: usize = 4096;
let data: Vec<u8> = (0..3 * CHUNK + 100).map(|i| (i % 251) as u8).collect();
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&data).unwrap();
let file = tmp.reopen().unwrap();
let mut body = file_streaming_body(&file, data.len(), CHUNK, CRC::new(&data).0);
let mut got = Vec::new();
while let Some(frame) = std::future::poll_fn(|cx| {
http_body::Body::poll_frame(std::pin::Pin::new(&mut body), cx)
})
.await
{
let chunk = frame.unwrap().into_data().unwrap();
got.extend_from_slice(&chunk);
assert!(
!body.buf.try_reclaim(CHUNK),
"the buffer must be the one the live frame points into"
);
drop(chunk);
assert!(
body.buf.try_reclaim(CHUNK),
"the chunk buffer did not come back from the read"
);
}
assert_eq!(got, data);
}
/// Start a GET and return its body unread.
async fn open_read(state: &Arc<VolumeServerState>, path: &str) -> Body {
use tower::ServiceExt;
let resp = super::super::volume_server::build_public_router(state.clone())
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
resp.into_body()
}
/// A needle that streams in several chunks.
fn multi_chunk_data() -> Vec<u8> {
(0..9 * 1024 * 1024 + 123)
.map(|i| (i % 251) as u8)
.collect()
}
/// With -hasSlowRead=false a stream holds a data-file lease for its whole
/// life, and a writer waits for that lease under store.write(). Producing
/// a frame must not need the store lock, or the two wait on each other.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_streaming_body_progresses_while_a_writer_holds_the_store() {
use futures::StreamExt;
use std::sync::mpsc;
use std::time::{Duration, Instant};
const ID: u64 = 0x6e7a_0501;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
assert!(!state.has_slow_read);
let data = multi_chunk_data();
let path = put_test_needle(&state, ID, &data);
let mut stream = open_read(&state, &path).await.into_data_stream();
let mut got = stream.next().await.unwrap().unwrap().to_vec();
assert!(
got.len() < data.len(),
"the needle must span several frames"
);
// The writer takes store.write(), then parks on the stream's lease.
let (wrote_tx, wrote_rx) = mpsc::channel();
std::thread::spawn({
let state = state.clone();
move || {
put_test_needle(&state, ID + 1, b"written meanwhile");
let _ = wrote_tx.send(());
}
});
let deadline = Instant::now() + Duration::from_secs(10);
while state.store.try_read().is_ok() {
assert!(Instant::now() < deadline, "the writer never took the store");
std::thread::sleep(Duration::from_millis(1));
}
// Drained off the runtime: a frame stuck on the store lock would block
// its thread, which a tokio timeout cannot interrupt.
let (done_tx, done_rx) = mpsc::channel();
let rt = tokio::runtime::Handle::current();
std::thread::spawn(move || {
let rest = rt.block_on(async move {
let mut rest = Vec::new();
while let Some(chunk) = stream.next().await {
rest.extend_from_slice(&chunk?);
}
Ok::<_, axum::Error>(rest)
});
let _ = done_tx.send(rest);
});
let rest =
tokio::task::spawn_blocking(move || done_rx.recv_timeout(Duration::from_secs(10)))
.await
.unwrap()
.expect("the stream stalled behind a writer holding the store");
got.extend(rest.unwrap());
assert_eq!(got, data);
let wrote = tokio::task::spawn_blocking(move || {
wrote_rx.recv_timeout(Duration::from_secs(10)).is_ok()
})
.await
.unwrap();
assert!(wrote, "the writer must get the lease once the stream ends");
}
/// With -hasSlowRead a writer gets the data-file lease between chunks; if
/// its failed append leaves the volume unavailable, the stream must stop.
#[tokio::test]
async fn test_streaming_body_stops_once_the_volume_becomes_unavailable() {
use futures::StreamExt;
const ID: u64 = 0x6e7a_0701;
let tmp = tempfile::TempDir::new().unwrap();
let mut state = volume_test_state(&tmp);
Arc::get_mut(&mut state).unwrap().has_slow_read = true;
let data = multi_chunk_data();
let path = put_test_needle(&state, ID, &data);
let mut stream = open_read(&state, &path).await.into_data_stream();
let first = stream.next().await.unwrap().unwrap();
assert!(
first.len() < data.len(),
"the needle must span several frames"
);
{
let store = state.store.read().unwrap();
let (_, vol) = store.find_volume(VolumeId(1)).unwrap();
vol.fail_next_fsync_for_test(true);
vol.fail_next_truncate_for_test(true);
let mut n = Needle {
id: NeedleId(ID + 1),
cookie: Cookie(TEST_COOKIE),
data: b"never-landed".to_vec(),
data_size: 12,
..Needle::default()
};
store
.write_volume_needle(VolumeId(1), &mut n, true)
.unwrap_err();
let (_, vol) = store.find_volume(VolumeId(1)).unwrap();
vol.fail_next_fsync_for_test(false);
vol.fail_next_truncate_for_test(false);
assert!(vol.unavailable_error().is_some());
}
match stream.next().await {
Some(Err(err)) => assert!(
err.to_string().contains("volume is unavailable"),
"unexpected stream error: {err}"
),
other => panic!(
"the stream kept reading an unavailable volume: {:?}",
other.map(|chunk| chunk.map(|c| c.len()))
),
}
}
/// A leased stream blocks the compaction commit instead of racing the
/// file swap, and still delivers the needle's bytes; the commit goes
/// through once the stream releases its read lease.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_streaming_body_survives_a_vacuum_that_moves_the_needle() {
use futures::StreamExt;
const FILLER: u64 = 0x6e7a_0601;
const ID: u64 = 0x6e7a_0602;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
put_test_needle(&state, FILLER, &[7u8; 4096]);
let data = multi_chunk_data();
let path = put_test_needle(&state, ID, &data);
let mut filler = Needle {
id: NeedleId(FILLER),
cookie: Cookie(TEST_COOKIE),
..Needle::default()
};
state
.store
.write()
.unwrap()
.delete_volume_needle(VolumeId(1), &mut filler)
.unwrap();
let data_offset = || {
let mut plan = crate::storage::store::Store::needle_read_plan(
&state.store,
VolumeId(1),
NeedleId(ID),
false,
)
.unwrap();
let mut n = Needle::default();
plan.read_meta(&mut n).unwrap();
plan.into_stream_info(&n).data_file_offset
};
let before = data_offset();
let mut stream = open_read(&state, &path).await.into_data_stream();
let mut got = stream.next().await.unwrap().unwrap().to_vec();
assert!(
got.len() < data.len(),
"the needle must span several frames"
);
{
let mut store = state.store.write().unwrap();
store.compact_volume(VolumeId(1), 0, 0, |_| true).unwrap();
let err = store.commit_compact_volume(VolumeId(1)).unwrap_err();
assert!(
err.to_string()
.contains(crate::storage::volume::COMMIT_WAITS_FOR_READS),
"the leased stream must hold the commit back: {err}"
);
}
while let Some(chunk) = stream.next().await {
got.extend_from_slice(&chunk.expect("the stream failed across a vacuum"));
}
assert_eq!(got, data);
drop(stream);
{
let mut store = state.store.write().unwrap();
store.commit_compact_volume(VolumeId(1)).unwrap();
}
assert_ne!(
data_offset(),
before,
"the vacuum moves the needle once the stream drains"
);
}
#[tokio::test]
async fn http_sealed_volume_reports_refused_before_append_without_changing_data() {
use tower::ServiceExt;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
volume.set_read_only().unwrap();
let before = volume.dat_file_size().unwrap();
let response = super::super::volume_server::build_admin_router(state)
.oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/1,{:x}{TEST_COOKIE:08x}", 0x6e7a_0c01u64))
.body(Body::from("kv-http-bytes"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
response
.headers()
.get("x-seaweed-write-outcome")
.map(|value| value.to_str().unwrap()),
Some("refused-before-append")
);
assert_eq!(volume.dat_file_size().unwrap(), before);
}
#[tokio::test]
async fn http_local_refusal_after_replica_append_is_not_marked() {
for local_read_only in [true, false] {
use crate::storage::super_block::ReplicaPlacement;
use axum::{Json, Router, routing::get};
use tower::ServiceExt;
let tmp = tempfile::TempDir::new().unwrap();
let mut state = volume_test_state(&tmp);
Arc::get_mut(&mut state).unwrap().self_url = "127.0.0.1:8080".to_string();
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
volume
.set_replica_placement(ReplicaPlacement::from_string("001").unwrap())
.unwrap();
if local_read_only {
volume.set_read_only().unwrap();
}
let before = volume.dat_file_size().unwrap();
let peer_tmp = tempfile::TempDir::new().unwrap();
let peer = volume_test_state(&peer_tmp);
let peer_volume = peer
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
if !local_read_only {
peer_volume.set_read_only().unwrap();
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let peer_addr = listener.local_addr().unwrap();
let peer_server = tokio::spawn(async move {
axum::serve(
listener,
super::super::volume_server::build_admin_router(peer),
)
.await
.unwrap();
});
// The master's writable view can lag behind the local seal.
let master = Router::new().route(
"/dir/lookup",
get(move || async move {
Json(serde_json::json!({"locations": [
{"url": "127.0.0.1:8080"},
{"url": peer_addr.to_string()}
]}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
*state.current_master_url.write().await = listener.local_addr().unwrap().to_string();
let master_server = tokio::spawn(async move {
axum::serve(listener, master).await.unwrap();
});
let id = 0x6e7a_0c03u64;
let payload = b"replicated bytes";
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
super::super::volume_server::build_admin_router(state).oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/1,{id:x}{TEST_COOKIE:08x}"))
.body(Body::from(payload.as_slice()))
.unwrap(),
),
)
.await
.unwrap()
.unwrap();
assert_eq!(
response.status(),
if local_read_only {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
);
assert!(response.headers().get("x-seaweed-write-outcome").is_none());
let (refused, written) = if local_read_only {
(&volume, &peer_volume)
} else {
(&peer_volume, &volume)
};
assert_eq!(refused.dat_file_size().unwrap(), before);
let mut stored = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
..Needle::default()
};
written.read_needle(&mut stored).unwrap();
assert_eq!(stored.data, payload);
master_server.abort();
peer_server.abort();
}
}
#[tokio::test]
async fn http_replication_preserves_query_authentication() {
use crate::security::{SigningKey, gen_jwt};
use crate::storage::super_block::ReplicaPlacement;
use axum::{Json, Router, middleware, routing::get};
use tower::ServiceExt;
let tmp = tempfile::TempDir::new().unwrap();
let mut state = volume_test_state(&tmp);
Arc::get_mut(&mut state).unwrap().self_url = "127.0.0.1:8080".to_string();
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
volume
.set_replica_placement(ReplicaPlacement::from_string("001").unwrap())
.unwrap();
let peer_tmp = tempfile::TempDir::new().unwrap();
let peer = volume_test_state(&peer_tmp);
let peer_volume = peer
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
let key = SigningKey(b"replication-secret".to_vec());
state.guard.write().unwrap().signing_key = key.clone();
peer.guard.write().unwrap().signing_key = key.clone();
let queries = Arc::new(std::sync::Mutex::new(Vec::new()));
let received = queries.clone();
let app = super::super::volume_server::build_admin_router(peer).layer(middleware::from_fn(
move |request: Request<Body>, next: middleware::Next| {
received
.lock()
.unwrap()
.push(request.uri().query().unwrap().to_string());
async move { next.run(request).await }
},
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let peer_addr = listener.local_addr().unwrap();
let peer_server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let master = Router::new().route(
"/dir/lookup",
get(move || async move {
Json(serde_json::json!({"locations": [
{"url": "127.0.0.1:8080"},
{"url": peer_addr.to_string()}
]}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
*state.current_master_url.write().await = listener.local_addr().unwrap().to_string();
let master_server =
tokio::spawn(async move { axum::serve(listener, master).await.unwrap() });
let id = 0x6e7a_0c05u64;
let fid = format!("1,{id:x}{TEST_COOKIE:08x}");
let token = gen_jwt(&key, 60, &fid).unwrap();
let query =
"%6Awt=expired&tag=a%2Fb+%2B&tag=x%20y&bare&ts=1700000000&%74ype=client&type=old";
let expected_query =
"%6Awt=expired&tag=a%2Fb+%2B&tag=x%20y&bare&ts=1700000000&type=replicate";
let payload = b"authenticated replica bytes";
for method in [Method::POST, Method::DELETE] {
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
super::super::volume_server::build_admin_router(state.clone()).oneshot(
Request::builder()
.method(method.clone())
.uri(format!("/{fid}?{query}"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::from(if method == Method::POST {
payload.as_slice()
} else {
&[]
}))
.unwrap(),
),
)
.await
.unwrap()
.unwrap();
assert_eq!(
response.status(),
if method == Method::POST {
StatusCode::CREATED
} else {
StatusCode::ACCEPTED
}
);
for stored_volume in [&volume, &peer_volume] {
let mut stored = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
..Needle::default()
};
let result = stored_volume.read_needle(&mut stored);
if method == Method::POST {
result.unwrap();
assert_eq!(stored.data, payload);
} else {
assert!(result.is_err());
}
}
}
assert_eq!(*queries.lock().unwrap(), [expected_query, expected_query]);
master_server.abort();
peer_server.abort();
}
#[tokio::test]
async fn http_replica_stores_the_primary_needle() {
use crate::storage::super_block::ReplicaPlacement;
use axum::{Json, Router, routing::get};
use tower::ServiceExt;
let tmp = tempfile::TempDir::new().unwrap();
let mut state = volume_test_state(&tmp);
Arc::get_mut(&mut state).unwrap().self_url = "127.0.0.1:8080".to_string();
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
volume
.set_replica_placement(ReplicaPlacement::from_string("001").unwrap())
.unwrap();
let peer_tmp = tempfile::TempDir::new().unwrap();
let peer = volume_test_state(&peer_tmp);
let peer_volume = peer
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let peer_addr = listener.local_addr().unwrap();
let peer_server = tokio::spawn(async move {
axum::serve(
listener,
super::super::volume_server::build_admin_router(peer),
)
.await
.unwrap();
});
let master = Router::new().route(
"/dir/lookup",
get(move || async move {
Json(serde_json::json!({"locations": [
{"url": "127.0.0.1:8080"},
{"url": peer_addr.to_string()}
]}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
*state.current_master_url.write().await = listener.local_addr().unwrap().to_string();
let master_server =
tokio::spawn(async move { axum::serve(listener, master).await.unwrap() });
let payload = "compressible replica text ".repeat(64);
let boundary = "replica-boundary";
let multipart = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"ttl\"\r\n\r\n3m\r\n\
--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a \\\"b\\\".txt\"\r\n\
Content-Type: text/plain\r\n\r\n{payload}\r\n--{boundary}--\r\n"
);
let cases = [
(
0x6e7a_0c06u64,
Request::builder()
.header(header::CONTENT_TYPE, "text/plain")
.header(
header::CONTENT_DISPOSITION,
"attachment; filename=\"notes.txt\"",
)
.header("Content-MD5", compute_md5_base64(payload.as_bytes()))
.header("Seaweed-Color", "blue"),
Body::from(payload.clone()),
),
(
0x6e7a_0c07u64,
Request::builder().header(
header::CONTENT_TYPE,
format!("multipart/form-data; boundary={boundary}"),
),
Body::from(multipart),
),
(
0x6e7a_0c0au64,
Request::builder().header(
header::CONTENT_DISPOSITION,
"attachment; filename=\"untyped.txt\"",
),
Body::from(payload.clone()),
),
(
0x6e7a_0c0bu64,
Request::builder().header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}.txt\"", "n".repeat(300)),
),
Body::from(payload.clone()),
),
(
0x6e7a_0c0eu64,
Request::builder()
.header(header::CONTENT_TYPE, "Text/Plain; charset=UTF-8")
.header(
header::CONTENT_DISPOSITION,
"attachment; filename=\"upper.txt\"",
),
Body::from(payload.clone()),
),
];
for (id, request, body) in cases {
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
super::super::volume_server::build_admin_router(state.clone()).oneshot(
request
.method(Method::POST)
.uri(format!("/1,{id:x}{TEST_COOKIE:08x}?ts=1700000000"))
.body(body)
.unwrap(),
),
)
.await
.unwrap()
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let read = |v: &Arc<crate::storage::volume::Volume>| {
let mut stored = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
..Needle::default()
};
v.read_needle(&mut stored).unwrap();
stored
};
let (primary, replica) = (read(&volume), read(&peer_volume));
assert!(primary.is_compressed());
assert_eq!(
maybe_decompress_gzip(&primary.data).unwrap(),
payload.as_bytes()
);
assert_eq!(replica.data, primary.data);
assert_eq!(replica.flags, primary.flags);
assert_eq!(replica.name, primary.name);
assert_eq!(replica.mime, primary.mime);
assert_eq!(replica.pairs, primary.pairs);
assert_eq!(replica.ttl, primary.ttl);
assert_eq!(replica.last_modified, 1700000000);
}
master_server.abort();
peer_server.abort();
}
#[tokio::test]
async fn http_replica_keeps_uncompressed_bytes() {
use tower::ServiceExt;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
let payload = "compressible replica text ".repeat(64);
for (id, query, compressed) in [
(0x6e7a_0c08u64, "?type=replicate", false),
(0x6e7a_0c09u64, "", true),
] {
let response = super::super::volume_server::build_admin_router(state.clone())
.oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/1,{id:x}{TEST_COOKIE:08x}{query}"))
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(payload.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let mut stored = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
..Needle::default()
};
volume.read_needle(&mut stored).unwrap();
assert_eq!(stored.is_compressed(), compressed, "query {query:?}");
}
}
#[tokio::test]
async fn http_orientation_fix_follows_the_filename() {
use tower::ServiceExt;
let tmp = tempfile::TempDir::new().unwrap();
let mut state = volume_test_state(&tmp);
Arc::get_mut(&mut state).unwrap().fix_jpg_orientation = true;
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image::RgbaImage::new(3, 2))
.write_to(&mut encoded, image::ImageFormat::Jpeg)
.unwrap();
let exif: &[u8] =
b"Exif\0\0MM\0\x2a\0\0\0\x08\0\x01\x01\x12\0\x03\0\0\0\x01\0\x03\0\0\0\0\0\0";
let mut tagged = vec![0xFF, 0xD8, 0xFF, 0xE1, 0, exif.len() as u8 + 2];
tagged.extend_from_slice(exif);
tagged.extend_from_slice(&encoded.into_inner()[2..]);
let upload = |id: u64, suffix: &'static str, filename: &'static str, body: Vec<u8>| {
let (state, volume) = (state.clone(), volume.clone());
async move {
let response = super::super::volume_server::build_admin_router(state)
.oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/1,{id:x}{TEST_COOKIE:08x}{suffix}"))
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::CREATED,
"{suffix} {filename}"
);
let mut stored = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
..Needle::default()
};
volume.read_needle(&mut stored).unwrap();
stored.data
}
};
let rotated = upload(0x6e7a_0c10, "", "photo.jpg", tagged.clone()).await;
assert_ne!(rotated, tagged);
assert_eq!(
upload(0x6e7a_0c11, ".jpg", "photo.png", tagged.clone()).await,
tagged
);
assert_eq!(
upload(0x6e7a_0c12, "?type=replicate", "photo.jpg", tagged.clone()).await,
rotated
);
assert_eq!(
upload(0x6e7a_0c13, "?type=replicate", "photo.jpg", rotated.clone()).await,
rotated
);
}
#[tokio::test]
async fn http_multipart_replica_refusal_is_not_marked() {
use tower::ServiceExt;
for field_first in [true, false] {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
volume.set_read_only().unwrap();
let before = volume.dat_file_size().unwrap();
let field =
"--boundary\r\nContent-Disposition: form-data; name=\"type\"\r\n\r\nreplicate\r\n";
let file = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"data.bin\"\r\nContent-Type: application/octet-stream\r\n\r\nreplica bytes\r\n";
let body = if field_first {
format!("{field}{file}--boundary--\r\n")
} else {
format!("{file}{field}--boundary--\r\n")
};
let response = super::super::volume_server::build_admin_router(state)
.oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/1,{:x}{TEST_COOKIE:08x}", 0x6e7a_0c04u64))
.header(
header::CONTENT_TYPE,
"multipart/form-data; boundary=boundary",
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(response.headers().get("x-seaweed-write-outcome").is_none());
assert_eq!(volume.dat_file_size().unwrap(), before);
}
}
#[tokio::test]
async fn http_write_errors_without_no_append_proof_are_not_marked() {
use tower::ServiceExt;
for query in [
"",
"?fsync=true",
"?type=replicate",
"?type=%72eplicate",
"?%74ype=replicate",
] {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let volume = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
let id = 0x6e7a_0c02u64;
if query.is_empty() {
// This error is injected after the append. Its error variant
// cannot prove a safe retry, even if rollback later succeeds.
volume.fail_append_for_test(&[NeedleId(id)]);
} else {
volume.set_read_only().unwrap();
}
let response = super::super::volume_server::build_admin_router(state)
.oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/1,{id:x}{TEST_COOKIE:08x}{query}"))
.body(Body::from("kv-http-bytes"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
if query.is_empty() {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::CONFLICT
},
"{query}"
);
assert!(
response.headers().get("x-seaweed-write-outcome").is_none(),
"{query}"
);
}
}
/// A write queued behind another write's fsync is not indexed yet, so a
/// replicate delete of it finds nothing. The delete cancels the write, so
/// it is taken back when it lands instead of reviving the needle on this
/// copy alone, even when its handler was dropped while it waited in the
/// durable queue. A plain delete that finds nothing is not sent on to the
/// replicas, so it leaves the write alone.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[allow(clippy::await_holding_lock)]
async fn test_replicate_delete_cancels_a_queued_write() {
use crate::server::write_queue::{SyncBatchOption, WriteQueue};
use crate::storage::volume::VolumeError;
use std::time::{Duration, Instant};
for (id, fsync, query, abandon) in [
(0x6e7a_0c01u64, false, "?type=replicate", false),
(0x6e7a_0c02, true, "?type=replicate", false),
(0x6e7a_0c03, true, "?type=replicate", true),
(0x6e7a_0c04, false, "", false),
(0x6e7a_0c05, false, "?%74ype=replicat%65", false),
] {
let replicate = !query.is_empty();
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
assert!(
state
.write_queue
.set(WriteQueue::new(
state.clone(),
128,
SyncBatchOption::default()
))
.is_ok()
);
let vol = state
.store
.read()
.unwrap()
.find_volume(VolumeId(1))
.unwrap()
.1;
let held = vol.sync_lock_for_test();
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
data: b"late write".to_vec(),
data_size: 10,
..Needle::default()
};
n.checksum = CRC::new(&n.data);
let write = tokio::spawn({
let state = state.clone();
async move { write_upload_needle(&state, VolumeId(1), n, fsync).await }
});
let deadline = Instant::now() + Duration::from_secs(10);
while vol.pending_writes_for_test(NeedleId(id)) == 0 {
assert!(Instant::now() < deadline, "the write never registered");
tokio::time::sleep(Duration::from_millis(1)).await;
}
let path = format!("/1,{:x}{:08x}{}", id, TEST_COOKIE, query);
assert_eq!(send_delete(&state, &path).await, StatusCode::NOT_FOUND);
if abandon {
write.abort();
drop(held);
while vol.pending_writes_for_test(NeedleId(id)) > 0 {
assert!(Instant::now() < deadline, "the queued write never settled");
tokio::time::sleep(Duration::from_millis(1)).await;
}
} else {
drop(held);
let written = write.await.unwrap();
if replicate {
assert!(matches!(written, Err(VolumeError::Deleted)), "{id:x}");
} else {
assert!(matches!(written, Ok(Ok(_))), "{id:x}");
}
assert_eq!(vol.pending_writes_for_test(NeedleId(id)), 0);
}
let read = vol.read_needle(&mut Needle {
id: NeedleId(id),
..Needle::default()
});
if replicate {
assert!(read.is_err(), "{id:x}: the cancelled write is live");
} else {
assert!(read.is_ok(), "{id:x}: {read:?}");
}
}
}
/// Issue #801: an fsync upload takes the per-volume worker so its sync is
/// shared across the batch (Go's `writeNeedle2` split); a non-fsync write,
/// or an fsync write landing while the server is stopping, takes the
/// synchronous path instead.
#[tokio::test]
async fn test_write_upload_needle_routes_fsync_to_queue() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
assert!(
state
.write_queue
.set(crate::server::write_queue::WriteQueue::new(
state.clone(),
128,
crate::server::write_queue::SyncBatchOption::default(),
))
.is_ok()
);
let queue = state.write_queue.get().unwrap();
let needle = |id: u64| {
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
data: vec![id as u8; 8],
data_size: 8,
..Needle::default()
};
n.checksum = CRC::new(&n.data);
n
};
write_upload_needle(&state, VolumeId(1), needle(0x6e7a_0b01), true)
.await
.unwrap()
.unwrap();
assert_eq!(queue.submits_for_test(), 1);
write_upload_needle(&state, VolumeId(1), needle(0x6e7a_0b02), false)
.await
.unwrap()
.unwrap();
assert_eq!(queue.submits_for_test(), 1);
*state.is_stopping.write().unwrap() = true;
write_upload_needle(&state, VolumeId(1), needle(0x6e7a_0b03), true)
.await
.unwrap()
.unwrap();
assert_eq!(queue.submits_for_test(), 1);
}
}