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::error::{Error, Result};
16
17/// The OpenDSS executive command list, in definition order
18/// (Executive/ExecCommands.cpp). Order fixes abbreviation resolution: a verb
19/// matches exactly first, then the first command here with the verb as a
20/// prefix. Only a handful execute in this layer; the rest are preserved as
21/// [`RawCommand`]s.
22static COMMANDS: &[&str] = &[
23    "new",
24    "edit",
25    "more",
26    "m",
27    "~",
28    "select",
29    "save",
30    "show",
31    "solve",
32    "enable",
33    "disable",
34    "plot",
35    "reset",
36    "compile",
37    "set",
38    "dump",
39    "open",
40    "close",
41    "//",
42    "redirect",
43    "help",
44    "quit",
45    "?",
46    "next",
47    "panel",
48    "sample",
49    "clear",
50    "about",
51    "calcvoltagebases",
52    "setkvbase",
53    "buildy",
54    "get",
55    "init",
56    "export",
57    "fileedit",
58    "voltages",
59    "currents",
60    "powers",
61    "seqvoltages",
62    "seqcurrents",
63    "seqpowers",
64    "losses",
65    "phaselosses",
66    "cktlosses",
67    "allocateloads",
68    "formedit",
69    "totals",
70    "capacity",
71    "classes",
72    "userclasses",
73    "zsc",
74    "zsc10",
75    "zscrefresh",
76    "ysc",
77    "puvoltages",
78    "varvalues",
79    "varnames",
80    "buscoords",
81    "makebuslist",
82    "makeposseq",
83    "reduce",
84    "interpolate",
85    "alignfile",
86    "top",
87    "rotate",
88    "vdiff",
89    "summary",
90    "distribute",
91    "di_plot",
92    "comparecases",
93    "yearlycurves",
94    "cd",
95    "visualize",
96    "closedi",
97    "doscmd",
98    "estimate",
99    "reconductor",
100    "_initsnap",
101    "_solvenocontrol",
102    "_samplecontrols",
103    "_docontrolactions",
104    "_showcontrolqueue",
105    "_solvedirect",
106    "_solvepflow",
107    "addbusmarker",
108    "uuids",
109    "setloadandgenkv",
110    "cvrtloadshapes",
111    "nodediff",
112    "rephase",
113    "setbusxy",
114    "updatestorage",
115    "obfuscate",
116    "latlongcoords",
117    "batchedit",
118    "pstcalc",
119    "variable",
120    "reprocessbuses",
121    "clearbusmarkers",
122    "relcalc",
123    "var",
124    "cleanup",
125    "finishtimestep",
126    "nodelist",
127    "newactor",
128    "clearall",
129    "wait",
130    "solveall",
131    "calcincmatrix",
132    "calcincmatrix_o",
133    "tear_circuit",
134    "connect",
135    "disconnect",
136    "refine_buslevels",
137    "remove",
138    "abort",
139    "calclaplacian",
140    "clone",
141    "fncspublish",
142    "exportoverloads",
143    "exportvviolations",
144    "zsc012",
145    "aggregateprofiles",
146    "allpceatbus",
147    "allpdeatbus",
148    "totalpowers",
149    "comhelp",
150    "gis",
151    "giscoords",
152    "readefieldhdf",
153];
154
155fn command_index(verb: &str) -> Option<usize> {
156    let v = verb.to_ascii_lowercase();
157    COMMANDS
158        .iter()
159        .position(|c| *c == v)
160        .or_else(|| COMMANDS.iter().position(|c| c.starts_with(&v)))
161}
162
163/// One property assignment as applied to an object, in application order.
164#[derive(Clone, Debug, PartialEq)]
165pub struct RawProp {
166    /// Canonical property name when resolved against the class table;
167    /// the name as written when the class or property is unknown; `None`
168    /// for a positional value on an unknown class.
169    pub name: Option<String>,
170    pub value: Value,
171}
172
173/// An accumulated object: every `New`/`Edit`/`~`/`like` assignment that
174/// touched it, in order. Values are raw tokens.
175#[derive(Clone, Debug)]
176pub struct RawObject {
177    /// Canonical lowercase class name (`line`, `load`, ...), known or not.
178    pub class: String,
179    /// Object name as written; lookup is case insensitive.
180    pub name: String,
181    pub props: Vec<RawProp>,
182    /// Prop-count checkpoints at edit boundaries. Every object command line
183    /// (`New`/`Edit`/`~`/`More`/property reference) is one engine Edit, and
184    /// the class Edit ends in RecalcElementData; readers with end-of-edit
185    /// side effects (Load) segment `props` on these. `like=` splices the
186    /// source's checkpoints too: MakeLike copies the source's recalced
187    /// state, so its boundaries must replay.
188    pub edits: Vec<usize>,
189}
190
191impl RawObject {
192    /// The last assignment to a canonical property name, if any.
193    pub fn get(&self, name: &str) -> Option<&Value> {
194        self.props
195            .iter()
196            .rev()
197            .find(|p| p.name.as_deref() == Some(name))
198            .map(|p| &p.value)
199    }
200
201    /// Edit boundary checkpoints, closed over the full prop list: a
202    /// trailing segment without a recorded boundary counts as one more
203    /// edit, so callers always see `props.len()` last.
204    pub fn edit_bounds(&self) -> impl Iterator<Item = usize> + '_ {
205        let tail =
206            (self.edits.last().copied() != Some(self.props.len())).then_some(self.props.len());
207        self.edits.iter().copied().chain(tail)
208    }
209}
210
211/// A command this layer does not execute, preserved verbatim.
212#[derive(Clone, Debug, PartialEq)]
213pub struct RawCommand {
214    /// Canonical verb when recognized, the first token as written otherwise.
215    pub verb: String,
216    /// Everything after the verb, trimmed.
217    pub args: String,
218}
219
220/// Bus coordinates from a `BusCoords` file.
221#[derive(Clone, Debug, PartialEq)]
222pub struct BusCoord {
223    pub bus: String,
224    pub x: f64,
225    pub y: f64,
226}
227
228/// The executed script: objects, options, and preserved commands.
229#[derive(Debug, Default)]
230pub struct RawDss {
231    pub circuit_name: Option<String>,
232    pub objects: Vec<RawObject>,
233    /// `Set option=value` assignments in order.
234    pub options: Vec<(String, Value)>,
235    /// Commands preserved without execution (solve, calcvoltagebases, ...).
236    pub commands: Vec<RawCommand>,
237    pub buscoords: Vec<BusCoord>,
238    pub vars: VarMap,
239    pub warnings: Vec<String>,
240    /// Structured findings beside `warnings`; an `Error` entry marks an
241    /// incomplete parse the CLI must not exit 0 on.
242    pub diagnostics: Vec<crate::diagnostics::StructuredDiagnostic>,
243    index: BTreeMap<(String, String), usize>,
244    active: Option<usize>,
245}
246
247impl RawDss {
248    pub fn find(&self, class: &str, name: &str) -> Option<&RawObject> {
249        self.index
250            .get(&(class.to_ascii_lowercase(), name.to_ascii_lowercase()))
251            .map(|&i| &self.objects[i])
252    }
253
254    pub fn of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a RawObject> {
255        self.objects.iter().filter(move |o| o.class == class)
256    }
257
258    fn warn(&mut self, msg: impl Into<String>) {
259        self.warnings.push(msg.into());
260    }
261
262    fn clear(&mut self) {
263        // `Clear` resets the circuit, not the record of how the script was
264        // read. A warning or an `Error` finding already emitted describes input
265        // the caller wrote, and no later command un-writes it — dropping them
266        // here returned a network that had refused an include, or skipped one
267        // that escaped the case directory, with an empty `warnings` and nothing
268        // for `powerio package` to lift into a finding.
269        let (warnings, diagnostics) = (
270            std::mem::take(&mut self.warnings),
271            std::mem::take(&mut self.diagnostics),
272        );
273        *self = RawDss {
274            warnings,
275            diagnostics,
276            ..RawDss::default()
277        };
278    }
279}
280
281/// Supplies included file text, so tests can run without a filesystem.
282pub trait Loader {
283    fn load(&mut self, path: &Path) -> std::io::Result<String>;
284}
285
286impl<F> Loader for F
287where
288    F: FnMut(&Path) -> std::io::Result<String>,
289{
290    fn load(&mut self, path: &Path) -> std::io::Result<String> {
291        self(path)
292    }
293}
294
295/// Redirect nesting limit; OpenDSS recurses unbounded, this bounds cycles.
296const MAX_REDIRECT_DEPTH: usize = 64;
297
298/// Includes a single parse may follow. Depth bounds one branch, not the work:
299/// a file that redirects to itself twice expands into a binary tree of depth
300/// [`MAX_REDIRECT_DEPTH`], so 34 bytes never finish parsing. The largest
301/// fixture here (IEEE123Master.dss) follows 3.
302const MAX_TOTAL_INCLUDES: usize = 4096;
303
304/// Script text a single parse may pull in through includes. The include count
305/// alone still admits amplification: one large file that redirects to itself is
306/// re-executed once per load, so a few megabytes of input buy thousands of
307/// times that in scanning. Together the two budgets keep an include tree
308/// costing about what a single case file of this size costs. The root file is
309/// not charged against it, so a case without includes is never truncated.
310const MAX_TOTAL_INCLUDE_BYTES: usize = 64 << 20;
311
312/// Cap on the accumulated property assignments a single object may hold.
313/// `like=` splices the source object's whole prop list, so a self-referencing
314/// or mutually-referencing chain (`Edit Load.a like=a` repeated) doubles the
315/// count each edit — a few hundred bytes could otherwise reach memory
316/// exhaustion. No real object comes near this bound: the largest DSS class has
317/// on the order of 100 properties and legitimate scripts edit an object a
318/// handful of times.
319const MAX_OBJECT_PROPS: usize = 1 << 16;
320
321struct Executor<'l, L: Loader> {
322    raw: RawDss,
323    loader: &'l mut L,
324    /// Directory stack for relative include resolution; starts with the
325    /// root file's directory, so its depth is the redirect nesting level.
326    dirs: Vec<PathBuf>,
327    /// When set (file parsing), `Redirect`/`Compile`/`Buscoords` includes are
328    /// confined to this directory subtree, so an untrusted case file cannot
329    /// read outside its own directory. `None` leaves includes unconfined, for
330    /// the in-memory loaders used by tests and string parsing (which installs
331    /// a loader that reads nothing).
332    root: Option<PathBuf>,
333    /// Includes followed and script bytes they pulled in, against
334    /// [`MAX_TOTAL_INCLUDES`] and [`MAX_TOTAL_INCLUDE_BYTES`]. `budget_spent`
335    /// keeps the refusal to one message however many includes follow it.
336    includes: usize,
337    include_bytes: usize,
338    budget_spent: bool,
339}
340
341/// Collapses `.` and `..` lexically, without touching the filesystem. A
342/// leading `..` is preserved so a path that climbs above its base fails the
343/// containment check rather than silently resolving somewhere inside it.
344fn lexical_normalize(p: &Path) -> PathBuf {
345    use std::path::Component;
346    let mut out: Vec<Component<'_>> = Vec::new();
347    for comp in p.components() {
348        match comp {
349            Component::CurDir => {}
350            Component::ParentDir if matches!(out.last(), Some(Component::Normal(_))) => {
351                out.pop();
352            }
353            other => out.push(other),
354        }
355    }
356    out.into_iter().collect()
357}
358
359/// Splits script text into command lines, dropping block comments. A block
360/// comment starts when the first nonspace characters are `/*` and ends on the
361/// first line containing `*/`; both boundary lines are consumed whole,
362/// matching the OpenDSS executive.
363fn command_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
364    let mut in_block = false;
365    text.lines().enumerate().filter_map(move |(i, line)| {
366        if in_block {
367            if line.contains("*/") {
368                in_block = false;
369            }
370            return None;
371        }
372        if line.trim_start().starts_with("/*") {
373            in_block = true;
374            if line.contains("*/") {
375                in_block = false;
376            }
377            return None;
378        }
379        Some((i + 1, line))
380    })
381}
382
383impl<L: Loader> Executor<'_, L> {
384    fn run_script(&mut self, text: &str, file: &str) {
385        for (line_no, line) in command_lines(text) {
386            self.run_command(line, file, line_no);
387        }
388    }
389
390    fn run_command(&mut self, line: &str, file: &str, line_no: usize) {
391        // The scanner substitutes against a snapshot of the var table so the
392        // live table stays free for mutation: `var` inserts into it directly
393        // and redirected files both see and extend it. The snapshot only
394        // diverges for a self referencing `var` line, which OpenDSS scripts
395        // do not write.
396        let vars = self.raw.vars.clone();
397        let mut scan = Scanner::new(line, Some(&vars));
398        let ctx = |msg: String| format!("{file}:{line_no}: {msg}");
399        match scan.next_param() {
400            None => {}
401            Some(first) if first.value.text.is_empty() && first.name.is_none() => {}
402            Some(first) => {
403                if let Some(name) = first.name {
404                    // First parameter is name=value: a property reference
405                    // like `Transformer.Reg1.Taps=[...]`.
406                    self.edit_property_reference(&name, first.value, &mut scan, &ctx);
407                } else {
408                    self.dispatch(first.value.text, &mut scan, &ctx);
409                }
410            }
411        }
412    }
413
414    fn dispatch(&mut self, verb: String, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
415        match command_index(&verb).map(|i| COMMANDS[i]) {
416            Some("new") => self.do_new(scan, ctx),
417            Some("edit") => self.do_edit(scan, ctx),
418            Some("more" | "m" | "~") => self.do_more(scan, ctx),
419            Some("select") => self.do_select(scan, ctx),
420            Some("set") => self.do_set(scan),
421            Some("redirect") => self.do_redirect(scan, false, ctx),
422            Some("compile") => self.do_redirect(scan, true, ctx),
423            Some("buscoords") => self.do_buscoords(scan, ctx),
424            Some("var") => self.do_var(scan),
425            Some("clear" | "clearall") => self.raw.clear(),
426            Some("//") => {}
427            Some(canonical) => {
428                self.raw.commands.push(RawCommand {
429                    verb: canonical.to_string(),
430                    args: scan.remainder().to_string(),
431                });
432            }
433            None => {
434                self.raw.warn(ctx(format!(
435                    "unknown command `{verb}`; line preserved verbatim"
436                )));
437                self.raw.commands.push(RawCommand {
438                    verb,
439                    args: scan.remainder().to_string(),
440                });
441            }
442        }
443    }
444
445    /// `var @name=value ...` defines parser variables. TParserVar::Add
446    /// stores every value brace wrapped unless it begins with `@`;
447    /// CheckforVar unwraps the braces into a quoted token, so a definition
448    /// like `var @z=(8 1000 /)` still evaluates as RPN where it is used.
449    fn do_var(&mut self, scan: &mut Scanner) {
450        while let Some(p) = scan.next_param() {
451            if p.value.text.is_empty() && p.name.is_none() {
452                break;
453            }
454            if let Some(name) = p.name {
455                let stored = if p.value.text.starts_with('@') {
456                    p.value.text
457                } else {
458                    format!("{{{}}}", p.value.text)
459                };
460                self.raw.vars.insert(name.to_ascii_lowercase(), stored);
461            }
462        }
463    }
464
465    /// A leading `name=value` parameter is a property reference
466    /// (ExecCommands ProcessCommand): `Class.Name.Prop=value`,
467    /// `Name.Prop=value` with the class omitted, or `Prop=value` on the
468    /// active object. ParseObjName cuts the object part at the second dot;
469    /// SetObject resolves an omitted class to the last referenced one,
470    /// which here is the active object's class.
471    fn edit_property_reference(
472        &mut self,
473        spec: &str,
474        value: Value,
475        scan: &mut Scanner,
476        ctx: &dyn Fn(String) -> String,
477    ) {
478        let (object, prop) = match spec.split_once('.') {
479            None => (None, spec),
480            Some((first, rest)) => match rest.split_once('.') {
481                None => (Some((None, first)), rest),
482                Some((name, prop)) => (Some((Some(first), name)), prop),
483            },
484        };
485        let active_or = |raw: &mut RawDss| {
486            let active = raw.active;
487            if active.is_none() {
488                raw.warn(ctx(format!("`{spec}=` with no active object")));
489            }
490            active
491        };
492        let idx = match object {
493            None => match active_or(&mut self.raw) {
494                Some(idx) => idx,
495                None => return,
496            },
497            Some((class, name)) => {
498                let class = match class {
499                    Some(c) => c.to_ascii_lowercase(),
500                    None => match active_or(&mut self.raw) {
501                        Some(idx) => self.raw.objects[idx].class.clone(),
502                        None => return,
503                    },
504                };
505                if let Some(idx) = self
506                    .raw
507                    .index
508                    .get(&(class.clone(), name.to_ascii_lowercase()))
509                    .copied()
510                {
511                    idx
512                } else {
513                    self.raw.warn(ctx(format!(
514                        "property reference to unknown object `{class}.{name}`"
515                    )));
516                    return;
517                }
518            }
519        };
520        self.raw.active = Some(idx);
521        let table = prop_table(&self.raw.objects[idx].class);
522        let name = match table {
523            Some(c) => {
524                if let Some(i) = c.prop_index(prop) {
525                    c.props[i].to_string()
526                } else {
527                    self.raw.warn(ctx(format!(
528                        "unknown property `{prop}` on {}; kept as written",
529                        c.name
530                    )));
531                    prop.to_ascii_lowercase()
532                }
533            }
534            None => prop.to_ascii_lowercase(),
535        };
536        let mut props = vec![RawProp {
537            name: Some(name),
538            value,
539        }];
540        props.extend(collect_props_for(
541            table,
542            scan,
543            Some(prop),
544            &mut self.raw.warnings,
545            ctx,
546        ));
547        self.apply_props(idx, props, ctx);
548    }
549
550    fn do_new(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
551        let Some((class, name)) = self.object_spec(scan, ctx) else {
552            return;
553        };
554        if class.eq_ignore_ascii_case("circuit") {
555            // A new circuit brings its Vsource named "source"; the line's
556            // remaining properties edit that source. Its defaults (bus1 =
557            // sourcebus etc.) stay implicit here so the reader can tell
558            // written values from materialized defaults.
559            self.raw.circuit_name = Some(name);
560            let idx = self.make_object("vsource", "source".into());
561            self.consume_and_apply(idx, scan, ctx);
562            return;
563        }
564        let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
565        let idx = match self.raw.index.get(&key) {
566            Some(&existing) => {
567                self.raw.warn(ctx(format!(
568                    "duplicate `New {class}.{name}`; editing the existing object"
569                )));
570                existing
571            }
572            None => self.make_object(&class, name),
573        };
574        self.consume_and_apply(idx, scan, ctx);
575    }
576
577    fn do_edit(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
578        let Some((class, name)) = self.object_spec(scan, ctx) else {
579            return;
580        };
581        let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
582        let Some(&idx) = self.raw.index.get(&key) else {
583            self.raw
584                .warn(ctx(format!("`Edit {class}.{name}` on an unknown object")));
585            return;
586        };
587        self.consume_and_apply(idx, scan, ctx);
588    }
589
590    fn do_more(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
591        let Some(idx) = self.raw.active else {
592            self.raw.warn(ctx("`~` with no active object".into()));
593            return;
594        };
595        self.consume_and_apply(idx, scan, ctx);
596    }
597
598    fn do_select(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
599        let Some((class, name)) = self.object_spec(scan, ctx) else {
600            return;
601        };
602        let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
603        match self.raw.index.get(&key) {
604            Some(&idx) => self.raw.active = Some(idx),
605            None => self
606                .raw
607                .warn(ctx(format!("`Select {class}.{name}` on an unknown object"))),
608        }
609    }
610
611    fn do_set(&mut self, scan: &mut Scanner) {
612        while let Some(p) = scan.next_param() {
613            if p.value.text.is_empty() && p.name.is_none() {
614                break;
615            }
616            let name = p.name.unwrap_or_default().to_ascii_lowercase();
617            self.raw.options.push((name, p.value));
618        }
619    }
620
621    /// Resolves a file argument relative to the current file's directory.
622    /// Backslash separators (the format's DOS heritage) become `/`. Returns
623    /// `None` when a confinement root is set (file parsing) and the resolved
624    /// path does not lexically sit under the root — whether it climbs out with
625    /// `..` or is an absolute path outside the root — so an untrusted case file
626    /// cannot pull in arbitrary paths. An absolute include is admitted only
627    /// when it strips the root as a prefix, which requires the root itself to
628    /// be absolute; the file entry points normalize the case file's parent, so
629    /// a case given by an absolute path admits absolute includes inside its
630    /// own directory, while a relative case path admits only relative ones.
631    fn resolve(&self, file_arg: &str) -> Option<PathBuf> {
632        use std::path::Component;
633        let rel = file_arg.replace('\\', "/");
634        let base = self.dirs.last().cloned().unwrap_or_default();
635        let joined = base.join(&rel);
636        match &self.root {
637            None => Some(joined),
638            Some(root) => {
639                // Containment: after stripping the root prefix, only plain
640                // name components may remain. A leftover `..`, root, or drive
641                // prefix means the path escapes — this also covers an empty
642                // root (case file in the working directory), where
643                // `starts_with` alone would accept absolute paths, and a root
644                // that itself begins with `..`, where counting leading `..`
645                // components would misjudge the climb.
646                let normalized = lexical_normalize(&joined);
647                normalized
648                    .strip_prefix(root)
649                    .is_ok_and(|rest| rest.components().all(|c| matches!(c, Component::Normal(_))))
650                    .then_some(normalized)
651            }
652        }
653    }
654
655    /// Resolves an include argument, warning when it is refused for escaping
656    /// the case directory. `None` tells the caller to skip the include.
657    fn resolve_or_warn(
658        &mut self,
659        verb: &str,
660        file_arg: &str,
661        ctx: &dyn Fn(String) -> String,
662    ) -> Option<PathBuf> {
663        let resolved = self.resolve(file_arg);
664        if resolved.is_none() {
665            let message = ctx(format!(
666                "{verb} {file_arg}: refused; include escapes the case directory"
667            ));
668            self.refuse_escape(message);
669        }
670        resolved
671    }
672
673    /// Records an include refused for leaving the case directory: the warning
674    /// line and the `Error` finding that keeps the run from exiting 0.
675    fn refuse_escape(&mut self, message: String) {
676        self.refuse(
677            message,
678            crate::diagnostics::READ_DSS_INCLUDE_REFUSED,
679            "place included files inside the case directory, or merge them into the case",
680        );
681    }
682
683    /// Records a refused include: the warning line and the `Error` finding
684    /// that keeps the run from exiting 0.
685    fn refuse(&mut self, message: String, code: &'static str, suggested_action: &'static str) {
686        self.raw.warn(message.clone());
687        self.raw.diagnostics.push(
688            crate::diagnostics::StructuredDiagnostic::new(
689                code,
690                crate::diagnostics::DiagnosticSeverity::Error,
691                crate::diagnostics::DiagnosticStage::Parse,
692                message,
693            )
694            .with_suggested_action(suggested_action),
695        );
696    }
697
698    /// Charges one include against the budgets, returning whether to follow it.
699    /// Charged at the attempt, so the loader is never called past the budget
700    /// and the syscalls are bounded with the work. The counters live on the
701    /// executor rather than in `RawDss`, which `Clear` resets. The refusal it
702    /// emits stays put too: `RawDss::clear` keeps the parse record.
703    fn charge_include(&mut self, verb: &str, path: &Path, ctx: &dyn Fn(String) -> String) -> bool {
704        let over_budget = self.budget_spent
705            || self.includes >= MAX_TOTAL_INCLUDES
706            || self.include_bytes >= MAX_TOTAL_INCLUDE_BYTES;
707        if !over_budget {
708            self.includes += 1;
709            return true;
710        }
711        if !self.budget_spent {
712            self.budget_spent = true;
713            let message = ctx(format!(
714                "{verb} {}: refused; the case exceeded the include budget of {MAX_TOTAL_INCLUDES} \
715                 files and {} MiB, so the rest of the includes were not followed",
716                path.display(),
717                MAX_TOTAL_INCLUDE_BYTES >> 20,
718            ));
719            self.refuse(
720                message,
721                crate::diagnostics::READ_DSS_INCLUDE_BUDGET,
722                "check the case for an include cycle; a file that redirects to itself expands \
723                 without bound",
724            );
725        }
726        false
727    }
728
729    /// Records a failed include load. A containment refusal is the loader's
730    /// own, covering what the lexical check cannot see: the path is inside the
731    /// case directory but resolves out of it through a symbolic link. It
732    /// carries the same code and severity as a lexical refusal. Every other
733    /// load failure — including a `PermissionDenied` the filesystem raised on
734    /// an include that is where it claims to be — stays a warning.
735    fn warn_load_error(
736        &mut self,
737        verb: &str,
738        path: &Path,
739        e: &std::io::Error,
740        ctx: &dyn Fn(String) -> String,
741    ) {
742        let message = ctx(format!("{verb} {}: {e}", path.display()));
743        if Containment::refused_by_us(e) {
744            self.refuse_escape(message);
745        } else {
746            self.raw.warn(message);
747        }
748    }
749
750    fn do_redirect(&mut self, scan: &mut Scanner, compile: bool, ctx: &dyn Fn(String) -> String) {
751        let Some(p) = scan.next_param() else {
752            self.raw.warn(ctx("redirect with no file".into()));
753            return;
754        };
755        let verb = if compile { "compile" } else { "redirect" };
756        let Some(path) = self.resolve_or_warn(verb, &p.value.text, ctx) else {
757            return;
758        };
759        if self.dirs.len() > MAX_REDIRECT_DEPTH {
760            self.raw
761                .warn(ctx(format!("redirect depth limit at {}", path.display())));
762            return;
763        }
764        if !self.charge_include(verb, &path, ctx) {
765            return;
766        }
767        match self.loader.load(&path) {
768            Ok(text) => {
769                self.include_bytes += text.len();
770                let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
771                self.dirs.push(dir.clone());
772                self.run_script(&text, &path.display().to_string());
773                self.dirs.pop();
774                // The engine keeps one current directory: Redirect restores
775                // the caller's on return (SetCurrentDir(SaveDir)), Compile
776                // pins it to the compiled file's OWN directory — ExecHelper
777                // DoRedirect sets CurrDir once from the file path (~:300)
778                // and compile exit reapplies it via SetDataPath (~:361) —
779                // even when the compiled script itself compiled deeper. The
780                // caller's later relative paths follow the compiled file.
781                if compile && let Some(top) = self.dirs.last_mut() {
782                    *top = dir;
783                }
784            }
785            Err(e) => self.warn_load_error(verb, &path, &e, ctx),
786        }
787    }
788
789    fn do_buscoords(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
790        let Some(p) = scan.next_param() else {
791            self.raw.warn(ctx("buscoords with no file".into()));
792            return;
793        };
794        let Some(path) = self.resolve_or_warn("buscoords", &p.value.text, ctx) else {
795            return;
796        };
797        if !self.charge_include("buscoords", &path, ctx) {
798            return;
799        }
800        match self.loader.load(&path) {
801            Ok(text) => {
802                self.include_bytes += text.len();
803                for (line_no, line) in text.lines().enumerate() {
804                    let mut s = Scanner::new(line, None);
805                    let Some(bus) = s.next_param() else { continue };
806                    if bus.value.text.is_empty() {
807                        continue;
808                    }
809                    let x = s.next_param().map(|p| p.value).unwrap_or_default();
810                    let y = s.next_param().map(|p| p.value).unwrap_or_default();
811                    match (x.to_f64(None), y.to_f64(None)) {
812                        (Ok(x), Ok(y)) => self.raw.buscoords.push(BusCoord {
813                            bus: bus.value.text,
814                            x,
815                            y,
816                        }),
817                        _ => self.raw.warn(ctx(format!(
818                            "buscoords {}:{}: unparseable coordinates",
819                            path.display(),
820                            line_no + 1
821                        ))),
822                    }
823                }
824            }
825            Err(e) => self.warn_load_error("buscoords", &path, &e, ctx),
826        }
827    }
828
829    /// Reads `Class.Name` (or `object=Class.Name`) from the next parameter.
830    fn object_spec(
831        &mut self,
832        scan: &mut Scanner,
833        ctx: &dyn Fn(String) -> String,
834    ) -> Option<(String, String)> {
835        let p = scan.next_param()?;
836        if let Some(name) = &p.name {
837            if !name.eq_ignore_ascii_case("object") {
838                self.raw
839                    .warn(ctx(format!("expected Class.Name, got `{name}=`")));
840                return None;
841            }
842        }
843        let spec = p.value.text;
844        match spec.split_once('.') {
845            Some((class, name)) if !class.is_empty() && !name.is_empty() => {
846                Some((class.to_string(), name.to_string()))
847            }
848            _ => {
849                self.raw
850                    .warn(ctx(format!("malformed object spec `{spec}`")));
851                None
852            }
853        }
854    }
855
856    fn make_object(&mut self, class: &str, name: String) -> usize {
857        let class_lc = class.to_ascii_lowercase();
858        let idx = self.raw.objects.len();
859        self.raw
860            .index
861            .insert((class_lc.clone(), name.to_ascii_lowercase()), idx);
862        self.raw.objects.push(RawObject {
863            class: class_lc,
864            name,
865            props: Vec::new(),
866            edits: Vec::new(),
867        });
868        idx
869    }
870
871    fn consume_and_apply(
872        &mut self,
873        idx: usize,
874        scan: &mut Scanner,
875        ctx: &dyn Fn(String) -> String,
876    ) {
877        let props = collect_props_for(
878            prop_table(&self.raw.objects[idx].class),
879            scan,
880            None,
881            &mut self.raw.warnings,
882            ctx,
883        );
884        self.apply_props(idx, props, ctx);
885    }
886
887    fn apply_props(&mut self, idx: usize, props: Vec<RawProp>, ctx: &dyn Fn(String) -> String) {
888        self.raw.active = Some(idx);
889        for p in props {
890            // `like=<name>` splices the source object's accumulated props,
891            // checkpoints included: MakeLike copies the source's recalced
892            // state (Load.cpp ~810-815 takes kWBase, kvarBase, LoadSpecType,
893            // AND PFNominal), which equals replaying the source's writes
894            // with its own edit boundaries.
895            if p.name.as_deref() == Some("like") {
896                let class = self.raw.objects[idx].class.clone();
897                let key = (class.clone(), p.value.text.to_ascii_lowercase());
898                match self.raw.index.get(&key).copied() {
899                    Some(src) => {
900                        let base = self.raw.objects[idx].props.len();
901                        let src_len = self.raw.objects[src].props.len();
902                        // Refuse a splice that would push the object past the
903                        // cap. A self reference (`Edit X like=X`) or a mutual
904                        // chain otherwise doubles the prop count per edit; the
905                        // guard turns that into a warning instead of unbounded
906                        // growth.
907                        if base.saturating_add(src_len) > MAX_OBJECT_PROPS {
908                            self.raw.warn(ctx(format!(
909                                "like={}: {class} property count would exceed the supported \
910                                 maximum of {MAX_OBJECT_PROPS}; splice refused",
911                                p.value.text
912                            )));
913                            continue;
914                        }
915                        let cloned = self.raw.objects[src].props.clone();
916                        let bounds: Vec<usize> = self.raw.objects[src]
917                            .edit_bounds()
918                            .map(|e| base + e)
919                            .collect();
920                        self.raw.objects[idx].props.extend(cloned);
921                        self.raw.objects[idx].edits.extend(bounds);
922                    }
923                    None => self.raw.warn(ctx(format!(
924                        "like={} names an unknown {class}",
925                        p.value.text
926                    ))),
927                }
928                continue;
929            }
930            if self.raw.objects[idx].props.len() >= MAX_OBJECT_PROPS {
931                self.raw.warn(ctx(format!(
932                    "{}: property count exceeds the supported maximum of {MAX_OBJECT_PROPS}; \
933                     further assignments dropped",
934                    self.raw.objects[idx].class
935                )));
936                continue;
937            }
938            self.raw.objects[idx].props.push(p);
939        }
940        // This command line was one engine Edit; it ends in
941        // RecalcElementData, so record the boundary.
942        let end = self.raw.objects[idx].props.len();
943        self.raw.objects[idx].edits.push(end);
944    }
945}
946
947fn prop_table(class: &str) -> Option<&'static DssClass> {
948    prop::class_by_name(class)
949}
950
951/// Reads the remaining parameters of an object command, resolving names
952/// (with abbreviation) and positional order against the class table. The
953/// positional pointer continues from the last named property, as in the
954/// reference. `after` seeds the pointer for property reference lines.
955fn collect_props_for(
956    class: Option<&'static DssClass>,
957    scan: &mut Scanner,
958    after: Option<&str>,
959    warnings: &mut Vec<String>,
960    ctx: &dyn Fn(String) -> String,
961) -> Vec<RawProp> {
962    let mut out = Vec::new();
963    let mut pointer: Option<usize> = class.zip(after).and_then(|(c, name)| c.prop_index(name));
964    while let Some(p) = scan.next_param() {
965        if p.value.text.is_empty() && p.name.is_none() {
966            break;
967        }
968        let name = match (&p.name, class) {
969            (Some(written), Some(c)) => {
970                if let Some(i) = c.prop_index(written) {
971                    pointer = Some(i);
972                    Some(c.props[i].to_string())
973                } else {
974                    // Getcommand yields 0 for an unknown name, so the next
975                    // positional lands on property 1 (the class Edit loops:
976                    // `ParamPointer = CommandList.Getcommand(ParamName)`).
977                    pointer = None;
978                    warnings.push(ctx(format!(
979                        "unknown property `{written}` on {}; kept as written",
980                        c.name
981                    )));
982                    Some(written.to_ascii_lowercase())
983                }
984            }
985            (Some(written), None) => Some(written.to_ascii_lowercase()),
986            (None, Some(c)) => {
987                let next = pointer.map_or(0, |i| i + 1);
988                pointer = Some(next);
989                if let Some(canon) = c.props.get(next) {
990                    Some((*canon).to_string())
991                } else {
992                    warnings.push(ctx(format!(
993                        "positional value `{}` beyond the last {} property",
994                        p.value.text, c.name
995                    )));
996                    None
997                }
998            }
999            (None, None) => None,
1000        };
1001        out.push(RawProp {
1002            name,
1003            value: p.value,
1004        });
1005    }
1006    out
1007}
1008
1009/// Parses `.dss` text. `path` anchors relative includes; pass the file's
1010/// path when the text came from a file, anything descriptive otherwise.
1011///
1012/// Includes are resolved through `loader` without confinement: a caller that
1013/// passes a filesystem-backed loader lets `Redirect`/`Compile`/`Buscoords`
1014/// read any path the loader accepts. For untrusted input use
1015/// [`parse_dss_str`](crate::dss::parse_dss_str) (no filesystem access) or
1016/// [`parse_dss_file`](crate::dss::parse_dss_file) / [`parse_raw_file`]
1017/// (includes confined to the case directory), or enforce your own containment
1018/// inside the loader.
1019pub fn parse_raw_with(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1020    run_executor(text, path, None, loader)
1021}
1022
1023/// The case directory of `path` in canonical (symlink resolved) form, for
1024/// checking filesystem reads against the lexical confinement root. `None`
1025/// when canonicalization fails (directory missing or unreadable), which the
1026/// confined filesystem reader treats as "refuse every include".
1027pub(crate) fn canonical_case_root(path: &Path) -> Option<PathBuf> {
1028    let dir = path.parent().unwrap_or_else(|| Path::new(""));
1029    let dir = if dir.as_os_str().is_empty() {
1030        Path::new(".")
1031    } else {
1032        dir
1033    };
1034    dir.canonicalize().ok()
1035}
1036
1037/// Reads an include for confined file parsing. The executor's lexical check
1038/// already ran; this closes the symlink hole it cannot see: the path is
1039/// canonicalized (resolving symlinks) and refused unless the real file still
1040/// sits under the case directory's canonical root. The refusal surfaces
1041/// through the executor's ordinary load-error warning.
1042pub(crate) fn confined_fs_read(
1043    canonical_root: Option<&Path>,
1044    path: &Path,
1045) -> std::io::Result<String> {
1046    let Some(root) = canonical_root else {
1047        return Err(Containment::refused(
1048            "case directory cannot be resolved; includes are disabled",
1049        ));
1050    };
1051    let real = path.canonicalize()?;
1052    if !real.starts_with(root) {
1053        return Err(Containment::refused(
1054            "resolves outside the case directory through a symbolic link",
1055        ));
1056    }
1057    std::fs::read_to_string(&real)
1058}
1059
1060/// The loader's own containment refusal, carried inside the `io::Error` so it
1061/// stays distinguishable from a `PermissionDenied` the filesystem raised. A
1062/// mode 000 file inside the case directory is an ordinary unreadable include,
1063/// not an escape attempt, and must not be reported as one.
1064#[derive(Debug)]
1065pub(crate) struct Containment(&'static str);
1066
1067impl Containment {
1068    fn refused(reason: &'static str) -> std::io::Error {
1069        std::io::Error::new(std::io::ErrorKind::PermissionDenied, Containment(reason))
1070    }
1071
1072    /// Whether `e` is a refusal this module raised.
1073    pub(crate) fn refused_by_us(e: &std::io::Error) -> bool {
1074        e.get_ref()
1075            .is_some_and(<dyn std::error::Error + Send + Sync>::is::<Self>)
1076    }
1077}
1078
1079impl std::fmt::Display for Containment {
1080    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1081        f.write_str(self.0)
1082    }
1083}
1084
1085impl std::error::Error for Containment {}
1086
1087/// Like [`parse_raw_with`], but confines `Redirect`/`Compile`/`Buscoords`
1088/// includes to the directory of `path`: an include that is absolute or climbs
1089/// out of that directory with `..` is refused with a warning and read nothing.
1090/// Used by the file entry point so an untrusted case file on disk cannot read
1091/// arbitrary paths.
1092pub(crate) fn parse_raw_with_confined(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1093    let root = lexical_normalize(
1094        &Path::new(path)
1095            .parent()
1096            .map(Path::to_path_buf)
1097            .unwrap_or_default(),
1098    );
1099    run_executor(text, path, Some(root), loader)
1100}
1101
1102fn run_executor(text: &str, path: &str, root: Option<PathBuf>, loader: &mut impl Loader) -> RawDss {
1103    let mut exec = Executor {
1104        raw: RawDss::default(),
1105        loader,
1106        dirs: vec![
1107            Path::new(path)
1108                .parent()
1109                .map(Path::to_path_buf)
1110                .unwrap_or_default(),
1111        ],
1112        root,
1113        includes: 0,
1114        include_bytes: 0,
1115        budget_spent: false,
1116    };
1117    exec.run_script(text, path);
1118    exec.raw
1119}
1120
1121/// Parses a `.dss` file from disk, following its includes.
1122/// `Redirect`/`Compile`/`Buscoords` includes are confined to the case
1123/// directory, lexically and after symlink resolution, exactly like
1124/// [`parse_dss_file`](crate::dss::parse_dss_file); an include that escapes is
1125/// refused with a warning. Use [`parse_raw_with`] with your own loader for
1126/// unconfined resolution.
1127pub fn parse_raw_file(path: impl AsRef<Path>) -> Result<RawDss> {
1128    let path = path.as_ref();
1129    let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
1130        path: path.display().to_string(),
1131        source,
1132    })?;
1133    let root = canonical_case_root(path);
1134    Ok(parse_raw_with_confined(
1135        &text,
1136        &path.display().to_string(),
1137        &mut |p: &Path| confined_fs_read(root.as_deref(), p),
1138    ))
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::*;
1144
1145    fn no_files(_: &Path) -> std::io::Result<String> {
1146        Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test"))
1147    }
1148
1149    fn parse(text: &str) -> RawDss {
1150        parse_raw_with(text, "test.dss", &mut no_files)
1151    }
1152
1153    #[test]
1154    fn new_object_with_positional_and_named() {
1155        let raw = parse("New Line.l1 b1 b2 lc 0.3 phases=2 r1=0.1");
1156        let l = raw.find("line", "l1").unwrap();
1157        assert_eq!(l.get("bus1").unwrap().text, "b1");
1158        assert_eq!(l.get("bus2").unwrap().text, "b2");
1159        assert_eq!(l.get("linecode").unwrap().text, "lc");
1160        assert_eq!(l.get("length").unwrap().text, "0.3");
1161        assert_eq!(l.get("phases").unwrap().text, "2");
1162        assert_eq!(l.get("r1").unwrap().text, "0.1");
1163        assert!(raw.warnings.is_empty());
1164    }
1165
1166    #[test]
1167    fn positional_continues_after_named() {
1168        // After r1=0.1 (index 5), the next positional is x1 (index 6).
1169        let raw = parse("New Line.l1 r1=0.1 0.2");
1170        let l = raw.find("line", "l1").unwrap();
1171        assert_eq!(l.get("x1").unwrap().text, "0.2");
1172    }
1173
1174    #[test]
1175    fn unknown_property_resets_the_positional_pointer() {
1176        // `ParamPointer = Getcommand("bogus")` is 0 in the engine, so the
1177        // next positional gets property 1 (bus1), not the one after r1.
1178        let raw = parse("New Line.l1 r1=0.1 bogus=2 0.5");
1179        let l = raw.find("line", "l1").unwrap();
1180        assert_eq!(l.get("bus1").unwrap().text, "0.5");
1181        assert!(l.get("x1").is_none());
1182        assert_eq!(raw.warnings.len(), 1);
1183    }
1184
1185    #[test]
1186    fn tilde_continues_the_active_object() {
1187        let raw = parse("New Load.ld bus1=b1\n~ kW=15 kvar=3\nMore pf=0.9");
1188        let ld = raw.find("load", "ld").unwrap();
1189        assert_eq!(ld.get("kw").unwrap().text, "15");
1190        assert_eq!(ld.get("kvar").unwrap().text, "3");
1191        assert_eq!(ld.get("pf").unwrap().text, "0.9");
1192    }
1193
1194    #[test]
1195    fn abbreviated_property_names() {
1196        let raw = parse("New Line.l1 ph=3 len=2 rm=(1 | 0 1)");
1197        let l = raw.find("line", "l1").unwrap();
1198        assert_eq!(l.get("phases").unwrap().text, "3");
1199        assert_eq!(l.get("length").unwrap().text, "2");
1200        assert!(l.get("rmatrix").unwrap().quoted);
1201    }
1202
1203    #[test]
1204    fn new_circuit_creates_the_source() {
1205        let raw = parse("New Circuit.test basekv=115 pu=1.05\n~ angle=30");
1206        assert_eq!(raw.circuit_name.as_deref(), Some("test"));
1207        let vs = raw.find("vsource", "source").unwrap();
1208        assert_eq!(vs.get("basekv").unwrap().text, "115");
1209        assert_eq!(vs.get("angle").unwrap().text, "30");
1210        // bus1 was not written; the default (sourcebus) is the reader's to
1211        // materialize, so the raw layer must not invent it.
1212        assert!(vs.get("bus1").is_none());
1213    }
1214
1215    #[test]
1216    fn edit_and_property_reference() {
1217        let raw = parse("New Line.l1 length=1\nEdit Line.l1 length=2\nLine.l1.Length=3 phases=2");
1218        let l = raw.find("line", "l1").unwrap();
1219        assert_eq!(l.get("length").unwrap().text, "3");
1220        assert_eq!(l.get("phases").unwrap().text, "2");
1221    }
1222
1223    #[test]
1224    fn property_reference_resolves_abbreviations() {
1225        let raw = parse("New Line.l1 bus1=a\nLine.l1.Len=2.5");
1226        let l = raw.find("line", "l1").unwrap();
1227        assert_eq!(l.get("length").unwrap().text, "2.5");
1228        assert!(raw.warnings.is_empty());
1229    }
1230
1231    #[test]
1232    fn bare_property_edits_the_active_object() {
1233        let raw = parse("New Line.l1 bus1=a bus2=b\nlength=2.5");
1234        let l = raw.find("line", "l1").unwrap();
1235        assert_eq!(l.get("length").unwrap().text, "2.5");
1236        assert!(raw.warnings.is_empty());
1237    }
1238
1239    #[test]
1240    fn classless_reference_uses_the_active_class() {
1241        // SetObject with no dot in the spec looks the name up in the last
1242        // referenced class, line here via the active object.
1243        let raw = parse("New Line.l1 bus1=a\nNew Line.l2 bus1=b\nl1.length=7 phases=2");
1244        let l1 = raw.find("line", "l1").unwrap();
1245        assert_eq!(l1.get("length").unwrap().text, "7");
1246        assert_eq!(l1.get("phases").unwrap().text, "2");
1247        assert!(raw.find("line", "l2").unwrap().get("length").is_none());
1248        assert!(raw.warnings.is_empty());
1249    }
1250
1251    #[test]
1252    fn like_splices_source_props() {
1253        let raw = parse("New Load.a kW=10 pf=0.9\nNew Load.b like=a kW=20");
1254        let b = raw.find("load", "b").unwrap();
1255        assert_eq!(b.get("kw").unwrap().text, "20");
1256        assert_eq!(b.get("pf").unwrap().text, "0.9");
1257    }
1258
1259    #[test]
1260    fn self_referencing_like_cannot_explode_the_prop_count() {
1261        // `Edit X like=X` splices the object into itself; unbounded, each
1262        // repeat doubles the prop count. A few dozen lines would exhaust
1263        // memory. The cap turns the runaway splices into warnings.
1264        let mut script = String::from("New Load.a kW=1\n");
1265        for _ in 0..40 {
1266            script.push_str("Edit Load.a like=a\n");
1267        }
1268        let raw = parse(&script);
1269        let a = raw.find("load", "a").unwrap();
1270        assert!(
1271            a.props.len() <= MAX_OBJECT_PROPS,
1272            "prop count {} exceeded the cap",
1273            a.props.len()
1274        );
1275        assert!(
1276            raw.warnings.iter().any(|w| w.contains("splice refused")),
1277            "expected a refusal warning, got {:?}",
1278            raw.warnings
1279        );
1280    }
1281
1282    #[test]
1283    fn unknown_class_is_preserved_raw() {
1284        let raw = parse("New Reactor.r1 bus1=b1 x=3");
1285        let r = raw.find("reactor", "r1").unwrap();
1286        assert_eq!(r.get("bus1").unwrap().text, "b1");
1287        assert_eq!(r.get("x").unwrap().text, "3");
1288    }
1289
1290    #[test]
1291    fn set_options_accumulate() {
1292        let raw = parse("Set VoltageBases=[115, 12.47]\nset mode=snapshot");
1293        assert_eq!(raw.options[0].0, "voltagebases");
1294        assert_eq!(
1295            raw.options[0].1.to_vector(None).unwrap(),
1296            vec![115.0, 12.47]
1297        );
1298        assert_eq!(raw.options[1].0, "mode");
1299    }
1300
1301    #[test]
1302    fn unexecuted_commands_are_preserved() {
1303        let raw = parse("Solve\ncalcv\nShow Voltages LN");
1304        let verbs: Vec<&str> = raw.commands.iter().map(|c| c.verb.as_str()).collect();
1305        assert_eq!(verbs, vec!["solve", "calcvoltagebases", "show"]);
1306        assert_eq!(raw.commands[2].args, "Voltages LN");
1307    }
1308
1309    /// `Clear` resets the circuit and nothing about containment. The root and
1310    /// the include budget live on the executor precisely so this holds
1311    /// (GHSA-wg3j-3v62-fv3f); if they ever moved onto `RawDss`, a case could
1312    /// disarm the check by clearing first.
1313    #[test]
1314    fn clear_does_not_disarm_the_include_containment() {
1315        let requested: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
1316        let mut loader = |p: &Path| {
1317            requested.borrow_mut().push(p.display().to_string());
1318            Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1319        };
1320        let raw = parse_raw_with_confined(
1321            "Redirect ../../secret.dss\nClear\nRedirect ../../secret.dss\nRedirect /etc/passwd",
1322            "/case/dir/master.dss",
1323            &mut loader,
1324        );
1325        assert!(
1326            requested.borrow().is_empty(),
1327            "an escaping include reached the loader after Clear: {:?}",
1328            requested.borrow()
1329        );
1330        assert_eq!(
1331            raw.warnings
1332                .iter()
1333                .filter(|w| w.contains("escapes the case directory"))
1334                .count(),
1335            3,
1336            "every refusal is stated, and Clear does not erase the ones before it"
1337        );
1338    }
1339
1340    #[test]
1341    fn clear_resets() {
1342        let raw = parse("New Line.l1 length=1\nClear\nNew Line.l2 length=2");
1343        assert!(raw.find("line", "l1").is_none());
1344        assert!(raw.find("line", "l2").is_some());
1345    }
1346
1347    #[test]
1348    fn block_comments_skip_lines() {
1349        let raw = parse("/* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1350        assert!(raw.find("line", "l1").is_none());
1351        assert!(raw.find("line", "l2").is_some());
1352    }
1353
1354    #[test]
1355    fn indented_block_comments_skip_lines() {
1356        let raw = parse("  /* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1357        assert!(raw.find("line", "l1").is_none());
1358        assert!(raw.find("line", "l2").is_some());
1359    }
1360
1361    #[test]
1362    fn one_line_block_comment() {
1363        let raw = parse("\t/* x */\nNew Line.l2 length=2");
1364        assert!(raw.find("line", "l2").is_some());
1365    }
1366
1367    #[test]
1368    fn redirect_includes_a_file() {
1369        let mut files = BTreeMap::from([(
1370            PathBuf::from("sub/codes.dss"),
1371            "New Linecode.lc1 nphases=3".to_string(),
1372        )]);
1373        let mut loader = move |p: &Path| {
1374            files
1375                .remove(p)
1376                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1377        };
1378        let raw = parse_raw_with(
1379            "Redirect sub/codes.dss\nNew Line.l1 linecode=lc1",
1380            "test.dss",
1381            &mut loader,
1382        );
1383        assert!(raw.find("linecode", "lc1").is_some());
1384        assert!(raw.warnings.is_empty());
1385    }
1386
1387    /// A loader that serves `text` for every path, gives up at `give_up`
1388    /// loads, and reports how many it served. Without the include budget the
1389    /// count runs away, so the test fails instead of hanging.
1390    fn counting_loader(text: &str, give_up: usize, script: &str) -> (RawDss, usize) {
1391        let mut loads = 0usize;
1392        let raw = {
1393            let mut loader = |_: &Path| {
1394                loads += 1;
1395                if loads > give_up {
1396                    Err(std::io::Error::other("loader gave up"))
1397                } else {
1398                    Ok(text.to_string())
1399                }
1400            };
1401            parse_raw_with(script, "test.dss", &mut loader)
1402        };
1403        (raw, loads)
1404    }
1405
1406    fn budget_refusals(raw: &RawDss) -> usize {
1407        raw.diagnostics
1408            .iter()
1409            .filter(|d| d.code.as_str() == crate::diagnostics::READ_DSS_INCLUDE_BUDGET)
1410            .count()
1411    }
1412
1413    #[test]
1414    fn a_self_redirecting_include_stops_at_the_file_budget() {
1415        // Two self redirects per file expand into a binary tree of depth
1416        // MAX_REDIRECT_DEPTH: ~2^65 script runs, which is why the depth limit
1417        // alone is not a bound.
1418        let (raw, loads) = counting_loader(
1419            "Redirect a.dss\nRedirect a.dss",
1420            MAX_TOTAL_INCLUDES * 4,
1421            "Redirect a.dss",
1422        );
1423        assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1424        assert_eq!(budget_refusals(&raw), 1, "one refusal, however many follow");
1425    }
1426
1427    #[test]
1428    fn a_large_self_redirecting_include_stops_at_the_byte_budget() {
1429        // The same tree with a big file: the file budget would let it re-scan
1430        // MAX_TOTAL_INCLUDES copies of it, so the bytes are charged too. Each
1431        // load carries a quarter megabyte on one long line.
1432        let chunk = 256 << 10;
1433        let text = format!("Redirect a.dss\nRedirect a.dss\n// {}", "a".repeat(chunk));
1434        let (raw, loads) = counting_loader(&text, MAX_TOTAL_INCLUDES, "Redirect a.dss");
1435        assert!(
1436            loads <= MAX_TOTAL_INCLUDE_BYTES / chunk + 1,
1437            "{loads} includes followed, {} bytes",
1438            loads * chunk
1439        );
1440        assert!(
1441            loads < MAX_TOTAL_INCLUDES,
1442            "the byte budget is what stopped it"
1443        );
1444        assert_eq!(budget_refusals(&raw), 1);
1445    }
1446
1447    #[test]
1448    fn buscoords_is_charged_against_the_include_budget() {
1449        // Buscoords does not recurse, but a redirect tree can load one per
1450        // node and every line of it lands in `raw.buscoords`. An uncharged
1451        // Buscoords would push the load count past the budget.
1452        let (raw, loads) = counting_loader(
1453            "Redirect a.dss\nRedirect a.dss\nBuscoords a.csv",
1454            MAX_TOTAL_INCLUDES * 4,
1455            "Redirect a.dss",
1456        );
1457        assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1458        assert_eq!(budget_refusals(&raw), 1);
1459    }
1460
1461    #[test]
1462    fn clear_does_not_refund_the_include_budget() {
1463        // `Clear` resets the parsed script, so a budget counted in RawDss
1464        // would reset with it and the tree would run unbounded again.
1465        let (_, loads) = counting_loader(
1466            "Clear\nRedirect a.dss\nRedirect a.dss",
1467            MAX_TOTAL_INCLUDES * 4,
1468            "Redirect a.dss",
1469        );
1470        assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1471    }
1472
1473    #[test]
1474    fn missing_redirect_warns() {
1475        let raw = parse("Redirect nope.dss");
1476        assert_eq!(raw.warnings.len(), 1);
1477        assert!(raw.warnings[0].contains("nope.dss"));
1478    }
1479
1480    #[test]
1481    fn compile_moves_the_directory_redirect_restores_it() {
1482        // After `Compile sub/feeder.dss`, the caller's relative paths
1483        // resolve against sub/; after a Redirect they resolve against the
1484        // caller's own directory again. Both directories carry a lines.dss
1485        // so the wrong resolution shows up as the wrong object.
1486        let root = std::env::temp_dir().join(format!("powerio-dist-raw-{}", std::process::id()));
1487        let sub = root.join("sub");
1488        std::fs::create_dir_all(&sub).unwrap();
1489        std::fs::write(sub.join("feeder.dss"), "New Linecode.lc1 nphases=3").unwrap();
1490        std::fs::write(sub.join("lines.dss"), "New Line.fromsub bus1=a").unwrap();
1491        std::fs::write(root.join("lines.dss"), "New Line.fromroot bus1=a").unwrap();
1492        std::fs::write(
1493            root.join("compile.dss"),
1494            "Compile sub/feeder.dss\nRedirect lines.dss",
1495        )
1496        .unwrap();
1497        std::fs::write(
1498            root.join("redirect.dss"),
1499            "Redirect sub/feeder.dss\nRedirect lines.dss",
1500        )
1501        .unwrap();
1502
1503        let compiled = parse_raw_file(root.join("compile.dss")).unwrap();
1504        assert_eq!(compiled.warnings, Vec::<String>::new());
1505        assert!(compiled.find("line", "fromsub").is_some());
1506
1507        let redirected = parse_raw_file(root.join("redirect.dss")).unwrap();
1508        assert_eq!(redirected.warnings, Vec::<String>::new());
1509        assert!(redirected.find("line", "fromroot").is_some());
1510
1511        std::fs::remove_dir_all(&root).unwrap();
1512    }
1513
1514    #[test]
1515    fn compile_inside_compile_pins_the_compiled_files_directory() {
1516        // ExecHelper DoRedirect sets CurrDir from the file path once at
1517        // entry and compile exit reapplies it (SetDataPath → ChDir), so a
1518        // Compile that itself compiles deeper still leaves the caller in
1519        // the directly compiled file's directory, not the innermost one.
1520        // probe.dss exists in both sub/ and sub/inner/; the engine resolves
1521        // sub/probe.dss.
1522        let root =
1523            std::env::temp_dir().join(format!("powerio-dist-rawnest-{}", std::process::id()));
1524        let sub = root.join("sub");
1525        let inner = sub.join("inner");
1526        std::fs::create_dir_all(&inner).unwrap();
1527        std::fs::write(
1528            root.join("main.dss"),
1529            "Compile sub/a.dss\nRedirect probe.dss",
1530        )
1531        .unwrap();
1532        std::fs::write(sub.join("a.dss"), "Compile inner/b.dss").unwrap();
1533        std::fs::write(inner.join("b.dss"), "New Linecode.lc1 nphases=1").unwrap();
1534        std::fs::write(sub.join("probe.dss"), "New Line.fromsub bus1=a").unwrap();
1535        std::fs::write(inner.join("probe.dss"), "New Line.frominner bus1=a").unwrap();
1536
1537        let raw = parse_raw_file(root.join("main.dss")).unwrap();
1538        assert_eq!(raw.warnings, Vec::<String>::new());
1539        assert!(raw.find("linecode", "lc1").is_some());
1540        assert!(raw.find("line", "fromsub").is_some());
1541        assert!(raw.find("line", "frominner").is_none());
1542
1543        std::fs::remove_dir_all(&root).unwrap();
1544    }
1545
1546    #[test]
1547    fn edit_boundaries_are_recorded() {
1548        // One checkpoint per command line; like= splices the source's
1549        // boundaries (offset) before the splicing edit's own.
1550        let raw = parse("New Load.a kW=10 pf=0.9\n~ kvar=5\nNew Load.b like=a kw=20");
1551        let a = raw.find("load", "a").unwrap();
1552        assert_eq!(a.edits, vec![2, 3]);
1553        let b = raw.find("load", "b").unwrap();
1554        assert_eq!(b.props.len(), 4);
1555        assert_eq!(b.edits, vec![2, 3, 4]);
1556        assert_eq!(b.edit_bounds().collect::<Vec<_>>(), vec![2, 3, 4]);
1557    }
1558
1559    #[test]
1560    fn var_definition_and_use() {
1561        let raw = parse("var @kv=12.47\nNew Load.ld kv=@kv");
1562        let ld = raw.find("load", "ld").unwrap();
1563        assert_eq!(ld.get("kv").unwrap().text, "12.47");
1564    }
1565
1566    #[test]
1567    fn quoted_var_value_stays_rpn() {
1568        // The braces TParserVar::Add wraps around the stored value come
1569        // back off as a quoted token, so the substituted expression still
1570        // evaluates as RPN.
1571        let raw = parse("var @z=(8 1000 /)\nNew Load.ld kW=@z");
1572        let v = raw.find("load", "ld").unwrap().get("kw").unwrap();
1573        assert!(v.quoted);
1574        assert_eq!(v.to_f64(None), Ok(0.008));
1575    }
1576
1577    #[test]
1578    fn vars_cross_redirect_boundaries() {
1579        // A var defined in the parent substitutes inside the include, and a
1580        // var defined in the include survives back in the parent.
1581        let mut loader = |p: &Path| {
1582            if p == Path::new("inc.dss") {
1583                Ok("New Load.inner kv=@kv\nvar @kw=42".to_string())
1584            } else {
1585                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1586            }
1587        };
1588        let raw = parse_raw_with(
1589            "var @kv=12.47\nRedirect inc.dss\nNew Load.outer kW=@kw",
1590            "test.dss",
1591            &mut loader,
1592        );
1593        assert_eq!(raw.warnings, Vec::<String>::new());
1594        assert_eq!(
1595            raw.find("load", "inner").unwrap().get("kv").unwrap().text,
1596            "12.47"
1597        );
1598        assert_eq!(
1599            raw.find("load", "outer").unwrap().get("kw").unwrap().text,
1600            "42"
1601        );
1602    }
1603
1604    #[test]
1605    fn duplicate_new_warns_and_edits() {
1606        let raw = parse("New Line.l1 length=1\nNew Line.l1 length=2");
1607        assert_eq!(raw.warnings.len(), 1);
1608        assert_eq!(
1609            raw.find("line", "l1").unwrap().get("length").unwrap().text,
1610            "2"
1611        );
1612    }
1613
1614    #[test]
1615    fn rpn_value_via_props() {
1616        let raw = parse("New Load.ld kW=(8 1000 /)");
1617        let v = raw.find("load", "ld").unwrap().get("kw").unwrap().clone();
1618        assert_eq!(v.to_f64(None), Ok(0.008));
1619    }
1620
1621    #[test]
1622    fn confined_parsing_refuses_includes_outside_the_case_directory() {
1623        use std::cell::RefCell;
1624        // Records every path the loader is actually asked to read, so we can
1625        // assert a refused include never reaches the filesystem.
1626        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1627        let mut loader = |p: &Path| {
1628            requested.borrow_mut().push(p.display().to_string());
1629            Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1630        };
1631        let raw = parse_raw_with_confined(
1632            "Redirect ../../secret.dss\nRedirect /etc/passwd\nBuscoords ../up.csv",
1633            "/case/dir/master.dss",
1634            &mut loader,
1635        );
1636        assert!(
1637            requested.borrow().is_empty(),
1638            "escaping include reached the loader: {:?}",
1639            requested.borrow()
1640        );
1641        assert_eq!(
1642            raw.warnings
1643                .iter()
1644                .filter(|w| w.contains("escapes the case directory"))
1645                .count(),
1646            3
1647        );
1648        // Each refusal is also an Error-severity finding (#275): the parse
1649        // continued, but the network is incomplete.
1650        let refused: Vec<_> = raw
1651            .diagnostics
1652            .iter()
1653            .filter(|d| d.code.as_str() == crate::diagnostics::READ_DSS_INCLUDE_REFUSED)
1654            .collect();
1655        assert_eq!(refused.len(), 3);
1656        assert!(
1657            refused
1658                .iter()
1659                .all(|d| d.severity == crate::diagnostics::DiagnosticSeverity::Error)
1660        );
1661    }
1662
1663    #[test]
1664    fn confined_parsing_with_an_empty_root_refuses_absolute_and_climbing_includes() {
1665        use std::cell::RefCell;
1666        // A bare filename ("master.dss") has an empty parent, so the
1667        // confinement root is empty. `starts_with("")` holds for every path,
1668        // so containment must come from the component rule instead: only
1669        // plain relative includes stay inside the working directory.
1670        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1671        let mut loader = |p: &Path| {
1672            requested.borrow_mut().push(p.display().to_string());
1673            Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1674        };
1675        let raw = parse_raw_with_confined(
1676            "Redirect /etc/passwd\nRedirect ../secret.dss\nRedirect sub/ok.dss",
1677            "master.dss",
1678            &mut loader,
1679        );
1680        assert_eq!(
1681            *requested.borrow(),
1682            vec!["sub/ok.dss".to_string()],
1683            "an absolute or climbing include reached the loader"
1684        );
1685        assert_eq!(
1686            raw.warnings
1687                .iter()
1688                .filter(|w| w.contains("escapes the case directory"))
1689                .count(),
1690            2
1691        );
1692    }
1693
1694    #[test]
1695    fn confined_parsing_allows_includes_under_a_root_that_starts_with_parent_dirs() {
1696        use std::cell::RefCell;
1697        // The case path itself may climb ("../case/master.dss"); includes
1698        // under that same directory are inside the case and must load.
1699        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1700        let mut loader = |p: &Path| {
1701            requested.borrow_mut().push(p.display().to_string());
1702            Ok(String::new())
1703        };
1704        let raw = parse_raw_with_confined(
1705            "Redirect codes.dss\nRedirect ../../outside.dss",
1706            "../case/master.dss",
1707            &mut loader,
1708        );
1709        assert_eq!(*requested.borrow(), vec!["../case/codes.dss".to_string()]);
1710        assert_eq!(
1711            raw.warnings
1712                .iter()
1713                .filter(|w| w.contains("escapes the case directory"))
1714                .count(),
1715            1
1716        );
1717    }
1718
1719    #[cfg(unix)]
1720    #[test]
1721    fn file_parsing_refuses_includes_that_escape_through_a_symlink() {
1722        // A lexically contained include that is really a symlink out of the
1723        // case directory must not be read.
1724        let root =
1725            std::env::temp_dir().join(format!("powerio-dist-symlink-{}", std::process::id()));
1726        let case = root.join("case");
1727        std::fs::create_dir_all(&case).unwrap();
1728        std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
1729        std::fs::write(case.join("master.dss"), "Redirect linked.dss").unwrap();
1730        std::os::unix::fs::symlink(root.join("secret.dss"), case.join("linked.dss")).unwrap();
1731
1732        let raw = parse_raw_file(case.join("master.dss")).unwrap();
1733        assert!(raw.find("line", "leaked").is_none());
1734        assert_eq!(
1735            raw.warnings
1736                .iter()
1737                .filter(|w| w.contains("outside the case directory"))
1738                .count(),
1739            1,
1740            "warnings: {:?}",
1741            raw.warnings
1742        );
1743
1744        std::fs::remove_dir_all(&root).unwrap();
1745    }
1746
1747    #[test]
1748    fn raw_file_parsing_refuses_includes_outside_the_case_directory() {
1749        let root =
1750            std::env::temp_dir().join(format!("powerio-dist-rawconf-{}", std::process::id()));
1751        std::fs::create_dir_all(root.join("case")).unwrap();
1752        std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
1753        std::fs::write(
1754            root.join("case").join("master.dss"),
1755            "Redirect ../secret.dss",
1756        )
1757        .unwrap();
1758
1759        let raw = parse_raw_file(root.join("case").join("master.dss")).unwrap();
1760        assert!(raw.find("line", "leaked").is_none());
1761        assert_eq!(
1762            raw.warnings
1763                .iter()
1764                .filter(|w| w.contains("escapes the case directory"))
1765                .count(),
1766            1
1767        );
1768
1769        std::fs::remove_dir_all(&root).unwrap();
1770    }
1771
1772    #[test]
1773    fn confined_parsing_allows_includes_within_the_case_directory() {
1774        use std::cell::RefCell;
1775        let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1776        let mut loader = |p: &Path| {
1777            requested.borrow_mut().push(p.display().to_string());
1778            Ok(String::new())
1779        };
1780        // A subdirectory include and an absolute path inside the root both load.
1781        parse_raw_with_confined(
1782            "Redirect sub/codes.dss\nRedirect /case/dir/abs.dss",
1783            "/case/dir/master.dss",
1784            &mut loader,
1785        );
1786        assert_eq!(
1787            *requested.borrow(),
1788            vec![
1789                "/case/dir/sub/codes.dss".to_string(),
1790                "/case/dir/abs.dss".to_string(),
1791            ]
1792        );
1793    }
1794}