feat: download batch

This commit is contained in:
Nathan Flurry 2026-02-23 09:51:18 -08:00
parent 3545139cd3
commit e1a09564e4
14 changed files with 702 additions and 91 deletions

View file

@ -26,7 +26,7 @@ use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tar::Archive;
use tar::{Archive, Builder};
use tower_http::trace::TraceLayer;
use tracing::Span;
use utoipa::{Modify, OpenApi, ToSchema};
@ -166,6 +166,7 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
.route("/fs/move", post(post_v1_fs_move))
.route("/fs/stat", get(get_v1_fs_stat))
.route("/fs/upload-batch", post(post_v1_fs_upload_batch))
.route("/fs/download-batch", get(get_v1_fs_download_batch))
.route(
"/config/mcp",
get(get_v1_config_mcp)
@ -295,6 +296,7 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
post_v1_fs_move,
get_v1_fs_stat,
post_v1_fs_upload_batch,
get_v1_fs_download_batch,
get_v1_config_mcp,
put_v1_config_mcp,
delete_v1_config_mcp,
@ -321,6 +323,7 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
FsEntriesQuery,
FsDeleteQuery,
FsUploadBatchQuery,
FsDownloadBatchQuery,
FsEntryType,
FsEntry,
FsStat,
@ -1075,6 +1078,129 @@ async fn post_v1_fs_upload_batch(
}))
}
fn tar_add_path(
builder: &mut Builder<&mut Vec<u8>>,
base: &StdPath,
path: &StdPath,
) -> Result<(), SandboxError> {
let metadata = fs::symlink_metadata(path).map_err(|err| map_fs_error(path, err))?;
if metadata.file_type().is_symlink() {
return Err(SandboxError::InvalidRequest {
message: format!(
"symlinks are not supported in download-batch: {}",
path.display()
),
});
}
let rel = path
.strip_prefix(base)
.map_err(|_| SandboxError::InvalidRequest {
message: format!("path is not under base: {}", path.display()),
})?;
let name = StdPath::new(".").join(rel);
if metadata.is_dir() {
builder
.append_dir(&name, path)
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
for entry in fs::read_dir(path).map_err(|err| map_fs_error(path, err))? {
let entry = entry.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
tar_add_path(builder, base, &entry.path())?;
}
return Ok(());
}
if metadata.is_file() {
builder
.append_path_with_name(path, &name)
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
return Ok(());
}
Err(SandboxError::InvalidRequest {
message: format!("unsupported filesystem entry type: {}", path.display()),
})
}
/// Download a tar archive of a file or directory.
///
/// Returns `application/x-tar` bytes containing the requested path. If the path is a directory,
/// the archive contains its contents (similar to `tar -C <dir> .`).
#[utoipa::path(
get,
path = "/v1/fs/download-batch",
tag = "v1",
params(
("path" = Option<String>, Query, description = "Source path (file or directory)")
),
responses(
(status = 200, description = "tar archive bytes")
)
)]
async fn get_v1_fs_download_batch(
Query(query): Query<FsDownloadBatchQuery>,
) -> Result<Response, ApiError> {
let raw = query.path.unwrap_or_else(|| ".".to_string());
let target = resolve_fs_path(&raw)?;
let metadata = fs::symlink_metadata(&target).map_err(|err| map_fs_error(&target, err))?;
if metadata.file_type().is_symlink() {
return Err(SandboxError::InvalidRequest {
message: format!(
"symlinks are not supported in download-batch: {}",
target.display()
),
}
.into());
}
let mut out = Vec::<u8>::new();
{
let mut builder = Builder::new(&mut out);
if metadata.is_dir() {
// Pack directory contents, not an extra top-level folder wrapper.
for entry in fs::read_dir(&target).map_err(|err| map_fs_error(&target, err))? {
let entry = entry.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
tar_add_path(&mut builder, &target, &entry.path())?;
}
} else if metadata.is_file() {
let name = StdPath::new(".").join(target.file_name().ok_or_else(|| {
SandboxError::InvalidRequest {
message: format!("invalid file path: {}", target.display()),
}
})?);
builder
.append_path_with_name(&target, name)
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
} else {
return Err(SandboxError::InvalidRequest {
message: format!("unsupported filesystem entry type: {}", target.display()),
}
.into());
}
builder.finish().map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
}
Ok((
[(header::CONTENT_TYPE, "application/x-tar")],
Bytes::from(out),
)
.into_response())
}
#[utoipa::path(
get,
path = "/v1/config/mcp",

View file

@ -128,6 +128,13 @@ pub struct FsUploadBatchQuery {
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsDownloadBatchQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum FsEntryType {

View file

@ -1,4 +1,5 @@
use super::*;
use std::io::Cursor;
#[tokio::test]
async fn v1_health_removed_legacy_and_opencode_unmounted() {
@ -134,6 +135,73 @@ async fn v1_filesystem_endpoints_round_trip() {
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn v1_filesystem_download_batch_returns_tar() {
let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, _) = send_request_raw(
&test_app.app,
Method::PUT,
"/v1/fs/file?path=docs/a.txt",
Some(b"aaa".to_vec()),
&[],
Some("application/octet-stream"),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _, _) = send_request_raw(
&test_app.app,
Method::PUT,
"/v1/fs/file?path=docs/nested/b.txt",
Some(b"bbb".to_vec()),
&[],
Some("application/octet-stream"),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, headers, body) = send_request_raw(
&test_app.app,
Method::GET,
"/v1/fs/download-batch?path=docs",
None,
&[],
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or(""),
"application/x-tar"
);
let mut archive = tar::Archive::new(Cursor::new(body));
let mut paths: Vec<String> = archive
.entries()
.expect("tar entries")
.map(|entry| {
entry
.expect("tar entry")
.path()
.expect("tar path")
.to_string_lossy()
.to_string()
})
.collect();
paths.sort();
let has_a = paths.iter().any(|p| p == "a.txt" || p == "./a.txt");
let has_b = paths
.iter()
.any(|p| p == "nested/b.txt" || p == "./nested/b.txt");
assert!(has_a, "expected a.txt in tar, got: {paths:?}");
assert!(has_b, "expected nested/b.txt in tar, got: {paths:?}");
}
#[tokio::test]
#[serial]
async fn require_preinstall_blocks_missing_agent() {