Skip to main content

git_policy_cli/
manual_push_probe.rs

1//! What:
2//!  The authoritative ref updates a `git push` would send, discovered by a private dry-run push.
3//! Why:
4//!  The manual-push policies must scan exactly what Git would publish, so the probe asks Git:
5//!  a private `pre-push` hook in a private directory records the update lines Git negotiated
6//!  (`src/policy-engine/manual-push-hook.ts`), and `git ls-remote --refs` confirms each destination value.
7//!  A remote ref that changed between negotiation and confirmation stops the probe.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const updates = await probeManualPushUpdates({ gitPath, cwd, args });
12//! ```
13
14/// The probe's Git runner and its environment.
15use super::auto_push_single_flight::{PushGit, git_command};
16/// Unguessable names for the private probe directory.
17use super::random_id::random_uuid;
18/// Starts that ride out a short shortage of processes.
19use super::start_retry::RetryStart;
20/// Owned OS strings for the probe's Git arguments.
21use std::ffi::OsString;
22/// Reading and writing the private hook and its captures.
23use std::fs;
24/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
25use std::path::{Path, PathBuf};
26/// `Stdio` connects the probe's standard input and output.
27use std::process::Stdio;
28
29/// Environment variable naming the capture directory the hook writes to.
30pub const CAPTURE_DIRECTORY_ENV: &str = "CLI_GIT_PUSH_FACTS_DIRECTORY";
31
32/// Fields of one `pre-push` update record: local ref, local object, remote ref, remote object.
33pub const PRE_PUSH_FIELD_COUNT: usize = 4;
34
35/// Largest standard input the recording hook accepts, in bytes, as the incumbent's hook does.
36pub const MAX_HOOK_INPUT_BYTES: u64 = 16 * 1024 * 1024;
37
38/// Private directory mode for the probe directories, shared with the incumbent.
39const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
40
41/// Name of the recording hook inside the private hooks directory.
42const HOOK_NAME: &str = "pre-push";
43
44/// Suffix of a capture file the hook writes, one per invocation.
45const CAPTURE_SUFFIX: &str = ".capture";
46
47/// What:
48///  Why the probe could not establish the updates Git would push.
49/// Why:
50///   Every variant stops the policy lifecycle with a content-unavailable failure; none guesses an update.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// class ManualPushProbeError extends Error {}
55/// ```
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct ManualPushProbeError {
58    /// Diagnostic naming what could not be established.
59    pub message: String,
60}
61
62impl std::fmt::Display for ManualPushProbeError {
63    /// Writes the diagnostic text.
64    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        return formatter.write_str(&self.message);
66    }
67}
68
69impl std::error::Error for ManualPushProbeError {}
70
71/// Build a probe error from text.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// new ManualPushProbeError(message)
76/// ```
77fn probe_error(message: String) -> ManualPushProbeError {
78    return ManualPushProbeError { message };
79}
80
81/// What:
82///  One update record a hook capture holds, before remote confirmation.
83/// Why:
84///   The advertised remote object is Git's negotiated value; the confirmation must agree with it.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type ProbedPushUpdate = { localRef; localOid; remoteName; remoteLocation; remoteRef; advertisedRemoteOid };
89/// ```
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct ProbedUpdate {
92    /// Local ref Git pushes from.
93    pub local_ref: String,
94    /// Local object name, all zeros for a deletion.
95    pub local_oid: String,
96    /// Destination remote name, as Git passed it to the hook.
97    pub remote_name: String,
98    /// Destination location, as Git passed it to the hook.
99    pub remote_location: String,
100    /// Destination ref Git updates.
101    pub remote_ref: String,
102    /// Destination object Git negotiated, all zeros when the ref is new.
103    pub advertised_remote_oid: String,
104}
105
106/// What:
107///  One confirmed push update, the public fact the policies read.
108/// Why:
109///   An absent value is `None`, which replaces the incumbent's `ABSENT_GIT_VALUE` sentinel.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// type PushUpdate = { localOid: string | typeof ABSENT_GIT_VALUE; remoteOid: string | typeof ABSENT_GIT_VALUE; remoteName; remoteRef };
114/// ```
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct PushUpdate {
117    /// Local object name; `None` for a deletion.
118    pub local_oid: Option<String>,
119    /// Destination object name confirmed by `ls-remote`; `None` when the ref is absent.
120    pub remote_oid: Option<String>,
121    /// Destination remote name.
122    pub remote_name: String,
123    /// Destination ref.
124    pub remote_ref: String,
125}
126
127/// What:
128///  Whether a hexadecimal object name is the all-zero absence value.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// function isZeroOid(oid: string): boolean { return /^0+$/.test(oid); }
133/// ```
134pub fn is_zero_oid(oid: &str) -> bool {
135    return !oid.is_empty()
136        && oid.bytes().all(|byte| {
137            return byte == b'0';
138        });
139}
140
141/// What:
142///  Parse the hook's standard input, one update per line.
143/// Why:
144///   Git guarantees four space-separated fields per update record; anything else stops the probe.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// function parseCapture(capture): ProbedPushUpdate[];
149/// ```
150pub fn parse_update_records(
151    input: &str,
152    remote_name: &str,
153    remote_location: &str,
154) -> Result<Vec<ProbedUpdate>, ManualPushProbeError> {
155    let mut updates: Vec<ProbedUpdate> = Vec::new();
156    for line in input.split('\n').filter(|line| {
157        return !line.is_empty();
158    }) {
159        let parts: Vec<&str> = line.split(' ').collect();
160        if parts.len() != PRE_PUSH_FIELD_COUNT {
161            return Err(probe_error(format!(
162                "Malformed pre-push update record: {line}"
163            )));
164        }
165        updates.push(ProbedUpdate {
166            local_ref: parts[0].to_owned(),
167            local_oid: parts[1].to_owned(),
168            remote_name: remote_name.to_owned(),
169            remote_location: remote_location.to_owned(),
170            remote_ref: parts[2].to_owned(),
171            advertised_remote_oid: parts[3].to_owned(),
172        });
173    }
174    return Ok(updates);
175}
176
177/// What:
178///  The probe's Git arguments: the invocation's global options, the hook path override,
179///  then `push` with `--dry-run --verify` placed before any `--` terminator.
180/// Why:
181///   The `--` terminator is the user's; the options Git interprets must stay before it.
182///
183/// In TS you'd write (pseudocode):
184/// ```ts
185/// probeArgs({ args, hooksDirectory });
186/// ```
187pub fn probe_arguments(
188    global_prefix: &[OsString],
189    post_subcommand: &[OsString],
190    hooks_directory: &Path,
191) -> Vec<OsString> {
192    let separator = post_subcommand.iter().position(|argument| {
193        return argument.as_os_str() == "--";
194    });
195    let insertion = separator.unwrap_or(post_subcommand.len());
196    let mut arguments: Vec<OsString> = global_prefix.to_vec();
197    arguments.push(OsString::from("-c"));
198    arguments.push(OsString::from(format!(
199        "core.hooksPath={}",
200        hooks_directory.display()
201    )));
202    arguments.push(OsString::from("push"));
203    arguments.extend(post_subcommand[..insertion].iter().cloned());
204    arguments.push(OsString::from("--dry-run"));
205    arguments.push(OsString::from("--verify"));
206    arguments.extend(post_subcommand[insertion..].iter().cloned());
207    return arguments;
208}
209
210/// What:
211///  The recording hook's shell program: write the remote name and location, then the update lines, to a
212///  new capture file named by the hook's process ID.
213/// Why:
214///   `set -C` makes the redirection exclusive, so two destinations never overwrite each other's capture.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// hookSource(): string; // `#!${process.execPath}` plus the capture writer
219/// ```
220pub fn hook_source() -> String {
221    return format!(
222        "#!/bin/sh\nset -C\n{{ printf '%s\\n%s\\n' \"$1\" \"$2\"; head -c {}; }} > \"${CAPTURE_DIRECTORY_ENV}/$$.capture\" || exit 1\n",
223        MAX_HOOK_INPUT_BYTES + 1
224    );
225}
226
227/// What:
228///  Create a private directory with mode `0700`.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// await mkdtemp(join(tmpdir(), 'cli-git-push-facts-'));
233/// ```
234fn create_private_directory(path: &Path) -> std::io::Result<()> {
235    use std::os::unix::fs::DirBuilderExt;
236    return fs::DirBuilder::new()
237        .mode(PRIVATE_DIRECTORY_MODE)
238        .create(path);
239}
240
241/// What:
242///  Whether a capture holds more update text than the limit allows.
243/// Why:
244///   The hook reads one byte past the limit, so exactly the limit is complete input and one more byte is proof
245///  that input was cut short.
246///
247/// In TS you'd write (pseudocode):
248/// ```ts
249/// const exceedsCaptureLimit = (length: number) => length > MAX_HOOK_INPUT_BYTES;
250/// ```
251pub fn exceeds_capture_limit(length: usize) -> bool {
252    return length as u64 > MAX_HOOK_INPUT_BYTES;
253}
254
255/// What:
256///  Run the private dry-run push and read back the updates its hook captured.
257/// Why:
258///   Git may exit non-zero after the hook has captured the negotiated updates, so the captures decide,
259///  and an empty capture set is a probe failure.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// async function captureProbedPushUpdates({ gitPath, cwd, args }): Promise<ProbedPushUpdate[]>;
264/// ```
265pub fn capture_probed_updates(
266    git: &PushGit,
267    global_prefix: &[OsString],
268    post_subcommand: &[OsString],
269    scratch: &Path,
270) -> Result<Vec<ProbedUpdate>, ManualPushProbeError> {
271    let hooks = scratch.join("hooks");
272    let captures = scratch.join("captures");
273    let created = |directory: &Path| -> Result<(), ManualPushProbeError> {
274        return create_private_directory(directory).map_err(|error| {
275            return probe_error(format!(
276                "private probe directory {} could not be created: {error}",
277                directory.display()
278            ));
279        });
280    };
281    // The scratch directory is created first, and its guard follows at once, so every later failure removes it.
282    // A scratch path that already exists is never adopted: creating it fails before any guard exists.
283    created(scratch)?;
284    let _scratch_guard = ScratchGuard(scratch.to_path_buf());
285    created(hooks.as_path())?;
286    created(captures.as_path())?;
287    let hook_path = hooks.join(HOOK_NAME);
288    fs::write(&hook_path, hook_source()).map_err(|error| {
289        return probe_error(format!("hook write failed: {error}"));
290    })?;
291    set_executable(&hook_path)?;
292    let arguments = probe_arguments(global_prefix, post_subcommand, &hooks);
293    let mut command = git_command(git, &arguments);
294    command
295        .stdin(Stdio::null())
296        .env(CAPTURE_DIRECTORY_ENV, &captures);
297    let probe = command.output_retrying().map_err(|error| {
298        return probe_error(format!("probe push could not start: {error}"));
299    })?;
300    let mut names: Vec<PathBuf> = fs::read_dir(&captures)
301        .map_err(|error| {
302            return probe_error(format!("captures unreadable: {error}"));
303        })?
304        .map(|entry| {
305            return entry.map(|found| {
306                return found.path();
307            });
308        })
309        .collect::<Result<Vec<PathBuf>, std::io::Error>>()
310        .map_err(|error| {
311            return probe_error(format!("captures unreadable: {error}"));
312        })?;
313    names.retain(|path| {
314        return path.to_string_lossy().ends_with(CAPTURE_SUFFIX);
315    });
316    names.sort();
317    if names.is_empty() {
318        return Err(probe_error(format!(
319            "Git could not determine push updates: {}",
320            String::from_utf8_lossy(&probe.stderr).trim_end()
321        )));
322    }
323    let mut updates: Vec<ProbedUpdate> = Vec::new();
324    for path in names {
325        let bytes = fs::read(&path).map_err(|error| {
326            return probe_error(format!("capture unreadable: {error}"));
327        })?;
328        let text = String::from_utf8_lossy(&bytes);
329        let mut lines = text.splitn(3, '\n');
330        let remote_name = lines.next().unwrap_or_default();
331        let remote_location = lines.next().unwrap_or_default();
332        let input = lines.next().unwrap_or_default();
333        if exceeds_capture_limit(input.len()) {
334            return Err(probe_error(
335                "Pre-push input exceeded capture limit.".to_owned(),
336            ));
337        }
338        updates.extend(parse_update_records(input, remote_name, remote_location)?);
339    }
340    return Ok(updates);
341}
342
343/// What:
344///  Removes the private probe directory when the probe ends, on every path.
345/// Why:
346///   Captures hold the names and tips of unpublished refs and must not outlive the probe.
347///
348/// In TS you'd write (pseudocode):
349/// ```ts
350/// await using directory = await createProbeDirectory();
351/// ```
352struct ScratchGuard(PathBuf);
353
354impl Drop for ScratchGuard {
355    /// Removes the directory; a failure leaves only a private directory in the temporary area.
356    fn drop(&mut self) {
357        let _removed = fs::remove_dir_all(&self.0);
358    }
359}
360
361/// Mark a file executable by its owner only.
362///
363/// In TS you'd write (pseudocode):
364/// ```ts
365/// await chmod(hookPath, 0o700);
366/// ```
367fn set_executable(path: &Path) -> Result<(), ManualPushProbeError> {
368    use std::os::unix::fs::PermissionsExt;
369    return fs::set_permissions(path, fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE)).map_err(
370        |error| {
371            return probe_error(format!("hook mode failed: {error}"));
372        },
373    );
374}
375
376/// Parse `git ls-remote --refs` records into a ref-to-object map.
377///
378/// In TS you'd write (pseudocode):
379/// ```ts
380/// function parseRemoteRefs(output: string): ReadonlyMap<string, string>;
381/// ```
382pub fn parse_remote_refs(output: &str) -> Result<Vec<(String, String)>, ManualPushProbeError> {
383    let mut refs: Vec<(String, String)> = Vec::new();
384    for line in output.split('\n').filter(|line| {
385        return !line.is_empty();
386    }) {
387        let Some((oid, name)) = line.split_once('\t') else {
388            return Err(probe_error(format!("Malformed ls-remote record: {line}")));
389        };
390        refs.push((name.to_owned(), oid.to_owned()));
391    }
392    return Ok(refs);
393}
394
395/// What:
396///  Confirm one destination's values with `git ls-remote --refs` and turn each probed update into a public update.
397/// Why:
398///   A destination that changed after negotiation would publish something the scan did not see,
399///  so a mismatch between negotiation and confirmation is an error, never a silent repair.
400///
401/// In TS you'd write (pseudocode):
402/// ```ts
403/// async function resolveRemoteGroup({ gitPath, cwd, remoteLocation, updates }): Promise<PushUpdate[]>;
404/// ```
405pub fn confirm_group(
406    git: &PushGit,
407    remote_location: &str,
408    updates: &[ProbedUpdate],
409) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
410    let mut arguments: Vec<OsString> = vec![
411        OsString::from("ls-remote"),
412        OsString::from("--refs"),
413        OsString::from(remote_location),
414    ];
415    arguments.extend(updates.iter().map(|update| {
416        return OsString::from(&update.remote_ref);
417    }));
418    let output = git_command(git, &arguments)
419        .stdin(Stdio::null())
420        .output_retrying()
421        .map_err(|error| {
422            return probe_error(format!("ls-remote could not start: {error}"));
423        })?;
424    if !output.status.success() {
425        return Err(probe_error(format!(
426            "ls-remote of {remote_location} failed: {}",
427            String::from_utf8_lossy(&output.stderr).trim_end()
428        )));
429    }
430    let remote_refs = parse_remote_refs(&String::from_utf8_lossy(&output.stdout))?;
431    let mut confirmed: Vec<PushUpdate> = Vec::new();
432    for update in updates {
433        let authoritative: Option<&String> = remote_refs
434            .iter()
435            .find(|(name, _oid)| {
436                return name == &update.remote_ref;
437            })
438            .map(|(_name, oid)| {
439                return oid;
440            });
441        let advertised_absent = is_zero_oid(&update.advertised_remote_oid);
442        if authoritative.is_none() != advertised_absent {
443            return Err(probe_error(format!(
444                "Remote ref changed during manual-push discovery: {}",
445                update.remote_ref
446            )));
447        }
448        if let Some(oid) = authoritative
449            && *oid != update.advertised_remote_oid
450        {
451            return Err(probe_error(format!(
452                "Remote ref changed during manual-push discovery: {}",
453                update.remote_ref
454            )));
455        }
456        confirmed.push(PushUpdate {
457            local_oid: if is_zero_oid(&update.local_oid) {
458                None
459            } else {
460                Some(update.local_oid.clone())
461            },
462            remote_oid: authoritative.cloned(),
463            remote_name: update.remote_name.clone(),
464            remote_ref: update.remote_ref.clone(),
465        });
466    }
467    return Ok(confirmed);
468}
469
470/// What:
471///  Discover the exact updates the push would send, confirmed against each destination.
472/// Why:
473///   The lifecycle's candidates and published-set reads both start from these updates.
474///
475/// In TS you'd write (pseudocode):
476/// ```ts
477/// async function probeManualPushUpdates({ gitPath, cwd, args }): Promise<PushUpdate[]>;
478/// ```
479pub fn probe_manual_push_updates(
480    git: &PushGit,
481    global_prefix: &[OsString],
482    post_subcommand: &[OsString],
483) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
484    let scratch = std::env::temp_dir().join(format!(
485        "cli-git-push-facts-{}",
486        random_uuid().map_err(|error| {
487            return probe_error(format!("random source failed: {}", error.0));
488        })?
489    ));
490    let probed = capture_probed_updates(git, global_prefix, post_subcommand, &scratch)?;
491    let mut locations: Vec<String> = Vec::new();
492    for update in &probed {
493        if !locations.contains(&update.remote_location) {
494            locations.push(update.remote_location.clone());
495        }
496    }
497    let mut confirmed: Vec<PushUpdate> = Vec::new();
498    for location in &locations {
499        let group: Vec<ProbedUpdate> = probed
500            .iter()
501            .filter(|update| {
502                return &update.remote_location == location;
503            })
504            .cloned()
505            .collect();
506        confirmed.extend(confirm_group(git, location, &group)?);
507    }
508    return Ok(confirmed);
509}
510
511/// The probe's parsers and its confirmation rules are controlled in the test module.
512#[cfg(test)]
513#[path = "manual_push_probe_tests.rs"]
514mod tests;