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;