1use crate::schema::graph::SchemaGraph;
29use crate::schema::metadata::TypeId;
30use crate::schema::schema_type::SchemaType;
31use crate::schema::schema_value::{
32 ResultValuePayload, SchemaValue, UnionValuePayload, VariantValuePayload,
33};
34use std::collections::HashSet;
35use std::fmt::{self, Write};
36
37#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
39pub enum HostManagedKind {
40 Secret,
41 QuotaToken,
42 PermissionCard,
43}
44
45impl HostManagedKind {
46 pub fn from_type(ty: &SchemaType) -> Option<Self> {
48 match ty {
49 SchemaType::Secret { .. } => Some(Self::Secret),
50 SchemaType::QuotaToken { .. } => Some(Self::QuotaToken),
51 SchemaType::PermissionCard { .. } => Some(Self::PermissionCard),
52 _ => None,
53 }
54 }
55
56 pub fn from_value(value: &SchemaValue) -> Option<Self> {
59 match value {
60 SchemaValue::Secret(_) => Some(Self::Secret),
61 SchemaValue::QuotaToken(_) => Some(Self::QuotaToken),
62 SchemaValue::PermissionCard(_) => Some(Self::PermissionCard),
63 _ => None,
64 }
65 }
66
67 pub fn kind_name(self) -> &'static str {
69 match self {
70 Self::Secret => "secret",
71 Self::QuotaToken => "quota-token",
72 Self::PermissionCard => "permission-card",
73 }
74 }
75
76 pub fn redacted_placeholder(self) -> &'static str {
79 match self {
80 Self::Secret => "<redacted: secret>",
81 Self::QuotaToken => "<redacted: quota-token>",
82 Self::PermissionCard => "<redacted: permission-card>",
83 }
84 }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct HostManagedOccurrence {
91 pub kind: HostManagedKind,
92 pub path: String,
93}
94
95impl fmt::Display for HostManagedOccurrence {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(f, "{} at {}", self.kind.kind_name(), self.path)
98 }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum HostManagedTraversalError {
103 DanglingRef { id: TypeId, path: String },
104 DuplicateTypeId { id: TypeId, path: String },
105}
106
107impl fmt::Display for HostManagedTraversalError {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 Self::DanglingRef { id, path } => {
111 write!(f, "dangling schema reference {id:?} at {path}")
112 }
113 Self::DuplicateTypeId { id, path } => {
114 write!(f, "duplicate schema type id {id:?} reached at {path}")
115 }
116 }
117 }
118}
119
120impl std::error::Error for HostManagedTraversalError {}
121
122pub fn find_host_managed_value(value: &SchemaValue) -> Option<HostManagedOccurrence> {
125 find_host_managed_value_at(value, "$".to_string())
126}
127
128fn find_host_managed_value_at(value: &SchemaValue, path: String) -> Option<HostManagedOccurrence> {
129 if let Some(kind) = HostManagedKind::from_value(value) {
130 return Some(HostManagedOccurrence { kind, path });
131 }
132
133 match value {
134 SchemaValue::Record { fields } => fields.iter().enumerate().find_map(|(index, value)| {
135 find_host_managed_value_at(value, format!("{path}.fields[{index}]"))
136 }),
137 SchemaValue::Variant(value) => value.payload.as_deref().and_then(|payload| {
138 find_host_managed_value_at(payload, format!("{path}.variant[{}]", value.case))
139 }),
140 SchemaValue::Tuple { elements } => find_host_managed_sequence(elements, &path, "tuple"),
141 SchemaValue::List { elements } => find_host_managed_sequence(elements, &path, "list"),
142 SchemaValue::FixedList { elements } => {
143 find_host_managed_sequence(elements, &path, "fixed-list")
144 }
145 SchemaValue::Map { entries } => {
146 entries
147 .iter()
148 .enumerate()
149 .find_map(|(index, (key, value))| {
150 find_host_managed_value_at(key, format!("{path}.map[{index}].key")).or_else(
151 || find_host_managed_value_at(value, format!("{path}.map[{index}].value")),
152 )
153 })
154 }
155 SchemaValue::Option { inner } => inner
156 .as_deref()
157 .and_then(|value| find_host_managed_value_at(value, format!("{path}.some"))),
158 SchemaValue::Result(ResultValuePayload::Ok { value }) => value
159 .as_deref()
160 .and_then(|value| find_host_managed_value_at(value, format!("{path}.ok"))),
161 SchemaValue::Result(ResultValuePayload::Err { value }) => value
162 .as_deref()
163 .and_then(|value| find_host_managed_value_at(value, format!("{path}.err"))),
164 SchemaValue::Union(value) => {
165 find_host_managed_value_at(&value.body, append_named_path(&path, "union", &value.tag))
166 }
167 _ => None,
168 }
169}
170
171fn find_host_managed_sequence(
172 values: &[SchemaValue],
173 path: &str,
174 kind: &str,
175) -> Option<HostManagedOccurrence> {
176 values.iter().enumerate().find_map(|(index, value)| {
177 find_host_managed_value_at(value, format!("{path}.{kind}[{index}]"))
178 })
179}
180
181pub fn find_host_managed_type(
186 graph: &SchemaGraph,
187 ty: &SchemaType,
188) -> Result<Option<HostManagedOccurrence>, HostManagedTraversalError> {
189 find_host_managed_type_at(graph, ty, "$".to_string(), &mut HashSet::new())
190}
191
192fn find_host_managed_type_at(
193 graph: &SchemaGraph,
194 ty: &SchemaType,
195 path: String,
196 visiting: &mut HashSet<TypeId>,
197) -> Result<Option<HostManagedOccurrence>, HostManagedTraversalError> {
198 if let Some(kind) = HostManagedKind::from_type(ty) {
199 return Ok(Some(HostManagedOccurrence { kind, path }));
200 }
201
202 match ty {
203 SchemaType::Ref { id, .. } => {
204 if !visiting.insert(id.clone()) {
205 return Ok(None);
206 }
207 let mut definitions = graph.defs.iter().filter(|definition| definition.id == *id);
208 let definition =
209 definitions
210 .next()
211 .ok_or_else(|| HostManagedTraversalError::DanglingRef {
212 id: id.clone(),
213 path: path.clone(),
214 })?;
215 if definitions.next().is_some() {
216 visiting.remove(id);
217 return Err(HostManagedTraversalError::DuplicateTypeId {
218 id: id.clone(),
219 path,
220 });
221 }
222 let result = find_host_managed_type_at(graph, &definition.body, path, visiting);
223 visiting.remove(id);
224 result
225 }
226 SchemaType::Record { fields, .. } => {
227 for field in fields {
228 if let Some(occurrence) = find_host_managed_type_at(
229 graph,
230 &field.body,
231 append_named_path(&path, "field", &field.name),
232 visiting,
233 )? {
234 return Ok(Some(occurrence));
235 }
236 }
237 Ok(None)
238 }
239 SchemaType::Variant { cases, .. } => {
240 for case in cases {
241 if let Some(payload) = &case.payload
242 && let Some(occurrence) = find_host_managed_type_at(
243 graph,
244 payload,
245 append_named_path(&path, "variant", &case.name),
246 visiting,
247 )?
248 {
249 return Ok(Some(occurrence));
250 }
251 }
252 Ok(None)
253 }
254 SchemaType::Tuple { elements, .. } => {
255 find_host_managed_type_sequence(graph, elements, &path, "tuple", visiting)
256 }
257 SchemaType::List { element, .. } => {
258 find_host_managed_type_at(graph, element, format!("{path}.list[]"), visiting)
259 }
260 SchemaType::FixedList { element, .. } => {
261 find_host_managed_type_at(graph, element, format!("{path}.fixed-list[]"), visiting)
262 }
263 SchemaType::Map { key, value, .. } => {
264 if let Some(occurrence) =
265 find_host_managed_type_at(graph, key, format!("{path}.map.key"), visiting)?
266 {
267 return Ok(Some(occurrence));
268 }
269 find_host_managed_type_at(graph, value, format!("{path}.map.value"), visiting)
270 }
271 SchemaType::Option { inner, .. } => {
272 find_host_managed_type_at(graph, inner, format!("{path}.some"), visiting)
273 }
274 SchemaType::Result { spec, .. } => {
275 if let Some(ok) = spec.ok.as_deref()
276 && let Some(occurrence) =
277 find_host_managed_type_at(graph, ok, format!("{path}.ok"), visiting)?
278 {
279 return Ok(Some(occurrence));
280 }
281 match spec.err.as_deref() {
282 Some(err) => find_host_managed_type_at(graph, err, format!("{path}.err"), visiting),
283 None => Ok(None),
284 }
285 }
286 SchemaType::Union { spec, .. } => {
287 for branch in &spec.branches {
288 if let Some(occurrence) = find_host_managed_type_at(
289 graph,
290 &branch.body,
291 append_named_path(&path, "union", &branch.tag),
292 visiting,
293 )? {
294 return Ok(Some(occurrence));
295 }
296 }
297 Ok(None)
298 }
299 SchemaType::Future { inner, .. } => match inner.as_deref() {
300 Some(inner) => {
301 find_host_managed_type_at(graph, inner, format!("{path}.future"), visiting)
302 }
303 None => Ok(None),
304 },
305 SchemaType::Stream { inner, .. } => match inner.as_deref() {
306 Some(inner) => {
307 find_host_managed_type_at(graph, inner, format!("{path}.stream"), visiting)
308 }
309 None => Ok(None),
310 },
311 _ => Ok(None),
312 }
313}
314
315fn find_host_managed_type_sequence(
316 graph: &SchemaGraph,
317 elements: &[SchemaType],
318 path: &str,
319 kind: &str,
320 visiting: &mut HashSet<TypeId>,
321) -> Result<Option<HostManagedOccurrence>, HostManagedTraversalError> {
322 for (index, element) in elements.iter().enumerate() {
323 if let Some(occurrence) =
324 find_host_managed_type_at(graph, element, format!("{path}.{kind}[{index}]"), visiting)?
325 {
326 return Ok(Some(occurrence));
327 }
328 }
329 Ok(None)
330}
331
332fn append_named_path(path: &str, kind: &str, name: &str) -> String {
333 let mut result = format!("{path}.{kind}[");
334 write!(&mut result, "{name:?}").expect("writing to String cannot fail");
335 result.push(']');
336 result
337}
338
339pub fn redact_host_managed_value(value: SchemaValue) -> SchemaValue {
355 if let Some(kind) = HostManagedKind::from_value(&value) {
356 return SchemaValue::String(kind.redacted_placeholder().to_string());
357 }
358
359 match value {
360 SchemaValue::Record { fields } => SchemaValue::Record {
361 fields: fields.into_iter().map(redact_host_managed_value).collect(),
362 },
363 SchemaValue::Variant(p) => SchemaValue::Variant(VariantValuePayload {
364 case: p.case,
365 payload: p
366 .payload
367 .map(|inner| Box::new(redact_host_managed_value(*inner))),
368 }),
369 SchemaValue::Tuple { elements } => SchemaValue::Tuple {
370 elements: elements
371 .into_iter()
372 .map(redact_host_managed_value)
373 .collect(),
374 },
375 SchemaValue::List { elements } => SchemaValue::List {
376 elements: elements
377 .into_iter()
378 .map(redact_host_managed_value)
379 .collect(),
380 },
381 SchemaValue::FixedList { elements } => SchemaValue::FixedList {
382 elements: elements
383 .into_iter()
384 .map(redact_host_managed_value)
385 .collect(),
386 },
387 SchemaValue::Map { entries } => SchemaValue::Map {
388 entries: entries
389 .into_iter()
390 .map(|(k, v)| (redact_host_managed_value(k), redact_host_managed_value(v)))
391 .collect(),
392 },
393 SchemaValue::Option { inner } => SchemaValue::Option {
394 inner: inner.map(|inner| Box::new(redact_host_managed_value(*inner))),
395 },
396 SchemaValue::Result(r) => SchemaValue::Result(match r {
397 ResultValuePayload::Ok { value } => ResultValuePayload::Ok {
398 value: value.map(|v| Box::new(redact_host_managed_value(*v))),
399 },
400 ResultValuePayload::Err { value } => ResultValuePayload::Err {
401 value: value.map(|v| Box::new(redact_host_managed_value(*v))),
402 },
403 }),
404 SchemaValue::Union(u) => SchemaValue::Union(UnionValuePayload {
405 tag: u.tag,
406 body: Box::new(redact_host_managed_value(*u.body)),
407 }),
408 other => other,
411 }
412}
413
414pub fn redact_host_managed_type(ty: SchemaType) -> SchemaType {
423 if HostManagedKind::from_type(&ty).is_some() {
424 return SchemaType::String {
425 metadata: crate::schema::metadata::MetadataEnvelope::default(),
426 };
427 }
428
429 match ty {
430 SchemaType::Record { fields, metadata } => SchemaType::Record {
431 fields: fields
432 .into_iter()
433 .map(|f| crate::schema::schema_type::NamedFieldType {
434 name: f.name,
435 body: redact_host_managed_type(f.body),
436 metadata: f.metadata,
437 })
438 .collect(),
439 metadata,
440 },
441 SchemaType::Variant { cases, metadata } => SchemaType::Variant {
442 cases: cases
443 .into_iter()
444 .map(|c| crate::schema::schema_type::VariantCaseType {
445 name: c.name,
446 payload: c.payload.map(redact_host_managed_type),
447 metadata: c.metadata,
448 })
449 .collect(),
450 metadata,
451 },
452 SchemaType::Tuple { elements, metadata } => SchemaType::Tuple {
453 elements: elements.into_iter().map(redact_host_managed_type).collect(),
454 metadata,
455 },
456 SchemaType::List { element, metadata } => SchemaType::List {
457 element: Box::new(redact_host_managed_type(*element)),
458 metadata,
459 },
460 SchemaType::FixedList {
461 element,
462 length,
463 metadata,
464 } => SchemaType::FixedList {
465 element: Box::new(redact_host_managed_type(*element)),
466 length,
467 metadata,
468 },
469 SchemaType::Map {
470 key,
471 value,
472 metadata,
473 } => SchemaType::Map {
474 key: Box::new(redact_host_managed_type(*key)),
475 value: Box::new(redact_host_managed_type(*value)),
476 metadata,
477 },
478 SchemaType::Option { inner, metadata } => SchemaType::Option {
479 inner: Box::new(redact_host_managed_type(*inner)),
480 metadata,
481 },
482 SchemaType::Result { spec, metadata } => SchemaType::Result {
483 spec: crate::schema::schema_type::ResultSpec {
484 ok: spec.ok.map(|t| Box::new(redact_host_managed_type(*t))),
485 err: spec.err.map(|t| Box::new(redact_host_managed_type(*t))),
486 },
487 metadata,
488 },
489 SchemaType::Union { spec, metadata } => SchemaType::Union {
490 spec: crate::schema::schema_type::UnionSpec {
491 branches: spec
492 .branches
493 .into_iter()
494 .map(|b| crate::schema::schema_type::UnionBranch {
495 tag: b.tag,
496 body: redact_host_managed_type(b.body),
497 discriminator: b.discriminator,
498 metadata: b.metadata,
499 })
500 .collect(),
501 },
502 metadata,
503 },
504 SchemaType::Future { inner, metadata } => SchemaType::Future {
505 inner: inner.map(|t| Box::new(redact_host_managed_type(*t))),
506 metadata,
507 },
508 SchemaType::Stream { inner, metadata } => SchemaType::Stream {
509 inner: inner.map(|t| Box::new(redact_host_managed_type(*t))),
510 metadata,
511 },
512 other => other,
515 }
516}
517
518pub fn redact_host_managed_typed_value(
522 typed: crate::schema::graph::TypedSchemaValue,
523) -> crate::schema::graph::TypedSchemaValue {
524 let (graph, value) = typed.into_parts();
525 let graph = crate::schema::graph::SchemaGraph {
526 defs: graph
527 .defs
528 .into_iter()
529 .map(|d| crate::schema::graph::SchemaTypeDef {
530 id: d.id,
531 name: d.name,
532 body: redact_host_managed_type(d.body),
533 })
534 .collect(),
535 root: redact_host_managed_type(graph.root),
536 };
537 crate::schema::graph::TypedSchemaValue::new(graph, redact_host_managed_value(value))
538}
539
540pub struct RedactedSchemaValue<'a>(&'a SchemaValue);
548
549pub fn redacted_schema_value_debug(value: &SchemaValue) -> RedactedSchemaValue<'_> {
551 RedactedSchemaValue(value)
552}
553
554impl fmt::Debug for RedactedSchemaValue<'_> {
555 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556 fmt_redacted(self.0, f)
557 }
558}
559
560fn fmt_redacted(value: &SchemaValue, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561 if let Some(kind) = HostManagedKind::from_value(value) {
562 return f.write_str(kind.redacted_placeholder());
563 }
564
565 match value {
566 SchemaValue::Record { fields } => {
567 f.write_str("Record { fields: ")?;
568 fmt_seq(fields, f)?;
569 f.write_str(" }")
570 }
571 SchemaValue::Variant(p) => {
572 write!(f, "Variant {{ case: {}, payload: ", p.case)?;
573 fmt_opt(p.payload.as_deref(), f)?;
574 f.write_str(" }")
575 }
576 SchemaValue::Tuple { elements } => {
577 f.write_str("Tuple { elements: ")?;
578 fmt_seq(elements, f)?;
579 f.write_str(" }")
580 }
581 SchemaValue::List { elements } => {
582 f.write_str("List { elements: ")?;
583 fmt_seq(elements, f)?;
584 f.write_str(" }")
585 }
586 SchemaValue::FixedList { elements } => {
587 f.write_str("FixedList { elements: ")?;
588 fmt_seq(elements, f)?;
589 f.write_str(" }")
590 }
591 SchemaValue::Map { entries } => {
592 f.write_str("Map { entries: [")?;
593 for (i, (k, v)) in entries.iter().enumerate() {
594 if i > 0 {
595 f.write_str(", ")?;
596 }
597 f.write_str("(")?;
598 fmt_redacted(k, f)?;
599 f.write_str(", ")?;
600 fmt_redacted(v, f)?;
601 f.write_str(")")?;
602 }
603 f.write_str("] }")
604 }
605 SchemaValue::Option { inner } => {
606 f.write_str("Option { inner: ")?;
607 fmt_opt(inner.as_deref(), f)?;
608 f.write_str(" }")
609 }
610 SchemaValue::Result(r) => match r {
611 crate::schema::schema_value::ResultValuePayload::Ok { value } => {
612 f.write_str("Ok(")?;
613 fmt_opt(value.as_deref(), f)?;
614 f.write_str(")")
615 }
616 crate::schema::schema_value::ResultValuePayload::Err { value } => {
617 f.write_str("Err(")?;
618 fmt_opt(value.as_deref(), f)?;
619 f.write_str(")")
620 }
621 },
622 SchemaValue::Union(u) => {
623 write!(f, "Union {{ tag: {:?}, body: ", u.tag)?;
624 fmt_redacted(&u.body, f)?;
625 f.write_str(" }")
626 }
627 other => write!(f, "{other:?}"),
631 }
632}
633
634fn fmt_seq(values: &[SchemaValue], f: &mut fmt::Formatter<'_>) -> fmt::Result {
635 f.write_str("[")?;
636 for (i, v) in values.iter().enumerate() {
637 if i > 0 {
638 f.write_str(", ")?;
639 }
640 fmt_redacted(v, f)?;
641 }
642 f.write_str("]")
643}
644
645fn fmt_opt(value: Option<&SchemaValue>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
646 match value {
647 Some(v) => {
648 f.write_str("Some(")?;
649 fmt_redacted(v, f)?;
650 f.write_str(")")
651 }
652 None => f.write_str("None"),
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use crate::model::EnvironmentId;
660 use crate::schema::graph::SchemaTypeDef;
661 use crate::schema::metadata::MetadataEnvelope;
662 use crate::schema::schema_type::{
663 NamedFieldType, PermissionCardSpec, QuotaTokenSpec, SecretSpec,
664 };
665 use crate::schema::schema_value::{
666 PermissionCardValuePayload, QuotaTokenValuePayload, SecretValuePayload,
667 };
668 use chrono::{TimeZone, Utc};
669 use test_r::test;
670
671 fn secret_value() -> SchemaValue {
672 SchemaValue::Secret(SecretValuePayload {
673 secret_id: uuid::Uuid::nil(),
674 config_key: None,
675 version: 0,
676 resolved_at: Utc.timestamp_opt(0, 0).unwrap(),
677 category: None,
678 })
679 }
680
681 fn quota_token_value() -> SchemaValue {
682 SchemaValue::QuotaToken(QuotaTokenValuePayload {
683 environment_id: EnvironmentId::new(uuid::Uuid::nil()),
684 resource_name: "gpu-quota".to_string(),
685 expected_use: 1,
686 last_credit: 0,
687 last_credit_at: Utc.timestamp_opt(0, 0).unwrap(),
688 })
689 }
690
691 fn permission_card_value() -> SchemaValue {
692 SchemaValue::PermissionCard(PermissionCardValuePayload {
693 card_id: uuid::Uuid::nil(),
694 parent_ids: Vec::new(),
695 expires_at: None,
696 polymorphic: false,
697 })
698 }
699
700 #[test]
701 fn classifies_capability_types_and_values() {
702 assert_eq!(
703 HostManagedKind::from_type(&SchemaType::secret(SecretSpec::default())),
704 Some(HostManagedKind::Secret)
705 );
706 assert_eq!(
707 HostManagedKind::from_type(&SchemaType::quota_token(QuotaTokenSpec::default())),
708 Some(HostManagedKind::QuotaToken)
709 );
710 assert_eq!(HostManagedKind::from_type(&SchemaType::string()), None);
711
712 assert_eq!(
713 HostManagedKind::from_value(&secret_value()),
714 Some(HostManagedKind::Secret)
715 );
716 assert_eq!(
717 HostManagedKind::from_value("a_token_value()),
718 Some(HostManagedKind::QuotaToken)
719 );
720 assert_eq!(HostManagedKind::from_value(&SchemaValue::U32(7)), None);
721 }
722
723 #[test]
724 fn kind_names_and_placeholders_are_stable() {
725 assert_eq!(HostManagedKind::Secret.kind_name(), "secret");
726 assert_eq!(HostManagedKind::QuotaToken.kind_name(), "quota-token");
727 assert_eq!(
728 HostManagedKind::Secret.redacted_placeholder(),
729 "<redacted: secret>"
730 );
731 assert_eq!(
732 HostManagedKind::QuotaToken.redacted_placeholder(),
733 "<redacted: quota-token>"
734 );
735 }
736
737 #[test]
738 fn finds_nested_host_managed_value_with_path() {
739 let value = SchemaValue::Record {
740 fields: vec![SchemaValue::Map {
741 entries: vec![(
742 SchemaValue::String("key".to_string()),
743 permission_card_value(),
744 )],
745 }],
746 };
747
748 assert_eq!(
749 find_host_managed_value(&value),
750 Some(HostManagedOccurrence {
751 kind: HostManagedKind::PermissionCard,
752 path: "$.fields[0].map[0].value".to_string(),
753 })
754 );
755 }
756
757 #[test]
758 fn finds_reachable_type_after_recursive_back_edge() {
759 let tree = TypeId::new("tree");
760 let graph = SchemaGraph {
761 defs: vec![SchemaTypeDef {
762 id: tree.clone(),
763 name: Some("Tree".to_string()),
764 body: SchemaType::Record {
765 fields: vec![
766 NamedFieldType {
767 name: "next".to_string(),
768 body: SchemaType::Option {
769 inner: Box::new(SchemaType::ref_to(tree.clone())),
770 metadata: MetadataEnvelope::default(),
771 },
772 metadata: MetadataEnvelope::default(),
773 },
774 NamedFieldType {
775 name: "token".to_string(),
776 body: SchemaType::quota_token(QuotaTokenSpec::default()),
777 metadata: MetadataEnvelope::default(),
778 },
779 ],
780 metadata: MetadataEnvelope::default(),
781 },
782 }],
783 root: SchemaType::ref_to(tree),
784 };
785
786 assert_eq!(
787 find_host_managed_type(&graph, &graph.root),
788 Ok(Some(HostManagedOccurrence {
789 kind: HostManagedKind::QuotaToken,
790 path: "$.field[\"token\"]".to_string(),
791 }))
792 );
793 }
794
795 #[test]
796 fn ignores_unreachable_host_managed_definition() {
797 let graph = SchemaGraph {
798 defs: vec![SchemaTypeDef {
799 id: TypeId::new("unused"),
800 name: None,
801 body: SchemaType::permission_card(PermissionCardSpec::default()),
802 }],
803 root: SchemaType::string(),
804 };
805
806 assert_eq!(find_host_managed_type(&graph, &graph.root), Ok(None));
807 }
808
809 #[test]
810 fn reachable_dangling_and_duplicate_refs_fail_closed() {
811 let missing = TypeId::new("missing\nref");
812 let dangling = SchemaGraph::anonymous(SchemaType::ref_to(missing.clone()));
813 assert_eq!(
814 find_host_managed_type(&dangling, &dangling.root),
815 Err(HostManagedTraversalError::DanglingRef {
816 id: missing,
817 path: "$".to_string(),
818 })
819 );
820
821 let duplicate = TypeId::new("duplicate");
822 let duplicated = SchemaGraph {
823 defs: vec![
824 SchemaTypeDef {
825 id: duplicate.clone(),
826 name: None,
827 body: SchemaType::string(),
828 },
829 SchemaTypeDef {
830 id: duplicate.clone(),
831 name: None,
832 body: SchemaType::permission_card(PermissionCardSpec::default()),
833 },
834 ],
835 root: SchemaType::ref_to(duplicate.clone()),
836 };
837 assert_eq!(
838 find_host_managed_type(&duplicated, &duplicated.root),
839 Err(HostManagedTraversalError::DuplicateTypeId {
840 id: duplicate,
841 path: "$".to_string(),
842 })
843 );
844 }
845
846 #[test]
847 fn escapes_union_tags_in_value_paths() {
848 let value = SchemaValue::Union(UnionValuePayload {
849 tag: "branch\"]\nforged".to_string(),
850 body: Box::new(secret_value()),
851 });
852
853 assert_eq!(
854 find_host_managed_value(&value),
855 Some(HostManagedOccurrence {
856 kind: HostManagedKind::Secret,
857 path: "$.union[\"branch\\\"]\\nforged\"]".to_string(),
858 })
859 );
860 }
861
862 #[test]
863 fn redacted_debug_hides_secret_material() {
864 let rendered = format!("{:?}", redacted_schema_value_debug(&secret_value()));
865 assert_eq!(rendered, "<redacted: secret>");
866 assert!(!rendered.contains("shhh-do-not-log"));
867 }
868
869 #[test]
870 fn redacted_debug_hides_quota_token_snapshot() {
871 let rendered = format!("{:?}", redacted_schema_value_debug("a_token_value()));
872 assert_eq!(rendered, "<redacted: quota-token>");
873 assert!(!rendered.contains("gpu-quota"));
874 }
875
876 #[test]
877 fn redacted_debug_recurses_through_all_container_paths() {
878 use crate::schema::schema_value::{
879 ResultValuePayload, UnionValuePayload, VariantValuePayload,
880 };
881
882 let cases: Vec<SchemaValue> = vec![
883 SchemaValue::Variant(VariantValuePayload {
885 case: 0,
886 payload: Some(Box::new(secret_value())),
887 }),
888 SchemaValue::Tuple {
890 elements: vec![quota_token_value()],
891 },
892 SchemaValue::FixedList {
893 elements: vec![secret_value()],
894 },
895 SchemaValue::Map {
897 entries: vec![(secret_value(), quota_token_value())],
898 },
899 SchemaValue::Result(ResultValuePayload::Ok {
901 value: Some(Box::new(secret_value())),
902 }),
903 SchemaValue::Result(ResultValuePayload::Err {
904 value: Some(Box::new(quota_token_value())),
905 }),
906 SchemaValue::Union(UnionValuePayload {
908 tag: "b".to_string(),
909 body: Box::new(secret_value()),
910 }),
911 ];
912
913 for value in &cases {
914 let rendered = format!("{:?}", redacted_schema_value_debug(value));
915 assert!(
916 !rendered.contains("shhh-do-not-log"),
917 "secret leaked through {value:?}: {rendered}"
918 );
919 assert!(
920 !rendered.contains("gpu-quota"),
921 "quota token leaked through {value:?}: {rendered}"
922 );
923 assert!(
924 rendered.contains("<redacted:"),
925 "expected a redacted placeholder in {rendered}"
926 );
927 }
928 }
929
930 #[test]
931 fn redacted_debug_recurses_into_containers() {
932 let value = SchemaValue::Record {
933 fields: vec![
934 SchemaValue::String("svc".to_string()),
935 SchemaValue::List {
936 elements: vec![secret_value(), quota_token_value()],
937 },
938 SchemaValue::Option {
939 inner: Some(Box::new(secret_value())),
940 },
941 ],
942 };
943 let rendered = format!("{:?}", redacted_schema_value_debug(&value));
944 assert!(!rendered.contains("shhh-do-not-log"), "{rendered}");
945 assert!(!rendered.contains("gpu-quota"), "{rendered}");
946 assert!(rendered.contains("<redacted: secret>"), "{rendered}");
947 assert!(rendered.contains("<redacted: quota-token>"), "{rendered}");
948 assert!(rendered.contains("svc"), "{rendered}");
950 }
951
952 #[test]
953 fn redact_value_replaces_capability_leaves_with_strings() {
954 assert_eq!(
955 redact_host_managed_value(quota_token_value()),
956 SchemaValue::String("<redacted: quota-token>".to_string())
957 );
958 assert_eq!(
959 redact_host_managed_value(secret_value()),
960 SchemaValue::String("<redacted: secret>".to_string())
961 );
962 }
963
964 #[test]
965 fn redact_value_preserves_structure_and_redacts_nested() {
966 let value = SchemaValue::Record {
967 fields: vec![
968 SchemaValue::String("svc".to_string()),
969 SchemaValue::List {
970 elements: vec![secret_value(), quota_token_value()],
971 },
972 SchemaValue::Option {
973 inner: Some(Box::new(quota_token_value())),
974 },
975 ],
976 };
977 let redacted = redact_host_managed_value(value);
978 assert_eq!(
979 redacted,
980 SchemaValue::Record {
981 fields: vec![
982 SchemaValue::String("svc".to_string()),
983 SchemaValue::List {
984 elements: vec![
985 SchemaValue::String("<redacted: secret>".to_string()),
986 SchemaValue::String("<redacted: quota-token>".to_string()),
987 ],
988 },
989 SchemaValue::Option {
990 inner: Some(Box::new(SchemaValue::String(
991 "<redacted: quota-token>".to_string()
992 ))),
993 },
994 ],
995 }
996 );
997 }
998
999 #[test]
1000 fn redact_type_replaces_capability_types_with_plain_string() {
1001 assert_eq!(
1002 redact_host_managed_type(SchemaType::quota_token(QuotaTokenSpec::default())),
1003 SchemaType::string()
1004 );
1005 assert_eq!(
1006 redact_host_managed_type(SchemaType::secret(SecretSpec::default())),
1007 SchemaType::string()
1008 );
1009 }
1010
1011 #[test]
1012 fn redact_typed_value_keeps_graph_and_value_kind_consistent() {
1013 use crate::schema::graph::{SchemaGraph, SchemaTypeDef, TypedSchemaValue};
1014 use crate::schema::metadata::{MetadataEnvelope, TypeId};
1015 use crate::schema::schema_type::NamedFieldType;
1016
1017 let graph = SchemaGraph {
1020 defs: vec![SchemaTypeDef {
1021 id: TypeId::new("cap"),
1022 name: None,
1023 body: SchemaType::quota_token(QuotaTokenSpec::default()),
1024 }],
1025 root: SchemaType::Record {
1026 fields: vec![
1027 NamedFieldType {
1028 name: "via_ref".to_string(),
1029 body: SchemaType::Ref {
1030 id: TypeId::new("cap"),
1031 metadata: MetadataEnvelope::default(),
1032 },
1033 metadata: MetadataEnvelope::default(),
1034 },
1035 NamedFieldType {
1036 name: "inline".to_string(),
1037 body: SchemaType::secret(SecretSpec::default()),
1038 metadata: MetadataEnvelope::default(),
1039 },
1040 ],
1041 metadata: MetadataEnvelope::default(),
1042 },
1043 };
1044 let value = SchemaValue::Record {
1045 fields: vec![quota_token_value(), secret_value()],
1046 };
1047
1048 let redacted = redact_host_managed_typed_value(TypedSchemaValue::new(graph, value));
1049 let (graph, value) = redacted.into_parts();
1050
1051 assert_eq!(graph.defs[0].body, SchemaType::string());
1053
1054 match &graph.root {
1057 SchemaType::Record { fields, .. } => {
1058 assert!(matches!(fields[0].body, SchemaType::Ref { .. }));
1059 assert_eq!(fields[1].body, SchemaType::string());
1060 }
1061 other => panic!("expected record root, got {other:?}"),
1062 }
1063
1064 assert_eq!(
1066 value,
1067 SchemaValue::Record {
1068 fields: vec![
1069 SchemaValue::String("<redacted: quota-token>".to_string()),
1070 SchemaValue::String("<redacted: secret>".to_string()),
1071 ],
1072 }
1073 );
1074 }
1075}