Skip to main content

stet_core/
vm_audit.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Audit global VM for PLRM 3.7.2 violations.
6//!
7//! PLRM 3.7.2: "An object in global VM is not allowed to contain a reference
8//! to an object in local VM." The rule exists because `restore` may deallocate
9//! the local object, leaving the global object with a dangling reference.
10//!
11//! stet enforces the rule in the operators that can perform such a store —
12//! `put`, `def`, `store`, `astore`, `putinterval`, `copy`, and the `[`…`]` /
13//! `<<`…`>>` constructors — each of which raises `invalidaccess` per PLRM.
14//!
15//! Operator-level enforcement cannot be the whole story, though. It only
16//! covers stores a PostScript program makes; the interpreter's own Rust code
17//! writes into dictionaries and arrays directly, and
18//! [`ArrayStore::get_mut`](crate::array_store::ArrayStore::get_mut) hands out
19//! `&mut [PsObject]`, so such a write passes no checkpoint at all. This module
20//! is the backstop: it sweeps the entity tables of both global stores and
21//! reports every element that is a composite living in local VM, independent
22//! of how it got there.
23//!
24//! # The sanctioned exception
25//!
26//! PLRM 3.7.5 carves out an explicit exception to 3.7.2:
27//!
28//! > **Note:** `systemdict`, a global dictionary, contains several entries
29//! > whose values are local dictionaries, such as `userdict` and `$error`.
30//! > This is an exception to the normal rule, described in Section 3.7.2 …
31//!
32//! `userdict`, `errordict`, `$error`, `statusdict`, and `FontDirectory` are
33//! standard *local* dictionaries (PLRM Table 3.3) reachable as permanent
34//! entries of the *global* `systemdict` (Table 3.4).
35//!
36//! What makes the exception safe is not the names but the lifetime: those
37//! dictionaries are built during interpreter bootstrap, before any `save`, so
38//! no `restore` can ever deallocate them. The audit therefore classifies by
39//! lifetime rather than by name — see [`Violation::target_reclaimable`]. A
40//! global→local reference whose target is unreclaimable can never dangle; one
41//! whose target is reclaimable is a live use-after-free hazard the moment
42//! `restore` starts releasing local VM.
43//!
44//! The sweep is O(global VM size) and is not on any hot path. It is intended
45//! for tests and for `stet --audit-vm` runs.
46
47use crate::context::Context;
48use crate::dict::DictKey;
49use crate::object::{EntityId, PsObject, PsValue};
50
51/// One global→local reference found by [`audit_global_vm`].
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct Violation {
54    /// The global composite holding the offending reference.
55    pub container: EntityId,
56    /// Human-readable identification of the container, e.g. `dict systemdict`
57    /// or `array #42`.
58    pub container_desc: String,
59    /// Where inside the container the reference sits: a dict key or an array
60    /// index, rendered for display.
61    pub slot: String,
62    /// PostScript type name of the local object being referenced.
63    pub value_type: &'static str,
64    /// Entity ID of the local object being referenced.
65    pub value_entity: EntityId,
66    /// Whether the referenced local object can ever be released by `restore`.
67    ///
68    /// `false` means the object was allocated before any `save` executed, so
69    /// it sits below every save level's high-water mark and no `restore` can
70    /// reclaim it. Such a reference cannot dangle; this is the case that
71    /// covers the PLRM 3.7.5 `systemdict` exception (`userdict`, `$error`, …).
72    ///
73    /// `true` means the object belongs to a save level that a `restore` may
74    /// release, leaving the global container pointing at freed storage. These
75    /// are the violations that matter.
76    pub target_reclaimable: bool,
77}
78
79impl std::fmt::Display for Violation {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(
82            f,
83            "{} [{}] -> local {} (entity {}){}",
84            self.container_desc,
85            self.slot,
86            self.value_type,
87            self.value_entity.raw_index(),
88            if self.target_reclaimable {
89                " RECLAIMABLE"
90            } else {
91                " (permanent, sanctioned by PLRM 3.7.5)"
92            }
93        )
94    }
95}
96
97/// Entity of `obj` if it is a composite living in local VM.
98///
99/// Simple objects (integers, names, operators, booleans, …) are not in VM at
100/// all and may be stored anywhere, so they return `None`. This is the exact
101/// complement of the `gcheck` operator's predicate.
102pub fn local_composite_entity(obj: &PsObject) -> Option<EntityId> {
103    let entity = match obj.value {
104        PsValue::String { entity, .. } => entity,
105        PsValue::Array { entity, .. } | PsValue::PackedArray { entity, .. } => entity,
106        PsValue::Dict(entity) => entity,
107        _ => return None,
108    };
109    if entity.is_global() {
110        None
111    } else {
112        Some(entity)
113    }
114}
115
116/// Whether `restore` could ever release the storage behind a local entity.
117///
118/// The test is `created_after_save != 0`: the entity was allocated while some
119/// `save` was outstanding, so that save's `restore` releases it.
120/// `EntityMeta::created_after_save` is stamped from
121/// [`SaveStack::last_save_id`](crate::save_stack::SaveStack::last_save_id),
122/// which reports 0 whenever no save is outstanding — including after a
123/// save/restore pair has completed. An entity allocated at the outermost level
124/// therefore sits below every future save's high-water mark and can never be
125/// reclaimed.
126///
127/// Note that `EntityMeta::save_level` is *not* part of the test. Copy-on-write
128/// bumps an entity's `save_level` to the level at which it was last modified,
129/// so a bootstrap dictionary such as `$error` acquires a nonzero `save_level`
130/// the first time anything writes to it inside a save bracket. That records
131/// where the COW backup lives, not where the storage was allocated, and using
132/// it here would report the whole PLRM 3.7.5 exception set as unsafe.
133fn is_reclaimable(ctx: &Context, obj: &PsObject, entity: EntityId) -> bool {
134    let meta = match obj.value {
135        PsValue::String { .. } => ctx.strings.local.entities.get(entity),
136        PsValue::Array { .. } | PsValue::PackedArray { .. } => {
137            ctx.arrays.local.entities.get(entity)
138        }
139        PsValue::Dict(_) => ctx.dicts.local.entities.get(entity),
140        _ => return false,
141    };
142    meta.created_after_save != 0
143}
144
145/// Render a dict key for diagnostics.
146fn describe_key(ctx: &Context, key: &DictKey) -> String {
147    match key {
148        DictKey::Name(id) => String::from_utf8_lossy(ctx.names.get_bytes(*id)).into_owned(),
149        DictKey::Int(v) => v.to_string(),
150        DictKey::Real(bits) => f64::from_bits(*bits).to_string(),
151        DictKey::Bool(v) => v.to_string(),
152        DictKey::String(bytes) => format!("({})", String::from_utf8_lossy(bytes)),
153        DictKey::Operator(op) => format!("op#{op}"),
154        DictKey::Identity(e, s, l) => format!("identity#{e}+{s}:{l}"),
155    }
156}
157
158/// Sweep global VM and report every reference into local VM.
159///
160/// An empty result means global VM satisfies PLRM 3.7.2.
161pub fn audit_global_vm(ctx: &Context) -> Vec<Violation> {
162    let mut out = Vec::new();
163
164    for (entity, entry) in ctx.dicts.global.iter_entities() {
165        let desc = {
166            let name = String::from_utf8_lossy(&entry.name);
167            if name.is_empty() {
168                format!("dict #{}", entity.raw_index())
169            } else {
170                format!("dict {name}")
171            }
172        };
173        for (key, value) in &entry.entries {
174            if let Some(value_entity) = local_composite_entity(value) {
175                out.push(Violation {
176                    container: entity,
177                    container_desc: desc.clone(),
178                    slot: describe_key(ctx, key),
179                    value_type: std::str::from_utf8(value.type_name()).unwrap_or("?"),
180                    value_entity,
181                    target_reclaimable: is_reclaimable(ctx, value, value_entity),
182                });
183            }
184        }
185    }
186
187    for (entity, elements) in ctx.arrays.global.iter_entities() {
188        for (index, value) in elements.iter().enumerate() {
189            if let Some(value_entity) = local_composite_entity(value) {
190                out.push(Violation {
191                    container: entity,
192                    container_desc: format!("array #{}", entity.raw_index()),
193                    slot: index.to_string(),
194                    value_type: std::str::from_utf8(value.type_name()).unwrap_or("?"),
195                    value_entity,
196                    target_reclaimable: is_reclaimable(ctx, value, value_entity),
197                });
198            }
199        }
200    }
201
202    out
203}
204
205/// A reference to an entity whose storage no longer exists.
206///
207/// Only possible once `restore` actually releases local VM: truncating an
208/// entity table makes every id at or above the new length invalid. Any
209/// surviving reference to one is a use-after-free, and indexing the table
210/// with it panics.
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub struct DanglingRef {
213    /// Where the stale reference lives.
214    pub holder: String,
215    /// Which slot inside the holder: a dict key, an array index, or a stack
216    /// position.
217    pub slot: String,
218    /// PostScript type name of the reference's target.
219    pub value_type: &'static str,
220    /// Entity index the reference points at.
221    pub target_index: usize,
222    /// Current length of the entity table it points into.
223    pub table_len: usize,
224}
225
226impl std::fmt::Display for DanglingRef {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        write!(
229            f,
230            "{} [{}] -> {} entity {} (table len {})",
231            self.holder, self.slot, self.value_type, self.target_index, self.table_len
232        )
233    }
234}
235
236/// Entity table length for the store `obj` lives in, or `None` for simples.
237///
238/// `Gstate` is included even though it indexes `Context::gstate_store` rather
239/// than an entity table: `restore` rewinds that store too, so a surviving
240/// `gstate` object can outlive its slot in exactly the same way.
241fn table_len_for(ctx: &Context, obj: &PsObject) -> Option<(usize, usize)> {
242    if let PsValue::Gstate(idx) = obj.value {
243        return Some((idx as usize, ctx.gstate_store.len()));
244    }
245    let (entity, len) = match obj.value {
246        PsValue::String { entity, .. } => (
247            entity,
248            if entity.is_global() {
249                ctx.strings.global.entities.len()
250            } else {
251                ctx.strings.local.entities.len()
252            },
253        ),
254        PsValue::Array { entity, .. } | PsValue::PackedArray { entity, .. } => (
255            entity,
256            if entity.is_global() {
257                ctx.arrays.global.entities.len()
258            } else {
259                ctx.arrays.local.entities.len()
260            },
261        ),
262        PsValue::Dict(entity) => (
263            entity,
264            if entity.is_global() {
265                ctx.dicts.global.entities.len()
266            } else {
267                ctx.dicts.local.entities.len()
268            },
269        ),
270        _ => return None,
271    };
272    Some((entity.raw_index(), len))
273}
274
275/// Walk a colour space for entity references, recursing through the
276/// base/alternative spaces that `Indexed`, `Separation`, and `DeviceN` nest.
277///
278/// The `match` is deliberately exhaustive: adding a `ColorSpace` variant that
279/// carries an `EntityId` or a `PsObject` should fail to compile here rather
280/// than silently escape the audit.
281fn check_color_space(
282    ctx: &Context,
283    cs: &crate::graphics_state::ColorSpace,
284    holder: &str,
285    slot: &str,
286    out: &mut Vec<DanglingRef>,
287) {
288    use crate::graphics_state::ColorSpace as Cs;
289    match cs {
290        Cs::DeviceGray | Cs::DeviceRGB | Cs::DeviceCMYK => {}
291        Cs::Indexed {
292            base, lookup_proc, ..
293        } => {
294            check_color_space(ctx, base, holder, &format!("{slot}.base"), out);
295            if let Some(p) = lookup_proc {
296                check_ref(ctx, p, holder, format!("{slot}.lookup_proc"), out);
297            }
298        }
299        Cs::CIEBasedABC { dict_entity, .. }
300        | Cs::CIEBasedA { dict_entity, .. }
301        | Cs::CIEBasedDEF { dict_entity, .. }
302        | Cs::CIEBasedDEFG { dict_entity, .. }
303        | Cs::ICCBased { dict_entity, .. } => {
304            check_ref(
305                ctx,
306                &PsObject::dict(*dict_entity),
307                holder,
308                format!("{slot}.dict"),
309                out,
310            );
311        }
312        Cs::Separation {
313            alt_space,
314            tint_transform,
315            ..
316        }
317        | Cs::DeviceN {
318            alt_space,
319            tint_transform,
320            ..
321        } => {
322            check_color_space(ctx, alt_space, holder, &format!("{slot}.alt_space"), out);
323            check_ref(
324                ctx,
325                tint_transform,
326                holder,
327                format!("{slot}.tint_transform"),
328                out,
329            );
330        }
331        Cs::Pattern { base } => {
332            if let Some(base) = base {
333                check_color_space(ctx, base, holder, &format!("{slot}.base"), out);
334            }
335        }
336    }
337}
338
339/// Walk one graphics state for entity references.
340///
341/// A `GraphicsState` is the largest non-VM root the interpreter keeps: fonts,
342/// halftone and transfer procedures, the colour space, and the page device
343/// are all VM objects held by raw handle. `restore` reinstates `gstate` and
344/// `gstate_stack` from the save record, but `gstate_store` — the backing
345/// array for `PsValue::Gstate` objects — is not rewound, so a `gstate`
346/// captured after the save keeps whatever was current when it was taken.
347fn check_gstate(
348    ctx: &Context,
349    gs: &crate::graphics_state::GraphicsState,
350    holder: &str,
351    out: &mut Vec<DanglingRef>,
352) {
353    let one = |obj: &Option<PsObject>, slot: &str, out: &mut Vec<DanglingRef>| {
354        if let Some(o) = obj {
355            check_ref(ctx, o, holder, slot.to_string(), out);
356        }
357    };
358    one(&gs.current_font, "current_font", out);
359    one(&gs.root_font, "root_font", out);
360    one(&gs.screen_proc, "screen_proc", out);
361    one(&gs.halftone, "halftone", out);
362    one(&gs.transfer_function, "transfer_function", out);
363    one(&gs.black_generation, "black_generation", out);
364    one(&gs.undercolor_removal, "undercolor_removal", out);
365    one(&gs.color_rendering, "color_rendering", out);
366
367    if let Some(pd) = gs.page_device {
368        check_ref(
369            ctx,
370            &PsObject::dict(pd),
371            holder,
372            "page_device".to_string(),
373            out,
374        );
375    }
376    if let Some(pat) = gs.current_pattern_dict {
377        check_ref(
378            ctx,
379            &PsObject::dict(pat),
380            holder,
381            "current_pattern_dict".to_string(),
382            out,
383        );
384    }
385    if let Some(screens) = &gs.color_screen {
386        for (i, (_, _, proc_obj)) in screens.iter().enumerate() {
387            check_ref(ctx, proc_obj, holder, format!("color_screen[{i}]"), out);
388        }
389    }
390    if let Some(transfers) = &gs.color_transfer {
391        for (i, proc_obj) in transfers.iter().enumerate() {
392            check_ref(ctx, proc_obj, holder, format!("color_transfer[{i}]"), out);
393        }
394    }
395    check_color_space(ctx, &gs.color_space, holder, "color_space", out);
396}
397
398/// Record `obj` in `out` if it points past the end of its entity table.
399fn check_ref(
400    ctx: &Context,
401    obj: &PsObject,
402    holder: &str,
403    slot: String,
404    out: &mut Vec<DanglingRef>,
405) {
406    if let Some((index, table_len)) = table_len_for(ctx, obj)
407        && index >= table_len
408    {
409        out.push(DanglingRef {
410            holder: holder.to_string(),
411            slot,
412            value_type: std::str::from_utf8(obj.type_name()).unwrap_or("?"),
413            target_index: index,
414            table_len,
415        });
416    }
417}
418
419/// Find every reference to storage that no longer exists.
420///
421/// Copy-on-write backup entities are skipped. They are allocated by
422/// `cow_copy` purely so `restore` can swap a composite's contents back, and
423/// are unreachable from PostScript in either state — before the restore they
424/// hold the snapshot, after it the discarded post-save data. Sweeping them
425/// reports every `save`-bracketed mutation as a dangling reference.
426///
427/// Sweeps both VM arenas plus every interpreter root that outlives a
428/// `restore`:
429///
430/// - the operand, execution, and dictionary stacks
431/// - the well-known dictionary handles cached on [`Context`]
432/// - the graphics state, the gstate stack, and `gstate_store`
433/// - the entity-keyed caches (`glyph_caches`, `form_cache`)
434///
435/// The display list and `PatternData` need no coverage: they live in
436/// `stet-graphics`, which does not depend on `stet-core`, so they cannot name
437/// an [`EntityId`] at all. That is a property of the crate graph rather than
438/// of any particular type, and `group_stack` inherits it — its frames hold
439/// display lists.
440///
441/// This is the companion to [`audit_global_vm`]. That one asks whether global
442/// VM *could* come to hold a dangling reference; this one asks whether
443/// anything at all *already does* — which became possible to answer in the
444/// affirmative once `restore` started truncating local VM and retiring
445/// entity ids. [`Context::vm_restore`] asserts the result is empty in debug
446/// builds, so every `cargo test` run polices the invariant that makes the
447/// truncation sound.
448pub fn audit_dangling_refs(ctx: &Context) -> Vec<DanglingRef> {
449    let mut out = Vec::new();
450
451    for (store, label) in [
452        (&ctx.dicts.local, "local dict"),
453        (&ctx.dicts.global, "global dict"),
454    ] {
455        for (entity, entry) in store.iter_entities() {
456            if store.entities.get(entity).is_cow_backup() {
457                continue;
458            }
459            let name = String::from_utf8_lossy(&entry.name);
460            let holder = if name.is_empty() {
461                format!("{label} #{}", entity.raw_index())
462            } else {
463                format!("{label} {name} #{}", entity.raw_index())
464            };
465            for (key, value) in &entry.entries {
466                check_ref(ctx, value, &holder, describe_key(ctx, key), &mut out);
467            }
468        }
469    }
470
471    for (store, label) in [
472        (&ctx.arrays.local, "local array"),
473        (&ctx.arrays.global, "global array"),
474    ] {
475        for (entity, elements) in store.iter_entities() {
476            if store.entities.get(entity).is_cow_backup() {
477                continue;
478            }
479            let holder = format!("{label} #{}", entity.raw_index());
480            for (index, value) in elements.iter().enumerate() {
481                check_ref(ctx, value, &holder, index.to_string(), &mut out);
482            }
483        }
484    }
485
486    for (index, obj) in ctx.o_stack.as_slice().iter().enumerate() {
487        check_ref(ctx, obj, "operand stack", index.to_string(), &mut out);
488    }
489    for (index, obj) in ctx.e_stack.as_slice().iter().enumerate() {
490        check_ref(ctx, obj, "execution stack", index.to_string(), &mut out);
491    }
492    for (index, entity) in ctx.d_stack.iter().enumerate() {
493        check_ref(
494            ctx,
495            &PsObject::dict(*entity),
496            "dictionary stack",
497            index.to_string(),
498            &mut out,
499        );
500    }
501
502    // Procedure data sources that have been handed to `filter` but not yet
503    // run. The `FileStore` holds the procedure until the read path pumps it,
504    // so the array behind it must outlive any `restore` in between.
505    for (entity, proc) in ctx.files.pending_proc_handles() {
506        check_ref(
507            ctx,
508            &proc,
509            "pending procedure data source",
510            format!("file #{}", entity.raw_index()),
511            &mut out,
512        );
513    }
514
515    // Well-known handles the interpreter keeps outside VM. A restore that
516    // retires one of these leaves the interpreter itself holding a stale id.
517    for (entity, name) in [
518        (ctx.systemdict, "systemdict"),
519        (ctx.globaldict, "globaldict"),
520        (ctx.userdict, "userdict"),
521        (ctx.errordict, "errordict"),
522        (ctx.dollar_error, "$error"),
523        (ctx.font_directory, "FontDirectory"),
524        (ctx.global_resources, "GlobalResources"),
525        (ctx.local_resources, "LocalResources"),
526        (ctx.category_registry, "CategoryRegistry"),
527        (ctx.user_params, "UserParams"),
528        (ctx.system_params, "SystemParams"),
529        (ctx.internaldict, "internaldict"),
530    ] {
531        check_ref(
532            ctx,
533            &PsObject::dict(entity),
534            "Context handle",
535            name.to_string(),
536            &mut out,
537        );
538    }
539
540    // The graphics state and everything that clones it. `gstate_store` backs
541    // `PsValue::Gstate`; unlike `gstate` / `gstate_stack` it is not rewound by
542    // `restore`, so it is the one of the three that can outlive its contents.
543    check_gstate(ctx, &ctx.gstate, "graphics state", &mut out);
544    for (index, entry) in ctx.gstate_stack.iter().enumerate() {
545        check_gstate(
546            ctx,
547            &entry.state,
548            &format!("gstate stack #{index}"),
549            &mut out,
550        );
551    }
552    for (index, state) in ctx.gstate_store.iter().enumerate() {
553        check_gstate(ctx, state, &format!("gstate store #{index}"), &mut out);
554    }
555
556    // Caches keyed by entity id. A stale key is not merely a leak: the next
557    // lookup indexes a truncated entity table with it.
558    for entity in ctx.glyph_caches.keys() {
559        check_ref(
560            ctx,
561            &PsObject::dict(*entity),
562            "glyph cache",
563            "key".to_string(),
564            &mut out,
565        );
566    }
567    for entity in ctx.form_cache.keys() {
568        check_ref(
569            ctx,
570            &PsObject::dict(*entity),
571            "form cache",
572            "key".to_string(),
573            &mut out,
574        );
575    }
576
577    out
578}
579
580/// Sweep global VM for references that could actually dangle.
581///
582/// This is [`audit_global_vm`] minus the permanent-target references that
583/// PLRM 3.7.5 sanctions. An empty result is the invariant that has to hold
584/// before `restore` can be allowed to release local VM.
585pub fn audit_global_vm_unsafe_only(ctx: &Context) -> Vec<Violation> {
586    audit_global_vm(ctx)
587        .into_iter()
588        .filter(|v| v.target_reclaimable)
589        .collect()
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::object::ObjFlags;
596
597    /// A bare context already contains the PLRM 3.7.5 exception: `systemdict`
598    /// is global and holds `userdict`, `errordict`, `$error`, and
599    /// `FontDirectory`, which are local per PLRM Table 3.3.
600    #[test]
601    fn bootstrap_exception_is_reported_but_not_reclaimable() {
602        let ctx = Context::new();
603        let all = audit_global_vm(&ctx);
604        assert!(!all.is_empty(), "expected the systemdict exception entries");
605        for v in &all {
606            assert_eq!(v.container_desc, "dict systemdict", "{v}");
607            assert!(!v.target_reclaimable, "{v}");
608        }
609        let slots: Vec<&str> = all.iter().map(|v| v.slot.as_str()).collect();
610        for expected in ["userdict", "errordict", "$error", "FontDirectory"] {
611            assert!(slots.contains(&expected), "missing {expected} in {slots:?}");
612        }
613        assert_eq!(audit_global_vm_unsafe_only(&ctx), Vec::new());
614    }
615
616    #[test]
617    fn detects_local_string_in_global_dict() {
618        let mut ctx = Context::new();
619        let gdict = ctx.dicts.allocate_with(4, b"gdict", 0, true, 0);
620        // save_level 1 / created_after_save 1 => a restore could release it.
621        let lstr = ctx.strings.allocate_with(5, 0, false, 1);
622        let key = DictKey::Name(ctx.names.intern(b"k"));
623        ctx.dicts.put(gdict, key, PsObject::string(lstr, 5));
624
625        let found = audit_global_vm_unsafe_only(&ctx);
626        assert_eq!(found.len(), 1, "{found:?}");
627        assert_eq!(found[0].container, gdict);
628        assert_eq!(found[0].slot, "k");
629        assert_eq!(found[0].value_type, "stringtype");
630        assert!(found[0].target_reclaimable);
631    }
632
633    #[test]
634    fn detects_local_array_written_through_get_mut() {
635        // The case a per-store hook cannot see.
636        let mut ctx = Context::new();
637        let garr = ctx.arrays.allocate_with(2, 0, true, 0);
638        let larr = ctx.arrays.allocate_with(1, 0, false, 1);
639        ctx.arrays.get_mut(garr, 0, 2)[1] = PsObject::array(larr, 1);
640
641        let found = audit_global_vm_unsafe_only(&ctx);
642        assert_eq!(found.len(), 1, "{found:?}");
643        assert_eq!(found[0].slot, "1");
644        assert_eq!(found[0].value_type, "arraytype");
645    }
646
647    #[test]
648    fn global_values_and_simple_values_are_clean() {
649        let mut ctx = Context::new();
650        let gdict = ctx.dicts.allocate_with(4, b"gdict", 0, true, 0);
651        let gstr = ctx.strings.allocate_with(5, 0, true, 0);
652        let k_str = DictKey::Name(ctx.names.intern(b"gs"));
653        let k_int = DictKey::Name(ctx.names.intern(b"n"));
654        ctx.dicts.put(gdict, k_str, PsObject::string(gstr, 5));
655        ctx.dicts.put(gdict, k_int, PsObject::int(7));
656
657        assert_eq!(audit_global_vm_unsafe_only(&ctx), Vec::new());
658    }
659
660    #[test]
661    fn local_container_holding_local_value_is_clean() {
662        // The rule is one-directional: local may reference anything.
663        let mut ctx = Context::new();
664        let ldict = ctx.dicts.allocate_at_level_zero(4, b"ldict");
665        let lstr = ctx.strings.allocate_with(5, 0, false, 1);
666        let key = DictKey::Name(ctx.names.intern(b"k"));
667        ctx.dicts.put(ldict, key, PsObject::string(lstr, 5));
668
669        assert_eq!(audit_global_vm_unsafe_only(&ctx), Vec::new());
670    }
671
672    #[test]
673    fn local_composite_entity_ignores_simple_objects() {
674        assert_eq!(local_composite_entity(&PsObject::int(3)), None);
675        assert_eq!(local_composite_entity(&PsObject::null()), None);
676        let named = PsObject {
677            value: PsValue::Name(crate::object::NameId(0)),
678            flags: ObjFlags::literal(),
679        };
680        assert_eq!(local_composite_entity(&named), None);
681    }
682
683    /// An id one past the end of the local dict table — what an entity becomes
684    /// the moment a `restore` truncates past it.
685    fn retired_dict(ctx: &Context) -> PsObject {
686        PsObject::dict(EntityId(ctx.dicts.local.entities.len() as u32))
687    }
688
689    #[test]
690    fn bootstrap_context_has_no_dangling_refs() {
691        assert_eq!(audit_dangling_refs(&Context::new()), Vec::new());
692    }
693
694    #[test]
695    fn gstate_font_reference_is_swept() {
696        let mut ctx = Context::new();
697        ctx.gstate.current_font = Some(retired_dict(&ctx));
698        let found = audit_dangling_refs(&ctx);
699        assert_eq!(found.len(), 1, "{found:?}");
700        assert_eq!(found[0].holder, "graphics state");
701        assert_eq!(found[0].slot, "current_font");
702    }
703
704    #[test]
705    fn gstate_stack_and_gstate_store_are_swept() {
706        let mut ctx = Context::new();
707        let stale = retired_dict(&ctx);
708        ctx.gstate_stack.push(crate::graphics_state::GstateEntry {
709            state: ctx.gstate.clone(),
710            saved_by_save: false,
711        });
712        ctx.gstate_stack[0].state.page_device = match stale.value {
713            PsValue::Dict(e) => Some(e),
714            _ => unreachable!(),
715        };
716        ctx.gstate_store.push(ctx.gstate.clone());
717        ctx.gstate_store[0].root_font = Some(stale);
718
719        let holders: Vec<&str> = audit_dangling_refs(&ctx)
720            .iter()
721            .map(|d| Box::leak(d.holder.clone().into_boxed_str()) as &str)
722            .collect();
723        assert!(holders.contains(&"gstate stack #0"), "{holders:?}");
724        assert!(holders.contains(&"gstate store #0"), "{holders:?}");
725    }
726
727    /// The colour space walk has to recurse: a `Separation`'s tint transform
728    /// hangs off the alternative space, not off the gstate directly.
729    #[test]
730    fn nested_color_space_reference_is_swept() {
731        use crate::graphics_state::ColorSpace;
732        let mut ctx = Context::new();
733        let stale_entity = EntityId(ctx.dicts.local.entities.len() as u32);
734        ctx.gstate.color_space = ColorSpace::Separation {
735            name: b"Spot".to_vec(),
736            alt_space: Box::new(ColorSpace::ICCBased {
737                dict_entity: stale_entity,
738                n: 4,
739                profile_hash: None,
740            }),
741            tint_transform: PsObject::null(),
742            num_alt_components: 4,
743        };
744        let found = audit_dangling_refs(&ctx);
745        assert_eq!(found.len(), 1, "{found:?}");
746        assert_eq!(found[0].slot, "color_space.alt_space.dict");
747    }
748
749    /// `gstate` objects index `gstate_store`, which `restore` rewinds; a
750    /// surviving one is dangling in the same sense as a retired entity id.
751    #[test]
752    fn stale_gstate_object_is_swept() {
753        let mut ctx = Context::new();
754        let stale = PsObject {
755            value: PsValue::Gstate(0),
756            flags: ObjFlags::literal(),
757        };
758        assert_eq!(ctx.gstate_store.len(), 0);
759        ctx.o_stack.push(stale).unwrap();
760        let found = audit_dangling_refs(&ctx);
761        assert_eq!(found.len(), 1, "{found:?}");
762        assert_eq!(found[0].value_type, "gstatetype");
763    }
764
765    #[test]
766    fn entity_keyed_caches_are_swept() {
767        let mut ctx = Context::new();
768        let stale_entity = EntityId(ctx.dicts.local.entities.len() as u32);
769        ctx.form_cache
770            .insert(stale_entity, crate::display_list::DisplayList::new());
771        let found = audit_dangling_refs(&ctx);
772        assert_eq!(found.len(), 1, "{found:?}");
773        assert_eq!(found[0].holder, "form cache");
774    }
775}