git_policy_cli/auto_push_branch_key.rs
1//! What:
2//! The reversible, flat, case-insensitive-safe file name of one branch's push coordination files.
3//! Why:
4//! The per-branch push lock and `last-pushed` record live under `<git-common-dir>/cli-git/push/`,
5//! and the incumbent (`src/auto-push-branch-key.ts`) and this wrapper must name them identically,
6//! because both wrappers contend on the same lock while both are installed.
7//! Uppercase letters are escaped too, so refs that differ only in case never share a file
8//! on a case-insensitive filesystem.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! encodeBranchKey('refs/heads/Main'); // 'refs%2Fheads%2F%4Dain'
13//! ```
14
15/// What:
16/// A push coordination key that `encode_branch_key` never produces.
17/// Why:
18/// Decoding a key that the incumbent did not write must stop the caller,
19/// never guess a ref name.
20///
21/// In TS you'd write (pseudocode):
22/// ```ts
23/// class BranchKeyError extends Error {}
24/// ```
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct BranchKeyError {
27 /// Diagnostic naming the key and the position of the fault.
28 pub message: String,
29}
30
31/// Displays the diagnostic only.
32impl std::fmt::Display for BranchKeyError {
33 /// Writes the diagnostic text.
34 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 return formatter.write_str(&self.message);
36 }
37}
38
39/// Marks the key failure as a standard error.
40impl std::error::Error for BranchKeyError {}
41
42/// The escape introducer `%`.
43const ESCAPE: u8 = b'%';
44
45/// Whether a byte is kept verbatim: lowercase ASCII letters, digits, `-`, `_` and `.`.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// const VERBATIM_CHARACTERS = new Set('abcdefghijklmnopqrstuvwxyz0123456789-_.');
50/// ```
51fn is_verbatim(byte: u8) -> bool {
52 return byte.is_ascii_lowercase()
53 || byte.is_ascii_digit()
54 || byte == b'-'
55 || byte == b'_'
56 || byte == b'.';
57}
58
59/// What:
60/// Encode a full ref name as one flat file name component.
61/// `&str` is borrowed UTF-8 text.
62/// Why:
63/// Each UTF-8 byte outside the verbatim set becomes `%XX` with uppercase hexadecimal digits,
64/// so every ref maps to one reversible name.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// function encodeBranchKey(refName: string): string;
69/// ```
70pub fn encode_branch_key(ref_name: &str) -> String {
71 let mut key = String::with_capacity(ref_name.len());
72 for &byte in ref_name.as_bytes() {
73 if is_verbatim(byte) {
74 key.push(char::from(byte));
75 } else {
76 key.push_str(&format!("%{byte:02X}"));
77 }
78 }
79 return key;
80}
81
82/// What:
83/// Read one `%XX` escape starting at `index`, the position of the introducer.
84/// Why:
85/// Only the uppercase form `encode_branch_key` writes is accepted,
86/// and an escape of a verbatim byte is refused,
87/// so every ref name has exactly one accepted key.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// function decodeEscape({ key, index }): number; // throws BranchKeyError
92/// ```
93fn decode_escape(key: &[u8], index: usize) -> Result<u8, BranchKeyError> {
94 let digits = key.get(index + 1..index + 3).unwrap_or(&[]);
95 if digits.len() != 2
96 || !digits.iter().all(|digit| {
97 return is_upper_hex(*digit);
98 })
99 {
100 return Err(BranchKeyError {
101 message: format!(
102 "Push coordination key has a malformed escape at {index}: {}",
103 String::from_utf8_lossy(key)
104 ),
105 });
106 }
107 let high = hex_value(digits[0]);
108 let low = hex_value(digits[1]);
109 let byte = high * 16 + low;
110 if is_verbatim(byte) {
111 return Err(BranchKeyError {
112 message: format!(
113 "Push coordination key escapes a verbatim character at {index}: {}",
114 String::from_utf8_lossy(key)
115 ),
116 });
117 }
118 return Ok(byte);
119}
120
121/// Whether a byte is an uppercase hexadecimal digit, the only digits `encode_branch_key` writes.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// /[0-9A-F]/.test(digit)
126/// ```
127fn is_upper_hex(digit: u8) -> bool {
128 return digit.is_ascii_digit() || (b'A'..=b'F').contains(&digit);
129}
130
131/// The value of one uppercase hexadecimal digit; the caller has checked the digit.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// Number.parseInt(digit, 16)
136/// ```
137fn hex_value(digit: u8) -> u8 {
138 if digit.is_ascii_digit() {
139 return digit - b'0';
140 }
141 return digit - b'A' + 10;
142}
143
144/// What:
145/// Reverse `encode_branch_key` in one left-to-right pass.
146/// `Result<A, B>` is either the decoded ref name or a `BranchKeyError`.
147/// Why:
148/// A key with an unescaped non-verbatim character, a malformed escape,
149/// or bytes that are not UTF-8 names no branch and must stop the caller.
150///
151/// In TS you'd write (pseudocode):
152/// ```ts
153/// function decodeBranchKey(key: string): string; // throws BranchKeyError
154/// ```
155pub fn decode_branch_key(key: &str) -> Result<String, BranchKeyError> {
156 let bytes = key.as_bytes();
157 let mut decoded: Vec<u8> = Vec::with_capacity(bytes.len());
158 let mut index = 0;
159 while index < bytes.len() {
160 let byte = bytes[index];
161 if byte == ESCAPE {
162 decoded.push(decode_escape(bytes, index)?);
163 index += 3;
164 } else if is_verbatim(byte) {
165 decoded.push(byte);
166 index += 1;
167 } else {
168 return Err(BranchKeyError {
169 message: format!(
170 "Push coordination key holds an unescaped character at {index}: {key}"
171 ),
172 });
173 }
174 }
175 return match String::from_utf8(decoded) {
176 Ok(text) => Ok(text),
177 Err(error) => Err(BranchKeyError {
178 message: format!("Push coordination key is not UTF-8 ({error}): {key}"),
179 }),
180 };
181}
182
183/// Encoding and decoding behave as the incumbent's branch key codec.
184#[cfg(test)]
185#[path = "auto_push_branch_key_tests.rs"]
186mod tests;