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    /// Selected status scope, retained alongside the dirty observation.
100    pub options: StatusOptions,
101    /// Raw status byte count and SHA-256; not a canonical source-tree identity.
102    pub status_identity: ArtifactIdentity,
103    /// Admitted Git executable identity.
104    pub tool_identity: ArtifactIdentity,
105    revision_evidence: CompletedExecution,
106    tree_evidence: CompletedExecution,
107    status_evidence: CompletedExecution,
108}
109
110impl GitObservations {
111    /// Borrow the validated lowercase HEAD object ID, without its final newline.
112    #[must_use]
113    pub fn revision(&self) -> &str {
114        object_id(&self.revision_evidence.stdout)
115    }
116
117    /// Borrow the validated lowercase HEAD tree ID, without its final newline.
118    #[must_use]
119    pub fn tree(&self) -> &str {
120        object_id(&self.tree_evidence.stdout)
121    }
122
123    /// Original bounded revision output and diagnostics, retained without copying.
124    #[must_use]
125    pub const fn revision_evidence(&self) -> &CompletedExecution {
126        &self.revision_evidence
127    }
128
129    /// Original bounded tree output and diagnostics, retained without copying.
130    #[must_use]
131    pub const fn tree_evidence(&self) -> &CompletedExecution {
132        &self.tree_evidence
133    }
134
135    /// Original bounded status output, including arbitrary pathname bytes.
136    #[must_use]
137    pub const fn status_evidence(&self) -> &CompletedExecution {
138        &self.status_evidence
139    }
140
141    /// Whether status reported bytes under the selected scope. Ignored files,
142    /// excluded untracked files and concurrent changes are not ruled out.
143    #[must_use]
144    pub const fn is_dirty(&self) -> bool {
145        self.status_identity.bytes != 0
146    }
147}
148
149/// Observe HEAD, its tree and status, with per-query bounds and no retries.
150///
151/// Commands disable optional locks and the filesystem monitor. The complete
152/// environment and working directory remain explicit; Git environment/config
153/// can redirect repository selection, and callers own that admission policy.
154/// Status is opaque porcelain-v1 `-z` output, checked only for NUL termination.
155/// These separate observations cannot prove an atomic or reproducible build.
156///
157/// # Errors
158/// Returns typed tool failures, malformed object IDs or unterminated status.
159/// Retains earlier captures and the failed query's available evidence.
160pub fn capture_git(
161    git: &AdmittedTool,
162    context: &ExecutionContext<'_>,
163    options: StatusOptions,
164    limits: OutputLimits,
165) -> Result<GitObservations, GitError> {
166    let mut completed = [None, None, None];
167    for (index, query) in [GitQuery::Revision, GitQuery::Tree, GitQuery::Status]
168        .into_iter()
169        .enumerate()
170    {
171        let evidence = match git
172            .run(&arguments(query, options), context, limits)
173            .and_then(CompletedExecution::require_success)
174            .and_then(CompletedExecution::require_complete)
175        {
176            Ok(evidence) => evidence,
177            Err(source) => {
178                return Err(GitError {
179                    query,
180                    failure: GitFailure::Tool(Box::new(source)),
181                    completed: Box::new(completed),
182                });
183            }
184        };
185        let failure = match query {
186            GitQuery::Revision | GitQuery::Tree if !valid_object_id(&evidence.stdout) => {
187                Some(GitFailure::InvalidObjectId)
188            }
189            GitQuery::Status
190                if !evidence.stdout.is_empty() && evidence.stdout.last() != Some(&0) =>
191            {
192                Some(GitFailure::UnterminatedStatus)
193            }
194            _ => None,
195        };
196        completed[index] = Some(evidence);
197        if let Some(failure) = failure {
198            return Err(GitError {
199                query,
200                failure,
201                completed: Box::new(completed),
202            });
203        }
204    }
205    let [Some(revision), Some(tree), Some(status)] = completed else {
206        unreachable!("every ordered query completed successfully");
207    };
208    let status_identity = ArtifactIdentity {
209        bytes: status.stdout.len() as u64,
210        sha256: Sha256Digest::compute(&status.stdout),
211    };
212    Ok(GitObservations {
213        options,
214        status_identity,
215        tool_identity: git.identity(),
216        revision_evidence: revision,
217        tree_evidence: tree,
218        status_evidence: status,
219    })
220}
221
222fn valid_object_id(bytes: &[u8]) -> bool {
223    matches!(bytes.len(), 41 | 65)
224        && bytes.last() == Some(&b'\n')
225        && bytes[..bytes.len() - 1]
226            .iter()
227            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
228}
229
230fn object_id(bytes: &[u8]) -> &str {
231    // Only capture_git constructs observations; their admitted captures are private.
232    std::str::from_utf8(&bytes[..bytes.len() - 1])
233        .expect("captured object ID was validated as ASCII plus LF")
234}
235
236fn arguments(query: GitQuery, options: StatusOptions) -> Vec<OsString> {
237    let mut arguments = vec![
238        "--no-optional-locks".into(),
239        "-c".into(),
240        "core.fsmonitor=false".into(),
241    ];
242    match query {
243        GitQuery::Revision => {
244            arguments.extend(["rev-parse".into(), "--verify".into(), "HEAD".into()]);
245        }
246        GitQuery::Tree => {
247            arguments.extend(["rev-parse".into(), "--verify".into(), "HEAD^{tree}".into()]);
248        }
249        GitQuery::Status => {
250            let untracked = match options.untracked {
251                UntrackedFiles::No => "no",
252                UntrackedFiles::Normal => "normal",
253                UntrackedFiles::All => "all",
254            };
255            let submodules = match options.ignore_submodules {
256                IgnoreSubmodules::None => "none",
257                IgnoreSubmodules::Untracked => "untracked",
258                IgnoreSubmodules::Dirty => "dirty",
259                IgnoreSubmodules::All => "all",
260            };
261            arguments.extend([
262                "status".into(),
263                "--porcelain=v1".into(),
264                "-z".into(),
265                format!("--untracked-files={untracked}").into(),
266                format!("--ignore-submodules={submodules}").into(),
267            ]);
268        }
269    }
270    arguments
271}