338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
#[path = "support/temp.rs"]
|
|
mod temp;
|
|
|
|
use bytes::Bytes;
|
|
use dever_runtime::api::upload;
|
|
use dever_runtime::config::UploadLimits;
|
|
use http_body_util::StreamBody;
|
|
use hyper::body::Frame;
|
|
use std::convert::Infallible;
|
|
|
|
fn body(
|
|
bytes: &[u8],
|
|
chunk: usize,
|
|
) -> impl hyper::body::Body<Data = Bytes, Error = Infallible> + Unpin {
|
|
StreamBody::new(futures_util::stream::iter(
|
|
bytes
|
|
.chunks(chunk)
|
|
.map(|chunk| Ok(Frame::data(Bytes::copy_from_slice(chunk))))
|
|
.collect::<Vec<Result<Frame<Bytes>, Infallible>>>(),
|
|
))
|
|
}
|
|
|
|
fn multipart(filename: &str, bytes: &[u8]) -> Vec<u8> {
|
|
let mut body = format!("--boundary\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\nhello\r\n--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: image/png\r\n\r\n").into_bytes();
|
|
body.extend_from_slice(bytes);
|
|
body.extend_from_slice(b"\r\n--boundary--\r\n");
|
|
body
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn streaming_split_boundaries_preserve_bytes_and_remove_owned_files() {
|
|
let directory = temp::TemporaryDirectory::new();
|
|
let mut payload = vec![b'x'; 170_001];
|
|
payload.extend_from_slice(b"\r\n--boundaryX is file content\r\n--boundary--X\r\n--boundary-");
|
|
for chunk in [1, 7, 16_384, 250_000] {
|
|
let bytes = multipart("photo.png", &payload);
|
|
let mut parts = upload::decode(
|
|
body(&bytes, chunk),
|
|
"multipart/form-data; boundary=boundary",
|
|
directory.path(),
|
|
UploadLimits::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
parts
|
|
.json("caption", dever_runtime::api::QueryValue::Text)
|
|
.unwrap(),
|
|
"\"hello\""
|
|
);
|
|
let file = parts.take("file").unwrap();
|
|
assert_eq!(file.filename(), "photo.png");
|
|
assert_eq!(file.content_type(), "image/png");
|
|
assert_eq!(file.size(), payload.len() as i64);
|
|
let files = std::fs::read_dir(directory.path())
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
assert_eq!(files.len(), 1);
|
|
assert_eq!(std::fs::read(files[0].path()).unwrap(), payload);
|
|
parts.finish().unwrap();
|
|
file.close();
|
|
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_limits_and_duplicate_fields_cleanup_partial_files() {
|
|
let directory = temp::TemporaryDirectory::new();
|
|
for bytes in [
|
|
multipart("../escape", b"hello"),
|
|
multipart("x", b"hello")[..150].to_vec(),
|
|
multipart("x", b"hello").repeat(2),
|
|
] {
|
|
assert!(
|
|
upload::decode(
|
|
body(&bytes, 3),
|
|
"multipart/form-data; boundary=boundary",
|
|
directory.path(),
|
|
UploadLimits::default()
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
|
|
}
|
|
let bytes = multipart("x", &[0; 40_000]);
|
|
for limits in [
|
|
UploadLimits {
|
|
file_bytes: 100,
|
|
..Default::default()
|
|
},
|
|
UploadLimits {
|
|
total_bytes: 256,
|
|
file_bytes: 100,
|
|
..Default::default()
|
|
},
|
|
UploadLimits {
|
|
header_bytes: 10,
|
|
..Default::default()
|
|
},
|
|
] {
|
|
assert!(
|
|
upload::decode(
|
|
body(&bytes, 1024),
|
|
"multipart/form-data; boundary=boundary",
|
|
directory.path(),
|
|
limits
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
|
|
}
|
|
let duplicate = b"--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"first\"\r\n\r\nfirst\r\n--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"second\"\r\n\r\nsecond\r\n--boundary--\r\n";
|
|
assert!(
|
|
upload::decode(
|
|
body(duplicate, 1),
|
|
"multipart/form-data; boundary=boundary",
|
|
directory.path(),
|
|
UploadLimits::default()
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancellation_drops_partial_upload_owner() {
|
|
let directory = temp::TemporaryDirectory::new();
|
|
let bytes = multipart("x", &[0; 40_000]);
|
|
let frames = futures_util::stream::iter(vec![Ok::<_, Infallible>(Frame::data(
|
|
Bytes::copy_from_slice(&bytes[..30_000]),
|
|
))]);
|
|
let stalled = futures_util::StreamExt::chain(frames, futures_util::stream::pending());
|
|
let mut decoding = Box::pin(upload::decode(
|
|
StreamBody::new(stalled),
|
|
"multipart/form-data; boundary=boundary",
|
|
directory.path(),
|
|
UploadLimits::default(),
|
|
));
|
|
let writing = async {
|
|
for _ in 0..100 {
|
|
if std::fs::read_dir(directory.path())
|
|
.unwrap()
|
|
.any(|file| file.unwrap().metadata().unwrap().len() > 1000)
|
|
{
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
panic!("decoder did not spool the initial body frames");
|
|
};
|
|
tokio::select! {
|
|
_ = writing => {},
|
|
_ = &mut decoding => panic!("incomplete decoder unexpectedly finished"),
|
|
}
|
|
drop(decoding);
|
|
for _ in 0..100 {
|
|
if std::fs::read_dir(directory.path()).unwrap().count() == 0 {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
panic!("cancelled upload retained a temporary file");
|
|
}
|
|
|
|
fn sources(app: &str, route: &str) -> dever_core::source::SourceMap {
|
|
let mut sources = dever_core::source::SourceMap::default();
|
|
sources.add("media/image/app.dever", app);
|
|
sources.add("media/image/api.dever", route);
|
|
sources
|
|
}
|
|
|
|
fn storage_sources() -> dever_core::source::SourceMap {
|
|
let mut sources = sources(
|
|
"accept(file: Upload) (key: Uuid) { key = port.store(file) }",
|
|
"post upload = app.accept",
|
|
);
|
|
sources.add(
|
|
"media/image/port.dever",
|
|
"store(file: Upload) (key: Uuid) fails dever.storage.PutResult",
|
|
);
|
|
sources.add(
|
|
"media/image/adapter.dever",
|
|
"port.store(file: Upload) (key: Uuid) { key = dever.storage.put(file) }",
|
|
);
|
|
sources
|
|
}
|
|
|
|
#[test]
|
|
fn upload_affinity_and_route_contract_are_checked() {
|
|
for (app, route) in [
|
|
(
|
|
"accept(file: Upload) (size: Int) { size = dever.api.upload_size(file) }",
|
|
"post upload = app.accept",
|
|
),
|
|
(
|
|
"accept(file: Upload) (size: Int) { dever.api.close_upload(file)\nsize = dever.api.upload_size(file) }",
|
|
"post upload = app.accept",
|
|
),
|
|
(
|
|
"accept(file: Upload) (size: Int) { size = dever.api.upload_size(file)\ndever.api.close_upload(file) }",
|
|
"get upload = app.accept",
|
|
),
|
|
(
|
|
"accept(file: Upload) (size: Int) { size = dever.api.upload_size(file)\ndever.api.close_upload(file) }",
|
|
"cmd upload = app.accept",
|
|
),
|
|
(
|
|
"type Payload { file: Upload }\naccept(file: Payload) (size: Int) { size = 1 }",
|
|
"post upload = app.accept",
|
|
),
|
|
(
|
|
"accept(file: Upload) (key: Uuid) { key = dever.storage.put(file) }",
|
|
"post upload = app.accept",
|
|
),
|
|
(
|
|
"accept(file: Upload) (size: Int) { size = helper(file) }\nhelper(file: Upload) (size: Int) { size = dever.api.upload_size(file)\ndever.api.close_upload(file) }",
|
|
"post upload = app.accept",
|
|
),
|
|
] {
|
|
assert!(
|
|
dever_core::check(&sources(app, route)).is_err(),
|
|
"unexpected accepted: {app}"
|
|
);
|
|
}
|
|
let sources = sources(
|
|
"accept(file: Upload, caption: Text) (size: Int) { size = dever.api.upload_size(file)\nname = dever.api.upload_filename(file)\ndever.api.close_upload(file) }",
|
|
"post upload = app.accept",
|
|
);
|
|
dever_core::check(&sources).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
let sources = storage_sources();
|
|
dever_core::check(&sources).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn upload_route_native_compiles_with_owned_resource() {
|
|
use std::io::{Read, Write};
|
|
let sources = storage_sources();
|
|
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
|
|
panic!(
|
|
"{}",
|
|
errors
|
|
.iter()
|
|
.map(|error| error.render(&sources))
|
|
.collect::<String>()
|
|
)
|
|
});
|
|
let native = dever_core::native::compile_project(
|
|
&program,
|
|
&sources,
|
|
std::ffi::OsStr::new("rustc"),
|
|
dever_runtime::config::RuntimeProfile::default(),
|
|
)
|
|
.unwrap();
|
|
assert!(native.executable().is_file());
|
|
let directory = temp::TemporaryDirectory::new();
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
std::fs::create_dir(directory.path().join("config")).unwrap();
|
|
std::fs::write(
|
|
directory.path().join("config/setting.json"),
|
|
format!(r#"{{"http":{{"host":"127.0.0.1","port":{port},"timeout_ms":1000}}}}"#),
|
|
)
|
|
.unwrap();
|
|
let executable = directory.path().join("upload-test");
|
|
native.save(&executable).unwrap();
|
|
let mut child = Child(
|
|
std::process::Command::new(executable)
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.unwrap(),
|
|
);
|
|
let started = std::time::Instant::now();
|
|
let mut peer = loop {
|
|
assert!(
|
|
started.elapsed() < std::time::Duration::from_secs(5),
|
|
"upload listener startup timeout"
|
|
);
|
|
if let Some(status) = child.0.try_wait().unwrap() {
|
|
panic!("upload application exited: {status}");
|
|
}
|
|
match std::net::TcpStream::connect(("127.0.0.1", port)) {
|
|
Ok(peer) => break peer,
|
|
Err(_) => std::thread::sleep(std::time::Duration::from_millis(10)),
|
|
}
|
|
};
|
|
peer.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
|
.unwrap();
|
|
let bytes = b"--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\n\r\nhello\r\n--boundary--\r\n";
|
|
write!(peer, "POST /media/image/upload HTTP/1.1\r\nHost: localhost\r\nContent-Type: multipart/form-data; boundary=boundary\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len()).unwrap();
|
|
peer.write_all(bytes).unwrap();
|
|
let mut response = String::new();
|
|
peer.read_to_string(&mut response).unwrap();
|
|
assert!(response.starts_with("HTTP/1.1 200"), "{response}");
|
|
let envelope: serde_json::Value =
|
|
serde_json::from_str(response.split_once("\r\n\r\n").unwrap().1).unwrap();
|
|
let key = envelope["data"].as_str().unwrap();
|
|
assert!(dever_runtime::orm::Uuid::parse(key).is_ok());
|
|
assert_eq!(
|
|
std::fs::read(directory.path().join("data/upload").join(key)).unwrap(),
|
|
b"hello"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_dir(directory.path().join("data/tmp"))
|
|
.unwrap()
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
struct Child(std::process::Child);
|
|
impl Drop for Child {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|