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