Skip to main content

powerio_dist/dss/
raw.rs

1//! Script execution and the raw object layer.
2//!
3//! A `.dss` file is a command script. This layer splits it into command
4//! lines (handling block comments), resolves command verbs with the same
5//! exact-then-prefix rule OpenDSS uses, follows `Redirect`/`Compile`
6//! includes, and accumulates `New`/`Edit`/`~` property assignments into raw
7//! objects with property names resolved against the class tables. Values
8//! stay untyped [`Value`] tokens; interpretation happens in the readers.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use super::lex::{Scanner, Value, VarMap};
14use super::prop::{self, DssClass};
15use crate::diagnostics::codes as C;
16use crate::error::{Error, Result};
17
18/// The OpenDSS executive command list, in definition order
19/// (Executive/ExecCommands.cpp). Order fixes abbreviation resolution: a verb
20/// matches exactly first, then the first command here with the verb as a
21/// prefix. Only a handful execute in this layer; the rest are preserved as
22/// [`RawCommand`]s.
23static COMMANDS: &[&str] = &[
24    "new",
25    "edit",
26    "more",
27    "m",
28    "~",
29    "select",
30    "save",
31    "show",
32    "solve",
33    "enable",
34    "disable",
35    "plot",
36    "reset",
37    "compile",
38    "set",
39    "dump",
40    "open",
41    "close",
42    "//",
43    "redirect",
44    "help",
45    "quit",
46    "?",
47    "next",
48    "panel",
49    "sample",
50    "clear",
51    "about",
52    "calcvoltagebases",
53    "setkvbase",
54    "buildy",
55    "get",
56    "init",
57    "export",
58    "fileedit",
59    "voltages",
60    "currents",
61    "powers",
62    "seqvoltages",
63    "seqcurrents",
64    "seqpowers",
65    "losses",
66    "phaselosses",
67    "cktlosses",
68    "allocateloads",
69    "formedit",
70    "totals",
71    "capacity",
72    "classes",
73    "userclasses",
74    "zsc",
75    "zsc10",
76    "zscrefresh",
77    "ysc",
78    "puvoltages",
79    "varvalues",
80    "varnames",
81    "buscoords",
82    "makebuslist",
83    "makeposseq",
84    "reduce",
85    "interpolate",
86    "alignfile",
87    "top",
88    "rotate",
89    "vdiff",
90    "summary",
91    "distribute",
92    "di_plot",
93    "comparecases",
94    "yearlycurves",
95    "cd",
96    "visualize",
97    "closedi",
98    "doscmd",
99    "estimate",
100    "reconductor",
101    "_initsnap",
102    "_solvenocontrol",
103    "_samplecontrols",
104    "_docontrolactions",
105    "_showcontrolqueue",
106    "_solvedirect",
107    "_solvepflow",
108    "addbusmarker",
109    "uuids",
110    "setloadandgenkv",
111    "cvrtloadshapes",
112    "nodediff",
113    "rephase",
114    "setbusxy",
115    "updatestorage",
116    "obfuscate",
117    "latlongcoords",
118    "batchedit",
119    "pstcalc",
120    "variable",
121    "reprocessbuses",
122    "clearbusmarkers",
123    "relcalc",
124    "var",
125    "cleanup",
126    "finishtimestep",
127    "nodelist",
128    "newactor",
129    "clearall",
130    "wait",
131    "solveall",
132    "calcincmatrix",
133    "calcincmatrix_o",
134    "tear_circuit",
135    "connect",
136    "disconnect",
137    "refine_buslevels",
138    "remove",
139    "abort",
140    "calclaplacian",
141    "clone",
142    "fncspublish",
143    "exportoverloads",
144    "exportvviolations",
145    "zsc012",
146    "aggregateprofiles",
147    "allpceatbus",
148    "allpdeatbus",
149    "totalpowers",
150    "comhelp",
151    "gis",
152    "giscoords",
153    "readefieldhdf",
154];
155
156fn command_index(verb: &str) -> Option<usize> {
157    let v = verb.to_ascii_lowercase();
158    COMMANDS
159        .iter()
160        .position(|c| *c == v)
161        .or_else(|| COMMANDS.iter().position(|c| c.starts_with(&v)))
162}
163
164/// One property assignment as applied to an object, in application order.
165#[derive(Clone, Debug, PartialEq)]
166pub struct RawProp {
167    /// Canonical property name when resolved against the class table;
168    /// the name as written when the class or property is unknown; `None`
169    /// for a positional value on an unknown class.
170    pub name: Option<String>,
171    pub value: Value,
172}
173
174/// An accumulated object: every `New`/`Edit`/`~`/`like` assignment that
175/// touched it, in order. Values are raw tokens.
176#[derive(Clone, Debug)]
177pub struct RawObject {
178    /// Canonical lowercase class name (`line`, `load`, ...), known or not.
179    pub class: String,
180    /// Object name as written; lookup is case insensitive.
181    pub name: String,
182    pub props: Vec<RawProp>,
183    /// Prop-count checkpoints at edit boundaries. Every object command line
184    /// (`New`/`Edit`/`~`/`More`/property reference) is one engine Edit, and
185    /// the class Edit ends in RecalcElementData; readers with end-of-edit
186    /// side effects (Load) segment `props` on these. `like=` splices the
187    /// source's checkpoints too: MakeLike copies the source's recalced
188    /// state, so its boundaries must replay.
189    pub edits: Vec<usize>,
190}
191
192impl RawObject {
193    /// The last assignment to a canonical property name, if any.
194    pub fn get(&self, name: &str) -> Option<&Value> {
195        self.props
196            .iter()
197            .rev()
198            .find(|p| p.name.as_deref() == Some(name))
199            .map(|p| &p.value)
200    }
201
202    /// Edit boundary checkpoints, closed over the full prop list: a
203    /// trailing segment without a recorded boundary counts as one more
204    /// edit, so callers always see `props.len()` last.
205    pub fn edit_bounds(&self) -> impl Iterator<Item = usize> + '_ {
206        let tail =
207            (self.edits.last().copied() != Some(self.props.len())).then_some(self.props.len());
208        self.edits.iter().copied().chain(tail)
209    }
210}
211
212/// A command this layer does not execute, preserved verbatim.
213#[derive(Clone, Debug, PartialEq)]
214pub struct RawCommand {
215    /// Canonical verb when recognized, the first token as written otherwise.
216    pub verb: String,
217    /// Everything after the verb, trimmed.
218    pub args: String,
219}
220
221/// Bus coordinates from a `BusCoords` file.
222#[derive(Clone, Debug, PartialEq)]
223pub struct BusCoord {
224    pub bus: String,
225    pub x: f64,
226    pub y: f64,
227}
228
229/// The executed script: objects, options, and preserved commands.
230#[derive(Debug, Default)]
231pub struct RawDss {
232    pub circuit_name: Option<String>,
233    pub objects: Vec<RawObject>,
234    /// `Set option=value` assignments in order.
235    pub options: Vec<(String, Value)>,
236    /// Commands preserved without execution (solve, calcvoltagebases, ...).
237    pub commands: Vec<RawCommand>,
238    pub buscoords: Vec<BusCoord>,
239    pub vars: VarMap,
240    /// Bumped by `clear`, so `run_command` can tell whether a `Clear` ran
241    /// during the command it just dispatched and, if so, skip restoring the
242    /// pre-command var snapshot over the empty table `clear` installed.
243    vars_generation: u64,
244    /// The reader's findings as `CODE: message` lines, rendered from
245    /// `diagnostics` so the text and the structure cannot disagree.
246    pub warnings: Vec<String>,
247    /// Structured findings beside `warnings`; an `Error` entry marks an
248    /// incomplete parse the CLI must not exit 0 on.
249    pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
250    index: BTreeMap<(String, String), usize>,
251    active: Option<usize>,
252    /// Properties retained across every object, charged by each push and
253    /// each `like=` splice against `MAX_TOTAL_PROPS`.
254    total_props: usize,
255    /// Diagnostic records retained, against `MAX_TOTAL_DIAGNOSTICS`. Survives
256    /// `clear` with the channel it counts.
257    total_diagnostics: usize,
258    /// Preserved records retained (`commands`, `options`, `buscoords`),
259    /// against `MAX_TOTAL_PRESERVED`. Reset by `clear` with the collections
260    /// it counts.
261    total_preserved: usize,
262    /// One of the whole parse ceilings was crossed: the terminal refusal is
263    /// recorded and no further script text executes.
264    halted: bool,
265}
266
267impl RawDss {
268    pub fn find(&self, class: &str, name: &str) -> Option<&RawObject> {
269        self.index
270            .get(&(class.to_ascii_lowercase(), name.to_ascii_lowercase()))
271            .map(|&i| &self.objects[i])
272    }
273
274    pub fn of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a RawObject> {
275        self.objects.iter().filter(move |o| o.class == class)
276    }
277
278    fn warn(&mut self, info: &'static crate::diagnostics::DiagnosticInfo, msg: impl Into<String>) {
279        self.record(crate::diagnostics::Diagnostic::of(info, msg));
280    }
281
282    /// Record one finding. Both channels move together: the line is rendered
283    /// from the record it is added with. At the whole parse ceiling one
284    /// terminal refusal is recorded and the parse halts.
285    fn record(&mut self, diagnostic: crate::diagnostics::Diagnostic) {
286        if self.total_diagnostics >= MAX_TOTAL_DIAGNOSTICS {
287            self.halt(format!(
288                "the script's retained findings reached the whole parse ceiling of \
289                 {MAX_TOTAL_DIAGNOSTICS}; execution stopped"
290            ));
291            return;
292        }
293        self.total_diagnostics += 1;
294        self.warnings
295            .push(crate::diagnostics::render_diagnostic(&diagnostic));
296        self.diagnostics.push(diagnostic);
297    }
298
299    /// The one halt step: set the flag and emit the single terminal refusal
300    /// into both channels directly, indivisibly, whichever ceiling fired
301    /// first and whatever the other counter holds.
302    fn halt(&mut self, message: String) {
303        if self.halted {
304            return;
305        }
306        self.halted = true;
307        let refusal = crate::diagnostics::Diagnostic::of(
308            &crate::diagnostics::codes::READ_DSS_INCLUDE_BUDGET,
309            message,
310        );
311        self.warnings
312            .push(crate::diagnostics::render_diagnostic(&refusal));
313        self.diagnostics.push(refusal);
314    }
315
316    /// Charge one preserved record (`commands`, `options`, `buscoords`);
317    /// false once the ceiling is crossed, after which the terminal refusal
318    /// is recorded and the parse halts.
319    fn charge_preserved(&mut self) -> bool {
320        if self.total_preserved >= MAX_TOTAL_PRESERVED {
321            self.halt(format!(
322                "the script's preserved records reached the whole parse ceiling of \
323                 {MAX_TOTAL_PRESERVED}; execution stopped"
324            ));
325            return false;
326        }
327        self.total_preserved += 1;
328        true
329    }
330
331    fn clear(&mut self) {
332        // `Clear` resets the circuit, not the record of how the script was
333        // read. A warning or an `Error` finding already emitted describes input
334        // the caller wrote, and no later command un-writes it — dropping them
335        // here returned a network that had refused an include, or skipped one
336        // that escaped the case directory, with an empty `warnings` and nothing
337        // for `powerio module` to lift into a finding.
338        let (warnings, diagnostics) = (
339            std::mem::take(&mut self.warnings),
340            std::mem::take(&mut self.diagnostics),
341        );
342        let (total_diagnostics, halted) = (self.total_diagnostics, self.halted);
343        let vars_generation = self.vars_generation.wrapping_add(1);
344        *self = RawDss {
345            vars_generation,
346            warnings,
347            diagnostics,
348            total_diagnostics,
349            halted,
350            ..RawDss::default()
351        };
352    }
353}
354
355/// Supplies included file text, so tests can run without a filesystem.
356pub trait Loader {
357    fn load(&mut self, path: &Path) -> std::io::Result<String>;
358}
359
360impl<F> Loader for F
361where
362    F: FnMut(&Path) -> std::io::Result<String>,
363{
364    fn load(&mut self, path: &Path) -> std::io::Result<String> {
365        self(path)
366    }
367}
368
369/// Redirect nesting limit; OpenDSS recurses unbounded, this bounds cycles.
370const MAX_REDIRECT_DEPTH: usize = 64;
371
372/// Includes a single parse may follow. Depth bounds one branch, not the work:
373/// a file that redirects to itself twice expands into a binary tree of depth
374/// [`MAX_REDIRECT_DEPTH`], so 34 bytes never finish parsing. The largest
375/// fixture here (IEEE123Master.dss) follows 3.
376const MAX_TOTAL_INCLUDES: usize = 4096;
377
378/// Diagnostic records one parse retains, `warnings` and `diagnostics`
379/// together (they move in step). A re-executed include repeats its findings;
380/// the ceiling bounds what the whole parse may keep, independent of how many
381/// times a file re-executes. The largest legitimate feeders stay far under
382/// it.
383const MAX_TOTAL_DIAGNOSTICS: usize = 1 << 18;
384
385/// Preserved records one parse retains across `commands`, `options`, and
386/// `buscoords` together, on the same rule as the diagnostic ceiling.
387const MAX_TOTAL_PRESERVED: usize = 1 << 20;
388
389/// Script text a single parse may pull in through includes. The include count
390/// alone still admits amplification: one large file that redirects to itself is
391/// re-executed once per load, so a few megabytes of input buy thousands of
392/// times that in scanning. Together the two budgets keep an include tree
393/// costing about what a single case file of this size costs. The root file is
394/// not charged against it, so a case without includes is never truncated.
395const MAX_TOTAL_INCLUDE_BYTES: usize = 64 << 20;
396
397/// Parser variables (`var @name=value`) a single parse may define, on the
398/// same amplification concern as includes: a script that redirects into
399/// itself while defining one each time would otherwise grow the table
400/// without bound. The largest legitimate feeders define a handful.
401const MAX_TOTAL_VARS: usize = 4096;
402
403/// Bytes stored across every var name and value together, on the same rule
404/// as the include byte budget.
405const MAX_TOTAL_VAR_BYTES: usize = 1 << 20;
406
407/// Cap on the accumulated property assignments a single object may hold.
408/// `like=` splices the source object's whole prop list, so a self-referencing
409/// or mutually-referencing chain (`Edit Load.a like=a` repeated) doubles the
410/// count each edit — a few hundred bytes could otherwise reach memory
411/// exhaustion. No real object comes near this bound: the largest DSS class has
412/// on the order of 100 properties and legitimate scripts edit an object a
413/// handful of times.
414const MAX_OBJECT_PROPS: usize = 1 << 16;
415
416/// Properties retained across every object of one parse. A short script can
417/// fill one object to its cap and then `like=` splice it into many new
418/// objects; the whole-parse budget bounds what all of them together may
419/// retain, refusing further splices and assignments through the clamp
420/// diagnostic. The largest legitimate feeders stay far under it.
421const MAX_TOTAL_PROPS: usize = 1 << 22;
422
423/// Which directory the include containment confines to, for diagnostic
424/// wording: the refusal names the boundary actually in force.
425#[derive(Clone, Copy, Debug, PartialEq)]
426pub(crate) enum IncludeBoundary {
427    CaseDirectory,
428    IncludeRoot,
429}
430
431impl IncludeBoundary {
432    fn noun(self) -> &'static str {
433        match self {
434            Self::CaseDirectory => "the case directory",
435            Self::IncludeRoot => "the include root",
436        }
437    }
438}
439
440struct Executor<'l, L: Loader> {
441    raw: RawDss,
442    loader: &'l mut L,
443    /// Directory stack for relative include resolution; starts with the
444    /// root file's directory, so its depth is the redirect nesting level.
445    dirs: Vec<PathBuf>,
446    /// When set (file parsing), `Redirect`/`Compile`/`Buscoords` includes are
447    /// confined to this directory subtree, so an untrusted case file cannot
448    /// read outside its own directory. `None` leaves includes unconfined, for
449    /// the in-memory loaders used by tests and string parsing (which installs
450    /// a loader that reads nothing).
451    root: Option<PathBuf>,
452    /// Names the confinement boundary in refusal messages: the case directory
453    /// by default, the caller's include root when one was widened onto `root`.
454    boundary: IncludeBoundary,
455    /// Includes followed and script bytes they pulled in, against
456    /// [`MAX_TOTAL_INCLUDES`] and [`MAX_TOTAL_INCLUDE_BYTES`]. `budget_spent`
457    /// keeps the refusal to one message however many includes follow it.
458    includes: usize,
459    include_bytes: usize,
460    budget_spent: bool,
461    /// Vars defined and bytes of their names and values stored, against
462    /// [`MAX_TOTAL_VARS`] and [`MAX_TOTAL_VAR_BYTES`]. Lives here rather than
463    /// on `RawDss`, which `Clear` resets, so a budget already spent stays
464    /// spent across a `Clear`, on the same rule as the include budget.
465    var_count: usize,
466    var_bytes: usize,
467    var_budget_spent: bool,
468}
469
470/// Collapses `.` and `..` lexically, without touching the filesystem. A
471/// leading `..` is preserved so a path that climbs above its base fails the
472/// containment check rather than silently resolving somewhere inside it.
473fn lexical_normalize(p: &Path) -> PathBuf {
474    use std::path::Component;
475    let mut out: Vec<Component<'_>> = Vec::new();
476    for comp in p.components() {
477        match comp {
478            Component::CurDir => {}
479            Component::ParentDir if matches!(out.last(), Some(Component::Normal(_))) => {
480                out.pop();
481            }
482            other => out.push(other),
483        }
484    }
485    out.into_iter().collect()
486}
487
488/// Splits script text into command lines, dropping block comments. A block
489/// comment starts when the first nonspace characters are `/*` and ends on the
490/// first line containing `*/`; both boundary lines are consumed whole,
491/// matching the OpenDSS executive.
492fn command_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
493    let mut in_block = false;
494    text.lines().enumerate().filter_map(move |(i, line)| {
495        if in_block {
496            if line.contains("*/") {
497                in_block = false;
498            }
499            return None;
500        }
501        if line.trim_start().starts_with("/*") {
502            in_block = true;
503            if line.contains("*/") {
504                in_block = false;
505            }
506            return None;
507        }
508        Some((i + 1, line))
509    })
510}
511
512impl<L: Loader> Executor<'_, L> {
513    fn run_script(&mut self, text: &str, file: &str) {
514        for (line_no, line) in command_lines(text) {
515            if self.raw.halted {
516                return;
517            }
518            self.run_command(line, file, line_no);
519        }
520    }
521
522    fn run_command(&mut self, line: &str, file: &str, line_no: usize) {
523        // The scanner substitutes against a snapshot of the var table so the
524        // live table stays free for mutation: `var` inserts into it directly
525        // and redirected files both see and extend it. The snapshot only
526        // diverges for a self referencing `var` line, which OpenDSS scripts
527        // do not write. Taking the table rather than cloning it avoids
528        // copying it on every command; `do_var` inserts land in the now
529        // empty live table and are merged back onto the snapshot afterward.
530        // A `Clear` during dispatch replaces the table outright, so the
531        // restore is skipped when `vars_generation` shows one ran.
532        let vars = std::mem::take(&mut self.raw.vars);
533        let generation = self.raw.vars_generation;
534        let mut scan = Scanner::new(line, Some(&vars));
535        let ctx = |msg: String| format!("{file}:{line_no}: {msg}");
536        match scan.next_param() {
537            None => {}
538            Some(first) if first.value.text.is_empty() && first.name.is_none() => {}
539            Some(first) => {
540                if let Some(name) = first.name {
541                    // First parameter is name=value: a property reference
542                    // like `Transformer.Reg1.Taps=[...]`.
543                    self.edit_property_reference(&name, first.value, &mut scan, &ctx);
544                } else {
545                    self.dispatch(first.value.text, &mut scan, &ctx);
546                }
547            }
548        }
549        if self.raw.vars_generation == generation {
550            let added = std::mem::replace(&mut self.raw.vars, vars);
551            self.raw.vars.extend(added);
552        }
553    }
554
555    fn dispatch(&mut self, verb: String, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
556        match command_index(&verb).map(|i| COMMANDS[i]) {
557            Some("new") => self.do_new(scan, ctx),
558            Some("edit") => self.do_edit(scan, ctx),
559            Some("more" | "m" | "~") => self.do_more(scan, ctx),
560            Some("select") => self.do_select(scan, ctx),
561            Some("set") => self.do_set(scan),
562            Some("redirect") => self.do_redirect(scan, false, ctx),
563            Some("compile") => self.do_redirect(scan, true, ctx),
564            Some("buscoords") => self.do_buscoords(scan, ctx),
565            Some("setbusxy") => self.do_setbusxy(scan, ctx),
566            Some("var") => self.do_var(scan, ctx),
567            Some("clear" | "clearall") => self.raw.clear(),
568            Some("//") => {}
569            Some(canonical) => {
570                if self.raw.charge_preserved() {
571                    self.raw.commands.push(RawCommand {
572                        verb: canonical.to_string(),
573                        args: scan.remainder().to_string(),
574                    });
575                }
576            }
577            None => {
578                self.raw.warn(
579                    &C::PARSE_DSS_SOURCE_MALFORMED,
580                    ctx(format!("unknown command `{verb}`; line preserved verbatim")),
581                );
582                if self.raw.charge_preserved() {
583                    self.raw.commands.push(RawCommand {
584                        verb,
585                        args: scan.remainder().to_string(),
586                    });
587                }
588            }
589        }
590    }
591
592    /// `var @name=value ...` defines parser variables. TParserVar::Add
593    /// stores every value brace wrapped unless it begins with `@`;
594    /// CheckforVar unwraps the braces into a quoted token, so a definition
595    /// like `var @z=(8 1000 /)` still evaluates as RPN where it is used.
596    fn do_var(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
597        while let Some(p) = scan.next_param() {
598            if p.value.text.is_empty() && p.name.is_none() {
599                break;
600            }
601            if let Some(name) = p.name {
602                let stored = if p.value.text.starts_with('@') {
603                    p.value.text
604                } else {
605                    format!("{{{}}}", p.value.text)
606                };
607                let key = name.to_ascii_lowercase();
608                if self.charge_var(&key, &stored, ctx) {
609                    self.raw.vars.insert(key, stored);
610                }
611            }
612        }
613    }
614
615    /// Charges one var definition against the budgets, returning whether to
616    /// store it. Charged at the attempt, so a definition right at either cap
617    /// is refused before insertion, on the same rule as [`Self::charge_include`].
618    fn charge_var(&mut self, name: &str, stored: &str, ctx: &dyn Fn(String) -> String) -> bool {
619        let bytes = name.len() + stored.len();
620        let over_budget = self.var_budget_spent
621            || self.var_count >= MAX_TOTAL_VARS
622            || self.var_bytes.saturating_add(bytes) > MAX_TOTAL_VAR_BYTES;
623        if !over_budget {
624            self.var_count += 1;
625            self.var_bytes += bytes;
626            return true;
627        }
628        if !self.var_budget_spent {
629            self.var_budget_spent = true;
630            let message = ctx(format!(
631                "var {name}: refused; the case exceeded the variable budget of {MAX_TOTAL_VARS} \
632                 entries and {} KiB, so further variables were not stored",
633                MAX_TOTAL_VAR_BYTES >> 10,
634            ));
635            self.raw
636                .warn(&crate::diagnostics::codes::READ_DSS_VALUE_CLAMPED, message);
637        }
638        false
639    }
640
641    /// A leading `name=value` parameter is a property reference
642    /// (ExecCommands ProcessCommand): `Class.Name.Prop=value`,
643    /// `Name.Prop=value` with the class omitted, or `Prop=value` on the
644    /// active object. ParseObjName cuts the object part at the second dot;
645    /// SetObject resolves an omitted class to the last referenced one,
646    /// which here is the active object's class.
647    fn edit_property_reference(
648        &mut self,
649        spec: &str,
650        value: Value,
651        scan: &mut Scanner,
652        ctx: &dyn Fn(String) -> String,
653    ) {
654        let (object, prop) = match spec.split_once('.') {
655            None => (None, spec),
656            Some((first, rest)) => match rest.split_once('.') {
657                None => (Some((None, first)), rest),
658                Some((name, prop)) => (Some((Some(first), name)), prop),
659            },
660        };
661        let active_or = |raw: &mut RawDss| {
662            let active = raw.active;
663            if active.is_none() {
664                raw.warn(
665                    &C::PARSE_DSS_SOURCE_MALFORMED,
666                    ctx(format!("`{spec}=` with no active object")),
667                );
668            }
669            active
670        };
671        let idx = match object {
672            None => match active_or(&mut self.raw) {
673                Some(idx) => idx,
674                None => return,
675            },
676            Some((class, name)) => {
677                let class = match class {
678                    Some(c) => c.to_ascii_lowercase(),
679                    None => match active_or(&mut self.raw) {
680                        Some(idx) => self.raw.objects[idx].class.clone(),
681                        None => return,
682                    },
683                };
684                if let Some(idx) = self
685                    .raw
686                    .index
687                    .get(&(class.clone(), name.to_ascii_lowercase()))
688                    .copied()
689                {
690                    idx
691                } else {
692                    self.raw.warn(
693                        &C::READ_DSS_REFERENCE_DROPPED,
694                        ctx(format!(
695                            "property reference to unknown object `{class}.{name}`"
696                        )),
697                    );
698                    return;
699                }
700            }
701        };
702        self.raw.active = Some(idx);
703        let table = prop_table(&self.raw.objects[idx].class);
704        let name = match table {
705            Some(c) => {
706                if let Some(i) = c.prop_index(prop) {
707                    c.props[i].to_string()
708                } else {
709                    self.raw.warn(
710                        &C::READ_DSS_PROPERTY_UNKNOWN,
711                        ctx(format!(
712                            "unknown property `{prop}` on {}; kept as written",
713                            c.name
714                        )),
715                    );
716                    prop.to_ascii_lowercase()
717                }
718            }
719            None => prop.to_ascii_lowercase(),
720        };
721        let mut props = vec![RawProp {
722            name: Some(name),
723            value,
724        }];
725        props.extend(collect_props_for(
726            table,
727            scan,
728            Some(prop),
729            &mut self.raw,
730            ctx,
731        ));
732        self.apply_props(idx, props, ctx);
733    }
734
735    fn do_new(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
736        let Some((class, name)) = self.object_spec(scan, ctx) else {
737            return;
738        };
739        if class.eq_ignore_ascii_case("circuit") {
740            // A new circuit brings its Vsource named "source"; the line's
741            // remaining properties edit that source. Its defaults (bus1 =
742            // sourcebus etc.) stay implicit here so the reader can tell
743            // written values from materialized defaults.
744            self.raw.circuit_name = Some(name);
745            let idx = self.make_object("vsource", "source".into());
746            self.consume_and_apply(idx, scan, ctx);
747            return;
748        }
749        let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
750        let idx = match self.raw.index.get(&key) {
751            Some(&existing) => {
752                self.raw.warn(
753                    &C::PARSE_DSS_SOURCE_MALFORMED,
754                    ctx(format!(
755                        "duplicate `New {class}.{name}`; editing the existing object"
756                    )),
757                );
758                existing
759            }
760            None => self.make_object(&class, name),
761        };
762        self.consume_and_apply(idx, scan, ctx);
763    }
764
765    fn do_edit(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
766        let Some((class, name)) = self.object_spec(scan, ctx) else {
767            return;
768        };
769        let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
770        let Some(&idx) = self.raw.index.get(&key) else {
771            self.raw.warn(
772                &C::READ_DSS_REFERENCE_DROPPED,
773                ctx(format!("`Edit {class}.{name}` on an unknown object")),
774            );
775            return;
776        };
777        self.consume_and_apply(idx, scan, ctx);
778    }
779
780    fn do_more(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
781        let Some(idx) = self.raw.active else {
782            self.raw.warn(
783                &C::PARSE_DSS_SOURCE_MALFORMED,
784                ctx("`~` with no active object".into()),
785            );
786            return;
787        };
788        self.consume_and_apply(idx, scan, ctx);
789    }
790
791    fn do_select(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
792        let Some((class, name)) = self.object_spec(scan, ctx) else {
793            return;
794        };
795        let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
796        match self.raw.index.get(&key) {
797            Some(&idx) => self.raw.active = Some(idx),
798            None => self.raw.warn(
799                &C::READ_DSS_REFERENCE_DROPPED,
800                ctx(format!("`Select {class}.{name}` on an unknown object")),
801            ),
802        }
803    }
804
805    fn do_set(&mut self, scan: &mut Scanner) {
806        while let Some(p) = scan.next_param() {
807            if p.value.text.is_empty() && p.name.is_none() {
808                break;
809            }
810            let name = p.name.unwrap_or_default().to_ascii_lowercase();
811            if self.raw.charge_preserved() {
812                self.raw.options.push((name, p.value));
813            }
814        }
815    }
816
817    /// Resolves a file argument relative to the current file's directory.
818    /// Backslash separators (the format's DOS heritage) become `/`. Returns
819    /// `None` when a confinement root is set (file parsing) and the resolved
820    /// path does not lexically sit under the root — whether it climbs out with
821    /// `..` or is an absolute path outside the root — so an untrusted case file
822    /// cannot pull in arbitrary paths. An absolute include is admitted only
823    /// when it strips the root as a prefix, which requires the root itself to
824    /// be absolute; the file entry points normalize the case file's parent, so
825    /// a case given by an absolute path admits absolute includes inside its
826    /// own directory, while a relative case path admits only relative ones.
827    fn resolve(&self, file_arg: &str) -> Option<PathBuf> {
828        use std::path::Component;
829        let rel = file_arg.replace('\\', "/");
830        let base = self.dirs.last().cloned().unwrap_or_default();
831        let joined = base.join(&rel);
832        match &self.root {
833            None => Some(joined),
834            Some(root) => {
835                // Containment: after stripping the root prefix, only plain
836                // name components may remain. A leftover `..`, root, or drive
837                // prefix means the path escapes — this also covers an empty
838                // root (case file in the working directory), where
839                // `starts_with` alone would accept absolute paths, and a root
840                // that itself begins with `..`, where counting leading `..`
841                // components would misjudge the climb.
842                let normalized = lexical_normalize(&joined);
843                normalized
844                    .strip_prefix(root)
845                    .is_ok_and(|rest| rest.components().all(|c| matches!(c, Component::Normal(_))))
846                    .then_some(normalized)
847            }
848        }
849    }
850
851    /// Resolves an include argument, warning when it is refused for escaping
852    /// the confinement boundary. `None` tells the caller to skip the include.
853    fn resolve_or_warn(
854        &mut self,
855        verb: &str,
856        file_arg: &str,
857        ctx: &dyn Fn(String) -> String,
858    ) -> Option<PathBuf> {
859        let resolved = self.resolve(file_arg);
860        if resolved.is_none() {
861            let message = ctx(format!(
862                "{verb} {file_arg}: refused; include escapes {}",
863                self.boundary.noun()
864            ));
865            self.refuse_escape(message);
866        }
867        resolved
868    }
869
870    /// Records an include refused for leaving the confinement boundary: the
871    /// warning line and the `Error` finding that keeps the run from exiting 0.
872    fn refuse_escape(&mut self, message: String) {
873        let suggested_action = match self.boundary {
874            IncludeBoundary::CaseDirectory => {
875                "place included files inside the case directory, or merge them into the case"
876            }
877            IncludeBoundary::IncludeRoot => {
878                "place included files inside the include root, or merge them into the case"
879            }
880        };
881        self.refuse(
882            message,
883            &crate::diagnostics::codes::READ_DSS_INCLUDE_REFUSED,
884            suggested_action,
885        );
886    }
887
888    /// Records a refused include: the warning line and the `Error` finding
889    /// that keeps the run from exiting 0.
890    fn refuse(
891        &mut self,
892        message: String,
893        code: &'static crate::diagnostics::DiagnosticInfo,
894        suggested_action: &'static str,
895    ) {
896        self.raw.record(
897            crate::diagnostics::Diagnostic::of(code, message)
898                .with_suggested_action(suggested_action),
899        );
900    }
901
902    /// Charges one include against the budgets, returning whether to follow it.
903    /// Charged at the attempt, so the loader is never called past the budget
904    /// and the syscalls are bounded with the work. The counters live on the
905    /// executor rather than in `RawDss`, which `Clear` resets. The refusal it
906    /// emits stays put too: `RawDss::clear` keeps the parse record.
907    fn charge_include(&mut self, verb: &str, path: &Path, ctx: &dyn Fn(String) -> String) -> bool {
908        let over_budget = self.budget_spent
909            || self.includes >= MAX_TOTAL_INCLUDES
910            || self.include_bytes >= MAX_TOTAL_INCLUDE_BYTES;
911        if !over_budget {
912            self.includes += 1;
913            return true;
914        }
915        if !self.budget_spent {
916            self.budget_spent = true;
917            let message = ctx(format!(
918                "{verb} {}: refused; the case exceeded the include budget of {MAX_TOTAL_INCLUDES} \
919                 files and {} MiB, so the rest of the includes were not followed",
920                path.display(),
921                MAX_TOTAL_INCLUDE_BYTES >> 20,
922            ));
923            self.refuse(
924                message,
925                &crate::diagnostics::codes::READ_DSS_INCLUDE_BUDGET,
926                "check the case for an include cycle; a file that redirects to itself expands \
927                 without bound",
928            );
929        }
930        false
931    }
932
933    /// Records a failed include load. A containment refusal is the loader's
934    /// own, covering what the lexical check cannot see: the path is inside the
935    /// case directory but resolves out of it through a symbolic link. It
936    /// carries the same code and severity as a lexical refusal. Every other
937    /// load failure — including a `PermissionDenied` the filesystem raised on
938    /// an include that is where it claims to be — stays a warning.
939    fn warn_load_error(
940        &mut self,
941        verb: &str,
942        path: &Path,
943        e: &std::io::Error,
944        ctx: &dyn Fn(String) -> String,
945    ) {
946        let message = ctx(format!("{verb} {}: {e}", path.display()));
947        if Containment::refused_by_us(e) {
948            self.refuse_escape(message);
949        } else {
950            self.raw.warn(&C::READ_DSS_INCLUDE_LOAD_FAILED, message);
951        }
952    }
953
954    fn do_redirect(&mut self, scan: &mut Scanner, compile: bool, ctx: &dyn Fn(String) -> String) {
955        let Some(p) = scan.next_param() else {
956            self.raw.warn(
957                &C::PARSE_DSS_SOURCE_MALFORMED,
958                ctx("redirect with no file".into()),
959            );
960            return;
961        };
962        let verb = if compile { "compile" } else { "redirect" };
963        let Some(path) = self.resolve_or_warn(verb, &p.value.text, ctx) else {
964            return;
965        };
966        if self.dirs.len() > MAX_REDIRECT_DEPTH {
967            self.raw.warn(
968                &C::READ_DSS_INCLUDE_DEPTH_LIMIT,
969                ctx(format!("redirect depth limit at {}", path.display())),
970            );
971            return;
972        }
973        if !self.charge_include(verb, &path, ctx) {
974            return;
975        }
976        match self.loader.load(&path) {
977            Ok(text) => {
978                self.include_bytes += text.len();
979                let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
980                self.dirs.push(dir.clone());
981                // `run_command` took the live var table out for `scan` to
982                // borrow, so it reads empty here; reinstall the ambient
983                // table (one copy, bounded by the include budget rather than
984                // paid per command) so the include's own commands see and
985                // can extend it, exactly like a directly nested command
986                // would. A `Clear` inside the include bumps the generation
987                // and installs its own empty table, which the check below
988                // leaves alone instead of overwriting with the pre-redirect
989                // snapshot.
990                let ambient = scan.vars().cloned().unwrap_or_default();
991                let outer = std::mem::replace(&mut self.raw.vars, ambient);
992                let generation = self.raw.vars_generation;
993                self.run_script(&text, &path.display().to_string());
994                if self.raw.vars_generation == generation {
995                    let added = std::mem::replace(&mut self.raw.vars, outer);
996                    self.raw.vars.extend(added);
997                }
998                self.dirs.pop();
999                // The engine keeps one current directory: Redirect restores
1000                // the caller's on return (SetCurrentDir(SaveDir)), Compile
1001                // pins it to the compiled file's OWN directory — ExecHelper
1002                // DoRedirect sets CurrDir once from the file path (~:300)
1003                // and compile exit reapplies it via SetDataPath (~:361) —
1004                // even when the compiled script itself compiled deeper. The
1005                // caller's later relative paths follow the compiled file.
1006                if compile && let Some(top) = self.dirs.last_mut() {
1007                    *top = dir;
1008                }
1009            }
1010            Err(e) => self.warn_load_error(verb, &path, &e, ctx),
1011        }
1012    }
1013
1014    fn do_buscoords(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
1015        let Some(p) = scan.next_param() else {
1016            self.raw.warn(
1017                &C::PARSE_DSS_SOURCE_MALFORMED,
1018                ctx("buscoords with no file".into()),
1019            );
1020            return;
1021        };
1022        let Some(path) = self.resolve_or_warn("buscoords", &p.value.text, ctx) else {
1023            return;
1024        };
1025        if !self.charge_include("buscoords", &path, ctx) {
1026            return;
1027        }
1028        match self.loader.load(&path) {
1029            Ok(text) => {
1030                self.include_bytes += text.len();
1031                for (line_no, line) in text.lines().enumerate() {
1032                    if self.raw.halted {
1033                        break;
1034                    }
1035                    let mut s = Scanner::new(line, None);
1036                    let Some(bus) = s.next_param() else { continue };
1037                    if bus.value.text.is_empty() {
1038                        continue;
1039                    }
1040                    let x = s.next_param().map(|p| p.value).unwrap_or_default();
1041                    let y = s.next_param().map(|p| p.value).unwrap_or_default();
1042                    match (x.to_f64(None), y.to_f64(None)) {
1043                        (Ok(x), Ok(y)) => {
1044                            if self.raw.charge_preserved() {
1045                                self.raw.buscoords.push(BusCoord {
1046                                    bus: bus.value.text,
1047                                    x,
1048                                    y,
1049                                });
1050                            }
1051                        }
1052                        _ => self.raw.warn(
1053                            &C::PARSE_DSS_SOURCE_MALFORMED,
1054                            ctx(format!(
1055                                "buscoords {}:{}: unparseable coordinates",
1056                                path.display(),
1057                                line_no + 1
1058                            )),
1059                        ),
1060                    }
1061                }
1062            }
1063            Err(e) => self.warn_load_error("buscoords", &path, &e, ctx),
1064        }
1065    }
1066
1067    /// `SetBusXY bus=name x=.. y=..`, named or positional. The inline twin of
1068    /// `Buscoords`: it states one bus's coordinates in the deck itself, so it
1069    /// is the form that survives a single-document round trip.
1070    fn do_setbusxy(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
1071        let mut bus: Option<String> = None;
1072        let mut x: Option<f64> = None;
1073        let mut y: Option<f64> = None;
1074        let mut position = 0usize;
1075        while let Some(p) = scan.next_param() {
1076            let slot = match p.name.as_deref().map(str::to_ascii_lowercase).as_deref() {
1077                Some("bus") => 0,
1078                Some("x") => 1,
1079                Some("y") => 2,
1080                Some(_) => continue,
1081                None => {
1082                    let slot = position;
1083                    position += 1;
1084                    slot
1085                }
1086            };
1087            match slot {
1088                0 => bus = Some(p.value.text),
1089                1 => x = p.value.to_f64(None).ok(),
1090                2 => y = p.value.to_f64(None).ok(),
1091                _ => {}
1092            }
1093        }
1094        match (bus, x, y) {
1095            (Some(bus), Some(x), Some(y)) if !bus.is_empty() => {
1096                if self.raw.charge_preserved() {
1097                    self.raw.buscoords.push(BusCoord { bus, x, y });
1098                }
1099            }
1100            _ => self.raw.warn(
1101                &C::PARSE_DSS_SOURCE_MALFORMED,
1102                ctx("setbusxy needs a bus and numeric x and y".into()),
1103            ),
1104        }
1105    }
1106
1107    /// Reads `Class.Name` (or `object=Class.Name`) from the next parameter.
1108    fn object_spec(
1109        &mut self,
1110        scan: &mut Scanner,
1111        ctx: &dyn Fn(String) -> String,
1112    ) -> Option<(String, String)> {
1113        let p = scan.next_param()?;
1114        if let Some(name) = &p.name
1115            && !name.eq_ignore_ascii_case("object")
1116        {
1117            self.raw.warn(
1118                &C::PARSE_DSS_SOURCE_MALFORMED,
1119                ctx(format!("expected Class.Name, got `{name}=`")),
1120            );
1121            return None;
1122        }
1123        let spec = p.value.text;
1124        match spec.split_once('.') {
1125            Some((class, name)) if !class.is_empty() && !name.is_empty() => {
1126                Some((class.to_string(), name.to_string()))
1127            }
1128            _ => {
1129                self.raw.warn(
1130                    &C::PARSE_DSS_SOURCE_MALFORMED,
1131                    ctx(format!("malformed object spec `{spec}`")),
1132                );
1133                None
1134            }
1135        }
1136    }
1137
1138    fn make_object(&mut self, class: &str, name: String) -> usize {
1139        let class_lc = class.to_ascii_lowercase();
1140        let idx = self.raw.objects.len();
1141        self.raw
1142            .index
1143            .insert((class_lc.clone(), name.to_ascii_lowercase()), idx);
1144        self.raw.objects.push(RawObject {
1145            class: class_lc,
1146            name,
1147            props: Vec::new(),
1148            edits: Vec::new(),
1149        });
1150        idx
1151    }
1152
1153    fn consume_and_apply(
1154        &mut self,
1155        idx: usize,
1156        scan: &mut Scanner,
1157        ctx: &dyn Fn(String) -> String,
1158    ) {
1159        let props = collect_props_for(
1160            prop_table(&self.raw.objects[idx].class),
1161            scan,
1162            None,
1163            &mut self.raw,
1164            ctx,
1165        );
1166        self.apply_props(idx, props, ctx);
1167    }
1168
1169    fn apply_props(&mut self, idx: usize, props: Vec<RawProp>, ctx: &dyn Fn(String) -> String) {
1170        self.raw.active = Some(idx);
1171        for p in props {
1172            // `like=<name>` splices the source object's accumulated props,
1173            // checkpoints included: MakeLike copies the source's recalced
1174            // state (Load.cpp ~810-815 takes kWBase, kvarBase, LoadSpecType,
1175            // AND PFNominal), which equals replaying the source's writes
1176            // with its own edit boundaries.
1177            if p.name.as_deref() == Some("like") {
1178                let class = self.raw.objects[idx].class.clone();
1179                let key = (class.clone(), p.value.text.to_ascii_lowercase());
1180                match self.raw.index.get(&key).copied() {
1181                    Some(src) => {
1182                        let base = self.raw.objects[idx].props.len();
1183                        let src_len = self.raw.objects[src].props.len();
1184                        // Refuse a splice that would push the object past the
1185                        // cap. A self reference (`Edit X like=X`) or a mutual
1186                        // chain otherwise doubles the prop count per edit; the
1187                        // guard turns that into a warning instead of unbounded
1188                        // growth.
1189                        if base.saturating_add(src_len) > MAX_OBJECT_PROPS {
1190                            self.raw.warn(
1191                                &C::READ_DSS_VALUE_CLAMPED,
1192                                ctx(format!(
1193                                    "like={}: {class} property count would exceed the supported \
1194                                 maximum of {MAX_OBJECT_PROPS}; splice refused",
1195                                    p.value.text
1196                                )),
1197                            );
1198                            continue;
1199                        }
1200                        if self.raw.total_props.saturating_add(src_len) > MAX_TOTAL_PROPS {
1201                            self.raw.warn(
1202                                &C::READ_DSS_VALUE_CLAMPED,
1203                                ctx(format!(
1204                                    "like={}: the script's retained property count would exceed \
1205                                 the supported maximum of {MAX_TOTAL_PROPS}; splice refused",
1206                                    p.value.text
1207                                )),
1208                            );
1209                            continue;
1210                        }
1211                        self.raw.total_props += src_len;
1212                        let cloned = self.raw.objects[src].props.clone();
1213                        let bounds: Vec<usize> = self.raw.objects[src]
1214                            .edit_bounds()
1215                            .map(|e| base + e)
1216                            .collect();
1217                        self.raw.objects[idx].props.extend(cloned);
1218                        self.raw.objects[idx].edits.extend(bounds);
1219                    }
1220                    None => self.raw.warn(
1221                        &C::READ_DSS_REFERENCE_DROPPED,
1222                        ctx(format!("like={} names an unknown {class}", p.value.text)),
1223                    ),
1224                }
1225                continue;
1226            }
1227            if self.raw.objects[idx].props.len() >= MAX_OBJECT_PROPS {
1228                self.raw.warn(
1229                    &C::READ_DSS_VALUE_CLAMPED,
1230                    ctx(format!(
1231                        "{}: property count exceeds the supported maximum of {MAX_OBJECT_PROPS}; \
1232                     further assignments dropped",
1233                        self.raw.objects[idx].class
1234                    )),
1235                );
1236                continue;
1237            }
1238            if self.raw.total_props >= MAX_TOTAL_PROPS {
1239                self.raw.warn(
1240                    &C::READ_DSS_VALUE_CLAMPED,
1241                    ctx(format!(
1242                        "{}: the script's retained property count exceeds the supported maximum \
1243                     of {MAX_TOTAL_PROPS}; further assignments dropped",
1244                        self.raw.objects[idx].class
1245                    )),
1246                );
1247                continue;
1248            }
1249            self.raw.total_props += 1;
1250            self.raw.objects[idx].props.push(p);
1251        }
1252        // This command line was one engine Edit; it ends in
1253        // RecalcElementData, so record the boundary.
1254        let end = self.raw.objects[idx].props.len();
1255        self.raw.objects[idx].edits.push(end);
1256    }
1257}
1258
1259fn prop_table(class: &str) -> Option<&'static DssClass> {
1260    prop::class_by_name(class)
1261}
1262
1263/// Reads the remaining parameters of an object command, resolving names
1264/// (with abbreviation) and positional order against the class table. The
1265/// positional pointer continues from the last named property, as in the
1266/// reference. `after` seeds the pointer for property reference lines.
1267fn collect_props_for(
1268    class: Option<&'static DssClass>,
1269    scan: &mut Scanner,
1270    after: Option<&str>,
1271    raw: &mut RawDss,
1272    ctx: &dyn Fn(String) -> String,
1273) -> Vec<RawProp> {
1274    let mut out = Vec::new();
1275    let mut pointer: Option<usize> = class.zip(after).and_then(|(c, name)| c.prop_index(name));
1276    while let Some(p) = scan.next_param() {
1277        if p.value.text.is_empty() && p.name.is_none() {
1278            break;
1279        }
1280        let name = match (&p.name, class) {
1281            (Some(written), Some(c)) => {
1282                if let Some(i) = c.prop_index(written) {
1283                    pointer = Some(i);
1284                    Some(c.props[i].to_string())
1285                } else {
1286                    // Getcommand yields 0 for an unknown name, so the next
1287                    // positional lands on property 1 (the class Edit loops:
1288                    // `ParamPointer = CommandList.Getcommand(ParamName)`).
1289                    pointer = None;
1290                    raw.warn(
1291                        &C::READ_DSS_PROPERTY_UNKNOWN,
1292                        ctx(format!(
1293                            "unknown property `{written}` on {}; kept as written",
1294                            c.name
1295                        )),
1296                    );
1297                    Some(written.to_ascii_lowercase())
1298                }
1299            }
1300            (Some(written), None) => Some(written.to_ascii_lowercase()),
1301            (None, Some(c)) => {
1302                let next = pointer.map_or(0, |i| i + 1);
1303                pointer = Some(next);
1304                if let Some(canon) = c.props.get(next) {
1305                    Some((*canon).to_string())
1306                } else {
1307                    raw.warn(
1308                        &C::PARSE_DSS_SOURCE_MALFORMED,
1309                        ctx(format!(
1310                            "positional value `{}` beyond the last {} property",
1311                            p.value.text, c.name
1312                        )),
1313                    );
1314                    None
1315                }
1316            }
1317            (None, None) => None,
1318        };
1319        out.push(RawProp {
1320            name,
1321            value: p.value,
1322        });
1323    }
1324    out
1325}
1326
1327/// Parses `.dss` text. `path` anchors relative includes; pass the file's
1328/// path when the text came from a file, anything descriptive otherwise.
1329///
1330/// Includes are resolved through `loader` without confinement: a caller that
1331/// passes a filesystem-backed loader lets `Redirect`/`Compile`/`Buscoords`
1332/// read any path the loader accepts. For untrusted input use
1333/// [`crate::convert::parse`] on a [`powerio_core::Source`] (acquisition
1334/// confined to the source's root) or [`__parse_raw_file`] (includes confined
1335/// to the case directory), or enforce your own containment inside the
1336/// loader.
1337#[doc(hidden)]
1338pub fn __parse_raw_with(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1339    run_executor(text, path, None, IncludeBoundary::CaseDirectory, loader)
1340}
1341
1342/// Projects a source acquisition failure onto the loader's `io::Error`,
1343/// keeping the executor's split: a refusal the acquisition stack raised (a
1344/// name escaping the root, a symbolic link) reports as a refused include,
1345/// and an ordinary filesystem failure as a load failure.
1346fn include_acquisition_error(error: &powerio_core::Error) -> std::io::Error {
1347    // Matched by registered code spelling: the code register is the stable
1348    // surface a consumer keys on.
1349    let refusal = error.diagnostics().first().is_some_and(|d| {
1350        d.code() == "REQUEST.SOURCE.ESCAPES_ROOT"
1351            || d.code() == "REQUEST.SOURCE.SYMLINK_REFUSED"
1352            || d.code() == "REQUEST.SOURCE.NOT_A_FILE"
1353    });
1354    if refusal {
1355        Containment::refused(error.to_string())
1356    } else {
1357        std::io::Error::other(error.to_string())
1358    }
1359}
1360
1361/// Reads an include the executor already resolved and confined lexically,
1362/// through the source's own acquisition walk, so a path component replaced
1363/// during the parse cannot redirect the read outside the root. The resolved
1364/// path is rebased onto `lexical_root` — the executor's confinement root —
1365/// and acquired by that root relative name; an in-memory source consults its
1366/// named buffers under the same names. A leading byte order mark on an
1367/// included file is not decoded (the retained bytes keep it).
1368fn source_include_loader(
1369    source: &powerio_core::Source,
1370    lexical_root: PathBuf,
1371) -> impl FnMut(&Path) -> std::io::Result<String> {
1372    move |p: &Path| {
1373        let relative = p.strip_prefix(&lexical_root).map_err(|_| {
1374            Containment::refused(format!(
1375                "{} does not sit under the confinement root",
1376                p.display()
1377            ))
1378        })?;
1379        let mut name = String::new();
1380        for component in relative.components() {
1381            if !name.is_empty() {
1382                name.push('/');
1383            }
1384            name.push_str(&component.as_os_str().to_string_lossy());
1385        }
1386        let buffer = source
1387            .root_buffer(&name)
1388            .map_err(|error| include_acquisition_error(&error))?;
1389        let text = std::str::from_utf8(buffer.content_bytes()).map_err(|e| {
1390            std::io::Error::new(
1391                std::io::ErrorKind::InvalidData,
1392                format!("stream did not contain valid UTF-8: {e}"),
1393            )
1394        })?;
1395        Ok(text.to_owned())
1396    }
1397}
1398
1399/// Executes one `.dss` source, following `Redirect`/`Compile`/`Buscoords`
1400/// includes through the source's own acquisition policy: a file source
1401/// admits includes beneath its acquisition root — the case directory, unless
1402/// a wider root was selected with
1403/// [`powerio_core::Source::with_acquisition_root`] — and an in-memory source
1404/// admits only the named buffers supplied to it. An include that escapes is
1405/// refused with an `Error` finding, whether it climbs out with `..`, is an
1406/// absolute path outside the root, or escapes through a symbolic link; the
1407/// acquisition walk refuses symbolic links at read time, so a component
1408/// replaced during the parse cannot redirect a read outside the root. The
1409/// include budget in files and bytes and the nesting limit are enforced by
1410/// the executor unchanged.
1411pub(crate) fn parse_raw_from_source(source: &powerio_core::Source) -> Result<RawDss> {
1412    let primary = source.primary_buffer().map_err(|error| Error::FormatRead {
1413        format: "dss",
1414        message: error.to_string(),
1415    })?;
1416    let text = std::str::from_utf8(primary.content_bytes()).map_err(|e| Error::FormatRead {
1417        format: "case text",
1418        message: format!("not valid UTF-8: {e}"),
1419    })?;
1420    if let Some(root) = source.selected_acquisition_root() {
1421        // A selected root is canonical, so resolution runs in the canonical
1422        // root's space, the same space the acquisition walk confines to. The
1423        // primary's own directory seeds relative includes.
1424        let mut file = root.to_path_buf();
1425        file.extend(primary.directory_segments());
1426        match Path::new(source.name()).file_name() {
1427            Some(base) => file.push(base),
1428            None => file.push(source.name()),
1429        }
1430        let mut loader = source_include_loader(source, root.to_path_buf());
1431        Ok(parse_raw_confined_under(
1432            text,
1433            &file.display().to_string(),
1434            root,
1435            &mut loader,
1436        ))
1437    } else if primary.directory_segments().next().is_some() {
1438        // A memory-tree primary is relative to the supplied tree root.
1439        let root = Path::new("");
1440        let mut loader = source_include_loader(source, root.to_path_buf());
1441        Ok(parse_raw_confined_under(
1442            text,
1443            source.name(),
1444            root,
1445            &mut loader,
1446        ))
1447    } else {
1448        // The default root of a file source is its containing directory, and
1449        // an in-memory source resolves the same way against its (possibly
1450        // empty) name prefix; either way the executor's lexical root is the
1451        // parent of the source name, and the loader rebases resolved paths
1452        // onto it.
1453        let lexical_root = lexical_normalize(
1454            Path::new(source.name())
1455                .parent()
1456                .unwrap_or_else(|| Path::new("")),
1457        );
1458        let mut loader = source_include_loader(source, lexical_root);
1459        Ok(parse_raw_with_confined(text, source.name(), &mut loader))
1460    }
1461}
1462
1463/// The loader's own containment refusal, carried inside the `io::Error` so it
1464/// stays distinguishable from a `PermissionDenied` the filesystem raised. A
1465/// mode 000 file inside the case directory is an ordinary unreadable include,
1466/// not an escape attempt, and must not be reported as one.
1467#[derive(Debug)]
1468pub(crate) struct Containment(String);
1469
1470impl Containment {
1471    pub(crate) fn refused(reason: impl Into<String>) -> std::io::Error {
1472        std::io::Error::new(
1473            std::io::ErrorKind::PermissionDenied,
1474            Containment(reason.into()),
1475        )
1476    }
1477
1478    /// Whether `e` is a refusal this module raised.
1479    pub(crate) fn refused_by_us(e: &std::io::Error) -> bool {
1480        e.get_ref()
1481            .is_some_and(<dyn std::error::Error + Send + Sync>::is::<Self>)
1482    }
1483}
1484
1485impl std::fmt::Display for Containment {
1486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1487        f.write_str(&self.0)
1488    }
1489}
1490
1491impl std::error::Error for Containment {}
1492
1493/// Like [`__parse_raw_with`], but confines `Redirect`/`Compile`/`Buscoords`
1494/// includes to the directory of `path`: an include that is absolute or climbs
1495/// out of that directory with `..` is refused with a warning and read nothing.
1496/// Used by the file entry point so an untrusted case file on disk cannot read
1497/// arbitrary paths.
1498pub(crate) fn parse_raw_with_confined(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1499    let root = lexical_normalize(
1500        &Path::new(path)
1501            .parent()
1502            .map(Path::to_path_buf)
1503            .unwrap_or_default(),
1504    );
1505    run_executor(
1506        text,
1507        path,
1508        Some(root),
1509        IncludeBoundary::CaseDirectory,
1510        loader,
1511    )
1512}
1513
1514/// Like [`parse_raw_with_confined`], but confines includes to `include_root`
1515/// instead of the case directory. The caller vouches that the case file sits
1516/// under `include_root`; refusals name the include root as the boundary.
1517pub(crate) fn parse_raw_confined_under(
1518    text: &str,
1519    path: &str,
1520    include_root: &Path,
1521    loader: &mut impl Loader,
1522) -> RawDss {
1523    let root = lexical_normalize(include_root);
1524    run_executor(text, path, Some(root), IncludeBoundary::IncludeRoot, loader)
1525}
1526
1527fn run_executor(
1528    text: &str,
1529    path: &str,
1530    root: Option<PathBuf>,
1531    boundary: IncludeBoundary,
1532    loader: &mut impl Loader,
1533) -> RawDss {
1534    let mut exec = Executor {
1535        raw: RawDss::default(),
1536        loader,
1537        dirs: vec![
1538            Path::new(path)
1539                .parent()
1540                .map(Path::to_path_buf)
1541                .unwrap_or_default(),
1542        ],
1543        root,
1544        boundary,
1545        includes: 0,
1546        include_bytes: 0,
1547        budget_spent: false,
1548        var_count: 0,
1549        var_bytes: 0,
1550        var_budget_spent: false,
1551    };
1552    exec.run_script(text, path);
1553    exec.raw
1554}
1555
1556/// Parses a `.dss` file from disk, following its includes through the
1557/// source's acquisition walk. `Redirect`/`Compile`/`Buscoords` includes are
1558/// confined to the case directory, lexically and with symbolic links refused
1559/// at read time, exactly like [`crate::convert::parse`] on a file source; an
1560/// include that escapes is refused with a warning. Use [`__parse_raw_with`]
1561/// with your own loader for unconfined resolution.
1562#[doc(hidden)]
1563pub fn __parse_raw_file(path: impl AsRef<Path>) -> Result<RawDss> {
1564    let path = path.as_ref();
1565    let source = powerio_core::Source::open(path).map_err(|error| Error::Io {
1566        path: path.display().to_string(),
1567        source: std::io::Error::other(error.to_string()),
1568    })?;
1569    parse_raw_from_source(&source)
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use super::*;
1575
1576    fn no_files(_: &Path) -> std::io::Result<String> {
1577        Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test"))
1578    }
1579
1580    fn parse(text: &str) -> RawDss {
1581        __parse_raw_with(text, "test.dss", &mut no_files)
1582    }
1583
1584    #[test]
1585    fn new_object_with_positional_and_named() {
1586        let raw = parse("New Line.l1 b1 b2 lc 0.3 phases=2 r1=0.1");
1587        let l = raw.find("line", "l1").unwrap();
1588        assert_eq!(l.get("bus1").unwrap().text, "b1");
1589        assert_eq!(l.get("bus2").unwrap().text, "b2");
1590        assert_eq!(l.get("linecode").unwrap().text, "lc");
1591        assert_eq!(l.get("length").unwrap().text, "0.3");
1592        assert_eq!(l.get("phases").unwrap().text, "2");
1593        assert_eq!(l.get("r1").unwrap().text, "0.1");
1594        assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1595    }
1596
1597    #[test]
1598    fn positional_continues_after_named() {
1599        // After r1=0.1 (index 5), the next positional is x1 (index 6).
1600        let raw = parse("New Line.l1 r1=0.1 0.2");
1601        let l = raw.find("line", "l1").unwrap();
1602        assert_eq!(l.get("x1").unwrap().text, "0.2");
1603    }
1604
1605    #[test]
1606    fn unknown_property_resets_the_positional_pointer() {
1607        // `ParamPointer = Getcommand("bogus")` is 0 in the engine, so the
1608        // next positional gets property 1 (bus1), not the one after r1.
1609        let raw = parse("New Line.l1 r1=0.1 bogus=2 0.5");
1610        let l = raw.find("line", "l1").unwrap();
1611        assert_eq!(l.get("bus1").unwrap().text, "0.5");
1612        assert!(l.get("x1").is_none());
1613        assert_eq!(raw.warnings.len(), 1);
1614    }
1615
1616    #[test]
1617    fn tilde_continues_the_active_object() {
1618        let raw = parse("New Load.ld bus1=b1\n~ kW=15 kvar=3\nMore pf=0.9");
1619        let ld = raw.find("load", "ld").unwrap();
1620        assert_eq!(ld.get("kw").unwrap().text, "15");
1621        assert_eq!(ld.get("kvar").unwrap().text, "3");
1622        assert_eq!(ld.get("pf").unwrap().text, "0.9");
1623    }
1624
1625    #[test]
1626    fn abbreviated_property_names() {
1627        let raw = parse("New Line.l1 ph=3 len=2 rm=(1 | 0 1)");
1628        let l = raw.find("line", "l1").unwrap();
1629        assert_eq!(l.get("phases").unwrap().text, "3");
1630        assert_eq!(l.get("length").unwrap().text, "2");
1631        assert!(l.get("rmatrix").unwrap().quoted);
1632    }
1633
1634    #[test]
1635    fn new_circuit_creates_the_source() {
1636        let raw = parse("New Circuit.test basekv=115 pu=1.05\n~ angle=30");
1637        assert_eq!(raw.circuit_name.as_deref(), Some("test"));
1638        let vs = raw.find("vsource", "source").unwrap();
1639        assert_eq!(vs.get("basekv").unwrap().text, "115");
1640        assert_eq!(vs.get("angle").unwrap().text, "30");
1641        // bus1 was not written; the default (sourcebus) is the reader's to
1642        // materialize, so the raw layer must not invent it.
1643        assert!(vs.get("bus1").is_none());
1644    }
1645
1646    #[test]
1647    fn edit_and_property_reference() {
1648        let raw = parse("New Line.l1 length=1\nEdit Line.l1 length=2\nLine.l1.Length=3 phases=2");
1649        let l = raw.find("line", "l1").unwrap();
1650        assert_eq!(l.get("length").unwrap().text, "3");
1651        assert_eq!(l.get("phases").unwrap().text, "2");
1652    }
1653
1654    #[test]
1655    fn property_reference_resolves_abbreviations() {
1656        let raw = parse("New Line.l1 bus1=a\nLine.l1.Len=2.5");
1657        let l = raw.find("line", "l1").unwrap();
1658        assert_eq!(l.get("length").unwrap().text, "2.5");
1659        assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1660    }
1661
1662    #[test]
1663    fn bare_property_edits_the_active_object() {
1664        let raw = parse("New Line.l1 bus1=a bus2=b\nlength=2.5");
1665        let l = raw.find("line", "l1").unwrap();
1666        assert_eq!(l.get("length").unwrap().text, "2.5");
1667        assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1668    }
1669
1670    #[test]
1671    fn classless_reference_uses_the_active_class() {
1672        // SetObject with no dot in the spec looks the name up in the last
1673        // referenced class, line here via the active object.
1674        let raw = parse("New Line.l1 bus1=a\nNew Line.l2 bus1=b\nl1.length=7 phases=2");
1675        let l1 = raw.find("line", "l1").unwrap();
1676        assert_eq!(l1.get("length").unwrap().text, "7");
1677        assert_eq!(l1.get("phases").unwrap().text, "2");
1678        assert!(raw.find("line", "l2").unwrap().get("length").is_none());
1679        assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1680    }
1681
1682    #[test]
1683    fn like_splices_source_props() {
1684        let raw = parse("New Load.a kW=10 pf=0.9\nNew Load.b like=a kW=20");
1685        let b = raw.find("load", "b").unwrap();
1686        assert_eq!(b.get("kw").unwrap().text, "20");
1687        assert_eq!(b.get("pf").unwrap().text, "0.9");
1688    }
1689
1690    #[test]
1691    fn the_whole_parse_record_ceilings_bound_repeated_execution() {
1692        // A tiny loader-backed include re-executed many times repeats its
1693        // preserved commands and findings; the ceilings bound what one parse
1694        // may keep and stop execution with exactly one terminal refusal.
1695        struct Repeat;
1696        impl Loader for Repeat {
1697            fn load(&mut self, _: &Path) -> std::io::Result<String> {
1698                // Each load yields many preserved commands and one warning.
1699                let mut text = String::new();
1700                for _ in 0..8192 {
1701                    text.push_str("Solve\n");
1702                }
1703                text.push_str("Bogus命令 arg\n");
1704                Ok(text)
1705            }
1706        }
1707        use std::fmt::Write as _;
1708        let mut script = String::new();
1709        for index in 0..512 {
1710            let _ = writeln!(script, "Redirect inc{index}.dss");
1711        }
1712        let raw = __parse_raw_with(&script, "test.dss", &mut Repeat);
1713        assert!(raw.commands.len() <= MAX_TOTAL_PRESERVED);
1714        assert!(raw.diagnostics.len() <= MAX_TOTAL_DIAGNOSTICS + 1);
1715        let refusals = raw
1716            .warnings
1717            .iter()
1718            .filter(|w| w.contains("whole parse ceiling"))
1719            .count();
1720        assert_eq!(refusals, 1, "exactly one terminal refusal: {refusals}");
1721    }
1722
1723    #[test]
1724    fn crossing_both_ceilings_in_either_order_leaves_one_refusal() {
1725        // Whichever ceiling fires first, the halt is one indivisible step:
1726        // exactly one READ.DSS.INCLUDE_BUDGET record lands in both channels
1727        // and the other ceiling's later crossing adds nothing.
1728        struct Warny;
1729        impl Loader for Warny {
1730            fn load(&mut self, _: &Path) -> std::io::Result<String> {
1731                let mut text = String::new();
1732                for _ in 0..8192 {
1733                    text.push_str("Bogus命令 arg\n");
1734                }
1735                for _ in 0..8192 {
1736                    text.push_str("Solve\n");
1737                }
1738                Ok(text)
1739            }
1740        }
1741        struct Quiet;
1742        impl Loader for Quiet {
1743            fn load(&mut self, _: &Path) -> std::io::Result<String> {
1744                let mut text = String::new();
1745                for _ in 0..16384 {
1746                    text.push_str("Solve\n");
1747                }
1748                text.push_str("Bogus命令 arg\n");
1749                Ok(text)
1750            }
1751        }
1752        let count = |raw: &RawDss| {
1753            (
1754                raw.warnings
1755                    .iter()
1756                    .filter(|w| w.contains("READ.DSS.INCLUDE_BUDGET"))
1757                    .count(),
1758                raw.diagnostics
1759                    .iter()
1760                    .filter(|d| d.code() == "READ.DSS.INCLUDE_BUDGET")
1761                    .count(),
1762            )
1763        };
1764        // Findings first: unknown commands warn AND preserve, so a warning
1765        // heavy prefix crosses the diagnostic ceiling, then preserved lines
1766        // keep arriving.
1767        let mut script = String::new();
1768        {
1769            use std::fmt::Write as _;
1770            for index in 0..64 {
1771                let _ = writeln!(script, "Redirect winc{index}.dss");
1772            }
1773        }
1774        let raw = __parse_raw_with(&script, "test.dss", &mut Warny);
1775        assert!(raw.commands.len() <= MAX_TOTAL_PRESERVED);
1776        assert!(raw.diagnostics.len() <= MAX_TOTAL_DIAGNOSTICS + 1);
1777        assert_eq!(count(&raw), (1, 1), "findings-first order");
1778
1779        // Preserved first: quiet preserved lines cross that ceiling, then
1780        // warning lines keep arriving.
1781        let mut script = String::new();
1782        {
1783            use std::fmt::Write as _;
1784            for index in 0..80 {
1785                let _ = writeln!(script, "Redirect qinc{index}.dss");
1786            }
1787        }
1788        let raw = __parse_raw_with(&script, "test.dss", &mut Quiet);
1789        assert!(raw.commands.len() <= MAX_TOTAL_PRESERVED);
1790        assert!(raw.diagnostics.len() <= MAX_TOTAL_DIAGNOSTICS + 1);
1791        assert_eq!(count(&raw), (1, 1), "preserved-first order");
1792    }
1793
1794    #[test]
1795    fn a_ceiling_crossed_inside_buscoords_halts_without_false_malformed_findings() {
1796        // Once the preserved ceiling fires partway through a coordinate
1797        // file, the halt stops the scan: lines past the refusal are neither
1798        // retained nor reported, and a well formed line is never called
1799        // malformed just because its charge was refused. The unparseable
1800        // tail after the crossing pins the latch itself: scanning past the
1801        // halt would report those lines malformed.
1802        use std::fmt::Write as _;
1803        let root = std::env::temp_dir().join(format!("powerio-dist-fs008-{}", std::process::id()));
1804        std::fs::create_dir_all(&root).unwrap();
1805        let extra = 64usize;
1806        let mut coords = String::with_capacity((MAX_TOTAL_PRESERVED + 2 * extra) * 12);
1807        for index in 0..MAX_TOTAL_PRESERVED + extra {
1808            let _ = writeln!(coords, "b{index} 1 2");
1809        }
1810        for index in 0..extra {
1811            let _ = writeln!(coords, "tail{index} xx yy");
1812        }
1813        std::fs::write(root.join("coords.csv"), coords).unwrap();
1814        std::fs::write(root.join("master.dss"), "Buscoords coords.csv\n").unwrap();
1815
1816        let raw = __parse_raw_file(root.join("master.dss")).unwrap();
1817        std::fs::remove_dir_all(&root).unwrap();
1818
1819        let warned = raw
1820            .warnings
1821            .iter()
1822            .filter(|w| w.contains("READ.DSS.INCLUDE_BUDGET"))
1823            .count();
1824        let recorded = raw
1825            .diagnostics
1826            .iter()
1827            .filter(|d| d.code() == "READ.DSS.INCLUDE_BUDGET")
1828            .count();
1829        assert_eq!((warned, recorded), (1, 1), "exactly one terminal refusal");
1830        let malformed_warned = raw
1831            .warnings
1832            .iter()
1833            .filter(|w| w.contains("PARSE.DSS.SOURCE_MALFORMED"))
1834            .count();
1835        let malformed_recorded = raw
1836            .diagnostics
1837            .iter()
1838            .filter(|d| d.code() == "PARSE.DSS.SOURCE_MALFORMED")
1839            .count();
1840        assert_eq!(
1841            (malformed_warned, malformed_recorded),
1842            (0, 0),
1843            "lines past the halt were scanned and reported"
1844        );
1845        assert_eq!(
1846            raw.buscoords.len(),
1847            MAX_TOTAL_PRESERVED,
1848            "retention must fill the budget exactly and stop"
1849        );
1850    }
1851
1852    #[test]
1853    fn the_whole_parse_property_retention_is_bounded() {
1854        // One object filled near its own cap, then spliced into many new
1855        // objects: per object each splice is legal, but the script's total
1856        // retained properties cross the whole-parse budget and further
1857        // splices refuse through the clamp diagnostic.
1858        use std::fmt::Write as _;
1859        let mut script = String::from("New Load.seed kW=1\n");
1860        // Grow the seed to ~32k props by doubling (self splice under the
1861        // per-object cap).
1862        for _ in 0..15 {
1863            script.push_str("Edit Load.seed like=seed\n");
1864        }
1865        for index in 0..200 {
1866            let _ = writeln!(script, "New Load.c{index} like=seed");
1867        }
1868        let raw = parse(&script);
1869        let total: usize = raw.objects.iter().map(|object| object.props.len()).sum();
1870        assert!(
1871            total <= MAX_TOTAL_PROPS,
1872            "retained {total} properties past the whole-parse budget"
1873        );
1874        assert!(
1875            raw.warnings
1876                .iter()
1877                .any(|w| w.contains("retained property count")),
1878            "expected the whole-parse refusal, got {} warnings",
1879            raw.warnings.len()
1880        );
1881    }
1882
1883    #[test]
1884    fn self_referencing_like_cannot_explode_the_prop_count() {
1885        // `Edit X like=X` splices the object into itself; unbounded, each
1886        // repeat doubles the prop count. A few dozen lines would exhaust
1887        // memory. The cap turns the runaway splices into warnings.
1888        let mut script = String::from("New Load.a kW=1\n");
1889        for _ in 0..40 {
1890            script.push_str("Edit Load.a like=a\n");
1891        }
1892        let raw = parse(&script);
1893        let a = raw.find("load", "a").unwrap();
1894        assert!(
1895            a.props.len() <= MAX_OBJECT_PROPS,
1896            "prop count {} exceeded the cap",
1897            a.props.len()
1898        );
1899        assert!(
1900            raw.warnings.iter().any(|w| w.contains("splice refused")),
1901            "expected a refusal warning, got {:?}",
1902            raw.warnings
1903        );
1904    }
1905
1906    #[test]
1907    fn unknown_class_is_preserved_raw() {
1908        let raw = parse("New Reactor.r1 bus1=b1 x=3");
1909        let r = raw.find("reactor", "r1").unwrap();
1910        assert_eq!(r.get("bus1").unwrap().text, "b1");
1911        assert_eq!(r.get("x").unwrap().text, "3");
1912    }
1913
1914    #[test]
1915    fn set_options_accumulate() {
1916        let raw = parse("Set VoltageBases=[115, 12.47]\nset mode=snapshot");
1917        assert_eq!(raw.options[0].0, "voltagebases");
1918        assert_eq!(
1919            raw.options[0].1.to_vector(None).unwrap(),
1920            vec![115.0, 12.47]
1921        );
1922        assert_eq!(raw.options[1].0, "mode");
1923    }
1924
1925    #[test]
1926    fn unexecuted_commands_are_preserved() {
1927        let raw = parse("Solve\ncalcv\nShow Voltages LN");
1928        let verbs: Vec<&str> = raw.commands.iter().map(|c| c.verb.as_str()).collect();
1929        assert_eq!(verbs, vec!["solve", "calcvoltagebases", "show"]);
1930        assert_eq!(raw.commands[2].args, "Voltages LN");
1931    }
1932
1933    /// `Clear` resets the circuit and nothing about containment. The root and
1934    /// the include budget live on the executor precisely so this holds
1935    /// (GHSA-wg3j-3v62-fv3f); if they ever moved onto `RawDss`, a case could
1936    /// disarm the check by clearing first.
1937    #[test]
1938    fn clear_does_not_disarm_the_include_containment() {
1939        let requested: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
1940        let mut loader = |p: &Path| {
1941            requested.borrow_mut().push(p.display().to_string());
1942            Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1943        };
1944        let raw = parse_raw_with_confined(
1945            "Redirect ../../secret.dss\nClear\nRedirect ../../secret.dss\nRedirect /etc/passwd",
1946            "/case/dir/master.dss",
1947            &mut loader,
1948        );
1949        assert!(
1950            requested.borrow().is_empty(),
1951            "an escaping include reached the loader after Clear: {:?}",
1952            requested.borrow()
1953        );
1954        assert_eq!(
1955            raw.warnings
1956                .iter()
1957                .filter(|w| w.contains("escapes the case directory"))
1958                .count(),
1959            3,
1960            "every refusal is stated, and Clear does not erase the ones before it"
1961        );
1962    }
1963
1964    #[test]
1965    fn clear_resets() {
1966        let raw = parse("New Line.l1 length=1\nClear\nNew Line.l2 length=2");
1967        assert!(raw.find("line", "l1").is_none());
1968        assert!(raw.find("line", "l2").is_some());
1969    }
1970
1971    #[test]
1972    fn block_comments_skip_lines() {
1973        let raw = parse("/* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1974        assert!(raw.find("line", "l1").is_none());
1975        assert!(raw.find("line", "l2").is_some());
1976    }
1977
1978    #[test]
1979    fn indented_block_comments_skip_lines() {
1980        let raw = parse("  /* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1981        assert!(raw.find("line", "l1").is_none());
1982        assert!(raw.find("line", "l2").is_some());
1983    }
1984
1985    #[test]
1986    fn one_line_block_comment() {
1987        let raw = parse("\t/* x */\nNew Line.l2 length=2");
1988        assert!(raw.find("line", "l2").is_some());
1989    }
1990
1991    #[test]
1992    fn redirect_includes_a_file() {
1993        let mut files = BTreeMap::from([(
1994            PathBuf::from("sub/codes.dss"),
1995            "New Linecode.lc1 nphases=3".to_string(),
1996        )]);
1997        let mut loader = move |p: &Path| {
1998            files
1999                .remove(p)
2000                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
2001        };
2002        let raw = __parse_raw_with(
2003            "Redirect sub/codes.dss\nNew Line.l1 linecode=lc1",
2004            "test.dss",
2005            &mut loader,
2006        );
2007        assert!(raw.find("linecode", "lc1").is_some());
2008        assert_eq!(raw.warnings, [] as [std::string::String; 0]);
2009    }
2010
2011    /// A loader that serves `text` for every path, gives up at `give_up`
2012    /// loads, and reports how many it served. Without the include budget the
2013    /// count runs away, so the test fails instead of hanging.
2014    fn counting_loader(text: &str, give_up: usize, script: &str) -> (RawDss, usize) {
2015        let mut loads = 0usize;
2016        let raw = {
2017            let mut loader = |_: &Path| {
2018                loads += 1;
2019                if loads > give_up {
2020                    Err(std::io::Error::other("loader gave up"))
2021                } else {
2022                    Ok(text.to_string())
2023                }
2024            };
2025            __parse_raw_with(script, "test.dss", &mut loader)
2026        };
2027        (raw, loads)
2028    }
2029
2030    fn budget_refusals(raw: &RawDss) -> usize {
2031        raw.diagnostics
2032            .iter()
2033            .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_INCLUDE_BUDGET.code)
2034            .count()
2035    }
2036
2037    #[test]
2038    fn a_self_redirecting_include_stops_at_the_file_budget() {
2039        // Two self redirects per file expand into a binary tree of depth
2040        // MAX_REDIRECT_DEPTH: ~2^65 script runs, which is why the depth limit
2041        // alone is not a bound.
2042        let (raw, loads) = counting_loader(
2043            "Redirect a.dss\nRedirect a.dss",
2044            MAX_TOTAL_INCLUDES * 4,
2045            "Redirect a.dss",
2046        );
2047        assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
2048        assert_eq!(budget_refusals(&raw), 1, "one refusal, however many follow");
2049    }
2050
2051    #[test]
2052    fn a_large_self_redirecting_include_stops_at_the_byte_budget() {
2053        // The same tree with a big file: the file budget would let it re-scan
2054        // MAX_TOTAL_INCLUDES copies of it, so the bytes are charged too. Each
2055        // load carries a quarter megabyte on one long line.
2056        let chunk = 256 << 10;
2057        let text = format!("Redirect a.dss\nRedirect a.dss\n// {}", "a".repeat(chunk));
2058        let (raw, loads) = counting_loader(&text, MAX_TOTAL_INCLUDES, "Redirect a.dss");
2059        assert!(
2060            loads <= MAX_TOTAL_INCLUDE_BYTES / chunk + 1,
2061            "{loads} includes followed, {} bytes",
2062            loads * chunk
2063        );
2064        assert!(
2065            loads < MAX_TOTAL_INCLUDES,
2066            "the byte budget is what stopped it"
2067        );
2068        assert_eq!(budget_refusals(&raw), 1);
2069    }
2070
2071    #[test]
2072    fn buscoords_is_charged_against_the_include_budget() {
2073        // Buscoords does not recurse, but a redirect tree can load one per
2074        // node and every line of it lands in `raw.buscoords`. An uncharged
2075        // Buscoords would push the load count past the budget.
2076        let (raw, loads) = counting_loader(
2077            "Redirect a.dss\nRedirect a.dss\nBuscoords a.csv",
2078            MAX_TOTAL_INCLUDES * 4,
2079            "Redirect a.dss",
2080        );
2081        assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
2082        assert_eq!(budget_refusals(&raw), 1);
2083    }
2084
2085    #[test]
2086    fn clear_does_not_refund_the_include_budget() {
2087        // `Clear` resets the parsed script, so a budget counted in RawDss
2088        // would reset with it and the tree would run unbounded again.
2089        let (_, loads) = counting_loader(
2090            "Clear\nRedirect a.dss\nRedirect a.dss",
2091            MAX_TOTAL_INCLUDES * 4,
2092            "Redirect a.dss",
2093        );
2094        assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
2095    }
2096
2097    #[test]
2098    fn missing_redirect_warns() {
2099        let raw = parse("Redirect nope.dss");
2100        assert_eq!(raw.warnings.len(), 1);
2101        assert!(raw.warnings[0].contains("nope.dss"));
2102    }
2103
2104    #[test]
2105    fn compile_moves_the_directory_redirect_restores_it() {
2106        // After `Compile sub/feeder.dss`, the caller's relative paths
2107        // resolve against sub/; after a Redirect they resolve against the
2108        // caller's own directory again. Both directories carry a lines.dss
2109        // so the wrong resolution shows up as the wrong object.
2110        let root = std::env::temp_dir().join(format!("powerio-dist-raw-{}", std::process::id()));
2111        let sub = root.join("sub");
2112        std::fs::create_dir_all(&sub).unwrap();
2113        std::fs::write(sub.join("feeder.dss"), "New Linecode.lc1 nphases=3").unwrap();
2114        std::fs::write(sub.join("lines.dss"), "New Line.fromsub bus1=a").unwrap();
2115        std::fs::write(root.join("lines.dss"), "New Line.fromroot bus1=a").unwrap();
2116        std::fs::write(
2117            root.join("compile.dss"),
2118            "Compile sub/feeder.dss\nRedirect lines.dss",
2119        )
2120        .unwrap();
2121        std::fs::write(
2122            root.join("redirect.dss"),
2123            "Redirect sub/feeder.dss\nRedirect lines.dss",
2124        )
2125        .unwrap();
2126
2127        let compiled = __parse_raw_file(root.join("compile.dss")).unwrap();
2128        assert_eq!(compiled.warnings, Vec::<String>::new());
2129        assert!(compiled.find("line", "fromsub").is_some());
2130
2131        let redirected = __parse_raw_file(root.join("redirect.dss")).unwrap();
2132        assert_eq!(redirected.warnings, Vec::<String>::new());
2133        assert!(redirected.find("line", "fromroot").is_some());
2134
2135        std::fs::remove_dir_all(&root).unwrap();
2136    }
2137
2138    #[test]
2139    fn compile_inside_compile_pins_the_compiled_files_directory() {
2140        // ExecHelper DoRedirect sets CurrDir from the file path once at
2141        // entry and compile exit reapplies it (SetDataPath → ChDir), so a
2142        // Compile that itself compiles deeper still leaves the caller in
2143        // the directly compiled file's directory, not the innermost one.
2144        // probe.dss exists in both sub/ and sub/inner/; the engine resolves
2145        // sub/probe.dss.
2146        let root =
2147            std::env::temp_dir().join(format!("powerio-dist-rawnest-{}", std::process::id()));
2148        let sub = root.join("sub");
2149        let inner = sub.join("inner");
2150        std::fs::create_dir_all(&inner).unwrap();
2151        std::fs::write(
2152            root.join("main.dss"),
2153            "Compile sub/a.dss\nRedirect probe.dss",
2154        )
2155        .unwrap();
2156        std::fs::write(sub.join("a.dss"), "Compile inner/b.dss").unwrap();
2157        std::fs::write(inner.join("b.dss"), "New Linecode.lc1 nphases=1").unwrap();
2158        std::fs::write(sub.join("probe.dss"), "New Line.fromsub bus1=a").unwrap();
2159        std::fs::write(inner.join("probe.dss"), "New Line.frominner bus1=a").unwrap();
2160
2161        let raw = __parse_raw_file(root.join("main.dss")).unwrap();
2162        assert_eq!(raw.warnings, Vec::<String>::new());
2163        assert!(raw.find("linecode", "lc1").is_some());
2164        assert!(raw.find("line", "fromsub").is_some());
2165        assert!(raw.find("line", "frominner").is_none());
2166
2167        std::fs::remove_dir_all(&root).unwrap();
2168    }
2169
2170    #[test]
2171    fn edit_boundaries_are_recorded() {
2172        // One checkpoint per command line; like= splices the source's
2173        // boundaries (offset) before the splicing edit's own.
2174        let raw = parse("New Load.a kW=10 pf=0.9\n~ kvar=5\nNew Load.b like=a kw=20");
2175        let a = raw.find("load", "a").unwrap();
2176        assert_eq!(a.edits, vec![2, 3]);
2177        let b = raw.find("load", "b").unwrap();
2178        assert_eq!(b.props.len(), 4);
2179        assert_eq!(b.edits, vec![2, 3, 4]);
2180        assert_eq!(b.edit_bounds().collect::<Vec<_>>(), vec![2, 3, 4]);
2181    }
2182
2183    #[test]
2184    fn var_definition_and_use() {
2185        let raw = parse("var @kv=12.47\nNew Load.ld kv=@kv");
2186        let ld = raw.find("load", "ld").unwrap();
2187        assert_eq!(ld.get("kv").unwrap().text, "12.47");
2188    }
2189
2190    #[test]
2191    fn quoted_var_value_stays_rpn() {
2192        // The braces TParserVar::Add wraps around the stored value come
2193        // back off as a quoted token, so the substituted expression still
2194        // evaluates as RPN.
2195        let raw = parse("var @z=(8 1000 /)\nNew Load.ld kW=@z");
2196        let v = raw.find("load", "ld").unwrap().get("kw").unwrap();
2197        assert!(v.quoted);
2198        assert_eq!(v.to_f64(None), Ok(0.008));
2199    }
2200
2201    #[test]
2202    fn vars_cross_redirect_boundaries() {
2203        // A var defined in the parent substitutes inside the include, and a
2204        // var defined in the include survives back in the parent.
2205        let mut loader = |p: &Path| {
2206            if p == Path::new("inc.dss") {
2207                Ok("New Load.inner kv=@kv\nvar @kw=42".to_string())
2208            } else {
2209                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
2210            }
2211        };
2212        let raw = __parse_raw_with(
2213            "var @kv=12.47\nRedirect inc.dss\nNew Load.outer kW=@kw",
2214            "test.dss",
2215            &mut loader,
2216        );
2217        assert_eq!(raw.warnings, Vec::<String>::new());
2218        assert_eq!(
2219            raw.find("load", "inner").unwrap().get("kv").unwrap().text,
2220            "12.47"
2221        );
2222        assert_eq!(
2223            raw.find("load", "outer").unwrap().get("kw").unwrap().text,
2224            "42"
2225        );
2226    }
2227
2228    #[test]
2229    fn var_defined_after_clear_does_not_see_the_pre_clear_table() {
2230        // Without the generation guard, `run_command`'s restore after
2231        // dispatching `Clear` would resurrect the pre-`Clear` var snapshot
2232        // over the empty table `clear` just installed.
2233        let raw = parse("var @a=1\nClear\nvar @b=2\nNew Load.ld kv=@a kw=@b");
2234        let ld = raw.find("load", "ld").unwrap();
2235        assert_eq!(ld.get("kw").unwrap().text, "2", "post-clear var resolves");
2236        assert_eq!(
2237            ld.get("kv").unwrap().text,
2238            "@a",
2239            "pre-clear var does not survive Clear"
2240        );
2241    }
2242
2243    #[test]
2244    fn the_maximum_permitted_var_count_parses() {
2245        use std::fmt::Write as _;
2246        let mut script = String::new();
2247        for i in 0..MAX_TOTAL_VARS {
2248            let _ = writeln!(script, "var @v{i}={i}");
2249        }
2250        script.push_str("New Load.ld kv=@v0\n");
2251        let raw = parse(&script);
2252        assert_eq!(raw.warnings, Vec::<String>::new());
2253        assert_eq!(raw.find("load", "ld").unwrap().get("kv").unwrap().text, "0");
2254    }
2255
2256    #[test]
2257    fn one_var_past_the_count_cap_is_refused() {
2258        use std::fmt::Write as _;
2259        let mut script = String::new();
2260        for i in 0..=MAX_TOTAL_VARS {
2261            let _ = writeln!(script, "var @v{i}={i}");
2262        }
2263        let _ = writeln!(script, "New Load.ld kv=@v{MAX_TOTAL_VARS}");
2264        let raw = parse(&script);
2265        assert_eq!(
2266            raw.diagnostics
2267                .iter()
2268                .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_VALUE_CLAMPED.code)
2269                .count(),
2270            1,
2271            "one refusal, however many follow"
2272        );
2273        // The var one past the cap was refused, so it never resolves.
2274        assert_eq!(
2275            raw.find("load", "ld").unwrap().get("kv").unwrap().text,
2276            format!("@v{MAX_TOTAL_VARS}")
2277        );
2278    }
2279
2280    #[test]
2281    fn one_byte_past_the_var_byte_cap_is_refused() {
2282        // One var sized to fill the byte budget, then one more small var
2283        // that must be refused for lack of budget rather than count.
2284        let filler = "x".repeat(MAX_TOTAL_VAR_BYTES);
2285        let raw = parse(&format!("var @big={filler}\nvar @small=1\n"));
2286        assert_eq!(
2287            raw.diagnostics
2288                .iter()
2289                .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_VALUE_CLAMPED.code)
2290                .count(),
2291            1,
2292            "one refusal, however many follow"
2293        );
2294    }
2295
2296    #[test]
2297    fn duplicate_new_warns_and_edits() {
2298        let raw = parse("New Line.l1 length=1\nNew Line.l1 length=2");
2299        assert_eq!(raw.warnings.len(), 1);
2300        assert_eq!(
2301            raw.find("line", "l1").unwrap().get("length").unwrap().text,
2302            "2"
2303        );
2304    }
2305
2306    #[test]
2307    fn rpn_value_via_props() {
2308        let raw = parse("New Load.ld kW=(8 1000 /)");
2309        let v = raw.find("load", "ld").unwrap().get("kw").unwrap().clone();
2310        assert_eq!(v.to_f64(None), Ok(0.008));
2311    }
2312
2313    #[test]
2314    fn confined_parsing_refuses_includes_outside_the_case_directory() {
2315        use std::cell::RefCell;
2316        // Records every path the loader is actually asked to read, so we can
2317        // assert a refused include never reaches the filesystem.
2318        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2319        let mut loader = |p: &Path| {
2320            requested.borrow_mut().push(p.display().to_string());
2321            Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
2322        };
2323        let raw = parse_raw_with_confined(
2324            "Redirect ../../secret.dss\nRedirect /etc/passwd\nBuscoords ../up.csv",
2325            "/case/dir/master.dss",
2326            &mut loader,
2327        );
2328        assert!(
2329            requested.borrow().is_empty(),
2330            "escaping include reached the loader: {:?}",
2331            requested.borrow()
2332        );
2333        assert_eq!(
2334            raw.warnings
2335                .iter()
2336                .filter(|w| w.contains("escapes the case directory"))
2337                .count(),
2338            3
2339        );
2340        // Each refusal is also an Error-severity finding (#275): the parse
2341        // continued, but the network is incomplete.
2342        let refused: Vec<_> = raw
2343            .diagnostics
2344            .iter()
2345            .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_INCLUDE_REFUSED.code)
2346            .collect();
2347        assert_eq!(refused.len(), 3);
2348        assert!(
2349            refused
2350                .iter()
2351                .all(|d| d.severity() == crate::diagnostics::DiagnosticSeverity::Error)
2352        );
2353    }
2354
2355    #[test]
2356    fn confined_parsing_with_an_empty_root_refuses_absolute_and_climbing_includes() {
2357        use std::cell::RefCell;
2358        // A bare filename ("master.dss") has an empty parent, so the
2359        // confinement root is empty. `starts_with("")` holds for every path,
2360        // so containment must come from the component rule instead: only
2361        // plain relative includes stay inside the working directory.
2362        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2363        let mut loader = |p: &Path| {
2364            requested.borrow_mut().push(p.display().to_string());
2365            Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
2366        };
2367        let raw = parse_raw_with_confined(
2368            "Redirect /etc/passwd\nRedirect ../secret.dss\nRedirect sub/ok.dss",
2369            "master.dss",
2370            &mut loader,
2371        );
2372        assert_eq!(
2373            *requested.borrow(),
2374            vec!["sub/ok.dss".to_string()],
2375            "an absolute or climbing include reached the loader"
2376        );
2377        assert_eq!(
2378            raw.warnings
2379                .iter()
2380                .filter(|w| w.contains("escapes the case directory"))
2381                .count(),
2382            2
2383        );
2384    }
2385
2386    #[test]
2387    fn confined_parsing_allows_includes_under_a_root_that_starts_with_parent_dirs() {
2388        use std::cell::RefCell;
2389        // The case path itself may climb ("../case/master.dss"); includes
2390        // under that same directory are inside the case and must load.
2391        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2392        let mut loader = |p: &Path| {
2393            requested.borrow_mut().push(p.display().to_string());
2394            Ok(String::new())
2395        };
2396        let raw = parse_raw_with_confined(
2397            "Redirect codes.dss\nRedirect ../../outside.dss",
2398            "../case/master.dss",
2399            &mut loader,
2400        );
2401        assert_eq!(*requested.borrow(), vec!["../case/codes.dss".to_string()]);
2402        assert_eq!(
2403            raw.warnings
2404                .iter()
2405                .filter(|w| w.contains("escapes the case directory"))
2406                .count(),
2407            1
2408        );
2409    }
2410
2411    #[cfg(unix)]
2412    #[test]
2413    fn file_parsing_refuses_includes_that_escape_through_a_symlink() {
2414        // A lexically contained include that is really a symlink out of the
2415        // case directory must not be read.
2416        let root =
2417            std::env::temp_dir().join(format!("powerio-dist-symlink-{}", std::process::id()));
2418        let case = root.join("case");
2419        std::fs::create_dir_all(&case).unwrap();
2420        std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
2421        std::fs::write(case.join("master.dss"), "Redirect linked.dss").unwrap();
2422        std::os::unix::fs::symlink(root.join("secret.dss"), case.join("linked.dss")).unwrap();
2423
2424        let raw = __parse_raw_file(case.join("master.dss")).unwrap();
2425        assert!(raw.find("line", "leaked").is_none());
2426        assert_eq!(
2427            raw.warnings
2428                .iter()
2429                .filter(|w| w.contains("symbolic link"))
2430                .count(),
2431            1,
2432            "warnings: {:?}",
2433            raw.warnings
2434        );
2435
2436        std::fs::remove_dir_all(&root).unwrap();
2437    }
2438
2439    #[test]
2440    fn raw_file_parsing_refuses_includes_outside_the_case_directory() {
2441        let root =
2442            std::env::temp_dir().join(format!("powerio-dist-rawconf-{}", std::process::id()));
2443        std::fs::create_dir_all(root.join("case")).unwrap();
2444        std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
2445        std::fs::write(
2446            root.join("case").join("master.dss"),
2447            "Redirect ../secret.dss",
2448        )
2449        .unwrap();
2450
2451        let raw = __parse_raw_file(root.join("case").join("master.dss")).unwrap();
2452        assert!(raw.find("line", "leaked").is_none());
2453        assert_eq!(
2454            raw.warnings
2455                .iter()
2456                .filter(|w| w.contains("escapes the case directory"))
2457                .count(),
2458            1
2459        );
2460
2461        std::fs::remove_dir_all(&root).unwrap();
2462    }
2463
2464    #[test]
2465    fn confined_parsing_allows_includes_within_the_case_directory() {
2466        use std::cell::RefCell;
2467        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2468        let mut loader = |p: &Path| {
2469            requested.borrow_mut().push(p.display().to_string());
2470            Ok(String::new())
2471        };
2472        // A subdirectory include and an absolute path inside the root both load.
2473        parse_raw_with_confined(
2474            "Redirect sub/codes.dss\nRedirect /case/dir/abs.dss",
2475            "/case/dir/master.dss",
2476            &mut loader,
2477        );
2478        assert_eq!(
2479            *requested.borrow(),
2480            vec![
2481                "/case/dir/sub/codes.dss".to_string(),
2482                "/case/dir/abs.dss".to_string(),
2483            ]
2484        );
2485    }
2486
2487    #[test]
2488    fn a_widened_include_root_admits_siblings_and_refuses_escapes_past_it() {
2489        use std::cell::RefCell;
2490        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2491        let mut loader = |p: &Path| {
2492            requested.borrow_mut().push(p.display().to_string());
2493            Ok(String::new())
2494        };
2495        // The shared sibling directory is inside the include root; a climb
2496        // past the root is refused with the root named as the boundary.
2497        let raw = parse_raw_confined_under(
2498            "Redirect ../shared/codes.dss\nRedirect ../../outside.dss",
2499            "/root/feeder/master.dss",
2500            Path::new("/root"),
2501            &mut loader,
2502        );
2503        assert_eq!(
2504            *requested.borrow(),
2505            vec!["/root/shared/codes.dss".to_string()]
2506        );
2507        assert_eq!(
2508            raw.warnings
2509                .iter()
2510                .filter(|w| w.contains("escapes the include root"))
2511                .count(),
2512            1,
2513            "warnings: {:?}",
2514            raw.warnings
2515        );
2516    }
2517}