git_policy_cli/event_path.rs
1//! What:
2//! A repository path as an event reports it:
3//! readable text,
4//! and the exact bytes
5//! as well when those bytes are not UTF-8.
6//! Why:
7//! Git allows any bytes in a file name,
8//! and a JSON string can hold only text.
9//! An
10//! event keeps `path` as text with replacement characters,
11//! so every existing reader
12//! works unchanged,
13//! and adds an optional `pathBytes` field,
14//! base64 encoded,
15//! only for
16//! a name that is not UTF-8,
17//! so a reader that needs the file can still find it.
18//! This is option C of `doc/planning/cli-git-rust-open-decisions.md`,
19//! section
20//! "Non-UTF-8 paths in events and journals",
21//! which the owner adopted.
22//!
23//! In TS you'd write (pseudocode):
24//! ```ts
25//! // const path = eventPath(bytes); line.path = path.text; if (path.exact) line.pathBytes = base64(path.exact);
26//! ```
27
28/// What:
29/// The 64 characters of the standard base64 alphabet,
30/// in value order.
31/// `&[u8; 64]` is a fixed-length byte array baked into the program.
32/// Why:
33/// RFC 4648 section 4 is the alphabet every common decoder reads by default
34/// (`Buffer.from(text, 'base64')`,
35/// Python's `base64.b64decode`,
36/// Go's `[]byte` JSON).
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
41/// ```
42const BASE64_ALPHABET: &[u8; 64] =
43 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
44
45/// What:
46/// One path as events report it.
47/// The fields are private,
48/// so the only ways to build
49/// one are the two constructors,
50/// and `exact` is present exactly when the bytes
51/// were not UTF-8.
52/// `#[derive(...)]` generates cloning,
53/// debug printing and `==`.
54/// Why:
55/// A reader must be able to rely on "no `pathBytes` means `path` is the exact name".
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// type EventPath = { readonly text: string; readonly exact?: Uint8Array };
60/// ```
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct EventPath {
63 /// The path as text;
64 /// bytes that are not UTF-8 are replaced by U+FFFD.
65 text: String,
66 /// The exact bytes,
67 /// kept only when they are not UTF-8.
68 exact: Option<Vec<u8>>,
69}
70
71/// What:
72/// `impl EventPath { ... }` attaches the constructors and the two reads.
73/// Why:
74/// The invariant between the two fields is kept in one place.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// class EventPath { static fromGitBytes(bytes) {} static display(text) {} }
79/// ```
80impl EventPath {
81 /// What:
82 /// The event form of a pathname exactly as Git printed it.
83 /// `&[u8]` borrows the
84 /// bytes;
85 /// `String::from_utf8_lossy` replaces each invalid sequence by U+FFFD.
86 /// Why:
87 /// A UTF-8 name is its own exact form and gets no extra field;
88 /// any other name
89 /// keeps its bytes,
90 /// because different names can share one replaced text.
91 ///
92 /// In TS you'd write (pseudocode):
93 /// ```ts
94 /// static fromGitBytes(bytes: Uint8Array): EventPath;
95 /// ```
96 pub fn from_git_bytes(bytes: &[u8]) -> EventPath {
97 // `match` on the strict decoding: text keeps no copy of the bytes, anything else does.
98 match std::str::from_utf8(bytes) {
99 Ok(text) => {
100 return EventPath {
101 text: String::from(text),
102 exact: None,
103 };
104 }
105 Err(_) => {
106 return EventPath {
107 text: String::from_utf8_lossy(bytes).into_owned(),
108 // `.to_vec()` copies the borrowed bytes into an owned list.
109 exact: Some(bytes.to_vec()),
110 };
111 }
112 }
113 }
114
115 /// What:
116 /// A path whose event form is text only.
117 /// `&str` borrows the text.
118 /// Why:
119 /// A constant name,
120 /// or the scanner's masked display of a name,
121 /// is already text;
122 /// the masked display must never carry the bytes it masks.
123 ///
124 /// In TS you'd write (pseudocode):
125 /// ```ts
126 /// static display(text: string): EventPath;
127 /// ```
128 pub fn display(text: &str) -> EventPath {
129 return EventPath {
130 text: String::from(text),
131 exact: None,
132 };
133 }
134
135 /// What:
136 /// The readable text of the path.
137 /// `&str` borrows it from the record.
138 /// Why:
139 /// Every event prints it as `path`,
140 /// whatever else it prints.
141 ///
142 /// In TS you'd write (pseudocode):
143 /// ```ts
144 /// get text(): string;
145 /// ```
146 pub fn text(&self) -> &str {
147 return self.text.as_str();
148 }
149
150 /// What:
151 /// The exact bytes,
152 /// only when they are not UTF-8.
153 /// `Option<&[u8]>` lends them or
154 /// says there are none.
155 /// Why:
156 /// Rendering adds `pathBytes` exactly when this is present.
157 ///
158 /// In TS you'd write (pseudocode):
159 /// ```ts
160 /// get exact(): Uint8Array | undefined;
161 /// ```
162 pub fn exact(&self) -> Option<&[u8]> {
163 // `.as_deref()` lends the owned list inside the option as a borrowed slice.
164 return self.exact.as_deref();
165 }
166
167 /// What:
168 /// The bytes of the name:
169 /// the exact ones when kept,
170 /// else the text's UTF-8 bytes.
171 /// Why:
172 /// A list of paths that holds one name that is not UTF-8 lists the bytes of every
173 /// name,
174 /// so its entries line up with the readable list one for one.
175 ///
176 /// In TS you'd write (pseudocode):
177 /// ```ts
178 /// get bytes(): Uint8Array { return this.exact ?? new TextEncoder().encode(this.text); }
179 /// ```
180 pub fn bytes(&self) -> &[u8] {
181 match &self.exact {
182 Some(exact) => return exact.as_slice(),
183 None => return self.text.as_bytes(),
184 }
185 }
186}
187
188/// What:
189/// The alphabet character for the low six bits of `value`.
190/// `u32` is an unsigned
191/// 32-bit integer;
192/// `char::from` turns one ASCII byte into a character.
193/// Why:
194/// Each base64 character carries six bits of the input.
195///
196/// In TS you'd write (pseudocode):
197/// ```ts
198/// const sextet = (value: number) => ALPHABET[value & 63];
199/// ```
200fn sextet(value: u32) -> char {
201 // `as usize` widens the masked value to an index; `& 63` keeps the low six bits.
202 return char::from(BASE64_ALPHABET[(value & 63) as usize]);
203}
204
205/// What:
206/// Encode bytes as standard base64 with `=` padding (RFC 4648 section 4).
207/// Why:
208/// `pathBytes` carries exact pathname bytes inside a JSON string.
209/// Base64 is what
210/// ripgrep's JSON output uses for a path that is not UTF-8 and what Go's JSON
211/// encoding uses for bytes;
212/// it is unmistakably not a name,
213/// so no reader uses it as
214/// one by accident.
215/// Every three input bytes become four characters;
216/// a final group
217/// of one or two bytes is padded to four characters with `=`.
218///
219/// In TS you'd write (pseudocode):
220/// ```ts
221/// function base64(bytes: Uint8Array): string { return Buffer.from(bytes).toString('base64'); }
222/// ```
223pub fn base64_standard(bytes: &[u8]) -> String {
224 let mut encoded: String = String::new();
225 // `.chunks(3)` lends the input three bytes at a time; the last group may be shorter.
226 for group in bytes.chunks(3) {
227 // `u32::from` widens each byte; `<<` shifts it into its place in the 24-bit group.
228 let first: u32 = u32::from(group[0]) << 16;
229 let second: u32 = match group.get(1) {
230 Some(byte) => u32::from(*byte) << 8,
231 None => 0,
232 };
233 let third: u32 = match group.get(2) {
234 Some(byte) => u32::from(*byte),
235 None => 0,
236 };
237 let joined: u32 = first | second | third;
238 encoded.push(sextet(joined >> 18));
239 encoded.push(sextet(joined >> 12));
240 if group.len() > 1 {
241 encoded.push(sextet(joined >> 6));
242 } else {
243 encoded.push('=');
244 }
245 if group.len() > 2 {
246 encoded.push(sextet(joined));
247 } else {
248 encoded.push('=');
249 }
250 }
251 return encoded;
252}
253
254/// Construction,
255/// invariant and encoding controls stay out of the release executable.
256#[cfg(test)]
257#[path = "event_path_tests.rs"]
258mod tests;