kranz_engine/gate_evaluation/
snapshot.rs1use super::evidence::{SnapshotEntry, SnapshotInventory};
4use super::protocol::{Digest, Id, WirePath};
5use crate::git_ops::GitRepo;
6use cap_fs_ext::{DirExt, OpenOptionsFollowExt};
7use cap_primitives::fs::FollowSymlinks;
8use cap_std::fs::{Dir, OpenOptions};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::io::Read;
12
13pub const MAX_SOURCE_BYTES: usize = 128 * 1024 * 1024;
14pub const MAX_SOURCE_FILE_BYTES: u64 = 8 * 1024 * 1024;
15
16pub const EXCLUDED_PREFIXES: &[&str] = &[
21 ".git",
22 ".claude",
23 ".codex",
24 ".ssh",
25 ".aws",
26 ".azure",
27 ".config",
28 ".git-credentials",
29 ".netrc",
30 ".npmrc",
31 ".pypirc",
32 ".kranz/missions",
33 ".kranz/control",
34 ".kranz/runs",
35 ".kranz/queue",
36 ".kranz/hook-status",
37 ".kranz/config.json",
38 ".kranz/serve.token",
39 ".kranz/serve.read.token",
40 ".kranz/domain-terms.local",
41];
42
43pub fn excluded(path: &str) -> bool {
44 let path = path.to_ascii_lowercase();
45 path.split('/')
46 .any(|part| part == ".env" || part.starts_with(".env."))
47 || std::iter::once(path.as_str())
48 .chain(path.match_indices('/').map(|(index, _)| &path[index + 1..]))
49 .any(|suffix| {
50 EXCLUDED_PREFIXES.iter().any(|prefix| {
51 suffix == *prefix
52 || suffix
53 .strip_prefix(prefix)
54 .is_some_and(|tail| tail.starts_with('/'))
55 })
56 })
57}
58
59fn contains_private_key(bytes: &[u8]) -> bool {
60 bytes.split(|b| *b == b'\n').any(|line| {
61 let line = line.trim_ascii();
62 line.strip_prefix(b"-----BEGIN ")
63 .and_then(|label| label.strip_suffix(b"PRIVATE KEY-----"))
64 .is_some_and(|kind| kind.iter().all(|b| b.is_ascii_uppercase() || *b == b' '))
65 })
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct Identity {
71 pub head: String,
72 pub base: String,
73 pub inventory_digest: Digest,
74 pub exclusions_digest: Digest,
75}
76
77#[derive(Debug)]
78pub struct SourceSnapshot {
79 pub identity: Identity,
80 pub inventory: Vec<u8>,
81 pub selection: Vec<u8>,
83 pub files: BTreeMap<Id, Vec<u8>>,
86 pub excluded_paths: Vec<String>,
87}
88
89impl SourceSnapshot {
90 pub fn capture(repo: &GitRepo, base: &str) -> Result<Self, String> {
91 let repo = repo.with_hooks_disabled().map_err(|e| e.to_string())?;
92 if !repo.has_normal_index_entries().map_err(|e| e.to_string())? {
93 return Err("gate source snapshot refuses hidden index flags".into());
94 }
95 let base = repo.rev_parse(base).map_err(|e| e.to_string())?;
96 let head = repo.head_sha().map_err(|e| e.to_string())?;
97 let root = Dir::open_ambient_dir(repo.root(), cap_std::ambient_authority())
98 .map_err(|e| e.to_string())?;
99 let paths = repo.gate_snapshot_paths(&base).map_err(|e| e.to_string())?;
100 let mut entries = Vec::new();
101 let mut files = BTreeMap::new();
102 let mut total = 0usize;
103 let mut excluded_paths = Vec::new();
104 for path in paths {
105 if excluded(&path) {
106 excluded_paths.push(path);
107 continue;
108 }
109 let path = WirePath::try_from(path)?;
110 let Some((bytes, executable)) = read_source(&root, &path)? else {
111 entries.push(SnapshotEntry::Deleted { path });
112 continue;
113 };
114 total = total
115 .checked_add(bytes.len())
116 .ok_or("source size overflow")?;
117 if total > MAX_SOURCE_BYTES {
118 return Err("gate source snapshot exceeds byte limit".into());
119 }
120 if contains_private_key(&bytes)
121 || !crate::scrub::scan_text(&String::from_utf8_lossy(&bytes)).is_empty()
122 {
123 excluded_paths.push(path.as_str().to_string());
124 continue;
125 }
126 let label_digest = Digest::of(path.as_str().as_bytes());
127 let artifact_id = Id::try_from(format!("source-{}", &label_digest.as_str()[7..]))?;
128 entries.push(SnapshotEntry::File {
129 path,
130 artifact_id: artifact_id.clone(),
131 digest: Digest::of(&bytes),
132 executable,
133 });
134 files.insert(artifact_id, bytes);
135 }
136 super::protocol::validate_paths(entries.iter().map(|entry| match entry {
137 SnapshotEntry::File { path, .. } | SnapshotEntry::Deleted { path } => path.as_str(),
138 }))?;
139 if repo.head_sha().map_err(|e| e.to_string())? != head {
140 return Err("candidate HEAD moved during gate snapshot capture".into());
141 }
142 let inventory = serde_json::to_vec(&SnapshotInventory {
143 schema_version: 1,
144 entries,
145 })
146 .map_err(|e| e.to_string())?;
147 let selection = serde_json::to_vec(&serde_json::json!({
148 "prefixes": EXCLUDED_PREFIXES,
149 "prefixMatching": "directory-bounded at any depth, ASCII case-insensitive",
150 "privateKeys": "files containing a PEM private-key BEGIN line are excluded in full",
151 "detectedCredentials": "files matching the engine secret scanner are excluded in full; changed excluded source blocks acceptance",
152 "missionRecords": "only the current mission's enumerated record files and missions/index.md are metadata, supplied through approved scope and evidence rather than source coverage",
153 "environmentFiles": ".env and .env.* at any depth, ASCII case-insensitive",
154 "ignoredFiles": "Git standard excludes at capture; ignored caches are not evidence",
155 "excludedPaths": excluded_paths,
156 }))
157 .map_err(|e| e.to_string())?;
158 Ok(Self {
159 identity: Identity {
160 head,
161 base,
162 inventory_digest: Digest::of(&inventory),
163 exclusions_digest: Digest::of(&selection),
164 },
165 inventory,
166 selection,
167 files,
168 excluded_paths,
169 })
170 }
171
172 pub fn verify_candidate(&self, repo: &GitRepo, mission_id: &str) -> Result<(), String> {
176 let repo = repo.with_hooks_disabled().map_err(|e| e.to_string())?;
177 if !repo.is_clean_tracked_strict().map_err(|e| e.to_string())? {
178 return Err(
179 "gate acceptance requires committed candidate bytes and normal index flags".into(),
180 );
181 }
182 for path in repo.untracked_files().map_err(|e| e.to_string())? {
183 let path = path
184 .to_str()
185 .ok_or("gate acceptance refuses a non-UTF-8 untracked path")?;
186 if !crate::contract_sweep::is_mission_record_path(mission_id, path) {
187 return Err(format!(
188 "gate acceptance requires committed candidate source; untracked path: {}",
189 crate::presentation::visible(path)
190 ));
191 }
192 }
193 let changed = repo
194 .review_changed_paths(&self.identity.base)
195 .map_err(|e| e.to_string())?;
196 let missing: Vec<_> = self
197 .excluded_paths
198 .iter()
199 .filter(|path| {
200 changed.contains(path)
201 && !crate::contract_sweep::is_mission_record_path(mission_id, path)
202 })
203 .collect();
204 if !missing.is_empty() {
205 return Err(format!("gate source coverage is incomplete; changed private/excluded paths require separate review: {}",
206 crate::presentation::visible(&missing.iter().map(|p| p.as_str()).collect::<Vec<_>>().join(", "))));
207 }
208 self.verify_current(&repo)
209 }
210
211 pub fn verify_current(&self, repo: &GitRepo) -> Result<(), String> {
212 let current = Self::capture(repo, &self.identity.base)?;
213 if current.identity != self.identity {
214 return Err("candidate bytes changed after gate evidence was frozen".into());
215 }
216 Ok(())
217 }
218}
219
220fn read_source(root: &Dir, path: &WirePath) -> Result<Option<(Vec<u8>, bool)>, String> {
221 let mut parent = root.try_clone().map_err(|e| e.to_string())?;
222 let mut components = path.as_str().split('/').peekable();
223 let name = loop {
224 let part = components.next().ok_or("empty source path")?;
225 if components.peek().is_none() {
226 break part;
227 }
228 parent = match parent.open_dir_nofollow(part) {
229 Ok(dir) => dir,
230 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
231 Err(_) => {
232 return Err(format!(
233 "source parent is not a real directory: {}",
234 path.as_str()
235 ))
236 }
237 };
238 };
239 let mut options = OpenOptions::new();
240 options.read(true).follow(FollowSymlinks::No);
241 #[cfg(unix)]
242 {
243 use cap_fs_ext::OpenOptionsExt;
244 options.custom_flags(libc::O_NONBLOCK);
245 }
246 let mut file = match parent.open_with(name, &options) {
247 Ok(file) => file.into_std(),
248 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
249 Err(_) => {
250 return Err(format!(
251 "source cannot be opened without following links: {}",
252 path.as_str()
253 ))
254 }
255 };
256 let before = file.metadata().map_err(|e| e.to_string())?;
257 if !before.is_file() || before.len() > MAX_SOURCE_FILE_BYTES {
258 return Err(format!(
259 "source requires a bounded regular file (submodules are unsupported): {}",
260 path.as_str()
261 ));
262 }
263 #[cfg(unix)]
264 {
265 use std::os::unix::fs::MetadataExt;
266 if before.nlink() != 1 {
267 return Err(format!(
268 "hard-linked source is unsupported: {}",
269 path.as_str()
270 ));
271 }
272 }
273 let mut bytes = Vec::new();
274 (&mut file)
275 .take(MAX_SOURCE_FILE_BYTES + 1)
276 .read_to_end(&mut bytes)
277 .map_err(|e| e.to_string())?;
278 let after = file.metadata().map_err(|e| e.to_string())?;
279 if bytes.len() as u64 != before.len()
280 || after.len() != before.len()
281 || after.modified().ok() != before.modified().ok()
282 || after.permissions() != before.permissions()
283 {
284 return Err(format!("source changed during capture: {}", path.as_str()));
285 }
286 #[cfg(unix)]
287 let executable = {
288 use std::os::unix::fs::PermissionsExt;
289 before.permissions().mode() & 0o111 != 0
290 };
291 #[cfg(not(unix))]
292 let executable = false;
293 Ok(Some((bytes, executable)))
294}