1use std::sync::Arc;
4
5use khive_storage::Entity;
6use serde_json::Value;
7
8use super::manifest::{digest_to_hex, scoped_digest, ManifestSnapshot, RuntimeFieldScope};
9use super::outcome::{FinalizerOutcome, ManifestFault};
10use crate::{EventAttribution, NamespaceToken, RuntimeError, RuntimeResult};
11
12#[cfg(test)]
13pub(crate) mod fixture;
14
15pub(crate) const EXEMPTION_STAMP: &str = "exempted:content-sha256-manifest-v1";
16
17#[derive(Debug, Clone, Copy)]
18pub(crate) enum EntityEntryPoint {
19 Create,
20 Claim,
21 Update,
22 Bulk,
23 CodeIngest,
24 CodeFindingsIngest,
25}
26
27impl EntityEntryPoint {
28 pub(crate) fn family(self, replacing: bool) -> &'static str {
29 match self {
30 Self::Update => "entity.update",
31 Self::Bulk => "entity.bulk",
32 Self::CodeIngest | Self::CodeFindingsIngest if replacing => "entity.update",
33 _ => "entity.create",
34 }
35 }
36
37 pub(crate) fn verb(self) -> &'static str {
38 match self {
39 Self::Update => "update",
40 Self::CodeIngest => "code.ingest",
41 Self::CodeFindingsIngest => "code.findings_ingest",
42 _ => "create",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy)]
49pub enum EntityCandidateOrigin {
50 CodeIngest,
51 CodeFindingsIngest,
52}
53
54#[derive(Debug, Clone)]
57pub struct EntityCandidateContext {
58 namespace: String,
59 snapshot: Arc<ManifestSnapshot>,
60 entry: EntityEntryPoint,
61 record: String,
62}
63
64#[derive(Debug)]
66pub struct EntityCandidatePrepared {
67 context: EntityCandidateContext,
68 candidate: Entity,
69}
70
71impl EntityCandidatePrepared {
72 pub fn entity(&self) -> &Entity {
73 &self.candidate
74 }
75
76 pub(crate) fn admit(self, token: &NamespaceToken) -> RuntimeResult<EntityAdmission> {
77 if token.namespace().as_str() != self.context.namespace {
78 return Err(RuntimeError::InvalidInput(
79 "direct-ingest candidate namespace differs from its authorization token".into(),
80 ));
81 }
82 admit(self.context, token, self.candidate)
83 }
84}
85
86impl EntityCandidateContext {
87 pub fn new(namespace: &str, origin: EntityCandidateOrigin) -> Self {
88 Self::capture(
89 namespace,
90 match origin {
91 EntityCandidateOrigin::CodeIngest => EntityEntryPoint::CodeIngest,
92 EntityCandidateOrigin::CodeFindingsIngest => EntityEntryPoint::CodeFindingsIngest,
93 },
94 )
95 }
96
97 fn capture(namespace: &str, entry: EntityEntryPoint) -> Self {
98 #[cfg(test)]
99 let snapshot = fixture::snapshot(namespace);
100 #[cfg(not(test))]
101 let snapshot = ManifestSnapshot::empty();
102 Self {
103 namespace: namespace.into(),
104 snapshot,
105 entry,
106 record: "entity".into(),
107 }
108 }
109
110 pub fn check_name_description(&self, value: &str) -> RuntimeResult<()> {
111 self.check_value(RuntimeFieldScope::NameDescription, value)
112 }
113
114 pub fn check_properties(&self, properties: Option<&Value>) -> RuntimeResult<()> {
115 crate::secret_gate::reject_reserved_secret_gate_property(properties)?;
116 if let Some(value) = properties {
117 let mut values = Vec::new();
118 property_strings(value, &mut values);
119 for value in values {
120 self.check_value(RuntimeFieldScope::JsonProperties, value)?;
121 }
122 }
123 Ok(())
124 }
125
126 pub fn check_tags(&self, tags: &[String]) -> RuntimeResult<()> {
127 for tag in tags {
128 self.check_value(RuntimeFieldScope::Tags, tag)?;
129 }
130 Ok(())
131 }
132
133 pub fn prepare(self, candidate: Entity) -> RuntimeResult<EntityCandidatePrepared> {
135 if candidate.namespace != self.namespace {
136 return Err(RuntimeError::InvalidInput(
137 "direct-ingest candidate namespace changed after preflight".into(),
138 ));
139 }
140 let matched = scan_candidate(&self, &candidate)?;
141 if matched.is_some() && candidate.content_ref.is_some() {
144 return Err(RuntimeError::InvalidInput(
145 "manifest direct-ingest candidates must not carry content_ref; use the attachment-aware entity constructor".into(),
146 ));
147 }
148 Ok(EntityCandidatePrepared {
149 context: self,
150 candidate,
151 })
152 }
153
154 fn check_value(&self, scope: RuntimeFieldScope, value: &str) -> RuntimeResult<()> {
155 if self.snapshot.lookup(scope, value).is_some() {
156 Ok(())
157 } else {
158 crate::secret_gate::check(value)
159 }
160 }
161}
162
163pub(crate) enum EntityAdmission {
164 Legacy(Entity),
165 Exempt(PreparedEntityExemption),
166}
167
168pub(crate) struct OrdinaryEntityUpdateContext(EntityCandidateContext);
171
172impl OrdinaryEntityUpdateContext {
173 pub(crate) fn capture(token: &NamespaceToken) -> Self {
174 Self(EntityCandidateContext::capture(
175 token.namespace().as_str(),
176 EntityEntryPoint::Update,
177 ))
178 }
179
180 pub(crate) fn check_patch(
181 &self,
182 name: Option<&str>,
183 description: Option<&str>,
184 properties: Option<&Value>,
185 tags: Option<&[String]>,
186 ) -> RuntimeResult<()> {
187 crate::secret_gate::reject_reserved_secret_gate_property(properties)?;
188 if let Some(name) = name {
189 crate::secret_gate::locate(self.0.check_name_description(name), "entity", "name")?;
190 }
191 if let Some(description) = description {
192 crate::secret_gate::locate(
193 self.0.check_name_description(description),
194 "entity",
195 "description",
196 )?;
197 }
198 if let Some(properties) = properties {
199 crate::secret_gate::locate(
200 self.0.check_properties(Some(properties)),
201 "entity",
202 "properties",
203 )?;
204 }
205 if let Some(tags) = tags {
206 crate::secret_gate::locate(self.0.check_tags(tags), "entity", "tags")?;
207 }
208 Ok(())
209 }
210
211 pub(crate) fn admit(
212 self,
213 token: &NamespaceToken,
214 candidate: Entity,
215 ) -> RuntimeResult<EntityAdmission> {
216 if self.0.snapshot.is_empty() {
217 crate::secret_gate::reject_reserved_secret_gate_property(
218 candidate.properties.as_ref(),
219 )?;
220 reject_injected_manifest_fault(&candidate.namespace)?;
221 Ok(EntityAdmission::Legacy(candidate))
222 } else {
223 admit(self.0, token, candidate)
224 }
225 }
226}
227
228#[derive(Debug, Clone)]
229pub(crate) struct PreparedEntityExemption {
230 pub(super) entity: Entity,
231 pub(super) snapshot: Arc<ManifestSnapshot>,
232 pub(super) entry: EntityEntryPoint,
233 pub(super) attribution: EventAttribution,
234 pub(super) digest_sha256: String,
235 pub(super) field_scope: &'static str,
236 pub(super) overridden_detector: String,
237}
238
239impl PreparedEntityExemption {
240 pub(crate) fn entity(&self) -> &Entity {
243 &self.entity
244 }
245}
246
247pub(crate) fn prepare_entity_admission(
248 token: &NamespaceToken,
249 entry: EntityEntryPoint,
250 candidate: Entity,
251) -> RuntimeResult<EntityAdmission> {
252 prepare_entity_admission_at(token, entry, candidate, "entity")
253}
254
255pub(crate) fn prepare_entity_admission_at(
256 token: &NamespaceToken,
257 entry: EntityEntryPoint,
258 candidate: Entity,
259 record: &str,
260) -> RuntimeResult<EntityAdmission> {
261 let mut context = EntityCandidateContext::capture(&candidate.namespace, entry);
262 context.record = record.into();
263 admit(context, token, candidate)
264}
265
266fn admit(
267 context: EntityCandidateContext,
268 token: &NamespaceToken,
269 mut candidate: Entity,
270) -> RuntimeResult<EntityAdmission> {
271 reject_injected_manifest_fault(&candidate.namespace)?;
272 let matched = scan_candidate(&context, &candidate)?;
273 let Some((scope, digest_sha256, overridden_detector)) = matched else {
274 return Ok(EntityAdmission::Legacy(candidate));
275 };
276 let properties = candidate
277 .properties
278 .get_or_insert_with(|| serde_json::json!({}));
279 let Some(properties) = properties.as_object_mut() else {
280 return Err(RuntimeError::InvalidInput(
281 "manifest-exempt entity properties must be an object".into(),
282 ));
283 };
284 properties.insert(
285 crate::secret_gate::RESERVED_SECRET_GATE_KEY.into(),
286 EXEMPTION_STAMP.into(),
287 );
288 let record_token = token.with_namespace(
289 crate::Namespace::parse(&candidate.namespace)
290 .map_err(|_| RuntimeError::InvalidInput("invalid entity namespace".into()))?,
291 );
292 Ok(EntityAdmission::Exempt(PreparedEntityExemption {
293 entity: candidate,
294 snapshot: context.snapshot,
295 entry: context.entry,
296 attribution: EventAttribution::from_token(&record_token),
297 digest_sha256,
298 field_scope: match scope {
299 RuntimeFieldScope::NameDescription => "name-description",
300 RuntimeFieldScope::JsonProperties => "json-properties",
301 RuntimeFieldScope::Tags => "tags",
302 RuntimeFieldScope::RecordContent => "record-content",
303 RuntimeFieldScope::CodeSource => "code-source",
304 },
305 overridden_detector,
306 }))
307}
308
309fn reject_injected_manifest_fault(namespace: &str) -> RuntimeResult<()> {
310 if let Some(fault) = super::faults::consume_manifest_invalid(namespace) {
311 observe(namespace, FinalizerOutcome::ManifestInvalid(fault));
312 return Err(RuntimeError::InvalidInput(
313 "secret-gate manifest invalid".into(),
314 ));
315 }
316 Ok(())
317}
318
319type CandidateMatch = (RuntimeFieldScope, String, String);
320
321fn scan_candidate(
322 context: &EntityCandidateContext,
323 entity: &Entity,
324) -> RuntimeResult<Option<CandidateMatch>> {
325 crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
326 if context.snapshot.is_empty() {
327 crate::secret_gate::check_at(&entity.name, &context.record, "name")?;
328 if let Some(description) = &entity.description {
329 crate::secret_gate::check_at(description, &context.record, "description")?;
330 }
331 if let Some(properties) = &entity.properties {
332 crate::secret_gate::check_json_at(properties, &context.record, "properties")?;
333 }
334 crate::secret_gate::check_tags_at(&entity.tags, &context.record, "tags")?;
335 return Ok(None);
336 }
337 let mut values = vec![(
338 RuntimeFieldScope::NameDescription,
339 entity.name.as_str(),
340 "name",
341 )];
342 if let Some(description) = &entity.description {
343 values.push((
344 RuntimeFieldScope::NameDescription,
345 description,
346 "description",
347 ));
348 }
349 if let Some(properties) = &entity.properties {
350 let mut leaves = Vec::new();
351 property_strings(properties, &mut leaves);
352 values.extend(
353 leaves
354 .into_iter()
355 .map(|value| (RuntimeFieldScope::JsonProperties, value, "properties")),
356 );
357 }
358 values.extend(
359 entity
360 .tags
361 .iter()
362 .map(|value| (RuntimeFieldScope::Tags, value.as_str(), "tags")),
363 );
364 let mut found: Option<CandidateMatch> = None;
365 for (scope, value, field) in values {
366 if let Some(meta) = context.snapshot.lookup(scope, value) {
367 let digest = digest_to_hex(&scoped_digest(scope, value));
368 if found.as_ref().is_some_and(|(old_scope, old_digest, _)| {
369 *old_scope != scope || *old_digest != digest
370 }) {
371 observe(
372 &entity.namespace,
373 FinalizerOutcome::ManifestInvalid(ManifestFault::MultipleMatches),
374 );
375 return Err(RuntimeError::InvalidInput(
376 "secret-gate manifest has multiple candidate matches".into(),
377 ));
378 }
379 found = Some((scope, digest, meta.overridden_detector.clone()));
380 } else {
381 crate::secret_gate::check_at(value, &context.record, field)?;
382 }
383 }
384 Ok(found)
385}
386
387fn property_strings<'a>(value: &'a Value, values: &mut Vec<&'a str>) {
388 match value {
389 Value::String(value) => values.push(value),
390 Value::Array(array) => array
391 .iter()
392 .for_each(|value| property_strings(value, values)),
393 Value::Object(object) => {
394 for (key, value) in object {
395 values.push(key);
396 property_strings(value, values);
397 }
398 }
399 _ => {}
400 }
401}
402
403pub(super) fn observe(namespace: &str, outcome: FinalizerOutcome) {
404 #[cfg(test)]
405 fixture::record_outcome(namespace, outcome);
406 #[cfg(not(test))]
407 let _ = (namespace, outcome);
408}