Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ The current implementation includes the CLI/executor, exact pipeline output, sav

## Build

Use stable Rust with the committed lockfile:
For a Windows x64 release build, use the same static CRT setting as the release workflow:

```sh
cargo build --release --locked
```powershell
$env:RUSTFLAGS = "-C target-feature=+crt-static"
cargo build --release --locked --target x86_64-pc-windows-msvc
```

For development:
Expand Down
2 changes: 1 addition & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The current automated suite includes unit tests for request defaults/validation,

## Current local verification

On Windows x64, the required formatting, strict Clippy, and full test commands pass with 41 library tests and 12 subprocess integration tests. The optimized `x86_64-pc-windows-msvc` executable also builds and reports `curly 0.1.0`. Its import table was inspected with `dumpbin`; only Windows system DLLs are referenced. A ZIP and SHA-256 checksum are generated under `dist/`.
On Windows x64, the required formatting, strict Clippy, and full test commands pass with 46 library tests and 12 subprocess integration tests. A fresh local-server smoke test verified exact binary stdout and stderr separation, a byte-exact 2 MiB file download, HTTP error body and exit code 22, JSON POST with ordered query values, saved-request execution, SQLite history, and noninteractive TUI rejection. PTY checks covered terminal JSON formatting, binary summaries, control-character escaping, composer send, history replay and edit, cancellation of a delayed request, and terminal restoration on quit. A 128 MiB streamed download matched its SHA-256 with 15.2 MiB peak process RSS (sampled roughly every 10 ms); an early-closing downstream pipe exited quietly. The Windows x64 release was built with `RUSTFLAGS=-C target-feature=+crt-static`. Its PE import table contains Windows system DLLs only, with no VCRUNTIME, OpenSSL, or external SQLite DLL. The exact rebuilt release executable was smoke-tested and installed on PATH. The local Windows ZIP and SHA-256 checksum were refreshed; the extracted executable matched the build hash and launched successfully.

Linux and macOS release packaging is currently out of the supported release matrix and will be added later after native verification is available. PTY-driven terminal formatting checks and manual TUI resize/restoration checks remain outstanding for Windows.

Expand Down
158 changes: 132 additions & 26 deletions src/executor.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
use std::{pin::Pin, time::Duration};
use std::{
io::{self, Read},
path::{Path, PathBuf},
pin::Pin,
time::Duration,
};

use bytes::Bytes;
use futures_util::{Stream, StreamExt};
use reqwest::{Body, Client, Response, header::HeaderValue, redirect::Policy};
use serde::Deserialize;
use tokio_util::{io::ReaderStream, sync::CancellationToken};

use crate::{
Expand Down Expand Up @@ -60,7 +66,10 @@ pub async fn execute(
None => {}
}

let (body, preview) = prepare_body(request).await?;
let (body, preview) = tokio::select! {
_ = cancel.cancelled() => return Err(CurlyError::Cancelled),
result = prepare_body(request, cancel) => result?,
};
if let Some(body) = body {
if matches!(
request.body.as_ref().map(BodySource::kind),
Expand Down Expand Up @@ -127,6 +136,7 @@ fn response_parts(response: Response, elapsed_to_headers: Duration) -> ExecutedR

async fn prepare_body(
request: &RequestDefinition,
cancel: &CancellationToken,
) -> Result<(Option<Body>, RequestPreview), CurlyError> {
const PREVIEW_LIMIT: usize = 64 * 1024;
let Some(source) = &request.body else {
Expand All @@ -142,29 +152,21 @@ async fn prepare_body(

match source {
BodySource::Inline { kind, value } => {
let bytes = value.as_bytes();
validate_json_if_needed(*kind, bytes)?;
let bytes = validate_json_bytes(*kind, value.as_bytes().to_vec(), cancel).await?;
let total_bytes = bytes.len() as u64;
let preview = bytes[..bytes.len().min(PREVIEW_LIMIT)].to_vec();
Ok((
Some(Body::from(value.clone())),
Some(Body::from(bytes)),
RequestPreview {
bytes: preview,
truncated: bytes.len() > PREVIEW_LIMIT,
total_bytes: bytes.len() as u64,
truncated: total_bytes > PREVIEW_LIMIT as u64,
total_bytes,
},
))
}
BodySource::File { kind, path } => {
if *kind == BodyKind::Json {
let validation_file = std::fs::File::open(path).map_err(|err| {
CurlyError::Invalid(format!(
"cannot open JSON body file {}: {err}",
path.display()
))
})?;
serde_json::from_reader::<_, serde_json::Value>(validation_file).map_err(
|err| CurlyError::Invalid(format!("invalid JSON in {}: {err}", path.display())),
)?;
validate_json_file(path, cancel).await?;
}
let metadata = tokio::fs::metadata(path).await.map_err(|err| {
CurlyError::Invalid(format!("cannot read body file {}: {err}", path.display()))
Expand All @@ -190,27 +192,106 @@ async fn prepare_body(
BodySource::Stdin { kind } => {
let mut bytes = Vec::new();
use tokio::io::AsyncReadExt;
tokio::io::stdin().read_to_end(&mut bytes).await?;
validate_json_if_needed(*kind, &bytes)?;
let mut stdin = tokio::io::stdin();
tokio::select! {
_ = cancel.cancelled() => return Err(CurlyError::Cancelled),
result = stdin.read_to_end(&mut bytes) => result?,
};
let bytes = validate_json_bytes(*kind, bytes, cancel).await?;
let total_bytes = bytes.len() as u64;
let preview = bytes[..bytes.len().min(PREVIEW_LIMIT)].to_vec();
Ok((
Some(Body::from(bytes.clone())),
Some(Body::from(bytes)),
RequestPreview {
bytes: preview,
truncated: bytes.len() > PREVIEW_LIMIT,
total_bytes: bytes.len() as u64,
truncated: total_bytes > PREVIEW_LIMIT as u64,
total_bytes,
},
))
}
}
}

fn validate_json_if_needed(kind: BodyKind, bytes: &[u8]) -> Result<(), CurlyError> {
if kind == BodyKind::Json {
serde_json::from_slice::<serde_json::Value>(bytes)
.map_err(|err| CurlyError::Invalid(format!("invalid JSON request body: {err}")))?;
async fn validate_json_bytes(
kind: BodyKind,
bytes: Vec<u8>,
cancel: &CancellationToken,
) -> Result<Vec<u8>, CurlyError> {
if kind != BodyKind::Json {
return Ok(bytes);
}

let worker_cancel = cancel.clone();
let task = tokio::task::spawn_blocking(move || {
let result = {
let reader = CancellationReader::new(
std::io::Cursor::new(bytes.as_slice()),
worker_cancel.clone(),
);
let mut deserializer = serde_json::Deserializer::from_reader(reader);
serde::de::IgnoredAny::deserialize(&mut deserializer).and_then(|_| deserializer.end())
};
if worker_cancel.is_cancelled() {
return Err(CurlyError::Cancelled);
}
result.map_err(|err| CurlyError::Invalid(format!("invalid JSON request body: {err}")))?;
Ok(bytes)
});

tokio::select! {
_ = cancel.cancelled() => Err(CurlyError::Cancelled),
result = task => result
.map_err(|err| CurlyError::Io(io::Error::other(format!("JSON validation worker failed: {err}"))))?,
}
}

async fn validate_json_file(path: &Path, cancel: &CancellationToken) -> Result<(), CurlyError> {
let path = path.to_path_buf();
let worker_cancel = cancel.clone();
let task =
tokio::task::spawn_blocking(move || validate_json_file_blocking(path, worker_cancel));
tokio::select! {
_ = cancel.cancelled() => Err(CurlyError::Cancelled),
result = task => result
.map_err(|err| CurlyError::Io(io::Error::other(format!("JSON validation worker failed: {err}"))))?,
}
}

fn validate_json_file_blocking(path: PathBuf, cancel: CancellationToken) -> Result<(), CurlyError> {
let file = std::fs::File::open(&path).map_err(|err| {
CurlyError::Invalid(format!(
"cannot open JSON body file {}: {err}",
path.display()
))
})?;
let reader = CancellationReader::new(file, cancel.clone());
let mut deserializer = serde_json::Deserializer::from_reader(reader);
let result =
serde::de::IgnoredAny::deserialize(&mut deserializer).and_then(|_| deserializer.end());
if cancel.is_cancelled() {
return Err(CurlyError::Cancelled);
}
result.map_err(|err| CurlyError::Invalid(format!("invalid JSON in {}: {err}", path.display())))
}

struct CancellationReader<R> {
inner: R,
cancel: CancellationToken,
}

impl<R> CancellationReader<R> {
fn new(inner: R, cancel: CancellationToken) -> Self {
Self { inner, cancel }
}
}

impl<R: Read> Read for CancellationReader<R> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if self.cancel.is_cancelled() {
return Err(io::Error::other("operation cancelled"));
}
self.inner.read(buffer)
}
Ok(())
}

pub async fn collect_stream(
Expand Down Expand Up @@ -260,4 +341,29 @@ mod tests {
assert!(matches!(error, CurlyError::Cancelled));
assert_eq!(error.exit_code(), 130);
}

#[tokio::test]
async fn json_validation_honors_cancellation() {
let cancellation = CancellationToken::new();
cancellation.cancel();
let result = validate_json_bytes(
BodyKind::Json,
br#"{"large":[1,2,3]}"#.to_vec(),
&cancellation,
)
.await;
assert!(matches!(result, Err(CurlyError::Cancelled)));
}

#[tokio::test]
async fn json_validation_rejects_trailing_data() {
let cancellation = CancellationToken::new();
let result = validate_json_bytes(
BodyKind::Json,
br#"{"ok":true} trailing"#.to_vec(),
&cancellation,
)
.await;
assert!(matches!(result, Err(CurlyError::Invalid(_))));
}
}
Loading
Loading