Skip to main content

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