Skip to main content

golem_schema/schema/
host_managed.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Closed classification of host-managed capability nodes and the shared
16//! redaction helpers that observability surfaces consult.
17//!
18//! Capability values such as [`SchemaValue::Secret`] and
19//! [`SchemaValue::QuotaToken`] carry material owned by the host authority, not
20//! the guest. Every human-facing surface (CLI display, agent-id rendering,
21//! error rendering, tracing) must redact them, and the placement rules must
22//! police where they may appear. Rather than each of those consumers
23//! re-matching the capability cases, they classify a node through
24//! [`HostManagedKind`] and obtain a stable kind name / redacted placeholder
25//! from one place. Adding a future capability case means adding one variant
26//! here; the consumers pick it up automatically.
27
28use 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/// A closed set of "host-managed" capability kinds.
38#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
39pub enum HostManagedKind {
40    Secret,
41    QuotaToken,
42    PermissionCard,
43}
44
45impl HostManagedKind {
46    /// Classifies a [`SchemaType`] node, returning `Some` for capability types.
47    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    /// Classifies a [`SchemaValue`] node, returning `Some` for capability
57    /// values.
58    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    /// Stable kebab-case name of the capability kind.
68    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    /// Placeholder rendered in place of the value on redacting surfaces, in the
77    /// form `<redacted: kind>`.
78    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/// The first host-managed capability reached while traversing a schema type or
88/// value tree.
89#[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
122/// Finds the first host-managed capability in `value`, including nested
123/// occurrences in every structural container.
124pub 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
233/// Finds the first host-managed capability type reachable from `ty` through
234/// `graph`. Only definitions reachable from `ty` are considered. Recursive
235/// references are followed once per active path, so recursive schemas
236/// terminate without suppressing occurrences reached through sibling paths.
237pub 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
391/// Replace every host-managed capability value (see [`HostManagedKind`]) with a
392/// plain string placeholder, recursing through every container.
393///
394/// This is a lossy, deterministic transform used at untrusted export boundaries
395/// (the public oplog / oplog-processor plugin interface) where a capability must
396/// neither leak its trusted snapshot nor be lowered to a live owned handle.
397/// Quota tokens have no value representation in the WIT wire form other than an
398/// owned handle, so the only safe rendering is an ordinary string. Secrets carry
399/// an opaque snapshot (secret id, pinned version, and metadata) that is likewise
400/// capability-identifying authority data, so they are redacted the same way.
401///
402/// The result is a well-formed [`SchemaValue`] in which every capability leaf
403/// has become a [`SchemaValue::String`]. Pair it with
404/// [`redact_host_managed_type`] (via [`redact_host_managed_typed_value`]) to keep
405/// a typed value's schema graph and value tree kind-consistent.
406pub 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        // Capability leaves were handled above; everything else is a
461        // non-capability leaf with no nested capability material.
462        other => other,
463    }
464}
465
466/// Replace every host-managed capability type (see [`HostManagedKind`]) with a
467/// plain `string` type, recursing through every container and named definition.
468///
469/// Keeps the schema graph kind-consistent with the value tree produced by
470/// [`redact_host_managed_value`], so a redacted [`TypedSchemaValue`] still
471/// validates and renders through kind-paired walkers. The replacement uses a
472/// default metadata envelope so capability-specific specs and example material
473/// never cross the export boundary.
474pub 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        // Capability nodes were handled above; refs, primitives, and rich
565        // semantic leaves carry no nested capability material.
566        other => other,
567    }
568}
569
570/// Produce a redacted copy of a [`TypedSchemaValue`] in which every host-managed
571/// capability node — in both the schema graph and the value tree — becomes a
572/// plain string. See [`redact_host_managed_value`] / [`redact_host_managed_type`].
573pub 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
592/// Wraps a [`SchemaValue`] so its `Debug` output redacts every host-managed
593/// capability node (see [`HostManagedKind`]).
594///
595/// Use this at tracing / diagnostic / error-formatting boundaries that would
596/// otherwise dump raw capability references or lease snapshots into logs. The
597/// derived `Debug` of [`SchemaValue`] is left untouched so internal code can
598/// still inspect raw values; redaction is opt-in at the observability boundary.
599pub struct RedactedSchemaValue<'a>(&'a SchemaValue);
600
601/// Wraps `value` for redacted `Debug` rendering. See [`RedactedSchemaValue`].
602pub 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        // Capability nodes are handled above. Everything else is a
680        // non-capability leaf with no nested capability material, so its
681        // derived `Debug` is safe to emit verbatim.
682        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(&quota_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(&quota_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            // Variant payload.
1016            SchemaValue::Variant(VariantValuePayload {
1017                case: 0,
1018                payload: Some(Box::new(secret_value())),
1019            }),
1020            // Tuple / fixed-list elements.
1021            SchemaValue::Tuple {
1022                elements: vec![quota_token_value()],
1023            },
1024            SchemaValue::FixedList {
1025                elements: vec![secret_value()],
1026            },
1027            // Map key and value.
1028            SchemaValue::Map {
1029                entries: vec![(secret_value(), quota_token_value())],
1030            },
1031            // Result Ok / Err payloads.
1032            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            // Union body.
1039            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        // Non-capability material is preserved.
1081        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        // A named definition whose body is a capability type, referenced from
1150        // the root record alongside an inline capability field.
1151        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        // The referenced definition body is rewritten to a plain string.
1184        assert_eq!(graph.defs[0].body, SchemaType::string());
1185
1186        // Inline capability type becomes a string; the ref is left intact and
1187        // resolves to the rewritten string definition.
1188        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        // Both capability values become plain string placeholders.
1197        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}