use super::diff::Finding;
use super::model::hex_digest;
pub const ACK_PHRASE: &str = "/ack-posture";
pub const SOURCE_SEPARATOR: &str = "<!-- autumn:ack-source -->";
pub const SHORT_DIGEST_LEN: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Acknowledgment {
pub digest: String,
pub reason: Option<String>,
}
#[must_use]
pub fn ack_digest(widening: &[&Finding]) -> String {
let mut lines: Vec<String> = widening.iter().map(|f| f.canonical()).collect();
lines.sort();
lines.dedup();
hex_digest(lines.join("\n").as_bytes())
}
#[must_use]
pub fn short(digest: &str) -> String {
digest.chars().take(SHORT_DIGEST_LEN).collect()
}
#[must_use]
pub fn parse_acks(text: &str) -> Vec<Acknowledgment> {
let mut acks = Vec::new();
let mut fence: Option<(char, usize)> = None;
let mut html_comment = false;
let mut quote_paragraph = false;
let mut raw_html: Option<RawBlock> = None;
let mut open_tag = TagState::default();
let mut code_span: Option<usize> = None;
for line in text.lines() {
if line.trim() == SOURCE_SEPARATOR {
fence = None;
html_comment = false;
quote_paragraph = false;
raw_html = None;
open_tag = TagState::default();
code_span = None;
continue;
}
if line.trim().is_empty() {
quote_paragraph = false;
code_span = None;
continue;
}
let visible = if fence.is_some() {
line.to_owned()
} else {
strip_html_comments(line, &mut html_comment)
};
if visible.trim().is_empty() {
continue;
}
let line = visible.as_str();
if fence.is_none() {
if let Some(block) = raw_html {
if block.closed_by(line) {
raw_html = None;
}
continue;
}
if let Some(block) = opens_raw_html(line) {
if !block.closed_by(line) {
raw_html = Some(block);
}
continue;
}
}
if fence.is_none() && is_indented_code(line) {
continue;
}
let indent = leading_indent(line);
let inside_tag = open_tag.open;
if fence.is_none() {
open_tag = scan_html_tag(line, open_tag);
}
if inside_tag {
continue;
}
let trimmed = line.trim_start();
match (fence, fence_run(trimmed)) {
(Some((open_char, open_len)), Some((c, len, bare)))
if c == open_char && len >= open_len && bare && indent <= 3 =>
{
fence = None;
continue;
}
(None, Some((c, len, _))) => {
fence = Some((c, len));
quote_paragraph = false;
continue;
}
_ => {}
}
if trimmed.starts_with('>') {
quote_paragraph = true;
continue;
}
if fence.is_some() || quote_paragraph {
continue;
}
let inside_span = code_span.is_some();
code_span = scan_code_span(line, code_span);
if inside_span {
continue;
}
if let Some(ack) = parse_marker(trimmed) {
acks.push(ack);
}
}
acks
}
fn strip_html_comments(line: &str, open: &mut bool) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
loop {
if *open {
match rest.find("-->") {
Some(end) => {
*open = false;
rest = &rest[end + 3..];
}
None => return out,
}
} else if let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
*open = true;
rest = &rest[start + 4..];
} else {
out.push_str(rest);
return out;
}
}
}
fn leading_indent(line: &str) -> usize {
line.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.map(|c| if c == '\t' { 4 } else { 1 })
.sum()
}
fn scan_code_span(line: &str, mut open: Option<usize>) -> Option<usize> {
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] != '`' {
i += 1;
continue;
}
let start = i;
while i < chars.len() && chars[i] == '`' {
i += 1;
}
let run = i - start;
match open {
Some(n) if n == run => open = None,
Some(_) => {}
None => open = Some(run),
}
}
open
}
fn scan_html_tag(line: &str, state: TagState) -> TagState {
let TagState {
mut open,
mut quote,
} = state;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if open {
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None if c == '"' || c == '\'' => quote = Some(c),
None if c == '>' => open = false,
None => {}
}
} else if c == '<'
&& chars
.peek()
.is_some_and(|next| next.is_ascii_alphabetic() || *next == '/')
{
open = true;
}
}
TagState { open, quote }
}
#[derive(Debug, Clone, Copy, Default)]
struct TagState {
open: bool,
quote: Option<char>,
}
const RAW_HTML_TAGS: [&str; 4] = ["pre", "script", "style", "textarea"];
#[derive(Debug, Clone, Copy)]
enum RawBlock {
Tag(&'static str),
Until(&'static str),
}
impl RawBlock {
fn closed_by(self, line: &str) -> bool {
let line = line.to_ascii_lowercase();
match self {
Self::Tag(tag) => {
let close = format!("</{tag}");
line.match_indices(&close).any(|(at, _)| {
line[at + close.len()..]
.chars()
.next()
.is_none_or(|next| next == '>' || next.is_whitespace())
})
}
Self::Until(delimiter) => line.contains(delimiter),
}
}
}
fn opens_raw_html(line: &str) -> Option<RawBlock> {
let trimmed = line.trim_start().to_ascii_lowercase();
if let Some(tag) = RAW_HTML_TAGS.into_iter().find(|tag| {
let open = format!("<{tag}");
trimmed
.strip_prefix(&open)
.is_some_and(|rest| rest.is_empty() || rest.starts_with(['>', ' ', '\t', '/']))
}) {
return Some(RawBlock::Tag(tag));
}
if trimmed.starts_with("<?") {
return Some(RawBlock::Until("?>"));
}
if trimmed.starts_with("<![cdata[") {
return Some(RawBlock::Until("]]>"));
}
if trimmed
.strip_prefix("<!")
.is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_alphabetic()))
{
return Some(RawBlock::Until(">"));
}
None
}
fn is_indented_code(line: &str) -> bool {
line.starts_with(" ") || line.starts_with('\t')
}
fn fence_run(line: &str) -> Option<(char, usize, bool)> {
let c = line.chars().next().filter(|c| *c == '`' || *c == '~')?;
let len = line.chars().take_while(|ch| *ch == c).count();
if len < 3 {
return None;
}
let rest = line.chars().skip(len).collect::<String>();
Some((c, len, rest.trim().is_empty()))
}
fn parse_marker(line: &str) -> Option<Acknowledgment> {
let head = line.get(..ACK_PHRASE.len())?;
if !head.eq_ignore_ascii_case(ACK_PHRASE) {
return None;
}
let rest = line.get(ACK_PHRASE.len()..)?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
let rest = rest.trim_start();
let (token, reason) = rest
.find(char::is_whitespace)
.map_or((rest, ""), |i| (&rest[..i], rest[i..].trim()));
let digest = token.to_ascii_lowercase();
if !is_digest(&digest) {
return None;
}
Some(Acknowledgment {
digest,
reason: (!reason.is_empty()).then(|| reason.to_owned()),
})
}
fn is_digest(candidate: &str) -> bool {
(8..=64).contains(&candidate.len()) && candidate.chars().all(|c| c.is_ascii_hexdigit())
}
#[must_use]
pub fn matching<'a>(acks: &'a [Acknowledgment], digest: &str) -> Option<&'a Acknowledgment> {
acks.iter()
.find(|ack| super::verify::marker_matches(digest, &ack.digest))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::posture::diff::Severity;
fn finding(kind: &'static str, path: &str) -> Finding {
Finding {
kind,
severity: Severity::Widening,
method: "GET".to_owned(),
path: path.to_owned(),
before: "gated (roles: admin)".to_owned(),
after: "public".to_owned(),
fingerprint: "class:gated->public".to_owned(),
detail: "d".to_owned(),
}
}
#[test]
fn digest_is_stable_across_finding_order() {
let a = finding("classification_downgraded", "/a");
let b = finding("route_added_open", "/b");
assert_eq!(ack_digest(&[&a, &b]), ack_digest(&[&b, &a]));
}
#[test]
fn a_new_widening_changes_the_digest() {
let a = finding("classification_downgraded", "/a");
let b = finding("route_added_open", "/b");
assert_ne!(ack_digest(&[&a]), ack_digest(&[&a, &b]));
}
#[test]
fn short_digest_is_sixteen_hex_characters() {
let a = finding("classification_downgraded", "/a");
let d = ack_digest(&[&a]);
assert_eq!(short(&d).len(), SHORT_DIGEST_LEN);
assert!(d.starts_with(&short(&d)));
assert!(short(&d).chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn parses_a_bare_marker() {
let acks = parse_acks("/ack-posture 0123456789abcdef");
assert_eq!(acks.len(), 1);
assert_eq!(acks[0].digest, "0123456789abcdef");
assert_eq!(acks[0].reason, None);
}
#[test]
fn parses_a_marker_with_a_reason() {
let acks = parse_acks(" /ack-posture 0123456789ABCDEF launch week, intentional\n");
assert_eq!(acks.len(), 1);
assert_eq!(acks[0].digest, "0123456789abcdef", "digest is normalized");
assert_eq!(acks[0].reason.as_deref(), Some("launch week, intentional"));
}
#[test]
fn parses_several_markers_across_harvested_comments() {
let text = "first comment\n/ack-posture aaaaaaaaaaaaaaaa\n---\n/ack-posture bbbbbbbbbbbbbbbb why not\n";
let acks = parse_acks(text);
assert_eq!(acks.len(), 2);
assert_eq!(acks[1].digest, "bbbbbbbbbbbbbbbb");
}
#[test]
fn a_quoted_marker_acknowledges_nothing() {
assert!(parse_acks("> /ack-posture 0123456789abcdef").is_empty());
assert!(parse_acks(">> /ack-posture 0123456789abcdef").is_empty());
}
#[test]
fn a_marker_inside_any_raw_html_block_acknowledges_nothing() {
for (open, close) in [
("<?example", "?>"),
("<!DOCTYPE note", ">"),
("<![CDATA[", "]]>"),
] {
let text = format!("{open}\n/ack-posture 0123456789abcdef\n{close}\n");
assert!(
parse_acks(&text).is_empty(),
"{open}: {:?}",
parse_acks(&text)
);
}
}
#[test]
fn a_marker_after_a_raw_html_block_of_any_kind_still_acknowledges() {
for (open, close) in [
("<?example", "?>"),
("<!DOCTYPE note", ">"),
("<![CDATA[", "]]>"),
] {
let text = format!("{open}\nsample\n{close}\n/ack-posture 0123456789abcdef yes\n");
assert_eq!(
parse_acks(&text).len(),
1,
"{open}: {:?}",
parse_acks(&text)
);
}
}
#[test]
fn a_marker_inside_a_multiline_code_span_acknowledges_nothing() {
let text = "like this: `\n/ack-posture 0123456789abcdef\n` — see?\n";
assert!(parse_acks(text).is_empty(), "{:?}", parse_acks(text));
}
#[test]
fn a_marker_after_a_closed_code_span_still_acknowledges() {
let text = "like this: `\nsample\n`\n/ack-posture 0123456789abcdef yes\n";
assert_eq!(parse_acks(text).len(), 1, "{:?}", parse_acks(text));
}
#[test]
fn a_balanced_code_span_does_not_swallow_the_next_line() {
let text = "the phrase is `/ack-posture`, like so:\n/ack-posture 0123456789abcdef yes\n";
assert_eq!(parse_acks(text).len(), 1, "{:?}", parse_acks(text));
}
#[test]
fn a_marker_inside_a_multiline_html_tag_acknowledges_nothing() {
let text = "<a title=\"note\n/ack-posture 0123456789abcdef\n\">link</a>\n";
assert!(parse_acks(text).is_empty(), "{:?}", parse_acks(text));
}
#[test]
fn a_marker_after_a_multiline_html_tag_still_acknowledges() {
let text = "<a title=\"note\n\">link</a>\n/ack-posture 0123456789abcdef yes\n";
assert_eq!(parse_acks(text).len(), 1, "{:?}", parse_acks(text));
}
#[test]
fn a_comparison_does_not_open_an_html_tag() {
let text = "when a < b, use this:\n/ack-posture 0123456789abcdef yes\n";
assert_eq!(parse_acks(text).len(), 1, "{:?}", parse_acks(text));
}
#[test]
fn a_marker_inside_a_raw_html_block_acknowledges_nothing() {
for tag in ["pre", "textarea", "script", "style"] {
let text = format!("<{tag}>\n/ack-posture 0123456789abcdef\n</{tag}>\n");
assert!(
parse_acks(&text).is_empty(),
"{tag}: {:?}",
parse_acks(&text)
);
}
}
#[test]
fn a_similarly_named_tag_does_not_close_a_raw_html_block() {
let text = "<pre>\n</prelude>\n/ack-posture 0123456789abcdef\n</pre>\n";
assert!(parse_acks(text).is_empty(), "{:?}", parse_acks(text));
}
#[test]
fn a_spaced_closing_tag_still_closes_a_raw_html_block() {
let text = "<pre>\nsample\n</pre >\n/ack-posture 0123456789abcdef yes\n";
assert_eq!(parse_acks(text).len(), 1, "{:?}", parse_acks(text));
}
#[test]
fn a_marker_after_a_raw_html_block_still_acknowledges() {
let text = "<pre>\nsample\n</pre>\n/ack-posture 0123456789abcdef yes\n";
let acks = parse_acks(text);
assert_eq!(acks.len(), 1, "{acks:?}");
}
#[test]
fn a_marker_inside_a_visible_html_block_still_acknowledges() {
let text = "<div>\n/ack-posture 0123456789abcdef yes\n</div>\n";
let acks = parse_acks(text);
assert_eq!(acks.len(), 1, "{acks:?}");
}
#[test]
fn an_html_comment_does_not_uncover_a_closing_fence() {
let text = "```\n<!-- note --> ```\n/ack-posture 0123456789abcdef\n";
assert!(parse_acks(text).is_empty(), "{:?}", parse_acks(text));
}
#[test]
fn a_marker_after_a_block_that_really_closed_still_acknowledges() {
let text = "```\n<!-- note --> ```\n```\n/ack-posture 0123456789abcdef yes\n";
let acks = parse_acks(text);
assert_eq!(acks.len(), 1, "{acks:?}");
}
#[test]
fn an_over_indented_fence_does_not_close_the_block() {
let text = "```\n ```\n/ack-posture 0123456789abcdef\n";
assert!(parse_acks(text).is_empty(), "{:?}", parse_acks(text));
let tab = "```\n\t```\n/ack-posture 0123456789abcdef\n";
assert!(parse_acks(tab).is_empty(), "{:?}", parse_acks(tab));
}
#[test]
fn a_fence_indented_three_spaces_still_closes_the_block() {
let text = "```\n ```\n/ack-posture 0123456789abcdef yes\n";
let acks = parse_acks(text);
assert_eq!(acks.len(), 1, "{acks:?}");
}
#[test]
fn a_lazy_continuation_of_a_quote_acknowledges_nothing() {
let text = "> Do not use this marker:\n/ack-posture 0123456789abcdef\n";
assert!(parse_acks(text).is_empty(), "{:?}", parse_acks(text));
}
#[test]
fn a_marker_after_the_quote_ends_still_acknowledges() {
let text = "> some quoted context\n\n/ack-posture 0123456789abcdef yes\n";
let acks = parse_acks(text);
assert_eq!(acks.len(), 1, "{acks:?}");
assert_eq!(acks[0].digest, "0123456789abcdef");
}
#[test]
fn a_quote_does_not_leak_across_harvested_comments() {
let text =
format!("> quoted tail\n{SOURCE_SEPARATOR}\n/ack-posture 0123456789abcdef yes\n");
assert_eq!(parse_acks(&text).len(), 1, "{:?}", parse_acks(&text));
}
#[test]
fn a_marker_inside_a_fenced_code_block_acknowledges_nothing() {
let text = "Here is how you do it:\n```\n/ack-posture 0123456789abcdef\n```\nthanks";
assert!(parse_acks(text).is_empty());
}
#[test]
fn a_marker_mid_sentence_acknowledges_nothing() {
assert!(parse_acks("I think /ack-posture 0123456789abcdef would work").is_empty());
}
#[test]
fn a_marker_without_a_digest_acknowledges_nothing() {
assert!(parse_acks("/ack-posture").is_empty());
assert!(parse_acks("/ack-posture please").is_empty());
assert!(parse_acks("/ack-posture 0123").is_empty(), "too short");
assert!(
parse_acks("/ack-posture 0123456789abcdefzz").is_empty(),
"not hex"
);
}
#[test]
fn the_phrase_is_case_insensitive() {
assert_eq!(parse_acks("/ACK-POSTURE 0123456789abcdef").len(), 1);
}
#[test]
fn a_marker_hidden_in_an_html_comment_does_not_acknowledge() {
let body = "\
Looks fine to me.
<!--
/ack-posture 0123456789abcdef
-->
";
assert!(parse_acks(body).is_empty());
}
#[test]
fn html_comment_tracking_does_not_swallow_the_source_separator() {
let harvested = format!(
"{SOURCE_SEPARATOR}\n<!-- an unterminated comment\n\
{SOURCE_SEPARATOR}\n/ack-posture 0123456789abcdef\n"
);
assert_eq!(
parse_acks(&harvested).len(),
1,
"a new comment body starts with a clean slate"
);
}
#[test]
fn a_marker_after_a_closed_html_comment_still_acknowledges() {
let acks = parse_acks("<!-- nit --> /ack-posture 0123456789abcdef agreed\n");
assert_eq!(acks.len(), 1);
}
#[test]
fn an_indented_code_sample_does_not_acknowledge() {
let body = "\
Before anyone runs off and does this:
/ack-posture 0123456789abcdef
…let us agree it is actually wanted.
";
assert!(parse_acks(body).is_empty());
}
#[test]
fn a_tab_indented_sample_does_not_acknowledge() {
assert!(parse_acks("here it is:\n\n\t/ack-posture 0123456789abcdef\n").is_empty());
}
#[test]
fn a_slightly_indented_marker_still_acknowledges() {
let acks = parse_acks(" /ack-posture 0123456789abcdef yes, intended\n");
assert_eq!(acks.len(), 1);
assert_eq!(acks[0].reason.as_deref(), Some("yes, intended"));
}
#[test]
fn a_nested_fence_does_not_reopen_the_block_it_sits_in() {
let quoting_the_report = "\
Not convinced by this one:
````
### 🛡️ Security posture diff
To acknowledge these exact changes, comment with:
```
/ack-posture 0123456789abcdef
```
````
Let us talk about it first.
";
assert!(
parse_acks(quoting_the_report).is_empty(),
"quoting the report is not acknowledging it"
);
}
#[test]
fn a_marker_after_a_closed_longer_fence_still_acknowledges() {
let body = "\
````
context
```
not a marker: /ack-posture ffffffffffffffff
```
````
/ack-posture 0123456789abcdef intentional, launch week
";
let acks = parse_acks(body);
assert_eq!(acks.len(), 1);
assert_eq!(acks[0].digest, "0123456789abcdef");
}
#[test]
fn a_tilde_fence_is_not_closed_by_backticks() {
let body = "~~~\n```\n/ack-posture 0123456789abcdef\n";
assert!(parse_acks(body).is_empty());
}
#[test]
fn an_unbalanced_fence_does_not_leak_into_the_next_comment() {
let harvested = format!(
"{SOURCE_SEPARATOR}\nHere is the failing log:\n```\nthread panicked\n\
{SOURCE_SEPARATOR}\n/ack-posture 0123456789abcdef\n"
);
let acks = parse_acks(&harvested);
assert_eq!(acks.len(), 1, "the second comment still acknowledges");
}
#[test]
fn an_unbalanced_fence_cannot_make_a_fenced_marker_live() {
let harvested = format!(
"{SOURCE_SEPARATOR}\nlog:\n```\noops\n\
{SOURCE_SEPARATOR}\nquoting the bot:\n```\n/ack-posture 0123456789abcdef\n```\n"
);
assert!(parse_acks(&harvested).is_empty());
}
#[test]
fn a_short_marker_matches_the_full_digest() {
let digest = ack_digest(&[&finding("route_added_open", "/a")]);
let acks = parse_acks(&format!("/ack-posture {}", short(&digest)));
assert!(matching(&acks, &digest).is_some());
}
#[test]
fn a_full_marker_matches_the_full_digest() {
let digest = ack_digest(&[&finding("route_added_open", "/a")]);
let acks = parse_acks(&format!("/ack-posture {digest}"));
assert!(matching(&acks, &digest).is_some());
}
#[test]
fn a_marker_for_another_finding_set_does_not_match() {
let acknowledged = ack_digest(&[&finding("route_added_open", "/a")]);
let now = ack_digest(&[
&finding("route_added_open", "/a"),
&finding("route_added_open", "/b"),
]);
let acks = parse_acks(&format!("/ack-posture {}", short(&acknowledged)));
assert!(
matching(&acks, &now).is_none(),
"re-widening after an acknowledgment must re-block"
);
}
#[test]
fn a_prefix_shorter_than_the_published_marker_never_matches() {
let digest = ack_digest(&[&finding("route_added_open", "/a")]);
let truncated: String = digest.chars().take(8).collect();
let acks = parse_acks(&format!("/ack-posture {truncated}"));
assert_eq!(acks.len(), 1, "it parses");
assert!(matching(&acks, &digest).is_none(), "but it does not match");
}
#[test]
fn a_marker_that_is_not_a_prefix_of_the_expected_digest_does_not_match() {
let digest = ack_digest(&[&finding("route_added_open", "/a")]);
let acks = parse_acks("/ack-posture 00000000000000000000");
assert!(matching(&acks, &digest).is_none());
}
}