Skip to main content

git_policy_cli/landing/
service_built_checks.rs

1//! What:
2//!  The two questions the landing asks about a commit that Git built (a signed commit), answered from the object store without
3//!  starting Git: which parents the commit has, and whether every object it adds to the branch is present.
4//! Why:
5//!  Each question was a `git rev-list` start (about five milliseconds warm, more when the host has evicted the Git executable)
6//!  on the wait of every signed commit. A commit that has the branch tip as its only parent adds only the objects that differ
7//!  from the tip's tree, which a walk of the two trees finds by reading the directories that changed. Only a proof is taken from
8//!  here: whatever the walk cannot show (a repository with replacement objects, grafts or a shallow history, a commit with
9//!  another header, an object that looks missing) is left to the `rev-list` the landing always ran, so a refusal is Git's.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const parents = parentsOf(commitName); // undefined: ask Git
14//! const intact = addsOnlyPresentObjects(commitName, tipName); // false: ask Git
15//! ```
16
17use crate::repo_read_layout::ObjectFormat;
18use crate::repo_read_object::{ObjectKind, from_hex, name_length, to_hex};
19use crate::repo_read_odb::ObjectDatabase;
20use crate::repo_read_tree::{MODE_GITLINK, TreeEntry, is_tree_mode, parse_commit, parse_tree};
21use crate::config_snapshot::ConfigSnapshot;
22use crate::transaction_git::GitContext;
23use std::collections::HashMap;
24use std::path::Path;
25
26/// How many tree entries one proof may examine before it gives up and leaves the question to Git.
27///  Exempt from a citation under `LQM` (class: reader fallback): a walk past this bound makes the landing run the `rev-list`
28///  it always ran, so a Git start is added and no result changes.
29const ENTRY_BUDGET: usize = 1_000_000;
30
31/// The deepest tree nesting one proof follows, as the readers of `repo_read_tree.rs` follow it.
32const TREE_DEPTH_LIMIT: usize = 256;
33
34thread_local! {
35    /// How many name comparisons the proofs of this thread have made, which a control reads to show the work grows with the
36    ///  directory and not with its square.
37    static NAME_STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
38}
39
40/// The name comparisons the proofs of this thread have made so far.
41#[cfg(test)]
42pub(crate) fn name_steps() -> u64 {
43    return NAME_STEPS.with(std::cell::Cell::get);
44}
45
46/// The commit's header facts the landing needs.
47#[derive(Debug, Eq, PartialEq)]
48pub struct BuiltCommit {
49    /// The commit's own name, as a binary name, which the proof looks up again when it runs.
50    name: Vec<u8>,
51    /// The parents, in order, as hexadecimal names.
52    pub parents: Vec<String>,
53    /// The root tree, as a binary name.
54    tree: Vec<u8>,
55}
56
57/// The format of the names, from their length.
58fn format_of(hex: &str) -> ObjectFormat {
59    return if hex.len() == ObjectFormat::Sha256.hex_length() { ObjectFormat::Sha256 } else { ObjectFormat::Sha1 };
60}
61
62/// The environment variables that change which history, which replacements or which object store Git reads.
63const HISTORY_VARIABLES: [&str; 6] = [
64    "GIT_SHALLOW_FILE",
65    "GIT_GRAFT_FILE",
66    "GIT_REPLACE_REF_BASE",
67    "GIT_NO_REPLACE_OBJECTS",
68    "GIT_OBJECT_DIRECTORY",
69    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
70];
71
72/// Whether the repository has anything that makes `rev-list` show another history than the commit objects say, at the places
73///  Git looks by default.
74fn history_is_rewritten(common_dir: &Path) -> bool {
75    return common_dir.join("shallow").exists() || common_dir.join("info").join("grafts").exists();
76}
77
78/// What:
79///  Whether Git, started the way the landing starts it, shows the history the commit objects say.
80/// Why:
81///   The checks of a commit Git built are answered from the object store only then. Git reads its history from the files at the
82///  default places unless a variable names others (`GIT_SHALLOW_FILE`, `GIT_GRAFT_FILE`), shows replacements from a namespace a
83///  variable names (`GIT_REPLACE_REF_BASE`) unless they are switched off (`GIT_NO_REPLACE_OBJECTS`, `--no-replace-objects`,
84///  `core.useReplaceRefs`), and reads another object store when told (`GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`). The
85///  environment is the service's own and the overlay of the landing's Git context; the configuration is the snapshot the landing
86///  read. A configuration that could not be read, a setting of any of these and a file at a default place all say "not plain".
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// snapshot !== undefined && !HISTORY_VARIABLES.some(isSet) && !prefix.some(changesReplacements) && !hasKey('core.usereplacerefs') && !filesAtDefaultPlaces()
91/// ```
92pub fn history_is_plain(common_dir: &Path, git: &GitContext, snapshot: Option<&ConfigSnapshot>) -> bool {
93    let Some(read) = snapshot else { return false };
94    if history_is_rewritten(common_dir) {
95        return false;
96    }
97    let overlay_sets = |name: &str| -> bool { return git.overlay.iter().any(|(key, _)| { return key.to_string_lossy() == name; }); };
98    if HISTORY_VARIABLES.iter().any(|name| { return std::env::var_os(name).is_some() || overlay_sets(name); }) {
99        return false;
100    }
101    let prefix_changes_replacements = git.global_prefix.iter().any(|word| {
102        let text = word.to_string_lossy().to_ascii_lowercase();
103        return text == "--no-replace-objects" || text.starts_with("--replace-refs") || text.contains("core.usereplacerefs");
104    });
105    return !prefix_changes_replacements && !read.entries().iter().any(|entry| { return entry.key == "core.usereplacerefs"; });
106}
107
108/// What:
109///  The parents of the commit `built`; `None` when Git is to say (the object is not a plain commit, or the repository's
110///  history is rewritten by grafts, a shallow file or replacement objects).
111/// Why:
112///   `git rev-list --parents --max-count=1 <commit>` prints the commit and these parents.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// readCommit(commonDir, built)?.parents
117/// ```
118pub fn read_commit(common_dir: &Path, built: &str) -> Option<BuiltCommit> {
119    if history_is_rewritten(common_dir) {
120        return None;
121    }
122    let format: ObjectFormat = format_of(built);
123    let database: ObjectDatabase = ObjectDatabase::open(common_dir, None, format)?;
124    let object = database.read(from_hex(built.as_bytes(), format)?.as_slice())?;
125    if object.kind != ObjectKind::Commit {
126        return None;
127    }
128    let fields = parse_commit(object.bytes.as_slice(), format)?;
129    return Some(BuiltCommit { name: from_hex(built.as_bytes(), format)?, parents: fields.parents.iter().map(|parent| { return to_hex(parent.as_slice()); }).collect(), tree: fields.tree });
130}
131
132/// What the walk found.
133#[derive(Debug, Eq, PartialEq)]
134enum Found {
135    /// Every object is present.
136    Present,
137    /// Something is absent or cannot be read here; Git decides.
138    Unproven,
139}
140
141/// One walk: where objects are read, and how much it may still examine.
142struct Proof<'a> {
143    /// The object store.
144    database: &'a ObjectDatabase,
145    /// The object format.
146    format: ObjectFormat,
147    /// Entries left to examine.
148    budget: usize,
149}
150
151impl Proof<'_> {
152    /// The entries of the tree `name`, or `None` when it is absent, not a tree or unreadable.
153    fn entries(&self, name: &[u8]) -> Option<Vec<TreeEntry>> {
154        let object = self.database.read(name)?;
155        if object.kind != ObjectKind::Tree {
156            return None;
157        }
158        return parse_tree(object.bytes.as_slice(), name_length(self.format));
159    }
160
161    /// Check the tree `new` against `old` (the tree the branch tip has at the same place, when it has one): an entry equal on both
162    ///  sides is reachable from the tip, every other blob must exist, and every other tree is read and checked the same way.
163    fn tree(&mut self, new: &[u8], old: Option<&[u8]>, depth: usize) -> Found {
164        if old == Some(new) {
165            return Found::Present;
166        }
167        if depth > TREE_DEPTH_LIMIT {
168            return Found::Unproven;
169        }
170        let Some(wanted) = self.entries(new) else { return Found::Unproven };
171        let before: Vec<TreeEntry> = old.and_then(|name| { return self.entries(name); }).unwrap_or_default();
172        // Both directories are paid for from the budget: a wide one is bounded by its width, whatever the other holds.
173        let cost: usize = wanted.len().saturating_add(before.len());
174        if cost > self.budget {
175            return Found::Unproven;
176        }
177        self.budget -= cost;
178        // The tip's entries are indexed by name once, so each new entry is matched with one lookup and the work grows with the
179        //  two directories and not with their product.
180        let mut by_name: HashMap<&[u8], &TreeEntry> = HashMap::with_capacity(before.len());
181        for candidate in &before {
182            NAME_STEPS.with(|steps| { steps.set(steps.get() + 1); });
183            by_name.insert(candidate.name.as_slice(), candidate);
184        }
185        for entry in wanted {
186            NAME_STEPS.with(|steps| { steps.set(steps.get() + 1); });
187            let same_name: Option<&&TreeEntry> = by_name.get(entry.name.as_slice());
188            if same_name.is_some_and(|candidate| { return candidate.id == entry.id && candidate.mode == entry.mode; }) || entry.mode == MODE_GITLINK {
189                continue;
190            }
191            if is_tree_mode(entry.mode) {
192                let below: Option<&[u8]> = same_name.filter(|candidate| { return is_tree_mode(candidate.mode); }).map(|candidate| { return candidate.id.as_slice(); });
193                if self.tree(entry.id.as_slice(), below, depth + 1) == Found::Unproven {
194                    return Found::Unproven;
195                }
196            } else if !self.database.contains(entry.id.as_slice()) {
197                return Found::Unproven;
198            }
199        }
200        return Found::Present;
201    }
202}
203
204/// What:
205///  Whether every object that the commit `built` adds to a branch at `tip` is present: the commit's tree and every tree and blob
206///  below it that the tip's tree does not have the same.
207/// Why:
208///   `git rev-list --objects <built> --not <tip>` fails when an object it would list is missing, and Git moves a branch to a
209///  commit without looking, so a prune that took an object after the commit was made must be seen before the branch moves. This
210///  answers `true` only for a commit whose one parent is the tip, where the objects to check are those that differ from the tip's
211///  tree; any other commit, and any object that looks absent, answers `false` so that Git decides.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// onlyParentIs(built, tip) && everyChangedObjectExists(built.tree, tip.tree)
216/// ```
217pub fn adds_only_present_objects(common_dir: &Path, built: &BuiltCommit, tip: &str) -> bool {
218    if history_is_rewritten(common_dir) || built.parents.len() != 1 || built.parents[0] != tip {
219        return false;
220    }
221    let format: ObjectFormat = format_of(tip);
222    let Some(database) = ObjectDatabase::open(common_dir, None, format) else { return false };
223    let Some(tip_bytes) = from_hex(tip.as_bytes(), format) else { return false };
224    let Some(tip_object) = database.read(tip_bytes.as_slice()) else { return false };
225    let Some(tip_fields) = (tip_object.kind == ObjectKind::Commit).then(|| { return parse_commit(tip_object.bytes.as_slice(), format); }).flatten() else { return false };
226    // The commit was read before the objects were checked, and an object can go in between: it is looked up again here.
227    if !database.contains(built.name.as_slice()) {
228        return false;
229    }
230    let mut proof: Proof<'_> = Proof { database: &database, format, budget: ENTRY_BUDGET };
231    return proof.tree(built.tree.as_slice(), Some(tip_fields.tree.as_slice()), 0) == Found::Present;
232}