Skip to main content

git_policy_cli/
candidate_object.rs

1//! What:
2//!  The two Git vocabulary types every candidate carries:
3//!  an object name and a file mode.
4//! Why:
5//!  Object names are written into the object reader's request stream,
6//!  so only text
7//!      proven to be a complete hexadecimal name may ever become one;
8//!  modes decide
9//!      whether a candidate has blob content at all.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! type ObjectId = string & { readonly brand: 'ObjectId' };
14//! type CandidateMode = 'regular' | 'executable' | 'symlink' | 'gitlink';
15//! ```
16
17/// What:
18///  Length in hexadecimal digits of a SHA-1 object name.
19///       `usize` is the unsigned integer every length and index uses
20///       (siblings `u32`,
21///  `u64`,
22///  `i64`).
23/// Why:
24///   `usize` is what `.len()` returns,
25///  so comparing needs no conversion.
26///
27/// In TS you'd write (pseudocode):
28/// ```ts
29/// const SHA1_HEX_LENGTH = 40;
30/// ```
31pub const SHA1_HEX_LENGTH: usize = 40;
32
33/// Length in hexadecimal digits of a SHA-256 object name,
34///  for `--object-format=sha256` repositories.
35pub const SHA256_HEX_LENGTH: usize = 64;
36
37/// What:
38///  A complete Git object name:
39///  40 or 64 lowercase hexadecimal digits.
40///       The field is private,
41///  so the only way to obtain a value is `parse_object_id`.
42///       `String` owns the text (sibling `&str` would borrow Git's output buffer).
43///       `Hash` lets the value be a `HashMap` key.
44/// Why:
45///   A value of this type is safe to send as one request line:
46///  it cannot contain
47///       a line feed,
48///  a space or a revision expression.
49///  The name outlives the Git
50///       output it was parsed from,
51///  so it owns its text.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// type ObjectId = string & { readonly brand: 'ObjectId' };
56/// ```
57#[derive(Clone, Debug, Eq, Hash, PartialEq)]
58pub struct ObjectId {
59    /// Validated lowercase hexadecimal text.
60    hex: String,
61}
62
63/// What:
64///  `impl ObjectId { ... }` attaches a read-only accessor,
65///  like a class getter.
66/// Why:
67///   Callers print or send the name without being able to change it.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// // a branded string is used directly
72/// ```
73impl ObjectId {
74    /// What:
75    ///  Lend the name as text.
76    ///  `&self` borrows the value read-only;
77    ///  `&str` is a
78    ///       borrowed view into the owned `String`.
79    /// Why:
80    ///   Request lines and Git arguments are built from this view without copying.
81    ///
82    /// In TS you'd write (pseudocode):
83    /// ```ts
84    /// toString(): string
85    /// ```
86    pub fn as_str(&self) -> &str {
87        return self.hex.as_str();
88    }
89}
90
91/// What:
92///  Named predicate:
93///  is this byte a lowercase hexadecimal digit?
94///       `&u8` borrows one byte (`u8` is 0 to 255).
95///  `.is_ascii_digit()` is true for
96///       `0` to `9`;
97///  `b'a'..=b'f'` is an inclusive byte range.
98/// Why:
99///   Git 2.56.0 prints object names in lowercase only;
100///  accepting uppercase would
101///       give one object two spellings and defeat lookups keyed by name.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// const isLowerHex = (byte: number): boolean => /[0-9a-f]/.test(String.fromCharCode(byte));
106/// ```
107fn is_lower_hex(byte: &u8) -> bool {
108    return byte.is_ascii_digit() || (b'a'..=b'f').contains(byte);
109}
110
111/// What:
112///  Accept bytes as an object name only when they are exactly 40 or 64 lowercase
113///       hexadecimal digits.
114///  `&[u8]` borrows bytes;
115///  `Option<ObjectId>` is "a name or nothing".
116/// Why:
117///   Everything Git prints as an object name passes;
118///  anything else (abbreviations,
119///       revision expressions,
120///  text containing a line feed) is refused before it can
121///       reach the reader's request stream.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// function parseObjectId(bytes: Uint8Array): ObjectId | undefined;
126/// ```
127pub fn parse_object_id(bytes: &[u8]) -> Option<ObjectId> {
128    if bytes.len() != SHA1_HEX_LENGTH && bytes.len() != SHA256_HEX_LENGTH {
129        // `None` is the "absent" variant.
130        return None;
131    }
132    // What: `.iter().all(is_lower_hex)` asks the named predicate about every byte.
133    // Why:  One pass decides validity before any text is built.
134    //
135    // In TS you'd write (pseudocode):
136    // ```ts
137    // if (!bytes.every(isLowerHex)) return undefined;
138    // ```
139    if !bytes.iter().all(is_lower_hex) {
140        return None;
141    }
142    // What: `String::new()` is an empty owned string; `char::from(*byte)` turns one
143    //       ASCII byte into a character.
144    // Why:  Every byte was just proven ASCII, so no decoding step can fail.
145    //
146    // In TS you'd write (pseudocode):
147    // ```ts
148    // const hex = String.fromCharCode(...bytes);
149    // ```
150    let mut hex: String = String::new();
151    for byte in bytes {
152        hex.push(char::from(*byte));
153    }
154    // `Some(...)` is the "present" variant.
155    return Some(ObjectId { hex });
156}
157
158/// What:
159///  The file modes Git records for a path that can be a candidate.
160/// Why:
161///   Regular,
162///  executable and symbolic-link entries name a blob;
163///  a gitlink names a
164///       commit of another repository (a submodule),
165///  which this repository's object
166///       store does not hold.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// type CandidateMode = 'regular' | 'executable' | 'symlink' | 'gitlink';
171/// ```
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum CandidateMode {
174    /// Mode `100644`:
175    ///  a non-executable file.
176    Regular,
177    /// Mode `100755`:
178    ///  an executable file.
179    Executable,
180    /// Mode `120000`:
181    ///  a symbolic link whose blob holds the link target.
182    Symlink,
183    /// Mode `160000`:
184    ///  a submodule commit,
185    ///  with no blob in this repository.
186    Gitlink,
187}
188
189/// What:
190///  Map Git's six-digit mode text to a candidate mode.
191///       `b"100644"` is a byte-string literal,
192///  compared with `==` byte for byte.
193/// Why:
194///   Any other mode (a directory `040000`,
195///  the absent side `000000`) cannot be a
196///       candidate;
197///  the caller reports it instead of guessing.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// function modeFromGit(text: string): CandidateMode | undefined;
202/// ```
203pub fn mode_from_git(text: &[u8]) -> Option<CandidateMode> {
204    if text == b"100644" {
205        return Some(CandidateMode::Regular);
206    }
207    if text == b"100755" {
208        return Some(CandidateMode::Executable);
209    }
210    if text == b"120000" {
211        return Some(CandidateMode::Symlink);
212    }
213    if text == b"160000" {
214        return Some(CandidateMode::Gitlink);
215    }
216    return None;
217}
218
219/// Name and mode controls stay out of the release executable.
220#[cfg(test)]
221#[path = "candidate_object_tests.rs"]
222mod tests;