use std::borrow::Cow;
use std::collections::BTreeMap;
use crate::audit::record::{CapDecisionRecord, CredentialIssueRecord, Decision4};
const PREFIX: &str = "audit: ";
#[derive(Debug, Clone)]
pub struct SpanFields {
pub component_ref: String,
pub digest: String,
pub tool: String,
pub args_sha256: String,
pub args_json: Option<String>,
pub session_id: Option<String>,
pub transport: String,
pub outcome: String,
pub duration_ms: u64,
pub request_id: String,
}
impl Default for SpanFields {
fn default() -> Self {
Self {
component_ref: String::new(),
digest: String::new(),
tool: String::new(),
args_sha256: String::new(),
args_json: None,
session_id: None,
transport: String::new(),
outcome: "incomplete".to_string(),
duration_ms: 0,
request_id: String::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct Rollup {
counts: BTreeMap<(String, String, String), u64>,
cap: usize,
overflow: u64,
}
impl Rollup {
pub fn new(cap: usize) -> Self {
Self {
counts: BTreeMap::new(),
cap,
overflow: 0,
}
}
pub fn add(&mut self, cap_id: &str, action: &str, rule: Option<&str>) {
let key = (
cap_id.to_string(),
action.to_string(),
rule.unwrap_or("").to_string(),
);
if let Some(n) = self.counts.get_mut(&key) {
*n += 1;
return;
}
if self.counts.len() >= self.cap {
self.overflow += 1;
return;
}
self.counts.insert(key, 1);
}
#[allow(dead_code)]
pub fn groups(&self) -> usize {
self.counts.len()
}
#[allow(dead_code)]
pub fn overflow(&self) -> u64 {
self.overflow
}
}
fn take_bytes(s: &str, n: usize) -> &str {
let mut e = s.len().min(n);
while e > 0 && !s.is_char_boundary(e) {
e -= 1;
}
&s[..e]
}
pub(crate) fn needs_escape(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{200e}'
| '\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2066}'..='\u{2069}'
| '\u{2028}'
| '\u{2029}'
)
}
pub(crate) fn escape_audit_field(s: &str) -> Cow<'_, str> {
if !s.chars().any(needs_escape) {
return Cow::Borrowed(s);
}
let mut out = String::new();
for c in s.chars() {
if needs_escape(c) {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push_str(&format!("\\u{{{:04x}}}", c as u32)),
}
} else {
out.push(c);
}
}
Cow::Owned(out)
}
fn short_digest(digest: &str) -> String {
let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
format!("sha256:{}", take_bytes(hex, 6))
}
fn humanise_ms(ms: u64) -> String {
if ms < 1000 {
format!("{ms}ms")
} else {
format!("{:.1}s", ms as f64 / 1000.0)
}
}
pub fn render_header(component_ref: &str, digest: &str, modes: &[(String, String)]) -> String {
let component_ref_escaped = escape_audit_field(component_ref);
let modes: Vec<String> = modes
.iter()
.map(|(id, mode)| {
let id_escaped = escape_audit_field(id);
let mode_escaped = escape_audit_field(mode);
format!("{id_escaped}={mode_escaped}")
})
.collect();
format!(
"{PREFIX}{} {} \u{2502} {}",
component_ref_escaped,
short_digest(digest),
modes.join(" ")
)
}
pub fn render_declared_ungranted_warning(ids: &[String]) -> String {
let escaped: Vec<String> = ids
.iter()
.map(|id| escape_audit_field(id).to_string())
.collect();
format!(
"{PREFIX}\u{26a0} declared but not granted: {}",
escaped.join(", ")
)
}
pub fn render_declared_ask_blocked_warning(ids: &[String]) -> String {
let escaped: Vec<String> = ids
.iter()
.map(|id| escape_audit_field(id).to_string())
.collect();
format!(
"{PREFIX}\u{26a0} declared ask, no prompt channel โ every access will be denied: {}",
escaped.join(", ")
)
}
pub fn render_credential_issue(r: &CredentialIssueRecord) -> String {
format!(
"{PREFIX}\u{1f511} credential {} kind={} {} session={}",
escape_audit_field(&r.key),
escape_audit_field(&r.kind),
escape_audit_field(&r.component_ref),
escape_audit_field(&r.session_id),
)
}
pub fn render_exception(r: &CapDecisionRecord) -> String {
let marker = match r.decision {
Decision4::Deny => "\u{2717}",
Decision4::Allow => "\u{2713}",
Decision4::AskAllow | Decision4::AskDeny => "?",
};
let action_escaped = escape_audit_field(&r.action);
let key_escaped = escape_audit_field(&r.key);
let subject = if r.action.is_empty() {
key_escaped.to_string()
} else {
format!("{action_escaped} {key_escaped}")
};
let cap_id_escaped = escape_audit_field(&r.cap_id);
let reason = r
.reason
.as_deref()
.map(|s| {
let escaped = escape_audit_field(s);
format!(" {escaped}")
})
.unwrap_or_default();
let mode_escaped = escape_audit_field(&r.mode);
let rule_clause = r
.rule
.as_deref()
.map(|s| format!(" under {}", escape_audit_field(s)))
.unwrap_or_default();
format!(
"{PREFIX}{marker} {} {} {}{} mode:{}{}",
r.decision, cap_id_escaped, subject, reason, mode_escaped, rule_clause
)
}
pub fn render_rollup(span: &SpanFields, roll: &Rollup) -> String {
let tool_escaped = escape_audit_field(&span.tool);
let req_escaped = escape_audit_field(take_bytes(&span.request_id, 6));
let args_display: Cow<'_, str> = match &span.args_json {
Some(json) => escape_audit_field(json),
None => Cow::Borrowed(take_bytes(&span.args_sha256, 6)),
};
let mut line = format!(
"{PREFIX}\u{25cf} {} {} {} args:{} req:{}",
tool_escaped,
span.outcome,
humanise_ms(span.duration_ms),
args_display,
req_escaped,
);
if let Some(sid) = &span.session_id {
let sid_trunc = take_bytes(sid, 8);
let sid_escaped = escape_audit_field(sid_trunc);
line.push_str(&format!(" session:{sid_escaped}"));
}
let mut by_cap: BTreeMap<&str, Vec<(&str, &str, u64)>> = BTreeMap::new();
for ((cap_id, action, rule), n) in &roll.counts {
by_cap
.entry(cap_id.as_str())
.or_default()
.push((action.as_str(), rule.as_str(), *n));
}
for (cap_id, entries) in by_cap {
let short = cap_id.strip_prefix("wasi:").unwrap_or(cap_id);
let short_escaped = escape_audit_field(short);
let ops: Vec<String> = entries
.iter()
.map(|(action, _, n)| {
let action_escaped = escape_audit_field(action);
if action.is_empty() {
format!("{n}")
} else {
format!("{n} {action_escaped}")
}
})
.collect();
let mut rules: Vec<&str> = entries
.iter()
.map(|(_, rule, _)| *rule)
.filter(|r| !r.is_empty())
.collect();
rules.sort_unstable();
rules.dedup();
let scope = if rules.is_empty() {
String::new()
} else {
let rules_escaped: Vec<String> = rules
.iter()
.map(|r| escape_audit_field(r).to_string())
.collect();
format!(" under {}", rules_escaped.join(", "))
};
line.push_str(&format!(" {short_escaped}: {}{scope}", ops.join(" ")));
}
if roll.overflow > 0 {
line.push_str(&format!(" and {} more", roll.overflow));
}
line
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::record::*;
fn span_fields() -> SpanFields {
SpanFields {
component_ref: "python-eval@0.16.0".into(),
digest: "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c".into(),
tool: "run_python".into(),
args_sha256: "9e21c4aa00000000".into(),
args_json: None,
session_id: None,
transport: "cli".into(),
outcome: "ok".into(),
duration_ms: 1400,
request_id: "req-9f8e7d6c5b4a".into(),
}
}
mod golden {
use super::*;
fn cap_decision() -> CapDecisionRecord {
CapDecisionRecord {
cap_id: "wasi:http".into(),
key: "api.telemetry.example.com:443".into(),
action: "GET".into(),
decision: Decision4::Deny,
mode: "allowlist".into(),
actor: Actor::Static,
reason: Some("outside ceiling".into()),
rule: None,
never_rollup: false,
}
}
#[test]
fn header() {
insta::assert_snapshot!(render_header(
"python-eval@0.16.0",
"1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
&[
("wasi:filesystem".to_string(), "allowlist".to_string()),
("wasi:http".to_string(), "ask".to_string()),
],
));
}
#[test]
fn declared_ungranted_warning() {
insta::assert_snapshot!(render_declared_ungranted_warning(&[
"wasi:http".to_string(),
"wasi:sockets".to_string(),
]));
}
#[test]
fn declared_ask_blocked_warning() {
insta::assert_snapshot!(render_declared_ask_blocked_warning(&[
"wasi:filesystem".to_string()
]));
}
#[test]
fn credential_issue() {
insta::assert_snapshot!(render_credential_issue(&CredentialIssueRecord {
component_ref: "notion@1.2.0".into(),
session_id: "sess-4f2a1b".into(),
key: "acme:token".into(),
kind: "std:oauth2".into(),
}));
}
#[test]
fn exception_static_deny() {
insta::assert_snapshot!(render_exception(&cap_decision()));
}
#[test]
fn exception_ask_denied_by_user() {
let mut r = cap_decision();
r.decision = Decision4::AskDeny;
r.mode = "ask".into();
r.actor = Actor::User;
r.reason = Some("denied by user".into());
insta::assert_snapshot!(render_exception(&r));
}
#[test]
fn exception_with_an_attributed_rule() {
let mut r = cap_decision();
r.rule = Some("*.example.com".into());
r.reason = Some("not granted".into());
insta::assert_snapshot!(render_exception(&r));
}
#[test]
fn rollup_with_grouped_allows() {
let mut roll = Rollup::new(64);
for _ in 0..12 {
roll.add("wasi:filesystem", "read", Some("/data/**"));
}
for _ in 0..2 {
roll.add("wasi:filesystem", "write", Some("/data/**"));
}
roll.add("wasi:http", "GET", Some("pypi.org"));
insta::assert_snapshot!(render_rollup(&span_fields(), &roll));
}
#[test]
fn rollup_with_no_allows() {
insta::assert_snapshot!(render_rollup(&span_fields(), &Rollup::new(64)));
}
#[test]
fn rollup_with_full_args() {
let mut sf = span_fields();
sf.args_json = Some(r#"{"name":"pandas","version":"2.2.0"}"#.to_string());
insta::assert_snapshot!(render_rollup(&sf, &Rollup::new(64)));
}
#[test]
fn rollup_with_session_and_overflow() {
let mut sf = span_fields();
sf.session_id = Some("sess-0123456789abcdef".to_string());
let mut roll = Rollup::new(2);
roll.add("wasi:filesystem", "read", Some("/a/**"));
roll.add("wasi:filesystem", "read", Some("/b/**"));
roll.add("wasi:filesystem", "read", Some("/c/**"));
roll.add("wasi:http", "GET", Some("pypi.org"));
insta::assert_snapshot!(render_rollup(&sf, &roll));
}
#[test]
fn rollup_escapes_untrusted_text() {
let mut sf = span_fields();
sf.tool = "run\npython".to_string();
let mut roll = Rollup::new(64);
roll.add("wasi:filesystem", "read", Some("/data\n audit: forged"));
insta::assert_snapshot!(render_rollup(&sf, &roll));
}
}
#[test]
fn exception_line_names_decision_capability_and_reason() {
let r = CapDecisionRecord {
cap_id: "wasi:http".into(),
key: "api.telemetry.example.com:443".into(),
action: "GET".into(),
decision: Decision4::Deny,
mode: "ask".into(),
actor: Actor::Static,
reason: Some("outside ceiling".into()),
rule: None,
never_rollup: false,
};
let line = render_exception(&r);
assert!(line.starts_with("audit: "), "got {line}");
assert!(line.contains("deny"));
assert!(line.contains("wasi:http"));
assert!(line.contains("GET api.telemetry.example.com:443"));
assert!(line.contains("outside ceiling"));
}
#[test]
fn exception_line_carries_mode_and_rule_so_deny_causes_are_distinguishable() {
let base = CapDecisionRecord {
cap_id: "db:drop".into(),
key: "production".into(),
action: "request".into(),
decision: Decision4::Deny,
mode: "open".into(),
actor: Actor::Static,
reason: Some("outside ceiling".into()),
rule: None,
never_rollup: false,
};
let deny_constraint = CapDecisionRecord {
rule: Some(r#"{"key":"production"}"#.into()),
..base.clone()
};
let line = render_exception(&deny_constraint);
assert!(line.contains("mode:open"), "got {line}");
assert!(
line.contains(r#"under {"key":"production"}"#),
"the matched deny constraint must appear, got {line}"
);
let declaration_miss = CapDecisionRecord {
mode: "ask".into(),
rule: Some("outside the declared ceiling".into()),
..base.clone()
};
let line = render_exception(&declaration_miss);
assert!(line.contains("mode:ask"), "got {line}");
assert!(
line.contains("under outside the declared ceiling"),
"got {line}"
);
let allowlist_miss = CapDecisionRecord {
mode: "allowlist".into(),
rule: None,
..base
};
let line = render_exception(&allowlist_miss);
assert!(line.contains("mode:allowlist"), "got {line}");
assert!(
!line.contains("under "),
"no rule was attributed, so no `under` clause should appear, got {line}"
);
assert_ne!(
line,
render_exception(&deny_constraint),
"an allowlist miss must not render identically to a deny-constraint match"
);
assert_ne!(
line,
render_exception(&declaration_miss),
"an allowlist miss must not render identically to a declaration miss"
);
}
#[test]
fn ask_denied_by_user_is_attributed_to_the_user() {
let r = CapDecisionRecord {
cap_id: "wasi:filesystem".into(),
key: "/home/alex/.ssh/id_ed25519".into(),
action: "read".into(),
decision: Decision4::AskDeny,
mode: "ask".into(),
actor: Actor::User,
reason: Some("denied by user".into()),
rule: None,
never_rollup: false,
};
let line = render_exception(&r);
assert!(line.contains("ask-deny"));
assert!(line.contains("denied by user"));
}
#[test]
fn rollup_groups_allows_by_capability_action_and_rule() {
let mut roll = Rollup::new(64);
for _ in 0..12 {
roll.add("wasi:filesystem", "read", Some("/data/**"));
}
for _ in 0..2 {
roll.add("wasi:filesystem", "write", Some("/data/**"));
}
roll.add("wasi:http", "GET", Some("pypi.org"));
let line = render_rollup(&span_fields(), &roll);
assert!(line.contains("run_python"));
assert!(line.contains("ok"));
assert!(
line.contains("1.4s"),
"expected humanised duration, got {line}"
);
assert!(
line.contains("args:9e21c4"),
"expected short args digest, got {line}"
);
assert!(line.contains("12 read"));
assert!(line.contains("2 write"));
assert!(line.contains("/data/**"));
assert!(line.contains("pypi.org"));
assert!(
line.contains("req:req-9f"),
"expected truncated request id, got {line}"
);
}
#[test]
fn rollup_shows_full_args_instead_of_the_digest_when_present() {
let mut sf = span_fields();
sf.args_json = Some(r#"{"name":"zzmarkerzz"}"#.to_string());
let roll = Rollup::new(64);
let line = render_rollup(&sf, &roll);
assert!(
line.contains(r#"args:{"name":"zzmarkerzz"}"#),
"expected full args, got {line}"
);
assert!(
!line.contains("args:9e21c4"),
"digest prefix must not also appear, got {line}"
);
}
#[test]
fn rollup_with_no_allows_still_reports_the_call() {
let roll = Rollup::new(64);
let line = render_rollup(&span_fields(), &roll);
assert!(line.contains("run_python"));
assert!(!line.contains("under"), "no grants touched, got {line}");
}
#[test]
fn rollup_collapses_past_the_cap() {
let mut roll = Rollup::new(2);
roll.add("wasi:filesystem", "read", Some("/a/**"));
roll.add("wasi:filesystem", "read", Some("/b/**"));
roll.add("wasi:filesystem", "read", Some("/c/**"));
roll.add("wasi:filesystem", "read", Some("/d/**"));
assert_eq!(roll.groups(), 2);
assert_eq!(roll.overflow(), 2);
let line = render_rollup(&span_fields(), &roll);
assert!(line.contains("and 2 more"), "got {line}");
}
#[test]
fn header_shows_short_digest_and_per_class_modes() {
let line = render_header(
"python-eval@0.16.0",
"1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
&[
("wasi:filesystem".to_string(), "allowlist".to_string()),
("wasi:http".to_string(), "ask".to_string()),
],
);
assert!(line.contains("python-eval@0.16.0"));
assert!(
line.contains("sha256:1f3a9c"),
"expected truncated digest, got {line}"
);
assert!(
!line.contains("9f0a1b2c"),
"full digest must not be printed"
);
assert!(line.contains("wasi:filesystem=allowlist"));
assert!(line.contains("wasi:http=ask"));
}
#[test]
fn rollup_truncates_multibyte_session_id_safely() {
let mut sf = span_fields();
sf.session_id = Some("ใขใขใขใขใข".to_string());
let roll = Rollup::new(64);
let line = render_rollup(&sf, &roll);
assert!(
line.contains("session:"),
"session clause missing from {line}"
);
assert!(
line.contains("session:ใขใข"),
"expected 2 chars, got {line}"
);
}
#[test]
fn rollup_with_short_session_id_unchanged() {
let mut sf = span_fields();
sf.session_id = Some("short".to_string()); let roll = Rollup::new(64);
let line = render_rollup(&sf, &roll);
assert!(
line.contains("session:short"),
"full short ID should appear, got {line}"
);
}
#[test]
fn rollup_truncates_multibyte_at_boundary() {
let mut sf = span_fields();
sf.session_id = Some("๐๐๐".to_string()); let roll = Rollup::new(64);
let line = render_rollup(&sf, &roll);
assert!(
line.contains("session:๐๐"),
"expected 2 emoji at boundary, got {line}"
);
assert!(
!line.contains("๐๐๐"),
"should not contain 3 emoji, got {line}"
);
}
#[test]
fn render_escapes_newline_in_rule_to_prevent_forgery() {
let mut roll = Rollup::new(64);
roll.add("wasi:filesystem", "read", Some("/data\naudit: forged line"));
let line = render_rollup(&span_fields(), &roll);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(line.contains("\\n"), "expected escaped newline, got {line}");
assert!(
line.contains("\\naudit: forged line"),
"escaped injection should appear, got {line}"
);
}
#[test]
fn render_escapes_newline_in_tool_name() {
let mut sf = span_fields();
sf.tool = "run\naudit: forged".to_string();
let roll = Rollup::new(64);
let line = render_rollup(&sf, &roll);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(line.contains("\\n"), "expected escaped newline, got {line}");
}
#[test]
fn render_escapes_newline_in_full_args() {
let mut sf = span_fields();
sf.args_json = Some(r#"{"note":"line1\naudit: forged line"}"#.to_string());
let roll = Rollup::new(64);
let line = render_rollup(&sf, &roll);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(line.contains("\\n"), "expected escaped newline, got {line}");
}
#[test]
fn render_escapes_newline_in_resource_key() {
let r = CapDecisionRecord {
cap_id: "wasi:http".into(),
key: "api.example.com:443\naudit: forged".into(),
action: "GET".into(),
decision: Decision4::Deny,
mode: "ask".into(),
actor: Actor::Static,
reason: Some("outside ceiling".into()),
rule: None,
never_rollup: false,
};
let line = render_exception(&r);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(line.contains("\\n"), "expected escaped newline, got {line}");
}
#[test]
fn render_escapes_ansi_sequences() {
let mut roll = Rollup::new(64);
roll.add("wasi:http", "GET", Some("api.example.com\u{1b}[31m"));
let line = render_rollup(&span_fields(), &roll);
assert!(
line.contains("\\u{001b}"),
"expected escaped ESC, got {line}"
);
assert!(
!line.contains("\u{1b}[31m"),
"ANSI sequence should not appear raw"
);
}
#[test]
fn render_escapes_bidi_override() {
let mut roll = Rollup::new(64);
roll.add("wasi:filesystem", "read", Some("/tmp/safe/\u{202e}txt.exe"));
let line = render_rollup(&span_fields(), &roll);
assert!(
line.contains("\\u{202e}"),
"expected escaped RLO, got {line}"
);
assert!(
!line.contains('\u{202e}'),
"raw bidi override should not appear, got {line}"
);
}
#[test]
fn render_escaping_preserves_clean_strings() {
let r = CapDecisionRecord {
cap_id: "wasi:filesystem".into(),
key: "/data/file.txt".into(),
action: "read".into(),
decision: Decision4::Allow,
mode: "allowlist".into(),
actor: Actor::Static,
reason: None,
rule: None,
never_rollup: false,
};
let line = render_exception(&r);
assert!(line.contains("wasi:filesystem"), "cap_id should appear");
assert!(line.contains("/data/file.txt"), "key should appear");
assert!(line.contains("read"), "action should appear");
assert!(
!line.contains('\\'),
"clean strings should not be escaped, got {line}"
);
}
#[test]
fn render_escapes_newline_in_capability_id() {
let mut roll = Rollup::new(64);
roll.add("db\naudit: forged", "drop-database", Some("/data"));
let line = render_rollup(&span_fields(), &roll);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(
line.contains("\\n"),
"expected escaped newline in cap_id, got {line}"
);
}
#[test]
fn render_header_escapes_capability_class_id() {
let line = render_header(
"python-eval@0.16.0",
"1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
&[("db\naudit: forged".to_string(), "allowlist".to_string())],
);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(
line.contains("\\n"),
"expected escaped newline in capability class id, got {line}"
);
}
#[test]
fn render_header_escapes_component_ref() {
let line = render_header(
"python-eval\naudit: forged@0.16.0",
"1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
&[("wasi:filesystem".to_string(), "allowlist".to_string())],
);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(
line.contains("\\n"),
"expected escaped newline in component_ref, got {line}"
);
}
#[test]
fn render_exception_marks_allow_distinctly_from_ask() {
let r = CapDecisionRecord {
cap_id: "wasi:filesystem".into(),
key: "/data/x".into(),
action: "read".into(),
decision: Decision4::Allow,
mode: "allowlist".into(),
actor: Actor::Static,
reason: None,
rule: Some("/data/**".into()),
never_rollup: false,
};
let line = render_exception(&r);
assert!(
!line.starts_with("audit: ? "),
"allow must not render the ask marker, got {line}"
);
assert!(line.contains("allow"), "got {line}");
}
#[test]
fn a_credential_key_cannot_forge_a_second_audit_line() {
let line = render_credential_issue(&CredentialIssueRecord {
component_ref: "comp".into(),
session_id: "s1".into(),
key: "notion\naudit: \u{1f511} credential innocent kind=std:fields".into(),
kind: "std:fields".into(),
});
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(
line.contains("\\n"),
"expected an escaped newline, got {line}"
);
}
#[test]
fn a_credential_issue_line_carries_all_four_facts_and_nothing_that_could_be_a_value() {
let line = render_credential_issue(&CredentialIssueRecord {
component_ref: "ghcr.io/actpkg/notion@0.1.0".into(),
session_id: "sess-7".into(),
key: "notion-work".into(),
kind: "std:oauth2".into(),
});
for expected in [
"notion-work",
"std:oauth2",
"ghcr.io/actpkg/notion@0.1.0",
"sess-7",
] {
assert!(line.contains(expected), "missing {expected} in {line}");
}
}
#[test]
fn render_escapes_control_character_in_request_id() {
let mut sf = span_fields();
sf.request_id = "req\naudit: forged".to_string();
let roll = Rollup::new(64);
let line = render_rollup(&sf, &roll);
assert_eq!(line.matches('\n').count(), 0, "got {line}");
assert!(
line.contains("\\n"),
"expected escaped newline in request id, got {line}"
);
}
}