Skip to main content

ic_host_process/provenance/
mod.rs

1//! Bounded Git observations through a consumer-admitted executable.
2//!
3//! Queries run separately, once each; they are not an atomic source snapshot.
4//! Consumers own executable pins, environment, worktree selection, concurrency
5//! exclusion and report schemas. No Git mutation or installation is performed.
6
7#[cfg(test)]
8mod tests;
9
10use crate::tool::{AdmittedTool, CompletedExecution, ExecutionContext, OutputLimits, ToolError};
11use ic_host_artifacts::artifact::{ArtifactIdentity, Sha256Digest};
12use std::{ffi::OsString, fmt};
13
14/// Explicit untracked-file policy for the status observation.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum UntrackedFiles {
17    /// Exclude untracked files.
18    No,
19    /// Report untracked directories without enumerating their contents.
20    Normal,
21    /// Report individual untracked files.
22    All,
23}
24
25/// Explicit submodule policy for the status observation.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum IgnoreSubmodules {
28    /// Observe all submodule changes, overriding configured exclusions.
29    None,
30    /// Ignore untracked submodule contents.
31    Untracked,
32    /// Ignore submodule working-tree changes, retaining commit differences.
33    Dirty,
34    /// Ignore all submodule changes.
35    All,
36}
37
38/// Consumer-selected status scope; there is deliberately no default.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct StatusOptions {
41    /// Untracked-file scope.
42    pub untracked: UntrackedFiles,
43    /// Submodule scope.
44    pub ignore_submodules: IgnoreSubmodules,
45}
46
47/// One of the ordered Git observations.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum GitQuery {
50    /// `rev-parse --verify HEAD`.
51    Revision,
52    /// `rev-parse --verify HEAD^{tree}`.
53    Tree,
54    /// NUL-terminated porcelain-v1 status.
55    Status,
56}
57
58/// Typed provenance failure independent of retained output.
59#[derive(Debug)]
60pub enum GitFailure {
61    /// Admission recheck or bounded execution failed; current output is here.
62    Tool(Box<ToolError>),
63    /// Object stdout was not one lowercase 40- or 64-digit hex ID plus LF.
64    InvalidObjectId,
65    /// Nonempty status stdout was not terminated by NUL.
66    UnterminatedStatus,
67}
68
69/// Failed query with all successfully captured output, including malformed output.
70#[derive(Debug)]
71pub struct GitError {
72    /// Query that failed; later queries were not attempted.
73    pub query: GitQuery,
74    /// Failure category; tool failures retain their own current evidence.
75    pub failure: GitFailure,
76    /// Revision, tree and status captures in order. `None` means no successful
77    /// process capture for that query. Malformed successful output is retained.
78    pub completed: Box<[Option<CompletedExecution>; 3]>,
79}
80
81impl fmt::Display for GitError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(f, "Git {:?} observation failed", self.query)
84    }
85}
86
87impl std::error::Error for GitError {
88    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
89        match &self.failure {
90            GitFailure::Tool(source) => Some(source.as_ref()),
91            GitFailure::InvalidObjectId | GitFailure::UnterminatedStatus => None,
92        }
93    }
94}
95
96/// Raw observations, without a product report or source-cleanliness guarantee.
97#[derive(Debug)]
98pub struct GitObservations {
99    /// Lowercase object ID returned for HEAD.
100    pub revision: String,
101    /// Lowercase object ID returned for the HEAD tree.
102    pub tree: String,
103    /// Selected status scope, retained alongside the dirty observation.
104    pub options: StatusOptions,
105    /// Raw status byte count and SHA-256; not a canonical source-tree identity.
106    pub status_identity: ArtifactIdentity,
107    /// Admitted Git executable identity.
108    pub tool_identity: ArtifactIdentity,
109    /// Original bounded revision output and diagnostics.
110    pub revision_evidence: CompletedExecution,
111    /// Original bounded tree output and diagnostics.
112    pub tree_evidence: CompletedExecution,
113    /// Original bounded status output, including arbitrary pathname bytes.
114    pub status_evidence: CompletedExecution,
115}
116
117impl GitObservations {
118    /// Whether status reported bytes under the selected scope. Ignored files,
119    /// excluded untracked files and concurrent changes are not ruled out.
120    #[must_use]
121    pub const fn is_dirty(&self) -> bool {
122        self.status_identity.bytes != 0
123    }
124}
125
126/// Observe HEAD, its tree and status, with per-query bounds and no retries.
127///
128/// Commands disable optional locks and the filesystem monitor. The complete
129/// environment and working directory remain explicit; Git environment/config
130/// can redirect repository selection, and callers own that admission policy.
131/// Status is opaque porcelain-v1 `-z` output, checked only for NUL termination.
132/// These separate observations cannot prove an atomic or reproducible build.
133///
134/// # Errors
135/// Returns typed tool failures, malformed object IDs or unterminated status.
136/// Retains earlier captures and the failed query's available evidence.
137pub fn capture_git(
138    git: &AdmittedTool,
139    context: &ExecutionContext<'_>,
140    options: StatusOptions,
141    limits: OutputLimits,
142) -> Result<GitObservations, GitError> {
143    let mut completed = [None, None, None];
144    for (index, query) in [GitQuery::Revision, GitQuery::Tree, GitQuery::Status]
145        .into_iter()
146        .enumerate()
147    {
148        match observe(git, context, options, limits, query) {
149            Ok(evidence) => completed[index] = Some(evidence),
150            Err(mut error) => {
151                // Preserve the failed query's own capture while moving only
152                // earlier successful observations into its error evidence.
153                error.completed[..index].swap_with_slice(&mut completed[..index]);
154                return Err(error);
155            }
156        }
157    }
158    let [Some(revision), Some(tree), Some(status)] = completed else {
159        unreachable!("every ordered query completed successfully");
160    };
161    let status_identity = ArtifactIdentity {
162        bytes: status.stdout.len() as u64,
163        sha256: Sha256Digest::compute(&status.stdout),
164    };
165    // Object validation above admits only ASCII, so conversion is lossless.
166    let revision_id = object_id(&revision.stdout);
167    let tree_id = object_id(&tree.stdout);
168    Ok(GitObservations {
169        revision: revision_id,
170        tree: tree_id,
171        options,
172        status_identity,
173        tool_identity: git.identity(),
174        revision_evidence: revision,
175        tree_evidence: tree,
176        status_evidence: status,
177    })
178}
179
180fn observe(
181    git: &AdmittedTool,
182    context: &ExecutionContext<'_>,
183    options: StatusOptions,
184    limits: OutputLimits,
185    query: GitQuery,
186) -> Result<CompletedExecution, GitError> {
187    let mut completed = [None, None, None];
188    let index = match query {
189        GitQuery::Revision => 0,
190        GitQuery::Tree => 1,
191        GitQuery::Status => 2,
192    };
193    let evidence = match git
194        .run(&arguments(query, options), context, limits)
195        .and_then(CompletedExecution::require_success)
196        .and_then(CompletedExecution::require_complete)
197    {
198        Ok(evidence) => evidence,
199        Err(source) => {
200            return Err(GitError {
201                query,
202                failure: GitFailure::Tool(Box::new(source)),
203                completed: Box::new(completed),
204            });
205        }
206    };
207    let failure = match query {
208        GitQuery::Revision | GitQuery::Tree if !valid_object_id(&evidence.stdout) => {
209            Some(GitFailure::InvalidObjectId)
210        }
211        GitQuery::Status if !evidence.stdout.is_empty() && evidence.stdout.last() != Some(&0) => {
212            Some(GitFailure::UnterminatedStatus)
213        }
214        _ => None,
215    };
216    if let Some(failure) = failure {
217        completed[index] = Some(evidence);
218        return Err(GitError {
219            query,
220            failure,
221            completed: Box::new(completed),
222        });
223    }
224    Ok(evidence)
225}
226
227fn valid_object_id(bytes: &[u8]) -> bool {
228    matches!(bytes.len(), 41 | 65)
229        && bytes.last() == Some(&b'\n')
230        && bytes[..bytes.len() - 1]
231            .iter()
232            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
233}
234
235fn object_id(bytes: &[u8]) -> String {
236    bytes[..bytes.len() - 1]
237        .iter()
238        .map(|&byte| char::from(byte))
239        .collect()
240}
241
242fn arguments(query: GitQuery, options: StatusOptions) -> Vec<OsString> {
243    let mut arguments = vec![
244        "--no-optional-locks".into(),
245        "-c".into(),
246        "core.fsmonitor=false".into(),
247    ];
248    match query {
249        GitQuery::Revision => {
250            arguments.extend(["rev-parse".into(), "--verify".into(), "HEAD".into()]);
251        }
252        GitQuery::Tree => {
253            arguments.extend(["rev-parse".into(), "--verify".into(), "HEAD^{tree}".into()]);
254        }
255        GitQuery::Status => {
256            let untracked = match options.untracked {
257                UntrackedFiles::No => "no",
258                UntrackedFiles::Normal => "normal",
259                UntrackedFiles::All => "all",
260            };
261            let submodules = match options.ignore_submodules {
262                IgnoreSubmodules::None => "none",
263                IgnoreSubmodules::Untracked => "untracked",
264                IgnoreSubmodules::Dirty => "dirty",
265                IgnoreSubmodules::All => "all",
266            };
267            arguments.extend([
268                "status".into(),
269                "--porcelain=v1".into(),
270                "-z".into(),
271                format!("--untracked-files={untracked}").into(),
272                format!("--ignore-submodules={submodules}").into(),
273            ]);
274        }
275    }
276    arguments
277}