use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use bugwarden_core::guard::Guard;
use serde_json::json;
use tokio::io::AsyncWriteExt as _;
use tokio::process::Command;
#[path = "common/scrub_env.rs"]
mod scrub_env;
#[path = "common/startup_line.rs"]
mod startup_line;
const LOG_TIMEOUT: Duration = Duration::from_secs(20);
const CAP: usize = 1024;
const QUOTED_CAP: usize = 1016;
const SUPPORTED_VERSION: &str = "2025-11-25";
const PROBE: char = 'é';
const ESC: char = '\u{1b}';
const BEL: char = '\u{7}';
const LF: char = '\u{a}';
const CR: char = '\u{d}';
const TAB: char = '\u{9}';
const LS: char = '\u{2028}';
const FORGED_LINE: &str = "2026-09-05T00:00:00.000000Z INFO bugwarden::server: FORGED LINE";
const FORGING_PAIR: &str = "evil status=HACKED limit=999";
const FORGING_ESCAPES: &str = "evil\" status=HACKED \\";
#[test]
fn the_scrub_list_covers_every_environment_fallback() {
scrub_env::assert_the_scrub_list_covers_every_environment_fallback(
scrub_env::AMBIENT_VARS,
scrub_env::HTTP_TOKEN_VARS,
);
}
async fn log_line(
protocol_version: &str,
policy: Option<&Path>,
call: Option<(&str, serde_json::Value)>,
needle: &str,
) -> String {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bugwarden"));
cmd.args(["--transport", "stdio"])
.args(["--bugzilla-server", "https://bugzilla.example.invalid"])
.args(["--api-key", "test-key"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(path) = policy {
cmd.arg("--policy").arg(path);
}
for var in scrub_env::AMBIENT_VARS {
cmd.env_remove(var);
}
let mut child = cmd.spawn().expect("the built binary must start");
let mut stderr = startup_line::stderr_lines(&mut child);
let mut stdin = child.stdin.take().expect("stdin is piped");
let mut messages = vec![json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": protocol_version,
"capabilities": {},
"clientInfo": { "name": "binary-tracing-caps-test", "version": "0" }
}
})];
if let Some((tool, arguments)) = call {
messages.push(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
messages.push(json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": tool, "arguments": arguments }
}));
}
for message in messages {
stdin
.write_all(format!("{message}\n").as_bytes())
.await
.expect("the child must accept input");
}
let mut log = String::new();
let line = startup_line::wait_for_line(&mut stderr, &mut log, needle, LOG_TIMEOUT).await;
drop(stdin);
line
}
async fn tool_call_log_line(tool: &str, arguments: serde_json::Value, needle: &str) -> String {
log_line(SUPPORTED_VERSION, None, Some((tool, arguments)), needle).await
}
async fn stderr_through(messages: &[String], rust_log: Option<&str>, needle: &str) -> String {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bugwarden"));
cmd.args(["--transport", "stdio"])
.args(["--bugzilla-server", "https://bugzilla.example.invalid"])
.args(["--api-key", "test-key"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
for var in scrub_env::AMBIENT_VARS {
cmd.env_remove(var);
}
if let Some(filter) = rust_log {
cmd.env("RUST_LOG", filter);
}
let mut child = cmd.spawn().expect("the built binary must start");
let mut stderr = startup_line::stderr_lines(&mut child);
let mut stdin = child.stdin.take().expect("stdin is piped");
let payload: Vec<u8> = messages
.iter()
.flat_map(|line| format!("{line}\n").into_bytes())
.collect();
let (release, released) = tokio::sync::oneshot::channel::<()>();
let writes = tokio::spawn(async move {
stdin
.write_all(&payload)
.await
.expect("the child must accept input");
let _ = released.await;
});
let mut log = String::new();
startup_line::wait_for_line(&mut stderr, &mut log, needle, LOG_TIMEOUT).await;
let _ = release.send(());
writes.await.expect("the writer task must not panic");
tokio::time::timeout(LOG_TIMEOUT, async {
while startup_line::next_logged_line(&mut stderr, &mut log)
.await
.is_some()
{}
})
.await
.unwrap_or_else(|_| panic!("the child's stderr must reach EOF: {}", excerpt(&log)));
log
}
fn initialize(name: &str) -> String {
json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": SUPPORTED_VERSION,
"capabilities": {},
"clientInfo": { "name": name, "version": "0" }
}
})
.to_string()
}
fn initialized() -> String {
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }).to_string()
}
fn tools_call(id: serde_json::Value, tool: &str, arguments: serde_json::Value) -> String {
json!({
"jsonrpc": "2.0", "id": id, "method": "tools/call",
"params": { "name": tool, "arguments": arguments }
})
.to_string()
}
fn excerpt(log: &str) -> String {
log.chars().take(2_000).collect()
}
fn find_line<'a>(log: &'a str, needle: &str) -> &'a str {
log.lines()
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("stderr must carry a {needle:?} line: {}", excerpt(log)))
}
fn tracing_lines(log: &str) -> impl Iterator<Item = &str> {
log.lines().filter(|line| line.chars().nth(26) == Some('Z'))
}
fn after_target<'a>(line: &'a str, target: &str) -> &'a str {
line.split_once(&format!("{target}: "))
.unwrap_or_else(|| panic!("the line must be {target}'s: {}", excerpt(line)))
.1
}
fn longest_run(text: &str, probe: char) -> usize {
let mut longest = 0;
let mut run = 0;
for ch in text.chars() {
run = if ch == probe { run + 1 } else { 0 };
longest = longest.max(run);
}
longest
}
fn assert_escaped_never_raw(log: &str, bytes: &[(char, &str, &str)]) {
for (byte, name, escaped) in bytes {
assert!(
!log.contains(*byte),
"a raw {name} reached stderr: {}",
excerpt(log)
);
assert!(
log.contains(escaped),
"and its escaped form must be there, or this proves nothing: {}",
excerpt(log)
);
}
}
fn assert_control_bytes_escaped(log: &str, esc: &str, bel: &str) {
assert_escaped_never_raw(log, &[(ESC, "ESC", esc), (BEL, "BEL", bel)]);
}
fn assert_cr_and_tab_escaped(log: &str) {
assert_escaped_never_raw(log, &[(CR, "CR", "\\r"), (TAB, "TAB", "\\t")]);
}
fn logged_field<'a>(line: &'a str, field: &str, ends_with: Option<&str>) -> &'a str {
let after = line
.split_once(field)
.unwrap_or_else(|| panic!("the line must carry a {field} field: {line}"))
.1;
match ends_with {
None => after,
Some(end) => match after.split_once(end) {
Some((value, _)) => value,
None => panic!("the {field} field must be followed by {end}: {line}"),
},
}
}
fn quoted_field<'a>(line: &'a str, field: &str) -> (&'a str, &'a str) {
let after = line
.split_once(field)
.unwrap_or_else(|| panic!("the line must carry a {field} field: {line}"))
.1;
let inner = after
.strip_prefix('"')
.unwrap_or_else(|| panic!("the {field} field must open with a quote: {line}"));
let mut escaped = false;
for (at, ch) in inner.char_indices() {
match ch {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => return (&inner[..at], &inner[at + 1..]),
_ => {}
}
}
panic!("the {field} field must close its quote: {line}")
}
fn logged_ids(line: &str, field: &str) -> Vec<u64> {
logged_field(line, field, Some("]"))
.split(", ")
.map(|id| id.parse().unwrap_or_else(|e| panic!("{id:?}: {e}: {line}")))
.collect()
}
fn logfmt_keys(text: &str) -> Vec<&str> {
let mut keys = Vec::new();
let mut rest = text;
loop {
rest = rest.trim_start_matches(' ');
if rest.is_empty() {
return keys;
}
let token_end = rest.find(['=', ' ']).unwrap_or(rest.len());
if rest.as_bytes().get(token_end) != Some(&b'=') {
rest = &rest[token_end..];
continue;
}
keys.push(&rest[..token_end]);
let value = &rest[token_end + 1..];
rest = match value.strip_prefix('"') {
Some(inner) => {
let mut escaped = false;
let mut closed = None;
for (at, ch) in inner.char_indices() {
match ch {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => {
closed = Some(at);
break;
}
_ => {}
}
}
closed.map_or("", |at| &inner[at + 1..])
}
None => &value[value.find(' ').unwrap_or(value.len())..],
};
}
}
struct Site {
tool: Option<&'static str>,
arguments: serde_json::Value,
policy: Option<&'static str>,
needle: &'static str,
field: &'static str,
}
fn decoration(field: &str) -> usize {
field
.split_once('=')
.unwrap_or_else(|| panic!("a field needle carries its `=`: {field}"))
.1
.chars()
.count()
}
const DENY_ALL: &str = "[[rule]]\nname = \"deny-all\"\naction = \"deny\"\n\
[rule.match]\nproducts = [\"*\"]\n";
fn sites(probe: &str) -> Vec<Site> {
vec![
Site {
tool: Some("bugs_quicksearch"),
arguments: json!({ "query": probe }),
policy: None,
needle: "tool: bugs_quicksearch",
field: "query=\"",
},
Site {
tool: Some("bugs_quicksearch"),
arguments: json!({ "query": "kernel", "status": probe }),
policy: None,
needle: "tool: bugs_quicksearch",
field: "status=\"",
},
Site {
tool: Some("bugs_quicksearch"),
arguments: json!({ "query": "kernel", "include_fields": probe }),
policy: None,
needle: "tool: bugs_quicksearch",
field: "include_fields=\"",
},
Site {
tool: Some("bugs_quicksearch"),
arguments: json!({ "query": "kernel", "group_by": probe }),
policy: None,
needle: "tool: bugs_quicksearch",
field: "group_by=\"",
},
Site {
tool: Some("create_bug"),
arguments: json!({
"product": probe, "component": "kernel",
"summary": "s", "version": "v"
}),
policy: None,
needle: "tool: create_bug",
field: "product=\"",
},
Site {
tool: Some("create_bug"),
arguments: json!({
"product": "openSUSE", "component": probe,
"summary": "s", "version": "v"
}),
policy: None,
needle: "tool: create_bug",
field: "component=\"",
},
Site {
tool: Some("create_bug"),
arguments: json!({
"product": probe, "component": "kernel",
"summary": "s", "version": "v"
}),
policy: Some(DENY_ALL),
needle: "guard denied bug creation",
field: "product=\"",
},
Site {
tool: Some("add_attachment"),
arguments: json!({
"bug_id": 1, "data": "", "file_name": probe,
"summary": "s", "content_type": "text/plain"
}),
policy: None,
needle: "tool: add_attachment",
field: "file_name=\"",
},
Site {
tool: Some("update_bug_status"),
arguments: json!({ "bug_id": 1, "status": probe }),
policy: None,
needle: "tool: update_bug_status",
field: "status=\"",
},
Site {
tool: Some("update_bug_status"),
arguments: json!({ "bug_id": 1, "status": "RESOLVED", "resolution": probe }),
policy: None,
needle: "tool: update_bug_status",
field: "resolution=Some(\"",
},
Site {
tool: Some("assign_bug"),
arguments: json!({ "bug_id": 1, "assignee": probe }),
policy: None,
needle: "tool: assign_bug",
field: "assignee=\"",
},
Site {
tool: Some("update_bug_fields"),
arguments: json!({ "bug_id": 1, "priority": probe }),
policy: None,
needle: "tool: update_bug_fields",
field: "priority=Some(\"",
},
Site {
tool: Some("update_bug_fields"),
arguments: json!({ "bug_id": 1, "severity": probe }),
policy: None,
needle: "tool: update_bug_fields",
field: "severity=Some(\"",
},
Site {
tool: Some("update_bug_fields"),
arguments: json!({ "bug_id": 1, "resolution": probe }),
policy: None,
needle: "tool: update_bug_fields",
field: "resolution=Some(\"",
},
Site {
tool: Some("add_cc_to_bug"),
arguments: json!({ "bug_id": 1, "cc_email": probe }),
policy: None,
needle: "tool: add_cc_to_bug",
field: "cc_email=\"",
},
Site {
tool: None,
arguments: json!({}),
policy: None,
needle: "bugwarden::server: client requested unsupported",
field: "client_requested=\"",
},
Site {
tool: None,
arguments: json!({}),
policy: None,
needle: "rmcp::service::server: client requested unsupported",
field: "client_requested=",
},
]
}
fn policy_file(toml: &str) -> PathBuf {
let path = Path::new(env!("CARGO_TARGET_TMPDIR")).join("tracing_caps_policy.toml");
std::fs::write(&path, toml).expect("the policy file must be writable");
path
}
#[tokio::test]
async fn every_tracing_field_that_formats_a_client_string_is_cut_to_the_cap() {
let probe = PROBE.to_string().repeat(CAP * 4);
for site in sites(&probe) {
let policy = site.policy.map(policy_file);
let version = if site.tool.is_some() {
SUPPORTED_VERSION
} else {
probe.as_str()
};
let call = site.tool.map(|tool| (tool, site.arguments));
let line = log_line(version, policy.as_deref(), call, site.needle).await;
let value = line
.split_once(site.field)
.unwrap_or_else(|| panic!("the line must carry {}: {line}", site.field))
.1;
let run = value.chars().take_while(|c| *c == PROBE).count();
let tail = value
.char_indices()
.nth(run)
.map_or("", |(at, _)| &value[at..]);
let Some(opened) = site.field.strip_suffix('"') else {
assert_eq!(
run + decoration(site.field),
CAP,
"{} on {:?} must reach stderr as exactly {CAP} rendered chars: {line}",
site.field,
site.needle
);
continue;
};
assert_eq!(
run, QUOTED_CAP,
"{} on {:?} must carry exactly {QUOTED_CAP} of the client's \
own characters: {line}",
site.field, site.needle
);
let closer = if opened.ends_with("Some(") {
"\")"
} else {
"\""
};
assert!(
tail.starts_with(closer),
"and must close with {closer:?}, or a reader runs on into the \
next field: {line}"
);
assert!(
decoration(site.field) + run + closer.chars().count() <= CAP,
"and the whole field must fit the sink's {CAP}, or the sink \
cuts the closing delimiter off again: {line}"
);
}
}
#[tokio::test]
async fn rmcps_handshake_and_notification_lines_are_cut_at_the_sink() {
let name = PROBE.to_string().repeat(50_000);
let message = PROBE.to_string().repeat(100_000);
let log = stderr_through(
&[
initialize(&name),
initialized(),
json!({
"jsonrpc": "2.0", "method": "notifications/progress",
"params": { "progressToken": "t", "progress": 1, "message": message }
})
.to_string(),
],
None,
"notification=ProgressNotification",
)
.await;
for (needle, message_text, field) in [
(
"Service initialized as server",
"Service initialized as server",
" peer_info=",
),
(
"notification=ProgressNotification",
"received notification",
" notification=",
),
] {
let line = find_line(&log, needle);
let body = after_target(line, "rmcp::service").chars().count();
assert!(
body <= message_text.chars().count() + field.chars().count() + CAP,
"{needle:?} must fit its message, its field name and one \
capped value: {body} chars in {}",
excerpt(line)
);
assert!(
longest_run(line, PROBE) >= CAP / 2,
"and must still carry the client's text, or it bounds \
nothing: {}",
excerpt(line)
);
}
for line in tracing_lines(&log) {
assert!(
longest_run(line, PROBE) <= CAP,
"no line may carry more than {CAP} of the client's own \
characters: {}",
excerpt(line)
);
}
}
#[tokio::test]
async fn an_over_long_request_id_is_cut_at_the_sink() {
let id = PROBE.to_string().repeat(CAP * 4);
let log = stderr_through(
&[
initialize("binary-tracing-caps-test"),
initialized(),
tools_call(json!(id), "no_such_tool", json!({})),
],
None,
"response error",
)
.await;
let line = find_line(&log, "response error");
let value = logged_field(line, "id=", Some(" error="));
assert_eq!(
value.chars().count(),
CAP,
"a string request id must reach stderr as exactly {CAP} chars: {}",
excerpt(line)
);
assert!(
value.chars().all(|c| c == PROBE),
"and as a prefix of what the client actually sent: {}",
excerpt(line)
);
}
#[tokio::test]
async fn a_raised_filter_opens_no_line_the_sink_does_not_cut() {
let probe = PROBE.to_string().repeat(100_000);
let log = stderr_through(
&[
initialize("binary-tracing-caps-test"),
initialized(),
probe.clone(),
tools_call(json!(2), "bugs_quicksearch", json!({ "query": probe })),
],
Some("debug"),
"tool: bugs_quicksearch",
)
.await;
let parse_failure = find_line(&log, "Failed to parse message");
assert_eq!(
after_target(parse_failure, "rmcp::transport::async_rw")
.chars()
.count(),
CAP,
"an unparsable line reaches stderr as one capped message: {}",
excerpt(parse_failure)
);
const REQUEST: &str = "received request";
let request = find_line(&log, REQUEST);
let body = after_target(request, "rmcp::service").chars().count();
assert!(
body <= REQUEST.chars().count() + " id=2".len() + " request=".len() + CAP,
"a whole request at debug must fit its two field names and one \
capped value: {body} chars in {}",
excerpt(request)
);
assert!(
longest_run(request, PROBE) >= CAP / 2,
"and must still carry the client's text, or it bounds nothing: {}",
excerpt(request)
);
for line in tracing_lines(&log) {
assert!(
longest_run(line, PROBE) <= CAP,
"no line may carry more than {CAP} of the client's own \
characters, whatever the filter: {}",
excerpt(line)
);
}
}
#[tokio::test]
async fn a_tool_argument_never_reaches_stderr_as_a_raw_control_byte() {
let log = stderr_through(
&[
initialize("binary-tracing-caps-test"),
initialized(),
tools_call(
json!(2),
"bugs_quicksearch",
json!({ "query": format!("a{ESC}[2Jb{BEL}c") }),
),
],
None,
"tool: bugs_quicksearch",
)
.await;
assert_control_bytes_escaped(&log, "\\u{1b}", "\\u{7}");
assert!(
log.contains(r#"query="a\u{1b}[2Jb\u{7}c""#),
"and the field must read as the escape, not as a hole: {}",
excerpt(&log)
);
}
#[tokio::test]
async fn rmcps_own_fields_never_reach_stderr_as_raw_control_bytes() {
let log = stderr_through(
&[
initialize(&format!("client{ESC}[2Jname")),
initialized(),
tools_call(json!(format!("id{ESC}[2J{BEL}")), "no_such_tool", json!({})),
],
None,
"response error",
)
.await;
assert_control_bytes_escaped(&log, "\\x1b", "\\x07");
let line = find_line(&log, "response error");
assert!(
line.contains("id=id\\x1b[2J\\x07"),
"rmcp's `%id` is the field with no `Debug` to fall back on: {}",
excerpt(line)
);
}
#[tokio::test]
async fn a_client_field_can_neither_end_a_stderr_line_nor_open_one() {
let forging = format!("a{CR}b{TAB}c{LS}d{LF}{FORGED_LINE}");
let plain = forging.replace([CR, TAB, LF, LS], " ");
let mut runs = Vec::new();
for query in [plain, forging] {
runs.push(
stderr_through(
&[
initialize("binary-tracing-caps-test"),
initialized(),
tools_call(json!(2), "bugs_quicksearch", json!({ "query": query })),
],
None,
"tool: bugs_quicksearch",
)
.await,
);
}
let (plain_log, forged_log) = (&runs[0], &runs[1]);
let forged_lines: Vec<&str> = forged_log
.lines()
.filter(|line| line.contains("FORGED LINE"))
.collect();
assert_eq!(
forged_lines.len(),
1,
"the forged text may appear on exactly one line: {}",
excerpt(forged_log)
);
assert!(
forged_lines[0].contains("query="),
"and that line must be the tool's own, not a line of the \
client's making: {}",
excerpt(forged_log)
);
assert_eq!(
tracing_lines(forged_log).count(),
tracing_lines(plain_log).count(),
"a client's LF may not add a line to stderr:\nforged: {}\nplain: {}",
excerpt(forged_log),
excerpt(plain_log)
);
assert_eq!(
quoted_field(find_line(forged_log, "tool: bugs_quicksearch"), "query=").0,
format!("a\\rb\\tc\\u{{2028}}d\\n{FORGED_LINE}"),
"and the whole of it stays inside one field, escaped: {}",
excerpt(forged_log)
);
assert_cr_and_tab_escaped(forged_log);
}
#[tokio::test]
async fn a_client_field_cannot_forge_a_later_field_on_its_own_line() {
for (query, expected) in [
(FORGING_PAIR, FORGING_PAIR.to_string()),
(FORGING_ESCAPES, r#"evil\" status=HACKED \\"#.to_string()),
] {
let line = tool_call_log_line(
"bugs_quicksearch",
json!({ "query": query }),
"tool: bugs_quicksearch",
)
.await;
let (logged, rest) = quoted_field(&line, "query=");
assert_eq!(
logged, expected,
"the whole of the client's text must stay inside one field: {line}"
);
assert_eq!(
quoted_field(rest, " status=").0,
"ALL",
"and the `status` past its closing quote must be the server's own: {line}"
);
assert_eq!(
logfmt_keys(after_target(&line, "bugwarden::server")),
[
"query",
"status",
"include_fields",
"limit",
"offset",
"group_by"
],
"and the line's fields are the server's own, in its own order: {line}"
);
}
}
#[tokio::test]
async fn a_cut_client_field_still_closes_its_own_quote() {
let line = tool_call_log_line(
"bugs_quicksearch",
json!({ "query": "a".repeat(QUOTED_CAP + 7), "status": "x admin=true" }),
"tool: bugs_quicksearch",
)
.await;
let body = after_target(&line, "bugwarden::server");
let (query, rest) = quoted_field(body, "query=");
assert_eq!(
query,
"a".repeat(QUOTED_CAP),
"a cut value keeps its closing quote and loses characters instead: {line}"
);
assert_eq!(
quoted_field(rest, " status=").0,
"x admin=true",
"the next client field is still its own field: {line}"
);
let keys = logfmt_keys(body);
assert!(
keys.contains(&"status") && !keys.contains(&"admin"),
"and the line's keys are the server's, not the client's: {keys:?} in {line}"
);
let line = tool_call_log_line(
"bugs_quicksearch",
json!({ "query": LS.to_string().repeat(QUOTED_CAP / 8 + 1) }),
"tool: bugs_quicksearch",
)
.await;
assert_eq!(
quoted_field(after_target(&line, "bugwarden::server"), "query=").0,
"\\u{2028}".repeat(QUOTED_CAP / 8),
"an escaping value is bounded by what it RENDERS, and no escape is \
cut in half: {line}"
);
}
#[tokio::test]
async fn an_unparsable_line_reaches_the_message_field_escaped() {
let garbage = format!("garbage{CR}{TAB}{FORGED_LINE}");
let log = stderr_through(
&[
initialize("binary-tracing-caps-test"),
initialized(),
garbage,
tools_call(json!(2), "bugs_quicksearch", json!({ "query": "kernel" })),
],
Some("debug"),
"tool: bugs_quicksearch",
)
.await;
let parse_failure = find_line(&log, "Failed to parse message");
assert!(
parse_failure.contains(&format!("garbage\\r\\t{FORGED_LINE}")),
"rmcp's whole unparsable line rides `message`, and its control \
bytes must be escaped there too: {}",
excerpt(parse_failure)
);
assert_eq!(
log.lines()
.filter(|line| line.contains("FORGED LINE"))
.count(),
1,
"and the forgery stays on that one line: {}",
excerpt(&log)
);
assert_cr_and_tab_escaped(&log);
}
#[tokio::test]
async fn bug_ids_reach_stderr_as_a_raw_count_and_a_distinct_head() {
let mut repeated: Vec<u64> = vec![7; Guard::MAX_ASSESS_IDS];
repeated.push(8);
for (sent, head) in [
(
(1..=Guard::MAX_ASSESS_IDS as u64 + 5).collect::<Vec<u64>>(),
(1..=Guard::MAX_ASSESS_IDS as u64).collect::<Vec<u64>>(),
),
(repeated, vec![7, 8]),
] {
let line =
tool_call_log_line("bug_info", json!({ "bug_ids": sent }), "tool: bug_info").await;
assert!(
line.contains(&format!("bug_ids_len={}", sent.len())),
"the count is what the whole array used to say: {line}"
);
assert_eq!(
logged_ids(&line, "bug_ids=["),
head,
"and the head is exactly the distinct ids the call may serve: {line}"
);
}
}
#[tokio::test]
async fn every_dependency_array_reaches_stderr_as_a_count_and_a_head() {
let sent = Guard::MAX_ASSESS_IDS + 5;
let list = |base: u64| (base..base + sent as u64).collect::<Vec<u64>>();
let line = tool_call_log_line(
"update_bug_dependencies",
json!({
"bug_id": 1,
"blocks_add": list(1000),
"blocks_remove": list(2000),
"depends_on_add": list(3000),
"depends_on_remove": list(4000),
}),
"tool: update_bug_dependencies",
)
.await;
for (field, base) in [
("blocks_add", 1000),
("blocks_remove", 2000),
("depends_on_add", 3000),
("depends_on_remove", 4000),
] {
assert!(
line.contains(&format!("{field}_len={sent}")),
"{field} must carry the count of the whole array: {line}"
);
assert_eq!(
logged_ids(&line, &format!("{field}=Some([")),
(base..base + Guard::MAX_ASSESS_IDS as u64).collect::<Vec<u64>>(),
"and no more of it than the guard's own bound: {line}"
);
}
}