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, ExecutionContext, ExecutionEvidence, 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<ExecutionEvidence>; 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: ExecutionEvidence,
111    /// Original bounded tree output and diagnostics.
112    pub tree_evidence: ExecutionEvidence,
113    /// Original bounded status output, including arbitrary pathname bytes.
114    pub status_evidence: ExecutionEvidence,
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 revision = observe(git, context, options, limits, GitQuery::Revision)?;
144    let tree = match observe(git, context, options, limits, GitQuery::Tree) {
145        Ok(evidence) => evidence,
146        Err(mut error) => {
147            error.completed[0] = Some(revision);
148            return Err(error);
149        }
150    };
151    let status = match observe(git, context, options, limits, GitQuery::Status) {
152        Ok(evidence) => evidence,
153        Err(mut error) => {
154            error.completed[0] = Some(revision);
155            error.completed[1] = Some(tree);
156            return Err(error);
157        }
158    };
159    let status_identity = ArtifactIdentity {
160        bytes: status.stdout.len() as u64,
161        sha256: Sha256Digest::compute(&status.stdout),
162    };
163    // Object validation above admits only ASCII, so conversion is lossless.
164    let revision_id = object_id(&revision.stdout);
165    let tree_id = object_id(&tree.stdout);
166    Ok(GitObservations {
167        revision: revision_id,
168        tree: tree_id,
169        options,
170        status_identity,
171        tool_identity: git.identity(),
172        revision_evidence: revision,
173        tree_evidence: tree,
174        status_evidence: status,
175    })
176}
177
178fn observe(
179    git: &AdmittedTool,
180    context: &ExecutionContext<'_>,
181    options: StatusOptions,
182    limits: OutputLimits,
183    query: GitQuery,
184) -> Result<ExecutionEvidence, GitError> {
185    let mut completed = [None, None, None];
186    let index = match query {
187        GitQuery::Revision => 0,
188        GitQuery::Tree => 1,
189        GitQuery::Status => 2,
190    };
191    let evidence = match git.run(&arguments(query, options), context, limits) {
192        Ok(evidence) => evidence,
193        Err(source) => {
194            return Err(GitError {
195                query,
196                failure: GitFailure::Tool(Box::new(source)),
197                completed: Box::new(completed),
198            });
199        }
200    };
201    let failure = match query {
202        GitQuery::Revision | GitQuery::Tree if !valid_object_id(&evidence.stdout) => {
203            Some(GitFailure::InvalidObjectId)
204        }
205        GitQuery::Status if !evidence.stdout.is_empty() && evidence.stdout.last() != Some(&0) => {
206            Some(GitFailure::UnterminatedStatus)
207        }
208        _ => None,
209    };
210    if let Some(failure) = failure {
211        completed[index] = Some(evidence);
212        return Err(GitError {
213            query,
214            failure,
215            completed: Box::new(completed),
216        });
217    }
218    Ok(evidence)
219}
220
221fn valid_object_id(bytes: &[u8]) -> bool {
222    matches!(bytes.len(), 41 | 65)
223        && bytes.last() == Some(&b'\n')
224        && bytes[..bytes.len() - 1]
225            .iter()
226            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
227}
228
229fn object_id(bytes: &[u8]) -> String {
230    bytes[..bytes.len() - 1]
231        .iter()
232        .map(|&byte| char::from(byte))
233        .collect()
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}