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, "$".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
181/// Finds the first host-managed capability type reachable from `ty` through
182/// `graph`. Only definitions reachable from `ty` are considered. Recursive
183/// references are followed once per active path, so recursive schemas
184/// terminate without suppressing occurrences reached through sibling paths.
185pub 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
339/// Replace every host-managed capability value (see [`HostManagedKind`]) with a
340/// plain string placeholder, recursing through every container.
341///
342/// This is a lossy, deterministic transform used at untrusted export boundaries
343/// (the public oplog / oplog-processor plugin interface) where a capability must
344/// neither leak its trusted snapshot nor be lowered to a live owned handle.
345/// Quota tokens have no value representation in the WIT wire form other than an
346/// owned handle, so the only safe rendering is an ordinary string. Secrets carry
347/// an opaque snapshot (secret id, pinned version, and metadata) that is likewise
348/// capability-identifying authority data, so they are redacted the same way.
349///
350/// The result is a well-formed [`SchemaValue`] in which every capability leaf
351/// has become a [`SchemaValue::String`]. Pair it with
352/// [`redact_host_managed_type`] (via [`redact_host_managed_typed_value`]) to keep
353/// a typed value's schema graph and value tree kind-consistent.
354pub 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        // Capability leaves were handled above; everything else is a
409        // non-capability leaf with no nested capability material.
410        other => other,
411    }
412}
413
414/// Replace every host-managed capability type (see [`HostManagedKind`]) with a
415/// plain `string` type, recursing through every container and named definition.
416///
417/// Keeps the schema graph kind-consistent with the value tree produced by
418/// [`redact_host_managed_value`], so a redacted [`TypedSchemaValue`] still
419/// validates and renders through kind-paired walkers. The replacement uses a
420/// default metadata envelope so capability-specific specs and example material
421/// never cross the export boundary.
422pub 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        // Capability nodes were handled above; refs, primitives, and rich
513        // semantic leaves carry no nested capability material.
514        other => other,
515    }
516}
517
518/// Produce a redacted copy of a [`TypedSchemaValue`] in which every host-managed
519/// capability node — in both the schema graph and the value tree — becomes a
520/// plain string. See [`redact_host_managed_value`] / [`redact_host_managed_type`].
521pub 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
540/// Wraps a [`SchemaValue`] so its `Debug` output redacts every host-managed
541/// capability node (see [`HostManagedKind`]).
542///
543/// Use this at tracing / diagnostic / error-formatting boundaries that would
544/// otherwise dump raw capability references or lease snapshots into logs. The
545/// derived `Debug` of [`SchemaValue`] is left untouched so internal code can
546/// still inspect raw values; redaction is opt-in at the observability boundary.
547pub struct RedactedSchemaValue<'a>(&'a SchemaValue);
548
549/// Wraps `value` for redacted `Debug` rendering. See [`RedactedSchemaValue`].
550pub 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        // Capability nodes are handled above. Everything else is a
628        // non-capability leaf with no nested capability material, so its
629        // derived `Debug` is safe to emit verbatim.
630        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(&quota_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(&quota_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            // Variant payload.
884            SchemaValue::Variant(VariantValuePayload {
885                case: 0,
886                payload: Some(Box::new(secret_value())),
887            }),
888            // Tuple / fixed-list elements.
889            SchemaValue::Tuple {
890                elements: vec![quota_token_value()],
891            },
892            SchemaValue::FixedList {
893                elements: vec![secret_value()],
894            },
895            // Map key and value.
896            SchemaValue::Map {
897                entries: vec![(secret_value(), quota_token_value())],
898            },
899            // Result Ok / Err payloads.
900            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            // Union body.
907            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        // Non-capability material is preserved.
949        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        // A named definition whose body is a capability type, referenced from
1018        // the root record alongside an inline capability field.
1019        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        // The referenced definition body is rewritten to a plain string.
1052        assert_eq!(graph.defs[0].body, SchemaType::string());
1053
1054        // Inline capability type becomes a string; the ref is left intact and
1055        // resolves to the rewritten string definition.
1056        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        // Both capability values become plain string placeholders.
1065        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}