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}