1use crate::{
2 BuildIdentityV2, ConfigIdentityV2, DetectorIdentityV2, Evidence, EvidenceGap, HostIdentityV2,
3 SourceIdentityV2, WorkloadIdentityV2,
4};
5
6const HOST_IDENTITY_VERSION: u16 = 1;
7
8fn unavailable<T>(reason: EvidenceGap) -> Evidence<T> {
9 Evidence::unavailable(reason)
10}
11
12#[cfg(feature = "host-identity")]
13fn read_text(path: &str) -> Result<String, EvidenceGap> {
14 std::fs::read_to_string(path).map_err(|error| {
15 if error.kind() == std::io::ErrorKind::PermissionDenied {
16 EvidenceGap::PermissionDenied
17 } else {
18 EvidenceGap::Unavailable
19 }
20 })
21}
22
23#[cfg(any(feature = "build-identity", feature = "host-identity"))]
24fn digest_text(value: &str) -> String {
25 use sha2::{Digest, Sha256};
26 hex::encode(Sha256::digest(value.as_bytes()))
27}
28
29#[cfg(all(feature = "host-identity", target_os = "linux"))]
30fn linux_cpu_identity() -> (Evidence<String>, Evidence<u32>, Evidence<String>) {
31 let cpuinfo = match read_text("/proc/cpuinfo") {
32 Ok(cpuinfo) => cpuinfo,
33 Err(reason) => {
34 return (
35 unavailable(reason),
36 unavailable(reason),
37 unavailable(reason),
38 )
39 }
40 };
41 let model = cpuinfo
42 .lines()
43 .find_map(|line| {
44 line.split_once(':')
45 .filter(|(key, _)| key.trim() == "model name")
46 })
47 .map(|(_, value)| value.trim().to_owned())
48 .filter(|value| !value.is_empty())
49 .map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded);
50
51 let mut cores = std::collections::BTreeSet::new();
52 for processor in cpuinfo.split("\n\n") {
53 let physical = processor.lines().find_map(|line| {
54 line.split_once(':')
55 .filter(|(key, _)| key.trim() == "physical id")
56 .map(|(_, value)| value.trim())
57 });
58 let core = processor.lines().find_map(|line| {
59 line.split_once(':')
60 .filter(|(key, _)| key.trim() == "core id")
61 .map(|(_, value)| value.trim())
62 });
63 if let (Some(physical), Some(core)) = (physical, core) {
64 cores.insert((physical.to_owned(), core.to_owned()));
65 }
66 }
67 let physical_cores = if cores.is_empty() {
68 unavailable(EvidenceGap::Unavailable)
69 } else {
70 Evidence::recorded(u32::try_from(cores.len()).unwrap_or(u32::MAX))
71 };
72
73 let features = cpuinfo
74 .lines()
75 .find_map(|line| {
76 line.split_once(':')
77 .filter(|(key, _)| matches!(key.trim(), "flags" | "Features"))
78 .map(|(_, value)| value)
79 })
80 .map(|features| {
81 let mut features = features.split_whitespace().collect::<Vec<_>>();
82 features.sort_unstable();
83 features.dedup();
84 digest_text(&features.join("\n"))
85 })
86 .map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded);
87 (model, physical_cores, features)
88}
89
90#[cfg(all(feature = "host-identity", target_os = "linux"))]
91fn linux_affinity_digest() -> Evidence<String> {
92 match read_text("/proc/self/status") {
93 Ok(status) => status
94 .lines()
95 .find_map(|line| line.strip_prefix("Cpus_allowed_list:"))
96 .map(str::trim)
97 .filter(|value| !value.is_empty())
98 .map(digest_text)
99 .map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded),
100 Err(reason) => unavailable(reason),
101 }
102}
103
104#[cfg(all(feature = "host-identity", target_os = "linux"))]
105fn linux_numa_digest() -> Evidence<String> {
106 match read_text("/sys/devices/system/node/online") {
107 Ok(nodes) => {
108 let nodes = nodes.trim();
109 if nodes.is_empty() {
110 unavailable(EvidenceGap::Unavailable)
111 } else {
112 Evidence::recorded(digest_text(nodes))
113 }
114 }
115 Err(reason) => unavailable(reason),
116 }
117}
118
119impl HostIdentityV2 {
120 pub fn capture() -> Self {
122 let logical_cpus = std::thread::available_parallelism()
123 .map(|count| u32::try_from(count.get()).unwrap_or(u32::MAX))
124 .unwrap_or(1);
125 #[cfg(not(feature = "host-identity"))]
126 {
127 let disabled = EvidenceGap::CollectorDisabled;
128 Self {
129 version: HOST_IDENTITY_VERSION,
130 operating_system: Evidence::recorded(std::env::consts::OS.to_owned()),
131 kernel_version: unavailable(disabled),
132 architecture: Evidence::recorded(std::env::consts::ARCH.to_owned()),
133 cpu_model: unavailable(disabled),
134 logical_cpus,
135 physical_cores: unavailable(disabled),
136 cpu_features_digest: unavailable(disabled),
137 affinity_digest: unavailable(disabled),
138 numa_digest: unavailable(disabled),
139 }
140 }
141 #[cfg(all(feature = "host-identity", target_os = "linux"))]
142 {
143 let (cpu_model, physical_cores, cpu_features_digest) = linux_cpu_identity();
144 let kernel_version = match read_text("/proc/sys/kernel/osrelease") {
145 Ok(value) if !value.trim().is_empty() => {
146 Evidence::recorded(value.trim().to_owned())
147 }
148 Ok(_) => unavailable(EvidenceGap::Unavailable),
149 Err(reason) => unavailable(reason),
150 };
151 Self {
152 version: HOST_IDENTITY_VERSION,
153 operating_system: Evidence::recorded(std::env::consts::OS.to_owned()),
154 kernel_version,
155 architecture: Evidence::recorded(std::env::consts::ARCH.to_owned()),
156 cpu_model,
157 logical_cpus,
158 physical_cores,
159 cpu_features_digest,
160 affinity_digest: linux_affinity_digest(),
161 numa_digest: linux_numa_digest(),
162 }
163 }
164 #[cfg(all(feature = "host-identity", not(target_os = "linux")))]
165 {
166 Self {
167 version: HOST_IDENTITY_VERSION,
168 operating_system: Evidence::recorded(std::env::consts::OS.to_owned()),
169 kernel_version: unavailable(EvidenceGap::Unsupported),
170 architecture: Evidence::recorded(std::env::consts::ARCH.to_owned()),
171 cpu_model: unavailable(EvidenceGap::Unsupported),
172 logical_cpus,
173 physical_cores: unavailable(EvidenceGap::Unsupported),
174 cpu_features_digest: unavailable(EvidenceGap::Unsupported),
175 affinity_digest: unavailable(EvidenceGap::Unsupported),
176 numa_digest: unavailable(EvidenceGap::Unsupported),
177 }
178 }
179 }
180}
181
182pub struct DetectorIdentityInput<'a> {
184 pub corpus_digest: &'a str,
185 pub compiled_plan_digest: Option<&'a str>,
186 pub enabled_detector_digest: Option<&'a str>,
187 pub backend_database_digest: Option<&'a str>,
188 pub external_provenance_digest: Option<&'a str>,
189}
190
191impl DetectorIdentityV2 {
192 pub fn capture(input: DetectorIdentityInput<'_>) -> Self {
194 fn optional(value: Option<&str>) -> Evidence<String> {
195 value
196 .filter(|value| !value.is_empty())
197 .map(|value| Evidence::recorded(value.to_owned()))
198 .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable))
199 }
200
201 Self {
202 version: 1,
203 corpus_digest: input.corpus_digest.to_owned(),
204 compiled_plan_digest: optional(input.compiled_plan_digest),
205 enabled_detector_digest: optional(input.enabled_detector_digest),
206 backend_database_digest: optional(input.backend_database_digest),
207 external_provenance_digest: optional(input.external_provenance_digest),
208 }
209 }
210}
211
212pub struct ConfigIdentityInput<'a> {
214 pub resolved_config_digest: &'a str,
215 pub policy_digest: Option<&'a str>,
216 pub preset: Option<&'a str>,
217 pub protection_state: Option<&'a str>,
218}
219
220impl ConfigIdentityV2 {
221 pub fn capture(input: ConfigIdentityInput<'_>) -> Self {
223 fn optional(value: Option<&str>) -> Evidence<String> {
224 value
225 .filter(|value| !value.is_empty())
226 .map(|value| Evidence::recorded(value.to_owned()))
227 .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable))
228 }
229
230 Self {
231 version: 1,
232 resolved_config_digest: input.resolved_config_digest.to_owned(),
233 policy_digest: optional(input.policy_digest),
234 preset: optional(input.preset),
235 protection_state: optional(input.protection_state),
236 }
237 }
238}
239
240pub struct SourceIdentityInput<'a> {
242 pub adapters: Vec<String>,
243 pub target_digest: Option<&'a str>,
244 pub partition_digest: Option<&'a str>,
245}
246
247impl SourceIdentityV2 {
248 pub fn capture(input: SourceIdentityInput<'_>) -> Self {
250 fn optional(value: Option<&str>) -> Evidence<String> {
251 value
252 .filter(|value| !value.is_empty())
253 .map(|value| Evidence::recorded(value.to_owned()))
254 .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable))
255 }
256
257 let mut adapters = input.adapters;
258 adapters.sort_unstable();
259 adapters.dedup();
260 Self {
261 version: 1,
262 adapters,
263 target_digest: optional(input.target_digest),
264 partition_digest: optional(input.partition_digest),
265 }
266 }
267}
268
269pub struct WorkloadIdentityInput<'a> {
271 pub class: &'a str,
272 pub raw_source_bytes: u64,
273 pub source_units: u64,
274 pub container_bytes: Option<u64>,
275 pub expanded_payload_bytes: Option<u64>,
276 pub derived_decoder_bytes: Option<u64>,
277 pub backend_dispatched_bytes: Option<u64>,
278}
279
280impl WorkloadIdentityV2 {
281 pub fn capture(input: WorkloadIdentityInput<'_>) -> Self {
283 fn optional(value: Option<u64>) -> Evidence<u64> {
284 value.map_or_else(|| unavailable(EvidenceGap::Unavailable), Evidence::recorded)
285 }
286 let size_bucket = match input.raw_source_bytes {
287 0 => "empty",
288 1..=4_096 => "tiny",
289 4_097..=1_048_576 => "small",
290 1_048_577..=67_108_864 => "medium",
291 67_108_865..=1_073_741_824 => "large",
292 _ => "huge",
293 };
294 let fanout_bucket = match input.source_units {
295 0 => "empty",
296 1 => "single",
297 2..=16 => "low",
298 17..=1_024 => "medium",
299 _ => "high",
300 };
301 Self {
302 version: 1,
303 class: input.class.to_owned(),
304 raw_source_bytes: input.raw_source_bytes,
305 source_units: input.source_units,
306 container_bytes: optional(input.container_bytes),
307 expanded_payload_bytes: optional(input.expanded_payload_bytes),
308 derived_decoder_bytes: optional(input.derived_decoder_bytes),
309 backend_dispatched_bytes: optional(input.backend_dispatched_bytes),
310 size_bucket: Evidence::recorded(size_bucket.to_owned()),
311 fanout_bucket: Evidence::recorded(fanout_bucket.to_owned()),
312 }
313 }
314}
315
316pub struct BuildIdentityInput<'a> {
318 pub binary_version: &'a str,
319 pub enabled_features: &'a [&'a str],
320 pub allocator: &'a str,
321 pub linked_backends: &'a [(&'a str, &'a str)],
322}
323
324#[cfg(feature = "build-identity")]
325fn digest_current_executable() -> Evidence<String> {
326 use sha2::{Digest, Sha256};
327 use std::io::Read;
328
329 let path = match std::env::current_exe() {
330 Ok(path) => path,
331 Err(_) => return unavailable(EvidenceGap::Unavailable),
332 };
333 let mut file = match std::fs::File::open(path) {
334 Ok(file) => file,
335 Err(error) => {
336 return unavailable(if error.kind() == std::io::ErrorKind::PermissionDenied {
337 EvidenceGap::PermissionDenied
338 } else {
339 EvidenceGap::Unavailable
340 });
341 }
342 };
343 let mut digest = Sha256::new();
344 let mut buffer = [0_u8; 64 * 1024];
345 loop {
346 match file.read(&mut buffer) {
347 Ok(0) => break,
348 Ok(read) => digest.update(&buffer[..read]),
349 Err(error) => {
350 return unavailable(if error.kind() == std::io::ErrorKind::PermissionDenied {
351 EvidenceGap::PermissionDenied
352 } else {
353 EvidenceGap::Unavailable
354 });
355 }
356 }
357 }
358 Evidence::recorded(hex::encode(digest.finalize()))
359}
360
361#[cfg(feature = "build-identity")]
362fn canonical_pairs_digest(values: &[(&str, &str)]) -> Evidence<String> {
363 if values.is_empty() {
364 return unavailable(EvidenceGap::Unavailable);
365 }
366 let mut values = values
367 .iter()
368 .map(|(name, version)| format!("{name}={version}"))
369 .collect::<Vec<_>>();
370 values.sort_unstable();
371 values.dedup();
372 Evidence::recorded(digest_text(&values.join("\n")))
373}
374
375impl BuildIdentityV2 {
376 pub fn capture(input: BuildIdentityInput<'_>) -> Self {
378 #[cfg(feature = "build-identity")]
379 {
380 let mut features = input.enabled_features.to_vec();
381 features.sort_unstable();
382 features.dedup();
383 let feature_digest = if features.is_empty() {
384 unavailable(EvidenceGap::Unavailable)
385 } else {
386 Evidence::recorded(digest_text(&features.join("\n")))
387 };
388 let source_revision = option_env!("KEYHOG_SOURCE_REVISION")
389 .filter(|value| !value.is_empty())
390 .map(|value| Evidence::recorded(value.to_owned()))
391 .unwrap_or_else(|| unavailable(EvidenceGap::Unavailable));
392 let compiler = env!("KEYHOG_PROFILE_RUSTC");
393 Self {
394 version: 1,
395 binary_version: input.binary_version.to_owned(),
396 binary_digest: digest_current_executable(),
397 source_revision,
398 build_profile: Evidence::recorded(env!("KEYHOG_PROFILE_BUILD_PROFILE").to_owned()),
399 target_triple: Evidence::recorded(env!("KEYHOG_PROFILE_BUILD_TARGET").to_owned()),
400 feature_digest,
401 compiler_identity: if compiler == "unavailable" {
402 unavailable(EvidenceGap::Unavailable)
403 } else {
404 Evidence::recorded(compiler.to_owned())
405 },
406 allocator_identity: if input.allocator.is_empty() {
407 unavailable(EvidenceGap::Unavailable)
408 } else {
409 Evidence::recorded(input.allocator.to_owned())
410 },
411 linked_backend_digest: canonical_pairs_digest(input.linked_backends),
412 }
413 }
414 #[cfg(not(feature = "build-identity"))]
415 {
416 let disabled = EvidenceGap::CollectorDisabled;
417 Self {
418 version: 1,
419 binary_version: input.binary_version.to_owned(),
420 binary_digest: unavailable(disabled),
421 source_revision: unavailable(disabled),
422 build_profile: unavailable(disabled),
423 target_triple: unavailable(disabled),
424 feature_digest: unavailable(disabled),
425 compiler_identity: unavailable(disabled),
426 allocator_identity: unavailable(disabled),
427 linked_backend_digest: unavailable(disabled),
428 }
429 }
430 }
431
432 pub(crate) fn capture_legacy(binary_version: &str) -> Self {
433 Self::capture(BuildIdentityInput {
434 binary_version,
435 enabled_features: &[],
436 allocator: "",
437 linked_backends: &[],
438 })
439 }
440}