Skip to main content

stix_pattern/ir/
print.rs

1//! The human-readable IR listing.
2
3use std::collections::HashMap;
4use std::fmt::Write as _;
5
6use crate::ast::ComparisonOperator;
7use crate::ir::render::{escape_string, render_literal, render_path, render_seconds};
8use crate::ir::{Block, BlockKind, InstrId, Op, Operand, Program};
9
10/// Minimum width of the mnemonic field, so operands line up in a column.
11///
12/// A mnemonic at least this wide — `not issuperset` is 14 — is padded to its own
13/// length plus one instead, since the operands still need a space in front of
14/// them.
15const MNEMONIC_WIDTH: usize = 12;
16
17/// Width of a line's prefix, given the widest result name in the listing.
18///
19/// A prefix is two spaces of indent, the result name, and `" = "`, as in
20/// `"  t0 = "`; a terminator names no result and gets that many spaces instead.
21/// The name field is as wide as the longest name the listing uses, so the `=`
22/// stays in one column once the ordinals reach two digits.
23fn prefix_width(name_width: usize) -> usize {
24    2 + name_width + 3
25}
26
27impl Program {
28    /// Render this program as a human-readable listing.
29    ///
30    /// Comparison-block results are named `t0`, `t1`, … across all blocks in
31    /// order; main-block results are named `o0`, `o1`, … . These names are
32    /// display ordinals, not [`InstrId`](crate::ir::InstrId) values.
33    pub fn to_listing(&self) -> String {
34        let mut names: HashMap<InstrId, String> = HashMap::new();
35        let mut t = 0usize;
36        for b in &self.blocks {
37            for i in &b.instructions {
38                if produces_value(&i.op) {
39                    names.insert(i.id, format!("t{t}"));
40                    t += 1;
41                }
42            }
43        }
44        let mut o = 0usize;
45        for i in &self.main.instructions {
46            if produces_value(&i.op) {
47                names.insert(i.id, format!("o{o}"));
48                o += 1;
49            }
50        }
51
52        let name_width = names.values().map(String::len).max().unwrap_or(2);
53
54        let mut out = String::new();
55        for b in &self.blocks {
56            write_block(&mut out, b, &names, name_width);
57            out.push('\n');
58        }
59        write_block(&mut out, &self.main, &names, name_width);
60        out
61    }
62}
63
64/// `false` for the terminators, which name no result.
65fn produces_value(op: &Op) -> bool {
66    !matches!(op, Op::Yield { .. } | Op::Ret { .. })
67}
68
69fn write_block(out: &mut String, b: &Block, names: &HashMap<InstrId, String>, name_width: usize) {
70    match b.kind {
71        BlockKind::Comparison => {
72            let _ = writeln!(out, "block b{} (comparison):", b.id.0);
73        }
74        BlockKind::Main => {
75            let _ = writeln!(out, "block main (observation):");
76        }
77    }
78    for i in &b.instructions {
79        let dest = match names.get(&i.id) {
80            Some(n) => format!("  {n:<name_width$} = "),
81            None => " ".repeat(prefix_width(name_width)),
82        };
83        let (mnemonic, operands) = describe(&i.op, names);
84        let width = MNEMONIC_WIDTH.max(mnemonic.len() + 1);
85        let padded = format!("{mnemonic:<width$}");
86        if operands.is_empty() {
87            let _ = writeln!(out, "{dest}{}", padded.trim_end());
88        } else {
89            let _ = writeln!(out, "{dest}{padded}{operands}");
90        }
91    }
92}
93
94fn name_of(id: InstrId, names: &HashMap<InstrId, String>) -> String {
95    names
96        .get(&id)
97        .cloned()
98        .unwrap_or_else(|| format!("%{}", id.0))
99}
100
101/// The mnemonic and operand text for one op.
102fn describe(op: &Op, names: &HashMap<InstrId, String>) -> (String, String) {
103    match op {
104        Op::Load { path } => ("load".to_string(), escape_controls(&render_path(path))),
105        Op::Compare {
106            operator,
107            negated,
108            lhs,
109            rhs,
110        } => {
111            let mut m = String::new();
112            if *negated {
113                m.push_str("not ");
114            }
115            m.push_str(mnemonic_for(*operator));
116            let operands = match rhs {
117                Operand::Absent => name_of(*lhs, names),
118                Operand::Literal(lit) => {
119                    format!(
120                        "{}, {}",
121                        name_of(*lhs, names),
122                        escape_controls(&render_literal(lit))
123                    )
124                }
125                Operand::Set(items) => {
126                    let inner: Vec<String> = items
127                        .iter()
128                        .map(|l| escape_controls(&render_literal(l)))
129                        .collect();
130                    format!("{}, ({})", name_of(*lhs, names), inner.join(", "))
131                }
132            };
133            (m, operands)
134        }
135        Op::And { lhs, rhs } => (
136            "and".to_string(),
137            format!("{}, {}", name_of(*lhs, names), name_of(*rhs, names)),
138        ),
139        Op::Or { lhs, rhs } => (
140            "or".to_string(),
141            format!("{}, {}", name_of(*lhs, names), name_of(*rhs, names)),
142        ),
143        Op::Yield { value } => ("yield".to_string(), name_of(*value, names)),
144        Op::Observe { block } => ("observe".to_string(), format!("b{}", block.0)),
145        Op::FollowedBy { lhs, rhs } => (
146            "followedby".to_string(),
147            format!("{}, {}", name_of(*lhs, names), name_of(*rhs, names)),
148        ),
149        Op::Within { input, seconds } => (
150            "within".to_string(),
151            format!("{}, {}", name_of(*input, names), render_seconds(*seconds)),
152        ),
153        Op::Repeats { input, count } => (
154            "repeats".to_string(),
155            format!("{}, {}", name_of(*input, names), count),
156        ),
157        Op::StartStop { input, start, stop } => (
158            "startstop".to_string(),
159            format!(
160                "{}, '{}', '{}'",
161                name_of(*input, names),
162                escape_controls(&escape_string(start)),
163                escape_controls(&escape_string(stop))
164            ),
165        ),
166        Op::Ret { value } => ("ret".to_string(), name_of(*value, names)),
167    }
168}
169
170/// Escape control characters so one instruction always occupies one line.
171///
172/// Applied to rendered text only in the listing, which is a display format and
173/// is never reparsed. Canonical text from [`render`](crate::ir::render) keeps the
174/// raw characters, since the STIX grammar defines only the `\'` and `\\`
175/// escapes. Control characters occur only inside quoted content, so applying this
176/// to a whole rendered fragment is safe.
177fn escape_controls(s: &str) -> String {
178    let mut out = String::with_capacity(s.len());
179    for c in s.chars() {
180        match c {
181            '\n' => out.push_str("\\n"),
182            '\r' => out.push_str("\\r"),
183            '\t' => out.push_str("\\t"),
184            c if c.is_control() => {
185                let _ = write!(out, "\\u{{{:x}}}", c as u32);
186            }
187            c => out.push(c),
188        }
189    }
190    out
191}
192
193fn mnemonic_for(op: ComparisonOperator) -> &'static str {
194    match op {
195        ComparisonOperator::Equal => "eq",
196        ComparisonOperator::NotEqual => "ne",
197        ComparisonOperator::GreaterThan => "gt",
198        ComparisonOperator::GreaterThanOrEqual => "ge",
199        ComparisonOperator::LessThan => "lt",
200        ComparisonOperator::LessThanOrEqual => "le",
201        ComparisonOperator::In => "in",
202        ComparisonOperator::Like => "like",
203        ComparisonOperator::Matches => "matches",
204        ComparisonOperator::IsSubset => "issubset",
205        ComparisonOperator::IsSuperset => "issuperset",
206        ComparisonOperator::Exists => "exists",
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use crate::ir::lower;
213    use crate::parse;
214
215    #[test]
216    fn prints_a_simple_comparison() {
217        let prog = lower(&parse("[file:size > 1024]").unwrap());
218        let expected = "\
219block b1 (comparison):
220  t0 = load        file:size
221  t1 = gt          t0, 1024
222       yield       t1
223
224block main (observation):
225  o0 = observe     b1
226       ret         o0
227";
228        assert_eq!(prog.to_listing(), expected);
229    }
230
231    // NOTE: `WITHIN` qualifies only the SECOND observation, not the FOLLOWEDBY.
232    // STIX 2.1 attaches qualifiers to the tightest production, so this parses as
233    // FollowedBy(A, Within(B, 300)) — hence `within` precedes `followedby` here
234    // and takes o1.
235    #[test]
236    fn prints_followedby_with_a_qualifier() {
237        let src = "[ipv4-addr:value = '1.2.3.4' AND file:size > 1024] \
238                   FOLLOWEDBY [domain-name:value = 'evil.example'] WITHIN 300 SECONDS";
239        let prog = lower(&parse(src).unwrap());
240        let expected = "\
241block b1 (comparison):
242  t0 = load        ipv4-addr:value
243  t1 = eq          t0, '1.2.3.4'
244  t2 = load        file:size
245  t3 = gt          t2, 1024
246  t4 = and         t1, t3
247       yield       t4
248
249block b2 (comparison):
250  t5 = load        domain-name:value
251  t6 = eq          t5, 'evil.example'
252       yield       t6
253
254block main (observation):
255  o0 = observe     b1
256  o1 = observe     b2
257  o2 = within      o1, 300
258  o3 = followedby  o0, o2
259       ret         o3
260";
261        assert_eq!(prog.to_listing(), expected);
262    }
263
264    #[test]
265    fn prints_exists_and_in() {
266        let prog = lower(&parse("[EXISTS file:name]").unwrap());
267        assert!(
268            prog.to_listing().contains("t1 = exists      t0"),
269            "{}",
270            prog.to_listing()
271        );
272
273        let prog = lower(&parse("[ipv4-addr:value IN ('1.1.1.1', '8.8.8.8')]").unwrap());
274        assert!(
275            prog.to_listing()
276                .contains("t1 = in          t0, ('1.1.1.1', '8.8.8.8')"),
277            "{}",
278            prog.to_listing()
279        );
280    }
281
282    #[test]
283    fn marks_negation() {
284        let prog = lower(&parse("[file:name NOT = 'x']").unwrap());
285        assert!(
286            prog.to_listing().contains("t1 = not eq      t0, 'x'"),
287            "{}",
288            prog.to_listing()
289        );
290    }
291
292    #[test]
293    fn keeps_a_space_after_an_overlong_mnemonic() {
294        // `not issubset` is exactly MNEMONIC_WIDTH and `not issuperset` is wider,
295        // so fixed-width padding would run the mnemonic into its first operand.
296        let prog = lower(&parse("[ipv4-addr:value NOT ISSUPERSET '10.0.0.0/8']").unwrap());
297        let listing = prog.to_listing();
298        assert!(
299            listing.contains("t1 = not issuperset t0, '10.0.0.0/8'"),
300            "{listing}"
301        );
302
303        let prog = lower(&parse("[ipv4-addr:value NOT ISSUBSET '10.0.0.0/8']").unwrap());
304        let listing = prog.to_listing();
305        assert!(
306            listing.contains("t1 = not issubset t0, '10.0.0.0/8'"),
307            "{listing}"
308        );
309    }
310
311    #[test]
312    fn columns_stay_aligned_past_the_tenth_ordinal() {
313        // Six terms give twelve Load/Compare results plus five Ands = seventeen
314        // comparison-tier results, so ordinals reach t16 and cross into two digits.
315        let terms: Vec<String> = (0..6).map(|i| format!("file:size > {i}")).collect();
316        let src = format!("[{}]", terms.join(" AND "));
317        let prog = lower(&parse(&src).unwrap());
318        let listing = prog.to_listing();
319        assert!(listing.contains("t10 = "), "should reach t10:\n{listing}");
320
321        // Every instruction line starts its mnemonic in the same column, whether it
322        // names a result or not.
323        // Block headers start at column zero; instruction lines are indented.
324        let instruction_lines: Vec<&str> =
325            listing.lines().filter(|l| l.starts_with("  ")).collect();
326        let mnemonic_columns: Vec<usize> = instruction_lines
327            .iter()
328            .map(|l| match l.find(" = ") {
329                // A line that names a result: the mnemonic follows `" = "`.
330                Some(i) => i + 3,
331                // A terminator: the mnemonic follows the blank prefix.
332                None => l.len() - l.trim_start().len(),
333            })
334            .collect();
335        assert!(
336            mnemonic_columns.windows(2).all(|w| w[0] == w[1]),
337            "mnemonics should share one column, got {mnemonic_columns:?}:\n{listing}"
338        );
339
340        // And the `=` signs line up with each other.
341        let equals_columns: Vec<usize> = instruction_lines
342            .iter()
343            .filter_map(|l| l.find(" = "))
344            .collect();
345        assert!(
346            equals_columns.windows(2).all(|w| w[0] == w[1]),
347            "`=` should share one column, got {equals_columns:?}:\n{listing}"
348        );
349    }
350
351    #[test]
352    fn escapes_quotes_in_a_start_stop_listing() {
353        let prog = lower(&parse(r"[file:name='a'] START t'2020\'x' STOP t'2021'").unwrap());
354        let listing = prog.to_listing();
355        assert!(
356            listing.contains(r"startstop   o0, '2020\'x', '2021'"),
357            "{listing}"
358        );
359    }
360
361    #[test]
362    fn a_newline_in_a_literal_stays_on_one_listing_line() {
363        let src = "[file:name = 'a\n  b']";
364        let ast = parse(src).unwrap();
365        let prog = lower(&ast);
366        let listing = prog.to_listing();
367        assert!(
368            listing.contains(r"t1 = eq          t0, 'a\n  b'"),
369            "{listing}"
370        );
371        // Every non-blank listing line is a header or an indented instruction.
372        for l in listing.lines().filter(|l| !l.is_empty()) {
373            assert!(l.starts_with("block ") || l.starts_with("  "), "{listing}");
374        }
375        assert_eq!(listing.lines().count(), 8, "{listing}");
376        // Canonical text still carries the raw character and reparses.
377        let text = crate::ir::render(&prog);
378        assert!(text.contains("'a\n  b'"), "{text:?}");
379        assert_eq!(parse(&text).unwrap(), ast);
380    }
381
382    #[test]
383    fn tabs_and_other_controls_are_escaped_in_the_listing() {
384        use crate::ast::Literal;
385        use crate::ir::{Op, Operand};
386        // Built by hand: the lexer reads non-ASCII bytes one at a time, so a
387        // U+0085 cannot be written through pattern text.
388        let mut prog = lower(&parse("[file:name IN ('x')]").unwrap());
389        for i in &mut prog.blocks[0].instructions {
390            if let Op::Compare { rhs, .. } = &mut i.op {
391                *rhs = Operand::Set(
392                    ["a\tb", "c\u{85}d", "e\u{7f}f", "g\rh", "i\u{1}j"]
393                        .map(|s| Literal::String(s.to_string()))
394                        .to_vec(),
395                );
396            }
397        }
398        let listing = prog.to_listing();
399        assert!(
400            listing.contains(r"('a\tb', 'c\u{85}d', 'e\u{7f}f', 'g\rh', 'i\u{1}j')"),
401            "{listing}"
402        );
403    }
404
405    #[test]
406    fn controls_are_escaped_in_start_stop_and_path_keys() {
407        let prog = lower(&parse("[file:name='a'] START t'2020\n01' STOP t'2021\t'").unwrap());
408        let listing = prog.to_listing();
409        assert!(listing.contains(r"'2020\n01', '2021\t'"), "{listing}");
410        let prog = lower(&parse("[file:'a\nb' = 1]").unwrap());
411        let listing = prog.to_listing();
412        assert!(listing.contains(r"load        file:'a\nb'"), "{listing}");
413        assert_eq!(listing.lines().count(), 8, "{listing}");
414    }
415}