beck_syntax/
security.rs

1//! What a source file may contain, before anything tries to read it.
2//!
3//! [`docs/35-standards-landscape.md`](../../../../../docs/35-standards-landscape.md) §35.5 item 2:
4//! "pin the Unicode version per release; add UTS #39's security profile with conformance vectors".
5//! [`docs/08-roadmap.md`](../../../../../docs/08-roadmap.md) §8.5.2 classes it **R** — a retrofit that
6//! becomes expensive "the moment identifiers exist in published packages", which is before the
7//! registry and therefore now.
8//!
9//! # The profile Beck adopts
10//!
11//! UTS #39 defines *restriction levels* for identifiers. Beck is at **Level 1, ASCII-Only**, and it
12//! is there by construction rather than by filtering: the Python surface's identifier production is
13//! `[A-Za-z_][A-Za-z0-9_]*` and always has been. That is the strictest level in the report, and it
14//! makes the two attacks UTS #39 is mostly about — confusables and mixed-script identifiers —
15//! *unrepresentable* rather than checked. §12.7's vocabulary for that distinction is
16//! "unrepresentable by construction", and `identifiers.rs` is the negative test proving it.
17//!
18//! What ASCII-only identifiers do **not** close is the other half of UTS #39 §4: **bidirectional
19//! confusion**, where a file renders in an editor differently from how it compiles. That is
20//! Trojan Source (CVE-2021-42574), it works through comments and string literals rather than
21//! identifiers, and no restriction on identifiers touches it. [`scan`] is that check.
22//!
23//! # Why the version pin is one line
24//!
25//! The compiler carries no Unicode tables: an ASCII-only profile needs none, and the character
26//! classes below are stable properties of characters that were assigned decades ago. [`UNICODE`] is
27//! therefore a statement of *which version these rules were written against* rather than a
28//! dependency — and the day Beck accepts a non-ASCII identifier, that constant stops being a note
29//! and starts being a thing with tables behind it.
30
31use beck_diag::{Diagnostic, Diagnostics, FileId, Span};
32
33/// The Unicode version this file's rules are stated against.
34///
35/// Pinned per release, per §35.5 item 2. See the module note: today this is a statement, not a
36/// dependency, and the difference is worth keeping visible.
37pub const UNICODE: &str = "17.0";
38
39/// The bidirectional formatting characters, refused anywhere in a source file.
40///
41/// These are the twelve from UTS #39 §4.1 and from the Trojan Source paper. Every one of them
42/// changes how following text is *displayed* without changing what it *is*, which is exactly the
43/// property that lets a reviewer read one program and a compiler read another.
44///
45/// They are refused in string literals too, and `\u{...}` is how a program that genuinely needs one
46/// writes it — a legitimate use is a runtime *value*, and a value spelled with an escape is a value
47/// a reviewer can see.
48const BIDI: &[(char, &str)] = &[
49    ('\u{202A}', "LEFT-TO-RIGHT EMBEDDING"),
50    ('\u{202B}', "RIGHT-TO-LEFT EMBEDDING"),
51    ('\u{202C}', "POP DIRECTIONAL FORMATTING"),
52    ('\u{202D}', "LEFT-TO-RIGHT OVERRIDE"),
53    ('\u{202E}', "RIGHT-TO-LEFT OVERRIDE"),
54    ('\u{2066}', "LEFT-TO-RIGHT ISOLATE"),
55    ('\u{2067}', "RIGHT-TO-LEFT ISOLATE"),
56    ('\u{2068}', "FIRST STRONG ISOLATE"),
57    ('\u{2069}', "POP DIRECTIONAL ISOLATE"),
58    ('\u{061C}', "ARABIC LETTER MARK"),
59    ('\u{200E}', "LEFT-TO-RIGHT MARK"),
60    ('\u{200F}', "RIGHT-TO-LEFT MARK"),
61];
62
63/// Check a source file before either surface reads it.
64///
65/// One place, both surfaces, because the S-expression reader is the same front end and a rule that
66/// holds on one notation and not the other is not a rule
67/// ([`adr/0012`](../../../../../docs/adr/0012-the-front-end-counts-its-own-recursion.md) makes the
68/// same argument about a different bound).
69///
70/// Zero-width *joiners* are deliberately not here. U+200D is how an emoji sequence is spelled, a
71/// string is data, and with identifiers already restricted to ASCII there is no confusable
72/// identifier for an invisible character to help build. A rule with no attack behind it is a rule
73/// somebody will eventually be forced to work around.
74pub fn scan(file: FileId, src: &str, diags: &mut Diagnostics) {
75    for (offset, c) in src.char_indices() {
76        // A byte-order mark is conventional at the start of a file and a zero-width no-break space
77        // anywhere else, so the position is the whole difference.
78        if c == '\u{FEFF}' && offset > 0 {
79            diags.push(
80                Diagnostic::error(
81                    "B0102",
82                    "a zero-width no-break space in the source",
83                    Span::new(file, offset..offset + c.len_utf8()),
84                )
85                .with_primary_label("U+FEFF, which is invisible here")
86                .with_note(
87                    "a byte-order mark is only a byte-order mark at the very start of a file"
88                        .to_string(),
89                ),
90            );
91            continue;
92        }
93        let Some((_, name)) = BIDI.iter().find(|(b, _)| *b == c) else {
94            continue;
95        };
96        diags.push(
97            Diagnostic::error(
98                "B0102",
99                "a bidirectional control character in the source",
100                Span::new(file, offset..offset + c.len_utf8()),
101            )
102            .with_primary_label(format!("U+{:04X} {name}, which is invisible", c as u32))
103            .with_note(
104                "these characters change how the text after them is displayed without changing \
105                 what it means, so a reviewer and the compiler can read the same file differently \
106                 (CVE-2021-42574). Beck adopts UTS #39's profile and refuses them; write \
107                 `\\u{...}` if a string genuinely needs one"
108                    .to_string(),
109            ),
110        );
111    }
112}
113
114/// Whether a character would be an identifier character in some language but is not one in Beck.
115///
116/// Used only to turn "unrecognised character" into a diagnostic that says *why*: a Cyrillic `а` in
117/// an identifier is not a typo, it is either a mistake worth naming or an attack, and either way
118/// "not a Beck token" is the least useful thing to say about it.
119pub fn is_non_ascii_letter(c: char) -> bool {
120    !c.is_ascii() && (c.is_alphabetic() || c.is_numeric() || c == '_')
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use beck_diag::SourceMap;
127
128    fn codes(src: &str) -> Vec<&'static str> {
129        let mut map = SourceMap::new();
130        let f = map.add("t.beck", src);
131        let mut d = Diagnostics::new();
132        scan(f, src, &mut d);
133        d.iter().map(|x| x.code).collect()
134    }
135
136    /// The Trojan Source vector, in the shape the paper uses: a comment that ends, visually, before
137    /// it actually does.
138    #[test]
139    fn a_comment_that_reorders_itself_is_refused() {
140        let src = "def f() -> Int:\n    # \u{202E} return 0 #\n    return 1\n";
141        assert_eq!(codes(src), vec!["B0102"]);
142    }
143
144    #[test]
145    fn a_string_literal_is_not_a_way_round_it() {
146        assert_eq!(
147            codes("x = \"\u{2066}admin\u{2069}\"\n"),
148            vec!["B0102", "B0102"]
149        );
150    }
151
152    #[test]
153    fn a_byte_order_mark_is_fine_at_the_start_and_not_anywhere_else() {
154        assert!(codes("\u{FEFF}def f() -> Int:\n    return 1\n").is_empty());
155        assert_eq!(
156            codes("def f\u{FEFF}() -> Int:\n    return 1\n"),
157            vec!["B0102"]
158        );
159    }
160
161    /// The characters this check deliberately does not refuse, asserted so the omission is a
162    /// decision rather than an oversight.
163    #[test]
164    fn an_emoji_sequence_in_a_string_still_reads() {
165        assert!(codes("greeting = \"hello \u{1F469}\u{200D}\u{1F4BB}\"\n").is_empty());
166    }
167}