Skip to main content

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;