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