Skip to main content

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;