use std::path::{Path, PathBuf};
use async_trait::async_trait;
use bytes::{Bytes, BytesMut};
use futures::stream::{self, BoxStream, StreamExt};
use tokio::io::AsyncReadExt;
use crate::StoreError;
pub const NAR_CHUNK_BYTES: usize = 4 * 1024 * 1024;
pub type NarStream = BoxStream<'static, Result<Bytes, StoreError>>;
#[async_trait]
pub trait NarSource: Send + Sync {
fn size_hint(&self) -> Option<u64> {
None
}
async fn open(&self) -> Result<NarStream, StoreError>;
}
#[derive(Debug, Clone)]
pub struct BytesNarSource {
bytes: Bytes,
}
impl BytesNarSource {
#[must_use]
pub fn new(bytes: impl Into<Bytes>) -> Self {
Self { bytes: bytes.into() }
}
}
impl From<&[u8]> for BytesNarSource {
fn from(v: &[u8]) -> Self {
Self::new(Bytes::copy_from_slice(v))
}
}
#[async_trait]
impl NarSource for BytesNarSource {
fn size_hint(&self) -> Option<u64> {
Some(self.bytes.len() as u64)
}
async fn open(&self) -> Result<NarStream, StoreError> {
Ok(bytes_stream(self.bytes.clone()))
}
}
#[derive(Debug, Clone)]
pub struct FileNarSource {
path: PathBuf,
len: Option<u64>,
}
impl FileNarSource {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into(), len: None }
}
#[must_use]
pub fn with_len(path: impl Into<PathBuf>, len: u64) -> Self {
Self { path: path.into(), len: Some(len) }
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
#[async_trait]
impl NarSource for FileNarSource {
fn size_hint(&self) -> Option<u64> {
self.len
}
async fn open(&self) -> Result<NarStream, StoreError> {
let file = tokio::fs::File::open(&self.path).await.map_err(StoreError::Io)?;
Ok(file_stream(file))
}
}
pub const DEFAULT_INGEST_MEMORY_CAP: usize = 256 * 1024 * 1024;
#[derive(Debug)]
struct SpoolGuard(PathBuf);
impl Drop for SpoolGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[derive(Debug)]
pub struct SpooledNarSource {
inner: FileNarSource,
_guard: SpoolGuard,
}
#[async_trait]
impl NarSource for SpooledNarSource {
fn size_hint(&self) -> Option<u64> {
self.inner.size_hint()
}
async fn open(&self) -> Result<NarStream, StoreError> {
self.inner.open().await
}
}
pub async fn spool_or_buffer<S, E>(
mut stream: S,
dir: &Path,
memory_cap: usize,
) -> Result<Box<dyn NarSource>, StoreError>
where
S: futures::Stream<Item = Result<Bytes, E>> + Send + Unpin,
E: std::fmt::Display + Send,
{
use tokio::io::AsyncWriteExt;
fn transport_err<E: std::fmt::Display>(e: E) -> StoreError {
StoreError::Io(std::io::Error::other(format!("nar ingest: {e}")))
}
let path = spool_path(dir);
let created = tokio::fs::File::create(&path).await;
let Ok(mut file) = created else {
let e = created.err().expect("checked Err");
tracing::warn!(
dir = %dir.display(),
error = %e,
cap = memory_cap,
"nar ingest: no spool file — falling back to a CAPPED in-memory buffer; \
NARs above the cap will be refused. Point TMPDIR at a writable volume.",
);
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(transport_err)?;
if buf.len() + chunk.len() > memory_cap {
return Err(StoreError::TooLarge {
limit: memory_cap as u64,
at_least: (buf.len() + chunk.len()) as u64,
});
}
buf.extend_from_slice(&chunk);
}
return Ok(Box::new(BytesNarSource::new(buf)));
};
let guard = SpoolGuard(path.clone());
let mut len: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(transport_err)?;
file.write_all(&chunk).await.map_err(StoreError::Io)?;
len += chunk.len() as u64;
}
file.flush().await.map_err(StoreError::Io)?;
drop(file);
Ok(Box::new(SpooledNarSource {
inner: FileNarSource::with_len(&path, len),
_guard: guard,
}))
}
fn spool_path(dir: &Path) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
dir.join(format!("sui-nar-spool.{}.{n}", std::process::id()))
}
#[must_use]
pub fn bytes_stream(bytes: Bytes) -> NarStream {
stream::unfold(bytes, |mut rest| async move {
if rest.is_empty() {
return None;
}
let take = rest.len().min(NAR_CHUNK_BYTES);
let chunk = rest.split_to(take);
Some((Ok(chunk), rest))
})
.boxed()
}
#[must_use]
pub fn file_stream(file: tokio::fs::File) -> NarStream {
stream::unfold(Some(file), |state| async move {
let mut file = state?;
let mut buf = BytesMut::zeroed(NAR_CHUNK_BYTES);
match file.read(&mut buf).await {
Ok(0) => None,
Ok(n) => {
buf.truncate(n);
Some((Ok(buf.freeze()), Some(file)))
}
Err(e) => Some((Err(StoreError::Io(e)), None)),
}
})
.boxed()
}
#[must_use]
pub fn whole_value_stream(data: Vec<u8>) -> NarStream {
bytes_stream(Bytes::from(data))
}
#[must_use]
pub fn empty_stream() -> NarStream {
stream::empty().boxed()
}
pub async fn collect_nar(mut stream: NarStream, limit: Option<usize>) -> Result<Vec<u8>, StoreError> {
let mut out: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if let Some(max) = limit {
if out.len() + chunk.len() > max {
return Err(StoreError::TooLarge {
limit: max as u64,
at_least: (out.len() + chunk.len()) as u64,
});
}
}
out.extend_from_slice(&chunk);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn pattern(n: usize) -> Vec<u8> {
(0..n).map(|i| (i % 251) as u8).collect()
}
#[tokio::test]
async fn bytes_source_chunks_are_bounded_and_reassemble() {
let data = pattern(NAR_CHUNK_BYTES * 2 + 7);
let src = BytesNarSource::new(data.clone());
assert_eq!(src.size_hint(), Some(data.len() as u64));
let mut s = src.open().await.unwrap();
let mut seen = Vec::new();
let mut chunks = 0usize;
while let Some(c) = s.next().await {
let c = c.unwrap();
assert!(c.len() <= NAR_CHUNK_BYTES, "a chunk exceeded the bound");
seen.extend_from_slice(&c);
chunks += 1;
}
assert_eq!(chunks, 3, "2 full chunks + a 7-byte tail");
assert_eq!(seen, data);
}
#[tokio::test]
async fn a_source_re_opens_to_identical_bytes() {
let data = pattern(NAR_CHUNK_BYTES + 1);
let src = BytesNarSource::new(data.clone());
for _ in 0..3 {
let got = collect_nar(src.open().await.unwrap(), None).await.unwrap();
assert_eq!(got, data);
}
}
#[tokio::test]
async fn file_source_re_opens_and_is_chunk_bounded() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("blob");
let data = pattern(NAR_CHUNK_BYTES * 2 + 13);
tokio::fs::write(&path, &data).await.unwrap();
let src = FileNarSource::with_len(&path, data.len() as u64);
assert_eq!(src.size_hint(), Some(data.len() as u64));
for _ in 0..2 {
let mut s = src.open().await.unwrap();
let mut seen = Vec::new();
while let Some(c) = s.next().await {
let c = c.unwrap();
assert!(c.len() <= NAR_CHUNK_BYTES);
seen.extend_from_slice(&c);
}
assert_eq!(seen, data);
}
}
#[tokio::test]
async fn file_source_open_of_a_missing_file_is_a_typed_error() {
let src = FileNarSource::new("/nonexistent/sui-castore/blob");
match src.open().await {
Err(StoreError::Io(_)) => {}
Err(other) => panic!("expected a typed Io error, got {other}"),
Ok(_) => panic!("opening a missing file must not succeed"),
}
}
#[tokio::test]
async fn empty_input_yields_no_chunks() {
let src = BytesNarSource::new(Vec::new());
assert!(src.open().await.unwrap().next().await.is_none());
assert!(collect_nar(src.open().await.unwrap(), None).await.unwrap().is_empty());
}
#[tokio::test]
async fn collect_with_a_limit_refuses_instead_of_growing() {
let data = pattern(NAR_CHUNK_BYTES * 3);
let src = BytesNarSource::new(data);
let err = collect_nar(src.open().await.unwrap(), Some(NAR_CHUNK_BYTES))
.await
.unwrap_err();
match err {
StoreError::TooLarge { limit, at_least } => {
assert_eq!(limit, NAR_CHUNK_BYTES as u64);
assert!(at_least > limit);
}
other => panic!("expected TooLarge, got {other}"),
}
}
#[tokio::test]
async fn collect_at_exactly_the_limit_is_accepted() {
let data = pattern(NAR_CHUNK_BYTES);
let src = BytesNarSource::new(data.clone());
let got = collect_nar(src.open().await.unwrap(), Some(NAR_CHUNK_BYTES)).await.unwrap();
assert_eq!(got, data);
}
#[tokio::test]
async fn whole_value_stream_round_trips() {
let data = pattern(1000);
let got = collect_nar(whole_value_stream(data.clone()), None).await.unwrap();
assert_eq!(got, data);
}
#[tokio::test]
async fn empty_stream_collects_to_nothing() {
assert!(collect_nar(empty_stream(), None).await.unwrap().is_empty());
}
fn one_shot(data: Vec<u8>, frame: usize) -> impl futures::Stream<Item = Result<Bytes, StoreError>> {
stream::unfold(0usize, move |sent| {
let data = data.clone();
async move {
if sent >= data.len() {
return None;
}
let n = (data.len() - sent).min(frame);
Some((Ok(Bytes::copy_from_slice(&data[sent..sent + n])), sent + n))
}
})
}
#[tokio::test]
async fn a_spooled_upload_re_opens_to_the_same_bytes_every_time() {
let dir = tempfile::tempdir().unwrap();
let data = pattern(NAR_CHUNK_BYTES + 2048);
let src = spool_or_buffer(
Box::pin(one_shot(data.clone(), 8192)),
dir.path(),
DEFAULT_INGEST_MEMORY_CAP,
)
.await
.unwrap();
assert_eq!(src.size_hint(), Some(data.len() as u64));
for _ in 0..3 {
assert_eq!(collect_nar(src.open().await.unwrap(), None).await.unwrap(), data);
}
}
#[tokio::test]
async fn dropping_a_spooled_source_removes_its_file() {
let dir = tempfile::tempdir().unwrap();
let src = spool_or_buffer(
Box::pin(one_shot(pattern(4096), 1024)),
dir.path(),
DEFAULT_INGEST_MEMORY_CAP,
)
.await
.unwrap();
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
drop(src);
assert_eq!(
std::fs::read_dir(dir.path()).unwrap().count(),
0,
"the spool file outlived its source",
);
}
#[tokio::test]
async fn an_unusable_spool_directory_falls_back_to_a_capped_buffer() {
let data = pattern(4096);
let src = spool_or_buffer(
Box::pin(one_shot(data.clone(), 512)),
std::path::Path::new("/nonexistent/sui-spool-dir"),
DEFAULT_INGEST_MEMORY_CAP,
)
.await
.expect("the fallback must keep small uploads working");
assert_eq!(collect_nar(src.open().await.unwrap(), None).await.unwrap(), data);
}
#[tokio::test]
async fn the_fallback_refuses_past_its_cap_rather_than_growing() {
match spool_or_buffer(
Box::pin(one_shot(pattern(64 * 1024), 4096)),
std::path::Path::new("/nonexistent/sui-spool-dir"),
8 * 1024,
)
.await
{
Err(StoreError::TooLarge { limit, at_least }) => {
assert_eq!(limit, 8 * 1024);
assert!(at_least > limit);
}
Err(other) => panic!("expected TooLarge, got {other}"),
Ok(_) => panic!("the fallback must refuse past its cap, not grow"),
}
}
#[tokio::test]
async fn an_empty_upload_spools_and_re_opens_as_empty() {
let dir = tempfile::tempdir().unwrap();
let src = spool_or_buffer(
Box::pin(one_shot(Vec::new(), 1024)),
dir.path(),
DEFAULT_INGEST_MEMORY_CAP,
)
.await
.unwrap();
assert_eq!(src.size_hint(), Some(0));
assert!(collect_nar(src.open().await.unwrap(), None).await.unwrap().is_empty());
}
#[tokio::test]
async fn concurrent_spools_do_not_share_a_file() {
let dir = tempfile::tempdir().unwrap();
let mut sources = Vec::new();
for i in 0..8u8 {
let data = vec![i; 1024];
sources.push((
data.clone(),
spool_or_buffer(
Box::pin(one_shot(data, 128)),
dir.path(),
DEFAULT_INGEST_MEMORY_CAP,
)
.await
.unwrap(),
));
}
for (expected, src) in &sources {
assert_eq!(&collect_nar(src.open().await.unwrap(), None).await.unwrap(), expected);
}
}
}