ic_host_tools/provenance/
mod.rs1#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum UntrackedFiles {
19 No,
21 Normal,
23 All,
25}
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum IgnoreSubmodules {
30 None,
32 Untracked,
34 Dirty,
36 All,
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct StatusOptions {
43 pub untracked: UntrackedFiles,
45 pub ignore_submodules: IgnoreSubmodules,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum GitQuery {
52 Revision,
54 Tree,
56 Status,
58}
59
60#[derive(Debug)]
62pub enum GitFailure {
63 Tool(Box<ToolError>),
65 InvalidObjectId,
67 UnterminatedStatus,
69}
70
71#[derive(Debug)]
73pub struct GitError {
74 pub query: GitQuery,
76 pub failure: GitFailure,
78 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#[derive(Debug)]
100pub struct GitObservations {
101 pub revision: String,
103 pub tree: String,
105 pub options: StatusOptions,
107 pub status_identity: ArtifactIdentity,
109 pub tool_identity: ArtifactIdentity,
111 pub revision_evidence: ExecutionEvidence,
113 pub tree_evidence: ExecutionEvidence,
115 pub status_evidence: ExecutionEvidence,
117}
118
119impl GitObservations {
120 #[must_use]
123 pub const fn is_dirty(&self) -> bool {
124 self.status_identity.bytes != 0
125 }
126}
127
128pub 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 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}