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, &mut Vec::new())
126}
127
128enum CapabilityPathSegment<'a> {
129 Index(&'static str, usize),
130 Map(usize, &'static str),
131 Payload(&'static str),
132 Union(&'a str),
133}
134
135fn find_host_managed_value_at<'a>(
136 value: &'a SchemaValue,
137 path: &mut Vec<CapabilityPathSegment<'a>>,
138) -> Option<HostManagedOccurrence> {
139 if let Some(kind) = HostManagedKind::from_value(value) {
140 let mut rendered = "$".to_string();
141 for segment in path.iter() {
142 match segment {
143 CapabilityPathSegment::Index(kind, index) => {
144 write!(&mut rendered, ".{kind}[{index}]").unwrap()
145 }
146 CapabilityPathSegment::Map(index, side) => {
147 write!(&mut rendered, ".map[{index}].{side}").unwrap()
148 }
149 CapabilityPathSegment::Payload(kind) => write!(&mut rendered, ".{kind}").unwrap(),
150 CapabilityPathSegment::Union(tag) => {
151 write!(&mut rendered, ".union[{tag:?}]").unwrap()
152 }
153 }
154 }
155 return Some(HostManagedOccurrence {
156 kind,
157 path: rendered,
158 });
159 }
160
161 match value {
162 SchemaValue::Record { fields } => fields.iter().enumerate().find_map(|(index, value)| {
163 find_host_managed_child(value, path, CapabilityPathSegment::Index("fields", index))
164 }),
165 SchemaValue::Variant(value) => value.payload.as_deref().and_then(|payload| {
166 find_host_managed_child(
167 payload,
168 path,
169 CapabilityPathSegment::Index("variant", value.case as usize),
170 )
171 }),
172 SchemaValue::Tuple { elements } => find_host_managed_sequence(elements, path, "tuple"),
173 SchemaValue::List { elements } => find_host_managed_sequence(elements, path, "list"),
174 SchemaValue::FixedList { elements } => {
175 find_host_managed_sequence(elements, path, "fixed-list")
176 }
177 SchemaValue::Map { entries } => {
178 entries
179 .iter()
180 .enumerate()
181 .find_map(|(index, (key, value))| {
182 find_host_managed_child(key, path, CapabilityPathSegment::Map(index, "key"))
183 .or_else(|| {
184 find_host_managed_child(
185 value,
186 path,
187 CapabilityPathSegment::Map(index, "value"),
188 )
189 })
190 })
191 }
192 SchemaValue::Option { inner } => inner.as_deref().and_then(|value| {
193 find_host_managed_child(value, path, CapabilityPathSegment::Payload("some"))
194 }),
195 SchemaValue::Result(ResultValuePayload::Ok { value }) => {
196 value.as_deref().and_then(|value| {
197 find_host_managed_child(value, path, CapabilityPathSegment::Payload("ok"))
198 })
199 }
200 SchemaValue::Result(ResultValuePayload::Err { value }) => {
201 value.as_deref().and_then(|value| {
202 find_host_managed_child(value, path, CapabilityPathSegment::Payload("err"))
203 })
204 }
205 SchemaValue::Union(value) => {
206 find_host_managed_child(&value.body, path, CapabilityPathSegment::Union(&value.tag))
207 }
208 _ => None,
209 }
210}
211
212fn find_host_managed_child<'a>(
213 value: &'a SchemaValue,
214 path: &mut Vec<CapabilityPathSegment<'a>>,
215 segment: CapabilityPathSegment<'a>,
216) -> Option<HostManagedOccurrence> {
217 path.push(segment);
218 let found = find_host_managed_value_at(value, path);
219 path.pop();
220 found
221}
222
223fn find_host_managed_sequence<'a>(
224 values: &'a [SchemaValue],
225 path: &mut Vec<CapabilityPathSegment<'a>>,
226 kind: &'static str,
227) -> Option<HostManagedOccurrence> {
228 values.iter().enumerate().find_map(|(index, value)| {
229 find_host_managed_child(value, path, CapabilityPathSegment::Index(kind, index))
230 })
231}
232
233pub fn find_host_managed_type(
238 graph: &SchemaGraph,
239 ty: &SchemaType,
240) -> Result<Option<HostManagedOccurrence>, HostManagedTraversalError> {
241 find_host_managed_type_at(graph, ty, "$".to_string(), &mut HashSet::new())
242}
243
244fn find_host_managed_type_at(
245 graph: &SchemaGraph,
246 ty: &SchemaType,
247 path: String,
248 visiting: &mut HashSet<TypeId>,
249) -> Result<Option<HostManagedOccurrence>, HostManagedTraversalError> {
250 if let Some(kind) = HostManagedKind::from_type(ty) {
251 return Ok(Some(HostManagedOccurrence { kind, path }));
252 }
253
254 match ty {
255 SchemaType::Ref { id, .. } => {
256 if !visiting.insert(id.clone()) {
257 return Ok(None);
258 }
259 let mut definitions = graph.defs.iter().filter(|definition| definition.id == *id);
260 let definition =
261 definitions
262 .next()
263 .ok_or_else(|| HostManagedTraversalError::DanglingRef {
264 id: id.clone(),
265 path: path.clone(),
266 })?;
267 if definitions.next().is_some() {
268 visiting.remove(id);
269 return Err(HostManagedTraversalError::DuplicateTypeId {
270 id: id.clone(),
271 path,
272 });
273 }
274 let result = find_host_managed_type_at(graph, &definition.body, path, visiting);
275 visiting.remove(id);
276 result
277 }
278 SchemaType::Record { fields, .. } => {
279 for field in fields {
280 if let Some(occurrence) = find_host_managed_type_at(
281 graph,
282 &field.body,
283 append_named_path(&path, "field", &field.name),
284 visiting,
285 )? {
286 return Ok(Some(occurrence));
287 }
288 }
289 Ok(None)
290 }
291 SchemaType::Variant { cases, .. } => {
292 for case in cases {
293 if let Some(payload) = &case.payload
294 && let Some(occurrence) = find_host_managed_type_at(
295 graph,
296 payload,
297 append_named_path(&path, "variant", &case.name),
298 visiting,
299 )?
300 {
301 return Ok(Some(occurrence));
302 }
303 }
304 Ok(None)
305 }
306 SchemaType::Tuple { elements, .. } => {
307 find_host_managed_type_sequence(graph, elements, &path, "tuple", visiting)
308 }
309 SchemaType::List { element, .. } => {
310 find_host_managed_type_at(graph, element, format!("{path}.list[]"), visiting)
311 }
312 SchemaType::FixedList { element, .. } => {
313 find_host_managed_type_at(graph, element, format!("{path}.fixed-list[]"), visiting)
314 }
315 SchemaType::Map { key, value, .. } => {
316 if let Some(occurrence) =
317 find_host_managed_type_at(graph, key, format!("{path}.map.key"), visiting)?
318 {
319 return Ok(Some(occurrence));
320 }
321 find_host_managed_type_at(graph, value, format!("{path}.map.value"), visiting)
322 }
323 SchemaType::Option { inner, .. } => {
324 find_host_managed_type_at(graph, inner, format!("{path}.some"), visiting)
325 }
326 SchemaType::Result { spec, .. } => {
327 if let Some(ok) = spec.ok.as_deref()
328 && let Some(occurrence) =
329 find_host_managed_type_at(graph, ok, format!("{path}.ok"), visiting)?
330 {
331 return Ok(Some(occurrence));
332 }
333 match spec.err.as_deref() {
334 Some(err) => find_host_managed_type_at(graph, err, format!("{path}.err"), visiting),
335 None => Ok(None),
336 }
337 }
338 SchemaType::Union { spec, .. } => {
339 for branch in &spec.branches {
340 if let Some(occurrence) = find_host_managed_type_at(
341 graph,
342 &branch.body,
343 append_named_path(&path, "union", &branch.tag),
344 visiting,
345 )? {
346 return Ok(Some(occurrence));
347 }
348 }
349 Ok(None)
350 }
351 SchemaType::Future { inner, .. } => match inner.as_deref() {
352 Some(inner) => {
353 find_host_managed_type_at(graph, inner, format!("{path}.future"), visiting)
354 }
355 None => Ok(None),
356 },
357 SchemaType::Stream { inner, .. } => match inner.as_deref() {
358 Some(inner) => {
359 find_host_managed_type_at(graph, inner, format!("{path}.stream"), visiting)
360 }
361 None => Ok(None),
362 },
363 _ => Ok(None),
364 }
365}
366
367fn find_host_managed_type_sequence(
368 graph: &SchemaGraph,
369 elements: &[SchemaType],
370 path: &str,
371 kind: &str,
372 visiting: &mut HashSet<TypeId>,
373) -> Result<Option<HostManagedOccurrence>, HostManagedTraversalError> {
374 for (index, element) in elements.iter().enumerate() {
375 if let Some(occurrence) =
376 find_host_managed_type_at(graph, element, format!("{path}.{kind}[{index}]"), visiting)?
377 {
378 return Ok(Some(occurrence));
379 }
380 }
381 Ok(None)
382}
383
384fn append_named_path(path: &str, kind: &str, name: &str) -> String {
385 let mut result = format!("{path}.{kind}[");
386 write!(&mut result, "{name:?}").expect("writing to String cannot fail");
387 result.push(']');
388 result
389}
390
391pub fn redact_host_managed_value(value: SchemaValue) -> SchemaValue {
407 if let Some(kind) = HostManagedKind::from_value(&value) {
408 return SchemaValue::String(kind.redacted_placeholder().to_string());
409 }
410
411 match value {
412 SchemaValue::Record { fields } => SchemaValue::Record {
413 fields: fields.into_iter().map(redact_host_managed_value).collect(),
414 },
415 SchemaValue::Variant(p) => SchemaValue::Variant(VariantValuePayload {
416 case: p.case,
417 payload: p
418 .payload
419 .map(|inner| Box::new(redact_host_managed_value(*inner))),
420 }),
421 SchemaValue::Tuple { elements } => SchemaValue::Tuple {
422 elements: elements
423 .into_iter()
424 .map(redact_host_managed_value)
425 .collect(),
426 },
427 SchemaValue::List { elements } => SchemaValue::List {
428 elements: elements
429 .into_iter()
430 .map(redact_host_managed_value)
431 .collect(),
432 },
433 SchemaValue::FixedList { elements } => SchemaValue::FixedList {
434 elements: elements
435 .into_iter()
436 .map(redact_host_managed_value)
437 .collect(),
438 },
439 SchemaValue::Map { entries } => SchemaValue::Map {
440 entries: entries
441 .into_iter()
442 .map(|(k, v)| (redact_host_managed_value(k), redact_host_managed_value(v)))
443 .collect(),
444 },
445 SchemaValue::Option { inner } => SchemaValue::Option {
446 inner: inner.map(|inner| Box::new(redact_host_managed_value(*inner))),
447 },
448 SchemaValue::Result(r) => SchemaValue::Result(match r {
449 ResultValuePayload::Ok { value } => ResultValuePayload::Ok {
450 value: value.map(|v| Box::new(redact_host_managed_value(*v))),
451 },
452 ResultValuePayload::Err { value } => ResultValuePayload::Err {
453 value: value.map(|v| Box::new(redact_host_managed_value(*v))),
454 },
455 }),
456 SchemaValue::Union(u) => SchemaValue::Union(UnionValuePayload {
457 tag: u.tag,
458 body: Box::new(redact_host_managed_value(*u.body)),
459 }),
460 other => other,
463 }
464}
465
466pub fn redact_host_managed_type(ty: SchemaType) -> SchemaType {
475 if HostManagedKind::from_type(&ty).is_some() {
476 return SchemaType::String {
477 metadata: crate::schema::metadata::MetadataEnvelope::default(),
478 };
479 }
480
481 match ty {
482 SchemaType::Record { fields, metadata } => SchemaType::Record {
483 fields: fields
484 .into_iter()
485 .map(|f| crate::schema::schema_type::NamedFieldType {
486 name: f.name,
487 body: redact_host_managed_type(f.body),
488 metadata: f.metadata,
489 })
490 .collect(),
491 metadata,
492 },
493 SchemaType::Variant { cases, metadata } => SchemaType::Variant {
494 cases: cases
495 .into_iter()
496 .map(|c| crate::schema::schema_type::VariantCaseType {
497 name: c.name,
498 payload: c.payload.map(redact_host_managed_type),
499 metadata: c.metadata,
500 })
501 .collect(),
502 metadata,
503 },
504 SchemaType::Tuple { elements, metadata } => SchemaType::Tuple {
505 elements: elements.into_iter().map(redact_host_managed_type).collect(),
506 metadata,
507 },
508 SchemaType::List { element, metadata } => SchemaType::List {
509 element: Box::new(redact_host_managed_type(*element)),
510 metadata,
511 },
512 SchemaType::FixedList {
513 element,
514 length,
515 metadata,
516 } => SchemaType::FixedList {
517 element: Box::new(redact_host_managed_type(*element)),
518 length,
519 metadata,
520 },
521 SchemaType::Map {
522 key,
523 value,
524 metadata,
525 } => SchemaType::Map {
526 key: Box::new(redact_host_managed_type(*key)),
527 value: Box::new(redact_host_managed_type(*value)),
528 metadata,
529 },
530 SchemaType::Option { inner, metadata } => SchemaType::Option {
531 inner: Box::new(redact_host_managed_type(*inner)),
532 metadata,
533 },
534 SchemaType::Result { spec, metadata } => SchemaType::Result {
535 spec: crate::schema::schema_type::ResultSpec {
536 ok: spec.ok.map(|t| Box::new(redact_host_managed_type(*t))),
537 err: spec.err.map(|t| Box::new(redact_host_managed_type(*t))),
538 },
539 metadata,
540 },
541 SchemaType::Union { spec, metadata } => SchemaType::Union {
542 spec: crate::schema::schema_type::UnionSpec {
543 branches: spec
544 .branches
545 .into_iter()
546 .map(|b| crate::schema::schema_type::UnionBranch {
547 tag: b.tag,
548 body: redact_host_managed_type(b.body),
549 discriminator: b.discriminator,
550 metadata: b.metadata,
551 })
552 .collect(),
553 },
554 metadata,
555 },
556 SchemaType::Future { inner, metadata } => SchemaType::Future {
557 inner: inner.map(|t| Box::new(redact_host_managed_type(*t))),
558 metadata,
559 },
560 SchemaType::Stream { inner, metadata } => SchemaType::Stream {
561 inner: inner.map(|t| Box::new(redact_host_managed_type(*t))),
562 metadata,
563 },
564 other => other,
567 }
568}
569
570pub fn redact_host_managed_typed_value(
574 typed: crate::schema::graph::TypedSchemaValue,
575) -> crate::schema::graph::TypedSchemaValue {
576 let (graph, value) = typed.into_parts();
577 let graph = crate::schema::graph::SchemaGraph {
578 defs: graph
579 .defs
580 .into_iter()
581 .map(|d| crate::schema::graph::SchemaTypeDef {
582 id: d.id,
583 name: d.name,
584 body: redact_host_managed_type(d.body),
585 })
586 .collect(),
587 root: redact_host_managed_type(graph.root),
588 };
589 crate::schema::graph::TypedSchemaValue::new(graph, redact_host_managed_value(value))
590}
591
592pub struct RedactedSchemaValue<'a>(&'a SchemaValue);
600
601pub fn redacted_schema_value_debug(value: &SchemaValue) -> RedactedSchemaValue<'_> {
603 RedactedSchemaValue(value)
604}
605
606impl fmt::Debug for RedactedSchemaValue<'_> {
607 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
608 fmt_redacted(self.0, f)
609 }
610}
611
612fn fmt_redacted(value: &SchemaValue, f: &mut fmt::Formatter<'_>) -> fmt::Result {
613 if let Some(kind) = HostManagedKind::from_value(value) {
614 return f.write_str(kind.redacted_placeholder());
615 }
616
617 match value {
618 SchemaValue::Record { fields } => {
619 f.write_str("Record { fields: ")?;
620 fmt_seq(fields, f)?;
621 f.write_str(" }")
622 }
623 SchemaValue::Variant(p) => {
624 write!(f, "Variant {{ case: {}, payload: ", p.case)?;
625 fmt_opt(p.payload.as_deref(), f)?;
626 f.write_str(" }")
627 }
628 SchemaValue::Tuple { elements } => {
629 f.write_str("Tuple { elements: ")?;
630 fmt_seq(elements, f)?;
631 f.write_str(" }")
632 }
633 SchemaValue::List { elements } => {
634 f.write_str("List { elements: ")?;
635 fmt_seq(elements, f)?;
636 f.write_str(" }")
637 }
638 SchemaValue::FixedList { elements } => {
639 f.write_str("FixedList { elements: ")?;
640 fmt_seq(elements, f)?;
641 f.write_str(" }")
642 }
643 SchemaValue::Map { entries } => {
644 f.write_str("Map { entries: [")?;
645 for (i, (k, v)) in entries.iter().enumerate() {
646 if i > 0 {
647 f.write_str(", ")?;
648 }
649 f.write_str("(")?;
650 fmt_redacted(k, f)?;
651 f.write_str(", ")?;
652 fmt_redacted(v, f)?;
653 f.write_str(")")?;
654 }
655 f.write_str("] }")
656 }
657 SchemaValue::Option { inner } => {
658 f.write_str("Option { inner: ")?;
659 fmt_opt(inner.as_deref(), f)?;
660 f.write_str(" }")
661 }
662 SchemaValue::Result(r) => match r {
663 crate::schema::schema_value::ResultValuePayload::Ok { value } => {
664 f.write_str("Ok(")?;
665 fmt_opt(value.as_deref(), f)?;
666 f.write_str(")")
667 }
668 crate::schema::schema_value::ResultValuePayload::Err { value } => {
669 f.write_str("Err(")?;
670 fmt_opt(value.as_deref(), f)?;
671 f.write_str(")")
672 }
673 },
674 SchemaValue::Union(u) => {
675 write!(f, "Union {{ tag: {:?}, body: ", u.tag)?;
676 fmt_redacted(&u.body, f)?;
677 f.write_str(" }")
678 }
679 other => write!(f, "{other:?}"),
683 }
684}
685
686fn fmt_seq(values: &[SchemaValue], f: &mut fmt::Formatter<'_>) -> fmt::Result {
687 f.write_str("[")?;
688 for (i, v) in values.iter().enumerate() {
689 if i > 0 {
690 f.write_str(", ")?;
691 }
692 fmt_redacted(v, f)?;
693 }
694 f.write_str("]")
695}
696
697fn fmt_opt(value: Option<&SchemaValue>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
698 match value {
699 Some(v) => {
700 f.write_str("Some(")?;
701 fmt_redacted(v, f)?;
702 f.write_str(")")
703 }
704 None => f.write_str("None"),
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use crate::model::EnvironmentId;
712 use crate::schema::graph::SchemaTypeDef;
713 use crate::schema::metadata::MetadataEnvelope;
714 use crate::schema::schema_type::{
715 NamedFieldType, PermissionCardSpec, QuotaTokenSpec, SecretSpec,
716 };
717 use crate::schema::schema_value::{
718 PermissionCardValuePayload, QuotaTokenValuePayload, SecretValuePayload,
719 };
720 use chrono::{TimeZone, Utc};
721 use test_r::test;
722
723 fn secret_value() -> SchemaValue {
724 SchemaValue::Secret(SecretValuePayload {
725 secret_id: uuid::Uuid::nil(),
726 config_key: None,
727 version: 0,
728 resolved_at: Utc.timestamp_opt(0, 0).unwrap(),
729 category: None,
730 })
731 }
732
733 fn quota_token_value() -> SchemaValue {
734 SchemaValue::QuotaToken(QuotaTokenValuePayload {
735 environment_id: EnvironmentId::new(uuid::Uuid::nil()),
736 resource_name: "gpu-quota".to_string(),
737 expected_use: 1,
738 last_credit: 0,
739 last_credit_at: Utc.timestamp_opt(0, 0).unwrap(),
740 })
741 }
742
743 fn permission_card_value() -> SchemaValue {
744 SchemaValue::PermissionCard(PermissionCardValuePayload {
745 card_id: uuid::Uuid::nil(),
746 parent_ids: Vec::new(),
747 expires_at: None,
748 polymorphic: false,
749 })
750 }
751
752 #[test]
753 fn classifies_capability_types_and_values() {
754 assert_eq!(
755 HostManagedKind::from_type(&SchemaType::secret(SecretSpec::default())),
756 Some(HostManagedKind::Secret)
757 );
758 assert_eq!(
759 HostManagedKind::from_type(&SchemaType::quota_token(QuotaTokenSpec::default())),
760 Some(HostManagedKind::QuotaToken)
761 );
762 assert_eq!(HostManagedKind::from_type(&SchemaType::string()), None);
763
764 assert_eq!(
765 HostManagedKind::from_value(&secret_value()),
766 Some(HostManagedKind::Secret)
767 );
768 assert_eq!(
769 HostManagedKind::from_value("a_token_value()),
770 Some(HostManagedKind::QuotaToken)
771 );
772 assert_eq!(HostManagedKind::from_value(&SchemaValue::U32(7)), None);
773 }
774
775 #[test]
776 fn kind_names_and_placeholders_are_stable() {
777 assert_eq!(HostManagedKind::Secret.kind_name(), "secret");
778 assert_eq!(HostManagedKind::QuotaToken.kind_name(), "quota-token");
779 assert_eq!(
780 HostManagedKind::Secret.redacted_placeholder(),
781 "<redacted: secret>"
782 );
783 assert_eq!(
784 HostManagedKind::QuotaToken.redacted_placeholder(),
785 "<redacted: quota-token>"
786 );
787 }
788
789 #[test]
790 fn finds_nested_host_managed_value_with_path() {
791 let value = SchemaValue::Record {
792 fields: vec![SchemaValue::Map {
793 entries: vec![(
794 SchemaValue::String("key".to_string()),
795 permission_card_value(),
796 )],
797 }],
798 };
799
800 assert_eq!(
801 find_host_managed_value(&value),
802 Some(HostManagedOccurrence {
803 kind: HostManagedKind::PermissionCard,
804 path: "$.fields[0].map[0].value".to_string(),
805 })
806 );
807 }
808
809 #[test]
810 fn capability_paths_preserve_first_occurrence_and_discard_sibling_segments() {
811 let containers = [
812 (
813 SchemaValue::Tuple {
814 elements: vec![secret_value()],
815 },
816 ".tuple[0]",
817 ),
818 (
819 SchemaValue::FixedList {
820 elements: vec![secret_value()],
821 },
822 ".fixed-list[0]",
823 ),
824 (
825 SchemaValue::Variant(VariantValuePayload {
826 case: 7,
827 payload: Some(Box::new(secret_value())),
828 }),
829 ".variant[7]",
830 ),
831 (
832 SchemaValue::Option {
833 inner: Some(Box::new(secret_value())),
834 },
835 ".some",
836 ),
837 (
838 SchemaValue::Result(ResultValuePayload::Ok {
839 value: Some(Box::new(secret_value())),
840 }),
841 ".ok",
842 ),
843 (
844 SchemaValue::Result(ResultValuePayload::Err {
845 value: Some(Box::new(secret_value())),
846 }),
847 ".err",
848 ),
849 (
850 SchemaValue::Map {
851 entries: vec![(secret_value(), quota_token_value())],
852 },
853 ".map[0].key",
854 ),
855 (
856 SchemaValue::Union(UnionValuePayload {
857 tag: "a\"\n".into(),
858 body: Box::new(secret_value()),
859 }),
860 ".union[\"a\\\"\\n\"]",
861 ),
862 ];
863 for (container, suffix) in containers {
864 let mut elements = vec![SchemaValue::U8(1); 10_000];
865 assert_eq!(
866 find_host_managed_value(&SchemaValue::List {
867 elements: elements.clone()
868 }),
869 None
870 );
871 elements.push(container);
872 elements.push(quota_token_value());
873 let value = SchemaValue::Record {
874 fields: vec![
875 SchemaValue::Option { inner: None },
876 SchemaValue::List { elements },
877 ],
878 };
879 assert_eq!(
880 find_host_managed_value(&value),
881 Some(HostManagedOccurrence {
882 kind: HostManagedKind::Secret,
883 path: format!("$.fields[1].list[10000]{suffix}"),
884 })
885 );
886 }
887 }
888
889 #[test]
890 fn finds_reachable_type_after_recursive_back_edge() {
891 let tree = TypeId::new("tree");
892 let graph = SchemaGraph {
893 defs: vec![SchemaTypeDef {
894 id: tree.clone(),
895 name: Some("Tree".to_string()),
896 body: SchemaType::Record {
897 fields: vec![
898 NamedFieldType {
899 name: "next".to_string(),
900 body: SchemaType::Option {
901 inner: Box::new(SchemaType::ref_to(tree.clone())),
902 metadata: MetadataEnvelope::default(),
903 },
904 metadata: MetadataEnvelope::default(),
905 },
906 NamedFieldType {
907 name: "token".to_string(),
908 body: SchemaType::quota_token(QuotaTokenSpec::default()),
909 metadata: MetadataEnvelope::default(),
910 },
911 ],
912 metadata: MetadataEnvelope::default(),
913 },
914 }],
915 root: SchemaType::ref_to(tree),
916 };
917
918 assert_eq!(
919 find_host_managed_type(&graph, &graph.root),
920 Ok(Some(HostManagedOccurrence {
921 kind: HostManagedKind::QuotaToken,
922 path: "$.field[\"token\"]".to_string(),
923 }))
924 );
925 }
926
927 #[test]
928 fn ignores_unreachable_host_managed_definition() {
929 let graph = SchemaGraph {
930 defs: vec![SchemaTypeDef {
931 id: TypeId::new("unused"),
932 name: None,
933 body: SchemaType::permission_card(PermissionCardSpec::default()),
934 }],
935 root: SchemaType::string(),
936 };
937
938 assert_eq!(find_host_managed_type(&graph, &graph.root), Ok(None));
939 }
940
941 #[test]
942 fn reachable_dangling_and_duplicate_refs_fail_closed() {
943 let missing = TypeId::new("missing\nref");
944 let dangling = SchemaGraph::anonymous(SchemaType::ref_to(missing.clone()));
945 assert_eq!(
946 find_host_managed_type(&dangling, &dangling.root),
947 Err(HostManagedTraversalError::DanglingRef {
948 id: missing,
949 path: "$".to_string(),
950 })
951 );
952
953 let duplicate = TypeId::new("duplicate");
954 let duplicated = SchemaGraph {
955 defs: vec![
956 SchemaTypeDef {
957 id: duplicate.clone(),
958 name: None,
959 body: SchemaType::string(),
960 },
961 SchemaTypeDef {
962 id: duplicate.clone(),
963 name: None,
964 body: SchemaType::permission_card(PermissionCardSpec::default()),
965 },
966 ],
967 root: SchemaType::ref_to(duplicate.clone()),
968 };
969 assert_eq!(
970 find_host_managed_type(&duplicated, &duplicated.root),
971 Err(HostManagedTraversalError::DuplicateTypeId {
972 id: duplicate,
973 path: "$".to_string(),
974 })
975 );
976 }
977
978 #[test]
979 fn escapes_union_tags_in_value_paths() {
980 let value = SchemaValue::Union(UnionValuePayload {
981 tag: "branch\"]\nforged".to_string(),
982 body: Box::new(secret_value()),
983 });
984
985 assert_eq!(
986 find_host_managed_value(&value),
987 Some(HostManagedOccurrence {
988 kind: HostManagedKind::Secret,
989 path: "$.union[\"branch\\\"]\\nforged\"]".to_string(),
990 })
991 );
992 }
993
994 #[test]
995 fn redacted_debug_hides_secret_material() {
996 let rendered = format!("{:?}", redacted_schema_value_debug(&secret_value()));
997 assert_eq!(rendered, "<redacted: secret>");
998 assert!(!rendered.contains("shhh-do-not-log"));
999 }
1000
1001 #[test]
1002 fn redacted_debug_hides_quota_token_snapshot() {
1003 let rendered = format!("{:?}", redacted_schema_value_debug("a_token_value()));
1004 assert_eq!(rendered, "<redacted: quota-token>");
1005 assert!(!rendered.contains("gpu-quota"));
1006 }
1007
1008 #[test]
1009 fn redacted_debug_recurses_through_all_container_paths() {
1010 use crate::schema::schema_value::{
1011 ResultValuePayload, UnionValuePayload, VariantValuePayload,
1012 };
1013
1014 let cases: Vec<SchemaValue> = vec![
1015 SchemaValue::Variant(VariantValuePayload {
1017 case: 0,
1018 payload: Some(Box::new(secret_value())),
1019 }),
1020 SchemaValue::Tuple {
1022 elements: vec![quota_token_value()],
1023 },
1024 SchemaValue::FixedList {
1025 elements: vec![secret_value()],
1026 },
1027 SchemaValue::Map {
1029 entries: vec![(secret_value(), quota_token_value())],
1030 },
1031 SchemaValue::Result(ResultValuePayload::Ok {
1033 value: Some(Box::new(secret_value())),
1034 }),
1035 SchemaValue::Result(ResultValuePayload::Err {
1036 value: Some(Box::new(quota_token_value())),
1037 }),
1038 SchemaValue::Union(UnionValuePayload {
1040 tag: "b".to_string(),
1041 body: Box::new(secret_value()),
1042 }),
1043 ];
1044
1045 for value in &cases {
1046 let rendered = format!("{:?}", redacted_schema_value_debug(value));
1047 assert!(
1048 !rendered.contains("shhh-do-not-log"),
1049 "secret leaked through {value:?}: {rendered}"
1050 );
1051 assert!(
1052 !rendered.contains("gpu-quota"),
1053 "quota token leaked through {value:?}: {rendered}"
1054 );
1055 assert!(
1056 rendered.contains("<redacted:"),
1057 "expected a redacted placeholder in {rendered}"
1058 );
1059 }
1060 }
1061
1062 #[test]
1063 fn redacted_debug_recurses_into_containers() {
1064 let value = SchemaValue::Record {
1065 fields: vec![
1066 SchemaValue::String("svc".to_string()),
1067 SchemaValue::List {
1068 elements: vec![secret_value(), quota_token_value()],
1069 },
1070 SchemaValue::Option {
1071 inner: Some(Box::new(secret_value())),
1072 },
1073 ],
1074 };
1075 let rendered = format!("{:?}", redacted_schema_value_debug(&value));
1076 assert!(!rendered.contains("shhh-do-not-log"), "{rendered}");
1077 assert!(!rendered.contains("gpu-quota"), "{rendered}");
1078 assert!(rendered.contains("<redacted: secret>"), "{rendered}");
1079 assert!(rendered.contains("<redacted: quota-token>"), "{rendered}");
1080 assert!(rendered.contains("svc"), "{rendered}");
1082 }
1083
1084 #[test]
1085 fn redact_value_replaces_capability_leaves_with_strings() {
1086 assert_eq!(
1087 redact_host_managed_value(quota_token_value()),
1088 SchemaValue::String("<redacted: quota-token>".to_string())
1089 );
1090 assert_eq!(
1091 redact_host_managed_value(secret_value()),
1092 SchemaValue::String("<redacted: secret>".to_string())
1093 );
1094 }
1095
1096 #[test]
1097 fn redact_value_preserves_structure_and_redacts_nested() {
1098 let value = SchemaValue::Record {
1099 fields: vec![
1100 SchemaValue::String("svc".to_string()),
1101 SchemaValue::List {
1102 elements: vec![secret_value(), quota_token_value()],
1103 },
1104 SchemaValue::Option {
1105 inner: Some(Box::new(quota_token_value())),
1106 },
1107 ],
1108 };
1109 let redacted = redact_host_managed_value(value);
1110 assert_eq!(
1111 redacted,
1112 SchemaValue::Record {
1113 fields: vec![
1114 SchemaValue::String("svc".to_string()),
1115 SchemaValue::List {
1116 elements: vec![
1117 SchemaValue::String("<redacted: secret>".to_string()),
1118 SchemaValue::String("<redacted: quota-token>".to_string()),
1119 ],
1120 },
1121 SchemaValue::Option {
1122 inner: Some(Box::new(SchemaValue::String(
1123 "<redacted: quota-token>".to_string()
1124 ))),
1125 },
1126 ],
1127 }
1128 );
1129 }
1130
1131 #[test]
1132 fn redact_type_replaces_capability_types_with_plain_string() {
1133 assert_eq!(
1134 redact_host_managed_type(SchemaType::quota_token(QuotaTokenSpec::default())),
1135 SchemaType::string()
1136 );
1137 assert_eq!(
1138 redact_host_managed_type(SchemaType::secret(SecretSpec::default())),
1139 SchemaType::string()
1140 );
1141 }
1142
1143 #[test]
1144 fn redact_typed_value_keeps_graph_and_value_kind_consistent() {
1145 use crate::schema::graph::{SchemaGraph, SchemaTypeDef, TypedSchemaValue};
1146 use crate::schema::metadata::{MetadataEnvelope, TypeId};
1147 use crate::schema::schema_type::NamedFieldType;
1148
1149 let graph = SchemaGraph {
1152 defs: vec![SchemaTypeDef {
1153 id: TypeId::new("cap"),
1154 name: None,
1155 body: SchemaType::quota_token(QuotaTokenSpec::default()),
1156 }],
1157 root: SchemaType::Record {
1158 fields: vec![
1159 NamedFieldType {
1160 name: "via_ref".to_string(),
1161 body: SchemaType::Ref {
1162 id: TypeId::new("cap"),
1163 metadata: MetadataEnvelope::default(),
1164 },
1165 metadata: MetadataEnvelope::default(),
1166 },
1167 NamedFieldType {
1168 name: "inline".to_string(),
1169 body: SchemaType::secret(SecretSpec::default()),
1170 metadata: MetadataEnvelope::default(),
1171 },
1172 ],
1173 metadata: MetadataEnvelope::default(),
1174 },
1175 };
1176 let value = SchemaValue::Record {
1177 fields: vec![quota_token_value(), secret_value()],
1178 };
1179
1180 let redacted = redact_host_managed_typed_value(TypedSchemaValue::new(graph, value));
1181 let (graph, value) = redacted.into_parts();
1182
1183 assert_eq!(graph.defs[0].body, SchemaType::string());
1185
1186 match &graph.root {
1189 SchemaType::Record { fields, .. } => {
1190 assert!(matches!(fields[0].body, SchemaType::Ref { .. }));
1191 assert_eq!(fields[1].body, SchemaType::string());
1192 }
1193 other => panic!("expected record root, got {other:?}"),
1194 }
1195
1196 assert_eq!(
1198 value,
1199 SchemaValue::Record {
1200 fields: vec![
1201 SchemaValue::String("<redacted: quota-token>".to_string()),
1202 SchemaValue::String("<redacted: secret>".to_string()),
1203 ],
1204 }
1205 );
1206 }
1207}