1use crate::findings::ArtifactKind;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ArtifactCapability {
10 BrowserAccess,
11 NetworkAccess,
12 InstallExecution,
13 ExposesBinary,
14 PrivilegedRuntime,
15 HostFilesystemAccess,
16 ProcessExecution,
17 SecretAccess,
18 PersistenceSurface,
19 FilesystemWrite,
20 IdentityAccess,
21 InboundNetworkSurface,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ArtifactCapabilitySource {
28 Declared,
29 Observed,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ArtifactCapabilityFact {
35 pub capability: ArtifactCapability,
36 pub source: ArtifactCapabilitySource,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ArtifactNode {
42 pub path: String,
43 pub kind: ArtifactKind,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub capabilities: Vec<ArtifactCapabilityFact>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum EndpointKind {
52 Remote,
54 Registry,
56 Transient,
58 ControlPlane,
60 Local,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ArtifactEdge {
67 pub from: String,
68 pub to: String,
69 pub relation: ArtifactRelation,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub endpoint_kind: Option<EndpointKind>,
72}
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum ArtifactRelation {
78 References,
79 Contains,
80 Locks,
81 Downloads,
82 Executes,
83 Loads,
84 Persists,
85 Mounts,
86 ConnectsTo,
87 Reads,
88 Writes,
89 AccessesSecrets,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct ArtifactGraph {
95 pub nodes: Vec<ArtifactNode>,
96 pub edges: Vec<ArtifactEdge>,
97}
98
99impl ArtifactGraph {
100 #[must_use]
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 pub fn add_node(&mut self, path: impl Into<String>, kind: ArtifactKind) {
106 let path = path.into();
107 self.add_node_with_capabilities(path, kind, Vec::new());
108 }
109
110 pub fn add_node_with_capabilities(
111 &mut self,
112 path: impl Into<String>,
113 kind: ArtifactKind,
114 capabilities: Vec<ArtifactCapabilityFact>,
115 ) {
116 let path = path.into();
117 if let Some(existing) = self.nodes.iter_mut().find(|node| node.path == path) {
118 if kind.specificity() > existing.kind.specificity() {
126 existing.kind = kind;
127 }
128 for capability in capabilities {
129 if !existing.capabilities.iter().any(|fact| {
130 fact.capability == capability.capability && fact.source == capability.source
131 }) {
132 existing.capabilities.push(capability);
133 }
134 }
135 return;
136 }
137
138 self.nodes.push(ArtifactNode {
139 path,
140 kind,
141 capabilities,
142 });
143 }
144
145 pub fn add_edge(
146 &mut self,
147 from: impl Into<String>,
148 to: impl Into<String>,
149 relation: ArtifactRelation,
150 ) {
151 self.add_edge_with_endpoint(from, to, relation, None);
152 }
153
154 pub fn add_edge_with_endpoint(
155 &mut self,
156 from: impl Into<String>,
157 to: impl Into<String>,
158 relation: ArtifactRelation,
159 endpoint_kind: Option<EndpointKind>,
160 ) {
161 let edge = ArtifactEdge {
162 from: from.into(),
163 to: to.into(),
164 relation,
165 endpoint_kind,
166 };
167
168 if let Some(existing) = self.edges.iter_mut().find(|existing| {
178 existing.from == edge.from
179 && existing.to == edge.to
180 && std::mem::discriminant(&existing.relation)
181 == std::mem::discriminant(&edge.relation)
182 }) {
183 existing.endpoint_kind =
184 upgrade_endpoint_kind(existing.endpoint_kind, edge.endpoint_kind);
185 return;
186 }
187
188 self.edges.push(edge);
189 }
190}
191
192fn upgrade_endpoint_kind(
205 existing: Option<EndpointKind>,
206 incoming: Option<EndpointKind>,
207) -> Option<EndpointKind> {
208 fn rank(kind: Option<EndpointKind>) -> u8 {
209 match kind {
210 Some(EndpointKind::ControlPlane) => 5,
211 Some(EndpointKind::Transient) => 4,
212 Some(EndpointKind::Remote) => 3,
213 Some(EndpointKind::Local) => 2,
214 Some(EndpointKind::Registry) => 1,
215 None => 0,
216 }
217 }
218 if rank(incoming) > rank(existing) {
219 incoming
220 } else {
221 existing
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
235 fn add_node_promotes_to_more_specific_kind() {
236 let mut g = ArtifactGraph::new();
237 g.add_node("/pkg/manifest", ArtifactKind::AgentInstruction);
238 g.add_node("/pkg/manifest", ArtifactKind::McpServerManifest);
239 let node = g
240 .nodes
241 .iter()
242 .find(|n| n.path == "/pkg/manifest")
243 .expect("node must exist");
244 assert_eq!(
245 node.kind,
246 ArtifactKind::McpServerManifest,
247 "More specific kind MUST replace less specific one"
248 );
249 }
250
251 #[test]
253 fn add_node_does_not_demote_kind() {
254 let mut g = ArtifactGraph::new();
255 g.add_node("/pkg/manifest", ArtifactKind::McpServerManifest);
256 g.add_node("/pkg/manifest", ArtifactKind::GenericArtifact);
257 let node = g.nodes.iter().find(|n| n.path == "/pkg/manifest").unwrap();
258 assert_eq!(
259 node.kind,
260 ArtifactKind::McpServerManifest,
261 "Less specific kind MUST NOT demote a more specific one"
262 );
263 }
264
265 #[test]
267 fn add_node_is_idempotent_for_same_kind() {
268 let mut g = ArtifactGraph::new();
269 g.add_node("/pkg/x", ArtifactKind::PackageManifest);
270 g.add_node("/pkg/x", ArtifactKind::PackageManifest);
271 assert_eq!(
272 g.nodes.iter().filter(|n| n.path == "/pkg/x").count(),
273 1,
274 "Re-inserting the same path must NOT duplicate the node"
275 );
276 }
277
278 #[test]
281 fn add_node_keeps_first_within_same_specificity_tier() {
282 let mut g = ArtifactGraph::new();
283 g.add_node("/pkg/x", ArtifactKind::PackageManifest); g.add_node("/pkg/x", ArtifactKind::McpServerManifest); let node = g.nodes.iter().find(|n| n.path == "/pkg/x").unwrap();
286 assert_eq!(node.kind, ArtifactKind::PackageManifest);
287 }
288
289 #[test]
300 fn add_edge_dedupes_on_triple_and_upgrades_endpoint_annotation() {
301 let mut g = ArtifactGraph::new();
302 g.add_edge_with_endpoint(
303 "a",
304 "b",
305 ArtifactRelation::Downloads,
306 Some(EndpointKind::Registry),
307 );
308 g.add_edge_with_endpoint(
309 "a",
310 "b",
311 ArtifactRelation::Downloads,
312 Some(EndpointKind::Remote),
313 );
314 assert_eq!(
315 g.edges.len(),
316 1,
317 "duplicate (from,to,relation) MUST NOT produce two edges; got {:?}",
318 g.edges
319 );
320 assert_eq!(
321 g.edges[0].endpoint_kind,
322 Some(EndpointKind::Remote),
323 "annotation must upgrade to the more-adversarial value (Remote > Registry)"
324 );
325 }
326
327 #[test]
334 fn add_edge_endpoint_priority_order_preserves_highest() {
335 let mut g = ArtifactGraph::new();
336 g.add_edge_with_endpoint("a", "b", ArtifactRelation::Downloads, None);
339 g.add_edge_with_endpoint(
340 "a",
341 "b",
342 ArtifactRelation::Downloads,
343 Some(EndpointKind::Registry),
344 );
345 g.add_edge_with_endpoint(
346 "a",
347 "b",
348 ArtifactRelation::Downloads,
349 Some(EndpointKind::Local),
350 );
351 g.add_edge_with_endpoint(
352 "a",
353 "b",
354 ArtifactRelation::Downloads,
355 Some(EndpointKind::Remote),
356 );
357 g.add_edge_with_endpoint(
358 "a",
359 "b",
360 ArtifactRelation::Downloads,
361 Some(EndpointKind::Transient),
362 );
363 g.add_edge_with_endpoint(
364 "a",
365 "b",
366 ArtifactRelation::Downloads,
367 Some(EndpointKind::ControlPlane),
368 );
369 assert_eq!(g.edges.len(), 1);
370 assert_eq!(
371 g.edges[0].endpoint_kind,
372 Some(EndpointKind::ControlPlane),
373 "ControlPlane (IMDS) MUST be the surviving annotation"
374 );
375 }
376
377 #[test]
384 fn add_edge_keeps_different_relations_distinct() {
385 let mut g = ArtifactGraph::new();
386 g.add_edge("a", "b", ArtifactRelation::Downloads);
387 g.add_edge("a", "b", ArtifactRelation::Reads);
388 assert_eq!(g.edges.len(), 2);
389 }
390}