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