Skip to content

Commit cad83ac

Browse files
committed
Fix bugs
1 parent e7f8c8a commit cad83ac

5 files changed

Lines changed: 142 additions & 1 deletion

File tree

crates/vespera_macro/src/router_codegen/export.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ impl Parse for ExportAppInput {
3030

3131
match ident_str.as_str() {
3232
"dir" => {
33+
// Reject a repeated `dir` with a spanned error instead of
34+
// silently letting the later value overwrite the earlier
35+
// one — matches the `vespera!` arg parser's duplicate guard.
36+
if dir.is_some() {
37+
return Err(syn::Error::new(
38+
ident.span(),
39+
"duplicate field `dir` in export_app! macro",
40+
));
41+
}
3342
input.parse::<syn::Token![=]>()?;
3443
dir = Some(input.parse()?);
3544
}
@@ -90,4 +99,21 @@ mod tests {
9099
assert_eq!(input.name.to_string(), "MyApp");
91100
assert_eq!(input.dir.unwrap().value(), "api");
92101
}
102+
103+
#[test]
104+
fn test_export_app_input_duplicate_dir() {
105+
// A repeated `dir` must be a spanned compile error, not a silent
106+
// last-wins overwrite.
107+
let tokens = quote::quote!(MyApp, dir = "api", dir = "other");
108+
let result: syn::Result<ExportAppInput> = syn::parse2(tokens);
109+
assert!(result.is_err(), "duplicate `dir` must be rejected");
110+
assert!(
111+
result
112+
.err()
113+
.unwrap()
114+
.to_compile_error()
115+
.to_string()
116+
.contains("duplicate field `dir`")
117+
);
118+
}
93119
}

crates/vespera_macro/src/router_codegen/input.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,21 @@ fn parse_tag_struct(input: ParseStream) -> syn::Result<TagConfig> {
222222

223223
let mut name: Option<String> = None;
224224
let mut description: Option<String> = None;
225+
// Reject a repeated tag field (e.g. `name = ..., name = ...`) with a
226+
// spanned error instead of silently letting the later value overwrite the
227+
// earlier one — matches the top-level `vespera!` arg parser and
228+
// `parse_security_scheme_struct`.
229+
let mut seen_fields = HashSet::<String>::new();
225230

226231
while !content.is_empty() {
227232
let ident: syn::Ident = content.parse()?;
228233
let ident_str = ident.to_string();
234+
if !seen_fields.insert(ident_str.clone()) {
235+
return Err(syn::Error::new(
236+
ident.span(),
237+
format!("duplicate tag field: `{ident_str}`"),
238+
));
239+
}
229240
content.parse::<syn::Token![=]>()?;
230241
let value: LitStr = content.parse()?;
231242

@@ -616,10 +627,21 @@ fn parse_server_struct(input: ParseStream) -> syn::Result<ServerConfig> {
616627

617628
let mut url: Option<String> = None;
618629
let mut description: Option<String> = None;
630+
// Reject a repeated server field (e.g. `url = ..., url = ...`) with a
631+
// spanned error instead of silently letting the later value overwrite the
632+
// earlier one — matches the top-level `vespera!` arg parser and
633+
// `parse_security_scheme_struct`.
634+
let mut seen_fields = HashSet::<String>::new();
619635

620636
while !content.is_empty() {
621637
let ident: syn::Ident = content.parse()?;
622638
let ident_str = ident.to_string();
639+
if !seen_fields.insert(ident_str.clone()) {
640+
return Err(syn::Error::new(
641+
ident.span(),
642+
format!("duplicate server field: `{ident_str}`"),
643+
));
644+
}
623645

624646
match ident_str.as_str() {
625647
"url" => {

crates/vespera_macro/src/router_codegen/input_tests.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,3 +641,36 @@ fn test_security_scheme_duplicate_field_rejected() {
641641
assert!(result.is_err(), "duplicate scheme field must be rejected");
642642
assert!(result.err().unwrap().to_string().contains("duplicate"));
643643
}
644+
645+
#[test]
646+
fn test_tag_duplicate_field_rejected() {
647+
// A repeated tag field (e.g. `name = ..., name = ...`) must be a spanned
648+
// compile error, not a silent last-wins overwrite.
649+
let tokens = quote::quote!(tags = [{ name = "a", name = "b" }]);
650+
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
651+
assert!(result.is_err(), "duplicate tag field must be rejected");
652+
assert!(
653+
result
654+
.err()
655+
.unwrap()
656+
.to_string()
657+
.contains("duplicate tag field")
658+
);
659+
}
660+
661+
#[test]
662+
fn test_server_duplicate_field_rejected() {
663+
// A repeated server field (e.g. `url = ..., url = ...`) must be a spanned
664+
// compile error, not a silent last-wins overwrite.
665+
let tokens =
666+
quote::quote!(servers = [{ url = "http://localhost:3000", url = "http://other:3000" }]);
667+
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
668+
assert!(result.is_err(), "duplicate server field must be rejected");
669+
assert!(
670+
result
671+
.err()
672+
.unwrap()
673+
.to_string()
674+
.contains("duplicate server field")
675+
);
676+
}

libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,8 @@ private CompletableFuture<ResponseEntity<?>> dispatchAsyncFlow(
469469
appName, method, path, query, headers, body);
470470
return VesperaBridge.dispatch(wireReq)
471471
.thenApplyAsync(
472-
wireResp -> buildResponseEntityFromWire(wireResp, method),
472+
wireResp -> buildCappedResponseEntityFromWire(
473+
wireResp, method, maxBufferedResponseBytes),
473474
asyncResponseExecutor)
474475
// The async executor uses AbortPolicy (NOT CallerRunsPolicy):
475476
// under saturation the heavy wire response build must NOT run on
@@ -911,6 +912,25 @@ static ResponseEntity<?> buildResponseEntityFromWire(byte[] wire, String method)
911912
new WireBodyResource(wire, bodyOff, bytesToExpose), httpHeaders, status);
912913
}
913914

915+
/**
916+
* Build the buffered {@code ASYNC} response entity, enforcing the
917+
* {@code vespera.bridge.max-buffered-response-bytes} cap FIRST — parity with
918+
* the {@code SYNC} path ({@link #dispatchSync} via
919+
* {@link #rejectOversizedBufferedResponse}). Without this a custom
920+
* {@link DispatchModeResolver} returning {@link DispatchMode#ASYNC} would
921+
* heap-buffer an arbitrarily large Rust response (retained through
922+
* {@link WireBodyResource} until Spring finishes writing it), defeating the
923+
* cap and risking OOM / GC pressure. Runs on the async response executor
924+
* (NOT the Tokio completion thread), so the cap check stays off the native
925+
* worker. Package-private + static so the cap wiring is unit-testable
926+
* without a live JNI dispatch.
927+
*/
928+
static ResponseEntity<?> buildCappedResponseEntityFromWire(
929+
byte[] wire, String method, long maxBufferedResponseBytes) {
930+
rejectOversizedBufferedResponse(wire, maxBufferedResponseBytes);
931+
return buildResponseEntityFromWire(wire, method);
932+
}
933+
914934
static final class BodyPermittingOutputStream extends OutputStream {
915935
private final OutputStream delegate;
916936
private final String method;

libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ProxyControllerBodyHeaderTest.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,46 @@ void asyncRejectionMapsTo503AndOtherFailuresPropagate() {
5757
() -> VesperaProxyController.asyncFailureToResponse(new RuntimeException("boom")));
5858
}
5959

60+
// ── ASYNC buffered-response cap parity with SYNC ─────────────────────
61+
62+
/** Build a wire response {@code [u32 BE headerLen | header JSON | body]}. */
63+
private static byte[] wireResponseWithBody(int bodyLen) {
64+
String json =
65+
"{\"v\":1,\"status\":200,\"headers\":{\"content-type\":\"application/json\"},"
66+
+ "\"metadata\":{\"version\":\"0.1.0\"}}";
67+
byte[] hb = json.getBytes(StandardCharsets.UTF_8);
68+
byte[] body = new byte[bodyLen];
69+
java.util.Arrays.fill(body, (byte) 'x');
70+
ByteBuffer buf = ByteBuffer.allocate(4 + hb.length + bodyLen);
71+
buf.putInt(hb.length);
72+
buf.put(hb);
73+
buf.put(body);
74+
return buf.array();
75+
}
76+
77+
@Test
78+
void asyncResponseEnforcesMaxBufferedResponseCap() {
79+
// A custom DispatchModeResolver returning ASYNC must honour the same
80+
// max-buffered-response cap as SYNC (dispatchSync), or it heap-buffers
81+
// an unbounded Rust response. The capped builder the async flow now
82+
// uses rejects an oversized body with 413, lets a within-cap body
83+
// through, and treats cap = 0 as unlimited (never rejects).
84+
byte[] oversized = wireResponseWithBody(100);
85+
ResponseStatusException tooLarge = assertThrows(
86+
ResponseStatusException.class,
87+
() -> VesperaProxyController.buildCappedResponseEntityFromWire(oversized, "GET", 10));
88+
assertEquals(413, tooLarge.getStatusCode().value());
89+
90+
byte[] small = wireResponseWithBody(5);
91+
ResponseEntity<?> ok =
92+
VesperaProxyController.buildCappedResponseEntityFromWire(small, "GET", 1000);
93+
assertEquals(200, ok.getStatusCode().value());
94+
95+
ResponseEntity<?> unlimited =
96+
VesperaProxyController.buildCappedResponseEntityFromWire(oversized, "GET", 0);
97+
assertEquals(200, unlimited.getStatusCode().value());
98+
}
99+
60100
@Test
61101
void duplicateCookieHeadersAreSemicolonJoined() {
62102
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");

0 commit comments

Comments
 (0)