1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use super::lex::{Scanner, Value, VarMap};
14use super::prop::{self, DssClass};
15use crate::diagnostics::codes as C;
16use crate::error::{Error, Result};
17
18static COMMANDS: &[&str] = &[
24 "new",
25 "edit",
26 "more",
27 "m",
28 "~",
29 "select",
30 "save",
31 "show",
32 "solve",
33 "enable",
34 "disable",
35 "plot",
36 "reset",
37 "compile",
38 "set",
39 "dump",
40 "open",
41 "close",
42 "//",
43 "redirect",
44 "help",
45 "quit",
46 "?",
47 "next",
48 "panel",
49 "sample",
50 "clear",
51 "about",
52 "calcvoltagebases",
53 "setkvbase",
54 "buildy",
55 "get",
56 "init",
57 "export",
58 "fileedit",
59 "voltages",
60 "currents",
61 "powers",
62 "seqvoltages",
63 "seqcurrents",
64 "seqpowers",
65 "losses",
66 "phaselosses",
67 "cktlosses",
68 "allocateloads",
69 "formedit",
70 "totals",
71 "capacity",
72 "classes",
73 "userclasses",
74 "zsc",
75 "zsc10",
76 "zscrefresh",
77 "ysc",
78 "puvoltages",
79 "varvalues",
80 "varnames",
81 "buscoords",
82 "makebuslist",
83 "makeposseq",
84 "reduce",
85 "interpolate",
86 "alignfile",
87 "top",
88 "rotate",
89 "vdiff",
90 "summary",
91 "distribute",
92 "di_plot",
93 "comparecases",
94 "yearlycurves",
95 "cd",
96 "visualize",
97 "closedi",
98 "doscmd",
99 "estimate",
100 "reconductor",
101 "_initsnap",
102 "_solvenocontrol",
103 "_samplecontrols",
104 "_docontrolactions",
105 "_showcontrolqueue",
106 "_solvedirect",
107 "_solvepflow",
108 "addbusmarker",
109 "uuids",
110 "setloadandgenkv",
111 "cvrtloadshapes",
112 "nodediff",
113 "rephase",
114 "setbusxy",
115 "updatestorage",
116 "obfuscate",
117 "latlongcoords",
118 "batchedit",
119 "pstcalc",
120 "variable",
121 "reprocessbuses",
122 "clearbusmarkers",
123 "relcalc",
124 "var",
125 "cleanup",
126 "finishtimestep",
127 "nodelist",
128 "newactor",
129 "clearall",
130 "wait",
131 "solveall",
132 "calcincmatrix",
133 "calcincmatrix_o",
134 "tear_circuit",
135 "connect",
136 "disconnect",
137 "refine_buslevels",
138 "remove",
139 "abort",
140 "calclaplacian",
141 "clone",
142 "fncspublish",
143 "exportoverloads",
144 "exportvviolations",
145 "zsc012",
146 "aggregateprofiles",
147 "allpceatbus",
148 "allpdeatbus",
149 "totalpowers",
150 "comhelp",
151 "gis",
152 "giscoords",
153 "readefieldhdf",
154];
155
156fn command_index(verb: &str) -> Option<usize> {
157 let v = verb.to_ascii_lowercase();
158 COMMANDS
159 .iter()
160 .position(|c| *c == v)
161 .or_else(|| COMMANDS.iter().position(|c| c.starts_with(&v)))
162}
163
164#[derive(Clone, Debug, PartialEq)]
166pub struct RawProp {
167 pub name: Option<String>,
171 pub value: Value,
172}
173
174#[derive(Clone, Debug)]
177pub struct RawObject {
178 pub class: String,
180 pub name: String,
182 pub props: Vec<RawProp>,
183 pub edits: Vec<usize>,
190}
191
192impl RawObject {
193 pub fn get(&self, name: &str) -> Option<&Value> {
195 self.props
196 .iter()
197 .rev()
198 .find(|p| p.name.as_deref() == Some(name))
199 .map(|p| &p.value)
200 }
201
202 pub fn edit_bounds(&self) -> impl Iterator<Item = usize> + '_ {
206 let tail =
207 (self.edits.last().copied() != Some(self.props.len())).then_some(self.props.len());
208 self.edits.iter().copied().chain(tail)
209 }
210}
211
212#[derive(Clone, Debug, PartialEq)]
214pub struct RawCommand {
215 pub verb: String,
217 pub args: String,
219}
220
221#[derive(Clone, Debug, PartialEq)]
223pub struct BusCoord {
224 pub bus: String,
225 pub x: f64,
226 pub y: f64,
227}
228
229#[derive(Debug, Default)]
231pub struct RawDss {
232 pub circuit_name: Option<String>,
233 pub objects: Vec<RawObject>,
234 pub options: Vec<(String, Value)>,
236 pub commands: Vec<RawCommand>,
238 pub buscoords: Vec<BusCoord>,
239 pub vars: VarMap,
240 vars_generation: u64,
244 pub warnings: Vec<String>,
247 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
250 index: BTreeMap<(String, String), usize>,
251 active: Option<usize>,
252 total_props: usize,
255 total_diagnostics: usize,
258 total_preserved: usize,
262 halted: bool,
265}
266
267impl RawDss {
268 pub fn find(&self, class: &str, name: &str) -> Option<&RawObject> {
269 self.index
270 .get(&(class.to_ascii_lowercase(), name.to_ascii_lowercase()))
271 .map(|&i| &self.objects[i])
272 }
273
274 pub fn of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a RawObject> {
275 self.objects.iter().filter(move |o| o.class == class)
276 }
277
278 fn warn(&mut self, info: &'static crate::diagnostics::DiagnosticInfo, msg: impl Into<String>) {
279 self.record(crate::diagnostics::Diagnostic::of(info, msg));
280 }
281
282 fn record(&mut self, diagnostic: crate::diagnostics::Diagnostic) {
286 if self.total_diagnostics >= MAX_TOTAL_DIAGNOSTICS {
287 self.halt(format!(
288 "the script's retained findings reached the whole parse ceiling of \
289 {MAX_TOTAL_DIAGNOSTICS}; execution stopped"
290 ));
291 return;
292 }
293 self.total_diagnostics += 1;
294 self.warnings
295 .push(crate::diagnostics::render_diagnostic(&diagnostic));
296 self.diagnostics.push(diagnostic);
297 }
298
299 fn halt(&mut self, message: String) {
303 if self.halted {
304 return;
305 }
306 self.halted = true;
307 let refusal = crate::diagnostics::Diagnostic::of(
308 &crate::diagnostics::codes::READ_DSS_INCLUDE_BUDGET,
309 message,
310 );
311 self.warnings
312 .push(crate::diagnostics::render_diagnostic(&refusal));
313 self.diagnostics.push(refusal);
314 }
315
316 fn charge_preserved(&mut self) -> bool {
320 if self.total_preserved >= MAX_TOTAL_PRESERVED {
321 self.halt(format!(
322 "the script's preserved records reached the whole parse ceiling of \
323 {MAX_TOTAL_PRESERVED}; execution stopped"
324 ));
325 return false;
326 }
327 self.total_preserved += 1;
328 true
329 }
330
331 fn clear(&mut self) {
332 let (warnings, diagnostics) = (
339 std::mem::take(&mut self.warnings),
340 std::mem::take(&mut self.diagnostics),
341 );
342 let (total_diagnostics, halted) = (self.total_diagnostics, self.halted);
343 let vars_generation = self.vars_generation.wrapping_add(1);
344 *self = RawDss {
345 vars_generation,
346 warnings,
347 diagnostics,
348 total_diagnostics,
349 halted,
350 ..RawDss::default()
351 };
352 }
353}
354
355pub trait Loader {
357 fn load(&mut self, path: &Path) -> std::io::Result<String>;
358}
359
360impl<F> Loader for F
361where
362 F: FnMut(&Path) -> std::io::Result<String>,
363{
364 fn load(&mut self, path: &Path) -> std::io::Result<String> {
365 self(path)
366 }
367}
368
369const MAX_REDIRECT_DEPTH: usize = 64;
371
372const MAX_TOTAL_INCLUDES: usize = 4096;
377
378const MAX_TOTAL_DIAGNOSTICS: usize = 1 << 18;
384
385const MAX_TOTAL_PRESERVED: usize = 1 << 20;
388
389const MAX_TOTAL_INCLUDE_BYTES: usize = 64 << 20;
396
397const MAX_TOTAL_VARS: usize = 4096;
402
403const MAX_TOTAL_VAR_BYTES: usize = 1 << 20;
406
407const MAX_OBJECT_PROPS: usize = 1 << 16;
415
416const MAX_TOTAL_PROPS: usize = 1 << 22;
422
423#[derive(Clone, Copy, Debug, PartialEq)]
426pub(crate) enum IncludeBoundary {
427 CaseDirectory,
428 IncludeRoot,
429}
430
431impl IncludeBoundary {
432 fn noun(self) -> &'static str {
433 match self {
434 Self::CaseDirectory => "the case directory",
435 Self::IncludeRoot => "the include root",
436 }
437 }
438}
439
440struct Executor<'l, L: Loader> {
441 raw: RawDss,
442 loader: &'l mut L,
443 dirs: Vec<PathBuf>,
446 root: Option<PathBuf>,
452 boundary: IncludeBoundary,
455 includes: usize,
459 include_bytes: usize,
460 budget_spent: bool,
461 var_count: usize,
466 var_bytes: usize,
467 var_budget_spent: bool,
468}
469
470fn lexical_normalize(p: &Path) -> PathBuf {
474 use std::path::Component;
475 let mut out: Vec<Component<'_>> = Vec::new();
476 for comp in p.components() {
477 match comp {
478 Component::CurDir => {}
479 Component::ParentDir if matches!(out.last(), Some(Component::Normal(_))) => {
480 out.pop();
481 }
482 other => out.push(other),
483 }
484 }
485 out.into_iter().collect()
486}
487
488fn command_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
493 let mut in_block = false;
494 text.lines().enumerate().filter_map(move |(i, line)| {
495 if in_block {
496 if line.contains("*/") {
497 in_block = false;
498 }
499 return None;
500 }
501 if line.trim_start().starts_with("/*") {
502 in_block = true;
503 if line.contains("*/") {
504 in_block = false;
505 }
506 return None;
507 }
508 Some((i + 1, line))
509 })
510}
511
512impl<L: Loader> Executor<'_, L> {
513 fn run_script(&mut self, text: &str, file: &str) {
514 for (line_no, line) in command_lines(text) {
515 if self.raw.halted {
516 return;
517 }
518 self.run_command(line, file, line_no);
519 }
520 }
521
522 fn run_command(&mut self, line: &str, file: &str, line_no: usize) {
523 let vars = std::mem::take(&mut self.raw.vars);
533 let generation = self.raw.vars_generation;
534 let mut scan = Scanner::new(line, Some(&vars));
535 let ctx = |msg: String| format!("{file}:{line_no}: {msg}");
536 match scan.next_param() {
537 None => {}
538 Some(first) if first.value.text.is_empty() && first.name.is_none() => {}
539 Some(first) => {
540 if let Some(name) = first.name {
541 self.edit_property_reference(&name, first.value, &mut scan, &ctx);
544 } else {
545 self.dispatch(first.value.text, &mut scan, &ctx);
546 }
547 }
548 }
549 if self.raw.vars_generation == generation {
550 let added = std::mem::replace(&mut self.raw.vars, vars);
551 self.raw.vars.extend(added);
552 }
553 }
554
555 fn dispatch(&mut self, verb: String, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
556 match command_index(&verb).map(|i| COMMANDS[i]) {
557 Some("new") => self.do_new(scan, ctx),
558 Some("edit") => self.do_edit(scan, ctx),
559 Some("more" | "m" | "~") => self.do_more(scan, ctx),
560 Some("select") => self.do_select(scan, ctx),
561 Some("set") => self.do_set(scan),
562 Some("redirect") => self.do_redirect(scan, false, ctx),
563 Some("compile") => self.do_redirect(scan, true, ctx),
564 Some("buscoords") => self.do_buscoords(scan, ctx),
565 Some("setbusxy") => self.do_setbusxy(scan, ctx),
566 Some("var") => self.do_var(scan, ctx),
567 Some("clear" | "clearall") => self.raw.clear(),
568 Some("//") => {}
569 Some(canonical) => {
570 if self.raw.charge_preserved() {
571 self.raw.commands.push(RawCommand {
572 verb: canonical.to_string(),
573 args: scan.remainder().to_string(),
574 });
575 }
576 }
577 None => {
578 self.raw.warn(
579 &C::PARSE_DSS_SOURCE_MALFORMED,
580 ctx(format!("unknown command `{verb}`; line preserved verbatim")),
581 );
582 if self.raw.charge_preserved() {
583 self.raw.commands.push(RawCommand {
584 verb,
585 args: scan.remainder().to_string(),
586 });
587 }
588 }
589 }
590 }
591
592 fn do_var(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
597 while let Some(p) = scan.next_param() {
598 if p.value.text.is_empty() && p.name.is_none() {
599 break;
600 }
601 if let Some(name) = p.name {
602 let stored = if p.value.text.starts_with('@') {
603 p.value.text
604 } else {
605 format!("{{{}}}", p.value.text)
606 };
607 let key = name.to_ascii_lowercase();
608 if self.charge_var(&key, &stored, ctx) {
609 self.raw.vars.insert(key, stored);
610 }
611 }
612 }
613 }
614
615 fn charge_var(&mut self, name: &str, stored: &str, ctx: &dyn Fn(String) -> String) -> bool {
619 let bytes = name.len() + stored.len();
620 let over_budget = self.var_budget_spent
621 || self.var_count >= MAX_TOTAL_VARS
622 || self.var_bytes.saturating_add(bytes) > MAX_TOTAL_VAR_BYTES;
623 if !over_budget {
624 self.var_count += 1;
625 self.var_bytes += bytes;
626 return true;
627 }
628 if !self.var_budget_spent {
629 self.var_budget_spent = true;
630 let message = ctx(format!(
631 "var {name}: refused; the case exceeded the variable budget of {MAX_TOTAL_VARS} \
632 entries and {} KiB, so further variables were not stored",
633 MAX_TOTAL_VAR_BYTES >> 10,
634 ));
635 self.raw
636 .warn(&crate::diagnostics::codes::READ_DSS_VALUE_CLAMPED, message);
637 }
638 false
639 }
640
641 fn edit_property_reference(
648 &mut self,
649 spec: &str,
650 value: Value,
651 scan: &mut Scanner,
652 ctx: &dyn Fn(String) -> String,
653 ) {
654 let (object, prop) = match spec.split_once('.') {
655 None => (None, spec),
656 Some((first, rest)) => match rest.split_once('.') {
657 None => (Some((None, first)), rest),
658 Some((name, prop)) => (Some((Some(first), name)), prop),
659 },
660 };
661 let active_or = |raw: &mut RawDss| {
662 let active = raw.active;
663 if active.is_none() {
664 raw.warn(
665 &C::PARSE_DSS_SOURCE_MALFORMED,
666 ctx(format!("`{spec}=` with no active object")),
667 );
668 }
669 active
670 };
671 let idx = match object {
672 None => match active_or(&mut self.raw) {
673 Some(idx) => idx,
674 None => return,
675 },
676 Some((class, name)) => {
677 let class = match class {
678 Some(c) => c.to_ascii_lowercase(),
679 None => match active_or(&mut self.raw) {
680 Some(idx) => self.raw.objects[idx].class.clone(),
681 None => return,
682 },
683 };
684 if let Some(idx) = self
685 .raw
686 .index
687 .get(&(class.clone(), name.to_ascii_lowercase()))
688 .copied()
689 {
690 idx
691 } else {
692 self.raw.warn(
693 &C::READ_DSS_REFERENCE_DROPPED,
694 ctx(format!(
695 "property reference to unknown object `{class}.{name}`"
696 )),
697 );
698 return;
699 }
700 }
701 };
702 self.raw.active = Some(idx);
703 let table = prop_table(&self.raw.objects[idx].class);
704 let name = match table {
705 Some(c) => {
706 if let Some(i) = c.prop_index(prop) {
707 c.props[i].to_string()
708 } else {
709 self.raw.warn(
710 &C::READ_DSS_PROPERTY_UNKNOWN,
711 ctx(format!(
712 "unknown property `{prop}` on {}; kept as written",
713 c.name
714 )),
715 );
716 prop.to_ascii_lowercase()
717 }
718 }
719 None => prop.to_ascii_lowercase(),
720 };
721 let mut props = vec![RawProp {
722 name: Some(name),
723 value,
724 }];
725 props.extend(collect_props_for(
726 table,
727 scan,
728 Some(prop),
729 &mut self.raw,
730 ctx,
731 ));
732 self.apply_props(idx, props, ctx);
733 }
734
735 fn do_new(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
736 let Some((class, name)) = self.object_spec(scan, ctx) else {
737 return;
738 };
739 if class.eq_ignore_ascii_case("circuit") {
740 self.raw.circuit_name = Some(name);
745 let idx = self.make_object("vsource", "source".into());
746 self.consume_and_apply(idx, scan, ctx);
747 return;
748 }
749 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
750 let idx = match self.raw.index.get(&key) {
751 Some(&existing) => {
752 self.raw.warn(
753 &C::PARSE_DSS_SOURCE_MALFORMED,
754 ctx(format!(
755 "duplicate `New {class}.{name}`; editing the existing object"
756 )),
757 );
758 existing
759 }
760 None => self.make_object(&class, name),
761 };
762 self.consume_and_apply(idx, scan, ctx);
763 }
764
765 fn do_edit(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
766 let Some((class, name)) = self.object_spec(scan, ctx) else {
767 return;
768 };
769 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
770 let Some(&idx) = self.raw.index.get(&key) else {
771 self.raw.warn(
772 &C::READ_DSS_REFERENCE_DROPPED,
773 ctx(format!("`Edit {class}.{name}` on an unknown object")),
774 );
775 return;
776 };
777 self.consume_and_apply(idx, scan, ctx);
778 }
779
780 fn do_more(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
781 let Some(idx) = self.raw.active else {
782 self.raw.warn(
783 &C::PARSE_DSS_SOURCE_MALFORMED,
784 ctx("`~` with no active object".into()),
785 );
786 return;
787 };
788 self.consume_and_apply(idx, scan, ctx);
789 }
790
791 fn do_select(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
792 let Some((class, name)) = self.object_spec(scan, ctx) else {
793 return;
794 };
795 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
796 match self.raw.index.get(&key) {
797 Some(&idx) => self.raw.active = Some(idx),
798 None => self.raw.warn(
799 &C::READ_DSS_REFERENCE_DROPPED,
800 ctx(format!("`Select {class}.{name}` on an unknown object")),
801 ),
802 }
803 }
804
805 fn do_set(&mut self, scan: &mut Scanner) {
806 while let Some(p) = scan.next_param() {
807 if p.value.text.is_empty() && p.name.is_none() {
808 break;
809 }
810 let name = p.name.unwrap_or_default().to_ascii_lowercase();
811 if self.raw.charge_preserved() {
812 self.raw.options.push((name, p.value));
813 }
814 }
815 }
816
817 fn resolve(&self, file_arg: &str) -> Option<PathBuf> {
828 use std::path::Component;
829 let rel = file_arg.replace('\\', "/");
830 let base = self.dirs.last().cloned().unwrap_or_default();
831 let joined = base.join(&rel);
832 match &self.root {
833 None => Some(joined),
834 Some(root) => {
835 let normalized = lexical_normalize(&joined);
843 normalized
844 .strip_prefix(root)
845 .is_ok_and(|rest| rest.components().all(|c| matches!(c, Component::Normal(_))))
846 .then_some(normalized)
847 }
848 }
849 }
850
851 fn resolve_or_warn(
854 &mut self,
855 verb: &str,
856 file_arg: &str,
857 ctx: &dyn Fn(String) -> String,
858 ) -> Option<PathBuf> {
859 let resolved = self.resolve(file_arg);
860 if resolved.is_none() {
861 let message = ctx(format!(
862 "{verb} {file_arg}: refused; include escapes {}",
863 self.boundary.noun()
864 ));
865 self.refuse_escape(message);
866 }
867 resolved
868 }
869
870 fn refuse_escape(&mut self, message: String) {
873 let suggested_action = match self.boundary {
874 IncludeBoundary::CaseDirectory => {
875 "place included files inside the case directory, or merge them into the case"
876 }
877 IncludeBoundary::IncludeRoot => {
878 "place included files inside the include root, or merge them into the case"
879 }
880 };
881 self.refuse(
882 message,
883 &crate::diagnostics::codes::READ_DSS_INCLUDE_REFUSED,
884 suggested_action,
885 );
886 }
887
888 fn refuse(
891 &mut self,
892 message: String,
893 code: &'static crate::diagnostics::DiagnosticInfo,
894 suggested_action: &'static str,
895 ) {
896 self.raw.record(
897 crate::diagnostics::Diagnostic::of(code, message)
898 .with_suggested_action(suggested_action),
899 );
900 }
901
902 fn charge_include(&mut self, verb: &str, path: &Path, ctx: &dyn Fn(String) -> String) -> bool {
908 let over_budget = self.budget_spent
909 || self.includes >= MAX_TOTAL_INCLUDES
910 || self.include_bytes >= MAX_TOTAL_INCLUDE_BYTES;
911 if !over_budget {
912 self.includes += 1;
913 return true;
914 }
915 if !self.budget_spent {
916 self.budget_spent = true;
917 let message = ctx(format!(
918 "{verb} {}: refused; the case exceeded the include budget of {MAX_TOTAL_INCLUDES} \
919 files and {} MiB, so the rest of the includes were not followed",
920 path.display(),
921 MAX_TOTAL_INCLUDE_BYTES >> 20,
922 ));
923 self.refuse(
924 message,
925 &crate::diagnostics::codes::READ_DSS_INCLUDE_BUDGET,
926 "check the case for an include cycle; a file that redirects to itself expands \
927 without bound",
928 );
929 }
930 false
931 }
932
933 fn warn_load_error(
940 &mut self,
941 verb: &str,
942 path: &Path,
943 e: &std::io::Error,
944 ctx: &dyn Fn(String) -> String,
945 ) {
946 let message = ctx(format!("{verb} {}: {e}", path.display()));
947 if Containment::refused_by_us(e) {
948 self.refuse_escape(message);
949 } else {
950 self.raw.warn(&C::READ_DSS_INCLUDE_LOAD_FAILED, message);
951 }
952 }
953
954 fn do_redirect(&mut self, scan: &mut Scanner, compile: bool, ctx: &dyn Fn(String) -> String) {
955 let Some(p) = scan.next_param() else {
956 self.raw.warn(
957 &C::PARSE_DSS_SOURCE_MALFORMED,
958 ctx("redirect with no file".into()),
959 );
960 return;
961 };
962 let verb = if compile { "compile" } else { "redirect" };
963 let Some(path) = self.resolve_or_warn(verb, &p.value.text, ctx) else {
964 return;
965 };
966 if self.dirs.len() > MAX_REDIRECT_DEPTH {
967 self.raw.warn(
968 &C::READ_DSS_INCLUDE_DEPTH_LIMIT,
969 ctx(format!("redirect depth limit at {}", path.display())),
970 );
971 return;
972 }
973 if !self.charge_include(verb, &path, ctx) {
974 return;
975 }
976 match self.loader.load(&path) {
977 Ok(text) => {
978 self.include_bytes += text.len();
979 let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
980 self.dirs.push(dir.clone());
981 let ambient = scan.vars().cloned().unwrap_or_default();
991 let outer = std::mem::replace(&mut self.raw.vars, ambient);
992 let generation = self.raw.vars_generation;
993 self.run_script(&text, &path.display().to_string());
994 if self.raw.vars_generation == generation {
995 let added = std::mem::replace(&mut self.raw.vars, outer);
996 self.raw.vars.extend(added);
997 }
998 self.dirs.pop();
999 if compile && let Some(top) = self.dirs.last_mut() {
1007 *top = dir;
1008 }
1009 }
1010 Err(e) => self.warn_load_error(verb, &path, &e, ctx),
1011 }
1012 }
1013
1014 fn do_buscoords(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
1015 let Some(p) = scan.next_param() else {
1016 self.raw.warn(
1017 &C::PARSE_DSS_SOURCE_MALFORMED,
1018 ctx("buscoords with no file".into()),
1019 );
1020 return;
1021 };
1022 let Some(path) = self.resolve_or_warn("buscoords", &p.value.text, ctx) else {
1023 return;
1024 };
1025 if !self.charge_include("buscoords", &path, ctx) {
1026 return;
1027 }
1028 match self.loader.load(&path) {
1029 Ok(text) => {
1030 self.include_bytes += text.len();
1031 for (line_no, line) in text.lines().enumerate() {
1032 if self.raw.halted {
1033 break;
1034 }
1035 let mut s = Scanner::new(line, None);
1036 let Some(bus) = s.next_param() else { continue };
1037 if bus.value.text.is_empty() {
1038 continue;
1039 }
1040 let x = s.next_param().map(|p| p.value).unwrap_or_default();
1041 let y = s.next_param().map(|p| p.value).unwrap_or_default();
1042 match (x.to_f64(None), y.to_f64(None)) {
1043 (Ok(x), Ok(y)) => {
1044 if self.raw.charge_preserved() {
1045 self.raw.buscoords.push(BusCoord {
1046 bus: bus.value.text,
1047 x,
1048 y,
1049 });
1050 }
1051 }
1052 _ => self.raw.warn(
1053 &C::PARSE_DSS_SOURCE_MALFORMED,
1054 ctx(format!(
1055 "buscoords {}:{}: unparseable coordinates",
1056 path.display(),
1057 line_no + 1
1058 )),
1059 ),
1060 }
1061 }
1062 }
1063 Err(e) => self.warn_load_error("buscoords", &path, &e, ctx),
1064 }
1065 }
1066
1067 fn do_setbusxy(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
1071 let mut bus: Option<String> = None;
1072 let mut x: Option<f64> = None;
1073 let mut y: Option<f64> = None;
1074 let mut position = 0usize;
1075 while let Some(p) = scan.next_param() {
1076 let slot = match p.name.as_deref().map(str::to_ascii_lowercase).as_deref() {
1077 Some("bus") => 0,
1078 Some("x") => 1,
1079 Some("y") => 2,
1080 Some(_) => continue,
1081 None => {
1082 let slot = position;
1083 position += 1;
1084 slot
1085 }
1086 };
1087 match slot {
1088 0 => bus = Some(p.value.text),
1089 1 => x = p.value.to_f64(None).ok(),
1090 2 => y = p.value.to_f64(None).ok(),
1091 _ => {}
1092 }
1093 }
1094 match (bus, x, y) {
1095 (Some(bus), Some(x), Some(y)) if !bus.is_empty() => {
1096 if self.raw.charge_preserved() {
1097 self.raw.buscoords.push(BusCoord { bus, x, y });
1098 }
1099 }
1100 _ => self.raw.warn(
1101 &C::PARSE_DSS_SOURCE_MALFORMED,
1102 ctx("setbusxy needs a bus and numeric x and y".into()),
1103 ),
1104 }
1105 }
1106
1107 fn object_spec(
1109 &mut self,
1110 scan: &mut Scanner,
1111 ctx: &dyn Fn(String) -> String,
1112 ) -> Option<(String, String)> {
1113 let p = scan.next_param()?;
1114 if let Some(name) = &p.name
1115 && !name.eq_ignore_ascii_case("object")
1116 {
1117 self.raw.warn(
1118 &C::PARSE_DSS_SOURCE_MALFORMED,
1119 ctx(format!("expected Class.Name, got `{name}=`")),
1120 );
1121 return None;
1122 }
1123 let spec = p.value.text;
1124 match spec.split_once('.') {
1125 Some((class, name)) if !class.is_empty() && !name.is_empty() => {
1126 Some((class.to_string(), name.to_string()))
1127 }
1128 _ => {
1129 self.raw.warn(
1130 &C::PARSE_DSS_SOURCE_MALFORMED,
1131 ctx(format!("malformed object spec `{spec}`")),
1132 );
1133 None
1134 }
1135 }
1136 }
1137
1138 fn make_object(&mut self, class: &str, name: String) -> usize {
1139 let class_lc = class.to_ascii_lowercase();
1140 let idx = self.raw.objects.len();
1141 self.raw
1142 .index
1143 .insert((class_lc.clone(), name.to_ascii_lowercase()), idx);
1144 self.raw.objects.push(RawObject {
1145 class: class_lc,
1146 name,
1147 props: Vec::new(),
1148 edits: Vec::new(),
1149 });
1150 idx
1151 }
1152
1153 fn consume_and_apply(
1154 &mut self,
1155 idx: usize,
1156 scan: &mut Scanner,
1157 ctx: &dyn Fn(String) -> String,
1158 ) {
1159 let props = collect_props_for(
1160 prop_table(&self.raw.objects[idx].class),
1161 scan,
1162 None,
1163 &mut self.raw,
1164 ctx,
1165 );
1166 self.apply_props(idx, props, ctx);
1167 }
1168
1169 fn apply_props(&mut self, idx: usize, props: Vec<RawProp>, ctx: &dyn Fn(String) -> String) {
1170 self.raw.active = Some(idx);
1171 for p in props {
1172 if p.name.as_deref() == Some("like") {
1178 let class = self.raw.objects[idx].class.clone();
1179 let key = (class.clone(), p.value.text.to_ascii_lowercase());
1180 match self.raw.index.get(&key).copied() {
1181 Some(src) => {
1182 let base = self.raw.objects[idx].props.len();
1183 let src_len = self.raw.objects[src].props.len();
1184 if base.saturating_add(src_len) > MAX_OBJECT_PROPS {
1190 self.raw.warn(
1191 &C::READ_DSS_VALUE_CLAMPED,
1192 ctx(format!(
1193 "like={}: {class} property count would exceed the supported \
1194 maximum of {MAX_OBJECT_PROPS}; splice refused",
1195 p.value.text
1196 )),
1197 );
1198 continue;
1199 }
1200 if self.raw.total_props.saturating_add(src_len) > MAX_TOTAL_PROPS {
1201 self.raw.warn(
1202 &C::READ_DSS_VALUE_CLAMPED,
1203 ctx(format!(
1204 "like={}: the script's retained property count would exceed \
1205 the supported maximum of {MAX_TOTAL_PROPS}; splice refused",
1206 p.value.text
1207 )),
1208 );
1209 continue;
1210 }
1211 self.raw.total_props += src_len;
1212 let cloned = self.raw.objects[src].props.clone();
1213 let bounds: Vec<usize> = self.raw.objects[src]
1214 .edit_bounds()
1215 .map(|e| base + e)
1216 .collect();
1217 self.raw.objects[idx].props.extend(cloned);
1218 self.raw.objects[idx].edits.extend(bounds);
1219 }
1220 None => self.raw.warn(
1221 &C::READ_DSS_REFERENCE_DROPPED,
1222 ctx(format!("like={} names an unknown {class}", p.value.text)),
1223 ),
1224 }
1225 continue;
1226 }
1227 if self.raw.objects[idx].props.len() >= MAX_OBJECT_PROPS {
1228 self.raw.warn(
1229 &C::READ_DSS_VALUE_CLAMPED,
1230 ctx(format!(
1231 "{}: property count exceeds the supported maximum of {MAX_OBJECT_PROPS}; \
1232 further assignments dropped",
1233 self.raw.objects[idx].class
1234 )),
1235 );
1236 continue;
1237 }
1238 if self.raw.total_props >= MAX_TOTAL_PROPS {
1239 self.raw.warn(
1240 &C::READ_DSS_VALUE_CLAMPED,
1241 ctx(format!(
1242 "{}: the script's retained property count exceeds the supported maximum \
1243 of {MAX_TOTAL_PROPS}; further assignments dropped",
1244 self.raw.objects[idx].class
1245 )),
1246 );
1247 continue;
1248 }
1249 self.raw.total_props += 1;
1250 self.raw.objects[idx].props.push(p);
1251 }
1252 let end = self.raw.objects[idx].props.len();
1255 self.raw.objects[idx].edits.push(end);
1256 }
1257}
1258
1259fn prop_table(class: &str) -> Option<&'static DssClass> {
1260 prop::class_by_name(class)
1261}
1262
1263fn collect_props_for(
1268 class: Option<&'static DssClass>,
1269 scan: &mut Scanner,
1270 after: Option<&str>,
1271 raw: &mut RawDss,
1272 ctx: &dyn Fn(String) -> String,
1273) -> Vec<RawProp> {
1274 let mut out = Vec::new();
1275 let mut pointer: Option<usize> = class.zip(after).and_then(|(c, name)| c.prop_index(name));
1276 while let Some(p) = scan.next_param() {
1277 if p.value.text.is_empty() && p.name.is_none() {
1278 break;
1279 }
1280 let name = match (&p.name, class) {
1281 (Some(written), Some(c)) => {
1282 if let Some(i) = c.prop_index(written) {
1283 pointer = Some(i);
1284 Some(c.props[i].to_string())
1285 } else {
1286 pointer = None;
1290 raw.warn(
1291 &C::READ_DSS_PROPERTY_UNKNOWN,
1292 ctx(format!(
1293 "unknown property `{written}` on {}; kept as written",
1294 c.name
1295 )),
1296 );
1297 Some(written.to_ascii_lowercase())
1298 }
1299 }
1300 (Some(written), None) => Some(written.to_ascii_lowercase()),
1301 (None, Some(c)) => {
1302 let next = pointer.map_or(0, |i| i + 1);
1303 pointer = Some(next);
1304 if let Some(canon) = c.props.get(next) {
1305 Some((*canon).to_string())
1306 } else {
1307 raw.warn(
1308 &C::PARSE_DSS_SOURCE_MALFORMED,
1309 ctx(format!(
1310 "positional value `{}` beyond the last {} property",
1311 p.value.text, c.name
1312 )),
1313 );
1314 None
1315 }
1316 }
1317 (None, None) => None,
1318 };
1319 out.push(RawProp {
1320 name,
1321 value: p.value,
1322 });
1323 }
1324 out
1325}
1326
1327#[doc(hidden)]
1338pub fn __parse_raw_with(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1339 run_executor(text, path, None, IncludeBoundary::CaseDirectory, loader)
1340}
1341
1342fn include_acquisition_error(error: &powerio_core::Error) -> std::io::Error {
1347 let refusal = error.diagnostics().first().is_some_and(|d| {
1350 d.code() == "REQUEST.SOURCE.ESCAPES_ROOT"
1351 || d.code() == "REQUEST.SOURCE.SYMLINK_REFUSED"
1352 || d.code() == "REQUEST.SOURCE.NOT_A_FILE"
1353 });
1354 if refusal {
1355 Containment::refused(error.to_string())
1356 } else {
1357 std::io::Error::other(error.to_string())
1358 }
1359}
1360
1361fn source_include_loader(
1369 source: &powerio_core::Source,
1370 lexical_root: PathBuf,
1371) -> impl FnMut(&Path) -> std::io::Result<String> {
1372 move |p: &Path| {
1373 let relative = p.strip_prefix(&lexical_root).map_err(|_| {
1374 Containment::refused(format!(
1375 "{} does not sit under the confinement root",
1376 p.display()
1377 ))
1378 })?;
1379 let mut name = String::new();
1380 for component in relative.components() {
1381 if !name.is_empty() {
1382 name.push('/');
1383 }
1384 name.push_str(&component.as_os_str().to_string_lossy());
1385 }
1386 let buffer = source
1387 .root_buffer(&name)
1388 .map_err(|error| include_acquisition_error(&error))?;
1389 let text = std::str::from_utf8(buffer.content_bytes()).map_err(|e| {
1390 std::io::Error::new(
1391 std::io::ErrorKind::InvalidData,
1392 format!("stream did not contain valid UTF-8: {e}"),
1393 )
1394 })?;
1395 Ok(text.to_owned())
1396 }
1397}
1398
1399pub(crate) fn parse_raw_from_source(source: &powerio_core::Source) -> Result<RawDss> {
1412 let primary = source.primary_buffer().map_err(|error| Error::FormatRead {
1413 format: "dss",
1414 message: error.to_string(),
1415 })?;
1416 let text = std::str::from_utf8(primary.content_bytes()).map_err(|e| Error::FormatRead {
1417 format: "case text",
1418 message: format!("not valid UTF-8: {e}"),
1419 })?;
1420 if let Some(root) = source.selected_acquisition_root() {
1421 let mut file = root.to_path_buf();
1425 file.extend(primary.directory_segments());
1426 match Path::new(source.name()).file_name() {
1427 Some(base) => file.push(base),
1428 None => file.push(source.name()),
1429 }
1430 let mut loader = source_include_loader(source, root.to_path_buf());
1431 Ok(parse_raw_confined_under(
1432 text,
1433 &file.display().to_string(),
1434 root,
1435 &mut loader,
1436 ))
1437 } else if primary.directory_segments().next().is_some() {
1438 let root = Path::new("");
1440 let mut loader = source_include_loader(source, root.to_path_buf());
1441 Ok(parse_raw_confined_under(
1442 text,
1443 source.name(),
1444 root,
1445 &mut loader,
1446 ))
1447 } else {
1448 let lexical_root = lexical_normalize(
1454 Path::new(source.name())
1455 .parent()
1456 .unwrap_or_else(|| Path::new("")),
1457 );
1458 let mut loader = source_include_loader(source, lexical_root);
1459 Ok(parse_raw_with_confined(text, source.name(), &mut loader))
1460 }
1461}
1462
1463#[derive(Debug)]
1468pub(crate) struct Containment(String);
1469
1470impl Containment {
1471 pub(crate) fn refused(reason: impl Into<String>) -> std::io::Error {
1472 std::io::Error::new(
1473 std::io::ErrorKind::PermissionDenied,
1474 Containment(reason.into()),
1475 )
1476 }
1477
1478 pub(crate) fn refused_by_us(e: &std::io::Error) -> bool {
1480 e.get_ref()
1481 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<Self>)
1482 }
1483}
1484
1485impl std::fmt::Display for Containment {
1486 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1487 f.write_str(&self.0)
1488 }
1489}
1490
1491impl std::error::Error for Containment {}
1492
1493pub(crate) fn parse_raw_with_confined(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1499 let root = lexical_normalize(
1500 &Path::new(path)
1501 .parent()
1502 .map(Path::to_path_buf)
1503 .unwrap_or_default(),
1504 );
1505 run_executor(
1506 text,
1507 path,
1508 Some(root),
1509 IncludeBoundary::CaseDirectory,
1510 loader,
1511 )
1512}
1513
1514pub(crate) fn parse_raw_confined_under(
1518 text: &str,
1519 path: &str,
1520 include_root: &Path,
1521 loader: &mut impl Loader,
1522) -> RawDss {
1523 let root = lexical_normalize(include_root);
1524 run_executor(text, path, Some(root), IncludeBoundary::IncludeRoot, loader)
1525}
1526
1527fn run_executor(
1528 text: &str,
1529 path: &str,
1530 root: Option<PathBuf>,
1531 boundary: IncludeBoundary,
1532 loader: &mut impl Loader,
1533) -> RawDss {
1534 let mut exec = Executor {
1535 raw: RawDss::default(),
1536 loader,
1537 dirs: vec![
1538 Path::new(path)
1539 .parent()
1540 .map(Path::to_path_buf)
1541 .unwrap_or_default(),
1542 ],
1543 root,
1544 boundary,
1545 includes: 0,
1546 include_bytes: 0,
1547 budget_spent: false,
1548 var_count: 0,
1549 var_bytes: 0,
1550 var_budget_spent: false,
1551 };
1552 exec.run_script(text, path);
1553 exec.raw
1554}
1555
1556#[doc(hidden)]
1563pub fn __parse_raw_file(path: impl AsRef<Path>) -> Result<RawDss> {
1564 let path = path.as_ref();
1565 let source = powerio_core::Source::open(path).map_err(|error| Error::Io {
1566 path: path.display().to_string(),
1567 source: std::io::Error::other(error.to_string()),
1568 })?;
1569 parse_raw_from_source(&source)
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use super::*;
1575
1576 fn no_files(_: &Path) -> std::io::Result<String> {
1577 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test"))
1578 }
1579
1580 fn parse(text: &str) -> RawDss {
1581 __parse_raw_with(text, "test.dss", &mut no_files)
1582 }
1583
1584 #[test]
1585 fn new_object_with_positional_and_named() {
1586 let raw = parse("New Line.l1 b1 b2 lc 0.3 phases=2 r1=0.1");
1587 let l = raw.find("line", "l1").unwrap();
1588 assert_eq!(l.get("bus1").unwrap().text, "b1");
1589 assert_eq!(l.get("bus2").unwrap().text, "b2");
1590 assert_eq!(l.get("linecode").unwrap().text, "lc");
1591 assert_eq!(l.get("length").unwrap().text, "0.3");
1592 assert_eq!(l.get("phases").unwrap().text, "2");
1593 assert_eq!(l.get("r1").unwrap().text, "0.1");
1594 assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1595 }
1596
1597 #[test]
1598 fn positional_continues_after_named() {
1599 let raw = parse("New Line.l1 r1=0.1 0.2");
1601 let l = raw.find("line", "l1").unwrap();
1602 assert_eq!(l.get("x1").unwrap().text, "0.2");
1603 }
1604
1605 #[test]
1606 fn unknown_property_resets_the_positional_pointer() {
1607 let raw = parse("New Line.l1 r1=0.1 bogus=2 0.5");
1610 let l = raw.find("line", "l1").unwrap();
1611 assert_eq!(l.get("bus1").unwrap().text, "0.5");
1612 assert!(l.get("x1").is_none());
1613 assert_eq!(raw.warnings.len(), 1);
1614 }
1615
1616 #[test]
1617 fn tilde_continues_the_active_object() {
1618 let raw = parse("New Load.ld bus1=b1\n~ kW=15 kvar=3\nMore pf=0.9");
1619 let ld = raw.find("load", "ld").unwrap();
1620 assert_eq!(ld.get("kw").unwrap().text, "15");
1621 assert_eq!(ld.get("kvar").unwrap().text, "3");
1622 assert_eq!(ld.get("pf").unwrap().text, "0.9");
1623 }
1624
1625 #[test]
1626 fn abbreviated_property_names() {
1627 let raw = parse("New Line.l1 ph=3 len=2 rm=(1 | 0 1)");
1628 let l = raw.find("line", "l1").unwrap();
1629 assert_eq!(l.get("phases").unwrap().text, "3");
1630 assert_eq!(l.get("length").unwrap().text, "2");
1631 assert!(l.get("rmatrix").unwrap().quoted);
1632 }
1633
1634 #[test]
1635 fn new_circuit_creates_the_source() {
1636 let raw = parse("New Circuit.test basekv=115 pu=1.05\n~ angle=30");
1637 assert_eq!(raw.circuit_name.as_deref(), Some("test"));
1638 let vs = raw.find("vsource", "source").unwrap();
1639 assert_eq!(vs.get("basekv").unwrap().text, "115");
1640 assert_eq!(vs.get("angle").unwrap().text, "30");
1641 assert!(vs.get("bus1").is_none());
1644 }
1645
1646 #[test]
1647 fn edit_and_property_reference() {
1648 let raw = parse("New Line.l1 length=1\nEdit Line.l1 length=2\nLine.l1.Length=3 phases=2");
1649 let l = raw.find("line", "l1").unwrap();
1650 assert_eq!(l.get("length").unwrap().text, "3");
1651 assert_eq!(l.get("phases").unwrap().text, "2");
1652 }
1653
1654 #[test]
1655 fn property_reference_resolves_abbreviations() {
1656 let raw = parse("New Line.l1 bus1=a\nLine.l1.Len=2.5");
1657 let l = raw.find("line", "l1").unwrap();
1658 assert_eq!(l.get("length").unwrap().text, "2.5");
1659 assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1660 }
1661
1662 #[test]
1663 fn bare_property_edits_the_active_object() {
1664 let raw = parse("New Line.l1 bus1=a bus2=b\nlength=2.5");
1665 let l = raw.find("line", "l1").unwrap();
1666 assert_eq!(l.get("length").unwrap().text, "2.5");
1667 assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1668 }
1669
1670 #[test]
1671 fn classless_reference_uses_the_active_class() {
1672 let raw = parse("New Line.l1 bus1=a\nNew Line.l2 bus1=b\nl1.length=7 phases=2");
1675 let l1 = raw.find("line", "l1").unwrap();
1676 assert_eq!(l1.get("length").unwrap().text, "7");
1677 assert_eq!(l1.get("phases").unwrap().text, "2");
1678 assert!(raw.find("line", "l2").unwrap().get("length").is_none());
1679 assert_eq!(raw.warnings, [] as [std::string::String; 0]);
1680 }
1681
1682 #[test]
1683 fn like_splices_source_props() {
1684 let raw = parse("New Load.a kW=10 pf=0.9\nNew Load.b like=a kW=20");
1685 let b = raw.find("load", "b").unwrap();
1686 assert_eq!(b.get("kw").unwrap().text, "20");
1687 assert_eq!(b.get("pf").unwrap().text, "0.9");
1688 }
1689
1690 #[test]
1691 fn the_whole_parse_record_ceilings_bound_repeated_execution() {
1692 struct Repeat;
1696 impl Loader for Repeat {
1697 fn load(&mut self, _: &Path) -> std::io::Result<String> {
1698 let mut text = String::new();
1700 for _ in 0..8192 {
1701 text.push_str("Solve\n");
1702 }
1703 text.push_str("Bogus命令 arg\n");
1704 Ok(text)
1705 }
1706 }
1707 use std::fmt::Write as _;
1708 let mut script = String::new();
1709 for index in 0..512 {
1710 let _ = writeln!(script, "Redirect inc{index}.dss");
1711 }
1712 let raw = __parse_raw_with(&script, "test.dss", &mut Repeat);
1713 assert!(raw.commands.len() <= MAX_TOTAL_PRESERVED);
1714 assert!(raw.diagnostics.len() <= MAX_TOTAL_DIAGNOSTICS + 1);
1715 let refusals = raw
1716 .warnings
1717 .iter()
1718 .filter(|w| w.contains("whole parse ceiling"))
1719 .count();
1720 assert_eq!(refusals, 1, "exactly one terminal refusal: {refusals}");
1721 }
1722
1723 #[test]
1724 fn crossing_both_ceilings_in_either_order_leaves_one_refusal() {
1725 struct Warny;
1729 impl Loader for Warny {
1730 fn load(&mut self, _: &Path) -> std::io::Result<String> {
1731 let mut text = String::new();
1732 for _ in 0..8192 {
1733 text.push_str("Bogus命令 arg\n");
1734 }
1735 for _ in 0..8192 {
1736 text.push_str("Solve\n");
1737 }
1738 Ok(text)
1739 }
1740 }
1741 struct Quiet;
1742 impl Loader for Quiet {
1743 fn load(&mut self, _: &Path) -> std::io::Result<String> {
1744 let mut text = String::new();
1745 for _ in 0..16384 {
1746 text.push_str("Solve\n");
1747 }
1748 text.push_str("Bogus命令 arg\n");
1749 Ok(text)
1750 }
1751 }
1752 let count = |raw: &RawDss| {
1753 (
1754 raw.warnings
1755 .iter()
1756 .filter(|w| w.contains("READ.DSS.INCLUDE_BUDGET"))
1757 .count(),
1758 raw.diagnostics
1759 .iter()
1760 .filter(|d| d.code() == "READ.DSS.INCLUDE_BUDGET")
1761 .count(),
1762 )
1763 };
1764 let mut script = String::new();
1768 {
1769 use std::fmt::Write as _;
1770 for index in 0..64 {
1771 let _ = writeln!(script, "Redirect winc{index}.dss");
1772 }
1773 }
1774 let raw = __parse_raw_with(&script, "test.dss", &mut Warny);
1775 assert!(raw.commands.len() <= MAX_TOTAL_PRESERVED);
1776 assert!(raw.diagnostics.len() <= MAX_TOTAL_DIAGNOSTICS + 1);
1777 assert_eq!(count(&raw), (1, 1), "findings-first order");
1778
1779 let mut script = String::new();
1782 {
1783 use std::fmt::Write as _;
1784 for index in 0..80 {
1785 let _ = writeln!(script, "Redirect qinc{index}.dss");
1786 }
1787 }
1788 let raw = __parse_raw_with(&script, "test.dss", &mut Quiet);
1789 assert!(raw.commands.len() <= MAX_TOTAL_PRESERVED);
1790 assert!(raw.diagnostics.len() <= MAX_TOTAL_DIAGNOSTICS + 1);
1791 assert_eq!(count(&raw), (1, 1), "preserved-first order");
1792 }
1793
1794 #[test]
1795 fn a_ceiling_crossed_inside_buscoords_halts_without_false_malformed_findings() {
1796 use std::fmt::Write as _;
1803 let root = std::env::temp_dir().join(format!("powerio-dist-fs008-{}", std::process::id()));
1804 std::fs::create_dir_all(&root).unwrap();
1805 let extra = 64usize;
1806 let mut coords = String::with_capacity((MAX_TOTAL_PRESERVED + 2 * extra) * 12);
1807 for index in 0..MAX_TOTAL_PRESERVED + extra {
1808 let _ = writeln!(coords, "b{index} 1 2");
1809 }
1810 for index in 0..extra {
1811 let _ = writeln!(coords, "tail{index} xx yy");
1812 }
1813 std::fs::write(root.join("coords.csv"), coords).unwrap();
1814 std::fs::write(root.join("master.dss"), "Buscoords coords.csv\n").unwrap();
1815
1816 let raw = __parse_raw_file(root.join("master.dss")).unwrap();
1817 std::fs::remove_dir_all(&root).unwrap();
1818
1819 let warned = raw
1820 .warnings
1821 .iter()
1822 .filter(|w| w.contains("READ.DSS.INCLUDE_BUDGET"))
1823 .count();
1824 let recorded = raw
1825 .diagnostics
1826 .iter()
1827 .filter(|d| d.code() == "READ.DSS.INCLUDE_BUDGET")
1828 .count();
1829 assert_eq!((warned, recorded), (1, 1), "exactly one terminal refusal");
1830 let malformed_warned = raw
1831 .warnings
1832 .iter()
1833 .filter(|w| w.contains("PARSE.DSS.SOURCE_MALFORMED"))
1834 .count();
1835 let malformed_recorded = raw
1836 .diagnostics
1837 .iter()
1838 .filter(|d| d.code() == "PARSE.DSS.SOURCE_MALFORMED")
1839 .count();
1840 assert_eq!(
1841 (malformed_warned, malformed_recorded),
1842 (0, 0),
1843 "lines past the halt were scanned and reported"
1844 );
1845 assert_eq!(
1846 raw.buscoords.len(),
1847 MAX_TOTAL_PRESERVED,
1848 "retention must fill the budget exactly and stop"
1849 );
1850 }
1851
1852 #[test]
1853 fn the_whole_parse_property_retention_is_bounded() {
1854 use std::fmt::Write as _;
1859 let mut script = String::from("New Load.seed kW=1\n");
1860 for _ in 0..15 {
1863 script.push_str("Edit Load.seed like=seed\n");
1864 }
1865 for index in 0..200 {
1866 let _ = writeln!(script, "New Load.c{index} like=seed");
1867 }
1868 let raw = parse(&script);
1869 let total: usize = raw.objects.iter().map(|object| object.props.len()).sum();
1870 assert!(
1871 total <= MAX_TOTAL_PROPS,
1872 "retained {total} properties past the whole-parse budget"
1873 );
1874 assert!(
1875 raw.warnings
1876 .iter()
1877 .any(|w| w.contains("retained property count")),
1878 "expected the whole-parse refusal, got {} warnings",
1879 raw.warnings.len()
1880 );
1881 }
1882
1883 #[test]
1884 fn self_referencing_like_cannot_explode_the_prop_count() {
1885 let mut script = String::from("New Load.a kW=1\n");
1889 for _ in 0..40 {
1890 script.push_str("Edit Load.a like=a\n");
1891 }
1892 let raw = parse(&script);
1893 let a = raw.find("load", "a").unwrap();
1894 assert!(
1895 a.props.len() <= MAX_OBJECT_PROPS,
1896 "prop count {} exceeded the cap",
1897 a.props.len()
1898 );
1899 assert!(
1900 raw.warnings.iter().any(|w| w.contains("splice refused")),
1901 "expected a refusal warning, got {:?}",
1902 raw.warnings
1903 );
1904 }
1905
1906 #[test]
1907 fn unknown_class_is_preserved_raw() {
1908 let raw = parse("New Reactor.r1 bus1=b1 x=3");
1909 let r = raw.find("reactor", "r1").unwrap();
1910 assert_eq!(r.get("bus1").unwrap().text, "b1");
1911 assert_eq!(r.get("x").unwrap().text, "3");
1912 }
1913
1914 #[test]
1915 fn set_options_accumulate() {
1916 let raw = parse("Set VoltageBases=[115, 12.47]\nset mode=snapshot");
1917 assert_eq!(raw.options[0].0, "voltagebases");
1918 assert_eq!(
1919 raw.options[0].1.to_vector(None).unwrap(),
1920 vec![115.0, 12.47]
1921 );
1922 assert_eq!(raw.options[1].0, "mode");
1923 }
1924
1925 #[test]
1926 fn unexecuted_commands_are_preserved() {
1927 let raw = parse("Solve\ncalcv\nShow Voltages LN");
1928 let verbs: Vec<&str> = raw.commands.iter().map(|c| c.verb.as_str()).collect();
1929 assert_eq!(verbs, vec!["solve", "calcvoltagebases", "show"]);
1930 assert_eq!(raw.commands[2].args, "Voltages LN");
1931 }
1932
1933 #[test]
1938 fn clear_does_not_disarm_the_include_containment() {
1939 let requested: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
1940 let mut loader = |p: &Path| {
1941 requested.borrow_mut().push(p.display().to_string());
1942 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1943 };
1944 let raw = parse_raw_with_confined(
1945 "Redirect ../../secret.dss\nClear\nRedirect ../../secret.dss\nRedirect /etc/passwd",
1946 "/case/dir/master.dss",
1947 &mut loader,
1948 );
1949 assert!(
1950 requested.borrow().is_empty(),
1951 "an escaping include reached the loader after Clear: {:?}",
1952 requested.borrow()
1953 );
1954 assert_eq!(
1955 raw.warnings
1956 .iter()
1957 .filter(|w| w.contains("escapes the case directory"))
1958 .count(),
1959 3,
1960 "every refusal is stated, and Clear does not erase the ones before it"
1961 );
1962 }
1963
1964 #[test]
1965 fn clear_resets() {
1966 let raw = parse("New Line.l1 length=1\nClear\nNew Line.l2 length=2");
1967 assert!(raw.find("line", "l1").is_none());
1968 assert!(raw.find("line", "l2").is_some());
1969 }
1970
1971 #[test]
1972 fn block_comments_skip_lines() {
1973 let raw = parse("/* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1974 assert!(raw.find("line", "l1").is_none());
1975 assert!(raw.find("line", "l2").is_some());
1976 }
1977
1978 #[test]
1979 fn indented_block_comments_skip_lines() {
1980 let raw = parse(" /* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1981 assert!(raw.find("line", "l1").is_none());
1982 assert!(raw.find("line", "l2").is_some());
1983 }
1984
1985 #[test]
1986 fn one_line_block_comment() {
1987 let raw = parse("\t/* x */\nNew Line.l2 length=2");
1988 assert!(raw.find("line", "l2").is_some());
1989 }
1990
1991 #[test]
1992 fn redirect_includes_a_file() {
1993 let mut files = BTreeMap::from([(
1994 PathBuf::from("sub/codes.dss"),
1995 "New Linecode.lc1 nphases=3".to_string(),
1996 )]);
1997 let mut loader = move |p: &Path| {
1998 files
1999 .remove(p)
2000 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
2001 };
2002 let raw = __parse_raw_with(
2003 "Redirect sub/codes.dss\nNew Line.l1 linecode=lc1",
2004 "test.dss",
2005 &mut loader,
2006 );
2007 assert!(raw.find("linecode", "lc1").is_some());
2008 assert_eq!(raw.warnings, [] as [std::string::String; 0]);
2009 }
2010
2011 fn counting_loader(text: &str, give_up: usize, script: &str) -> (RawDss, usize) {
2015 let mut loads = 0usize;
2016 let raw = {
2017 let mut loader = |_: &Path| {
2018 loads += 1;
2019 if loads > give_up {
2020 Err(std::io::Error::other("loader gave up"))
2021 } else {
2022 Ok(text.to_string())
2023 }
2024 };
2025 __parse_raw_with(script, "test.dss", &mut loader)
2026 };
2027 (raw, loads)
2028 }
2029
2030 fn budget_refusals(raw: &RawDss) -> usize {
2031 raw.diagnostics
2032 .iter()
2033 .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_INCLUDE_BUDGET.code)
2034 .count()
2035 }
2036
2037 #[test]
2038 fn a_self_redirecting_include_stops_at_the_file_budget() {
2039 let (raw, loads) = counting_loader(
2043 "Redirect a.dss\nRedirect a.dss",
2044 MAX_TOTAL_INCLUDES * 4,
2045 "Redirect a.dss",
2046 );
2047 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
2048 assert_eq!(budget_refusals(&raw), 1, "one refusal, however many follow");
2049 }
2050
2051 #[test]
2052 fn a_large_self_redirecting_include_stops_at_the_byte_budget() {
2053 let chunk = 256 << 10;
2057 let text = format!("Redirect a.dss\nRedirect a.dss\n// {}", "a".repeat(chunk));
2058 let (raw, loads) = counting_loader(&text, MAX_TOTAL_INCLUDES, "Redirect a.dss");
2059 assert!(
2060 loads <= MAX_TOTAL_INCLUDE_BYTES / chunk + 1,
2061 "{loads} includes followed, {} bytes",
2062 loads * chunk
2063 );
2064 assert!(
2065 loads < MAX_TOTAL_INCLUDES,
2066 "the byte budget is what stopped it"
2067 );
2068 assert_eq!(budget_refusals(&raw), 1);
2069 }
2070
2071 #[test]
2072 fn buscoords_is_charged_against_the_include_budget() {
2073 let (raw, loads) = counting_loader(
2077 "Redirect a.dss\nRedirect a.dss\nBuscoords a.csv",
2078 MAX_TOTAL_INCLUDES * 4,
2079 "Redirect a.dss",
2080 );
2081 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
2082 assert_eq!(budget_refusals(&raw), 1);
2083 }
2084
2085 #[test]
2086 fn clear_does_not_refund_the_include_budget() {
2087 let (_, loads) = counting_loader(
2090 "Clear\nRedirect a.dss\nRedirect a.dss",
2091 MAX_TOTAL_INCLUDES * 4,
2092 "Redirect a.dss",
2093 );
2094 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
2095 }
2096
2097 #[test]
2098 fn missing_redirect_warns() {
2099 let raw = parse("Redirect nope.dss");
2100 assert_eq!(raw.warnings.len(), 1);
2101 assert!(raw.warnings[0].contains("nope.dss"));
2102 }
2103
2104 #[test]
2105 fn compile_moves_the_directory_redirect_restores_it() {
2106 let root = std::env::temp_dir().join(format!("powerio-dist-raw-{}", std::process::id()));
2111 let sub = root.join("sub");
2112 std::fs::create_dir_all(&sub).unwrap();
2113 std::fs::write(sub.join("feeder.dss"), "New Linecode.lc1 nphases=3").unwrap();
2114 std::fs::write(sub.join("lines.dss"), "New Line.fromsub bus1=a").unwrap();
2115 std::fs::write(root.join("lines.dss"), "New Line.fromroot bus1=a").unwrap();
2116 std::fs::write(
2117 root.join("compile.dss"),
2118 "Compile sub/feeder.dss\nRedirect lines.dss",
2119 )
2120 .unwrap();
2121 std::fs::write(
2122 root.join("redirect.dss"),
2123 "Redirect sub/feeder.dss\nRedirect lines.dss",
2124 )
2125 .unwrap();
2126
2127 let compiled = __parse_raw_file(root.join("compile.dss")).unwrap();
2128 assert_eq!(compiled.warnings, Vec::<String>::new());
2129 assert!(compiled.find("line", "fromsub").is_some());
2130
2131 let redirected = __parse_raw_file(root.join("redirect.dss")).unwrap();
2132 assert_eq!(redirected.warnings, Vec::<String>::new());
2133 assert!(redirected.find("line", "fromroot").is_some());
2134
2135 std::fs::remove_dir_all(&root).unwrap();
2136 }
2137
2138 #[test]
2139 fn compile_inside_compile_pins_the_compiled_files_directory() {
2140 let root =
2147 std::env::temp_dir().join(format!("powerio-dist-rawnest-{}", std::process::id()));
2148 let sub = root.join("sub");
2149 let inner = sub.join("inner");
2150 std::fs::create_dir_all(&inner).unwrap();
2151 std::fs::write(
2152 root.join("main.dss"),
2153 "Compile sub/a.dss\nRedirect probe.dss",
2154 )
2155 .unwrap();
2156 std::fs::write(sub.join("a.dss"), "Compile inner/b.dss").unwrap();
2157 std::fs::write(inner.join("b.dss"), "New Linecode.lc1 nphases=1").unwrap();
2158 std::fs::write(sub.join("probe.dss"), "New Line.fromsub bus1=a").unwrap();
2159 std::fs::write(inner.join("probe.dss"), "New Line.frominner bus1=a").unwrap();
2160
2161 let raw = __parse_raw_file(root.join("main.dss")).unwrap();
2162 assert_eq!(raw.warnings, Vec::<String>::new());
2163 assert!(raw.find("linecode", "lc1").is_some());
2164 assert!(raw.find("line", "fromsub").is_some());
2165 assert!(raw.find("line", "frominner").is_none());
2166
2167 std::fs::remove_dir_all(&root).unwrap();
2168 }
2169
2170 #[test]
2171 fn edit_boundaries_are_recorded() {
2172 let raw = parse("New Load.a kW=10 pf=0.9\n~ kvar=5\nNew Load.b like=a kw=20");
2175 let a = raw.find("load", "a").unwrap();
2176 assert_eq!(a.edits, vec![2, 3]);
2177 let b = raw.find("load", "b").unwrap();
2178 assert_eq!(b.props.len(), 4);
2179 assert_eq!(b.edits, vec![2, 3, 4]);
2180 assert_eq!(b.edit_bounds().collect::<Vec<_>>(), vec![2, 3, 4]);
2181 }
2182
2183 #[test]
2184 fn var_definition_and_use() {
2185 let raw = parse("var @kv=12.47\nNew Load.ld kv=@kv");
2186 let ld = raw.find("load", "ld").unwrap();
2187 assert_eq!(ld.get("kv").unwrap().text, "12.47");
2188 }
2189
2190 #[test]
2191 fn quoted_var_value_stays_rpn() {
2192 let raw = parse("var @z=(8 1000 /)\nNew Load.ld kW=@z");
2196 let v = raw.find("load", "ld").unwrap().get("kw").unwrap();
2197 assert!(v.quoted);
2198 assert_eq!(v.to_f64(None), Ok(0.008));
2199 }
2200
2201 #[test]
2202 fn vars_cross_redirect_boundaries() {
2203 let mut loader = |p: &Path| {
2206 if p == Path::new("inc.dss") {
2207 Ok("New Load.inner kv=@kv\nvar @kw=42".to_string())
2208 } else {
2209 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
2210 }
2211 };
2212 let raw = __parse_raw_with(
2213 "var @kv=12.47\nRedirect inc.dss\nNew Load.outer kW=@kw",
2214 "test.dss",
2215 &mut loader,
2216 );
2217 assert_eq!(raw.warnings, Vec::<String>::new());
2218 assert_eq!(
2219 raw.find("load", "inner").unwrap().get("kv").unwrap().text,
2220 "12.47"
2221 );
2222 assert_eq!(
2223 raw.find("load", "outer").unwrap().get("kw").unwrap().text,
2224 "42"
2225 );
2226 }
2227
2228 #[test]
2229 fn var_defined_after_clear_does_not_see_the_pre_clear_table() {
2230 let raw = parse("var @a=1\nClear\nvar @b=2\nNew Load.ld kv=@a kw=@b");
2234 let ld = raw.find("load", "ld").unwrap();
2235 assert_eq!(ld.get("kw").unwrap().text, "2", "post-clear var resolves");
2236 assert_eq!(
2237 ld.get("kv").unwrap().text,
2238 "@a",
2239 "pre-clear var does not survive Clear"
2240 );
2241 }
2242
2243 #[test]
2244 fn the_maximum_permitted_var_count_parses() {
2245 use std::fmt::Write as _;
2246 let mut script = String::new();
2247 for i in 0..MAX_TOTAL_VARS {
2248 let _ = writeln!(script, "var @v{i}={i}");
2249 }
2250 script.push_str("New Load.ld kv=@v0\n");
2251 let raw = parse(&script);
2252 assert_eq!(raw.warnings, Vec::<String>::new());
2253 assert_eq!(raw.find("load", "ld").unwrap().get("kv").unwrap().text, "0");
2254 }
2255
2256 #[test]
2257 fn one_var_past_the_count_cap_is_refused() {
2258 use std::fmt::Write as _;
2259 let mut script = String::new();
2260 for i in 0..=MAX_TOTAL_VARS {
2261 let _ = writeln!(script, "var @v{i}={i}");
2262 }
2263 let _ = writeln!(script, "New Load.ld kv=@v{MAX_TOTAL_VARS}");
2264 let raw = parse(&script);
2265 assert_eq!(
2266 raw.diagnostics
2267 .iter()
2268 .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_VALUE_CLAMPED.code)
2269 .count(),
2270 1,
2271 "one refusal, however many follow"
2272 );
2273 assert_eq!(
2275 raw.find("load", "ld").unwrap().get("kv").unwrap().text,
2276 format!("@v{MAX_TOTAL_VARS}")
2277 );
2278 }
2279
2280 #[test]
2281 fn one_byte_past_the_var_byte_cap_is_refused() {
2282 let filler = "x".repeat(MAX_TOTAL_VAR_BYTES);
2285 let raw = parse(&format!("var @big={filler}\nvar @small=1\n"));
2286 assert_eq!(
2287 raw.diagnostics
2288 .iter()
2289 .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_VALUE_CLAMPED.code)
2290 .count(),
2291 1,
2292 "one refusal, however many follow"
2293 );
2294 }
2295
2296 #[test]
2297 fn duplicate_new_warns_and_edits() {
2298 let raw = parse("New Line.l1 length=1\nNew Line.l1 length=2");
2299 assert_eq!(raw.warnings.len(), 1);
2300 assert_eq!(
2301 raw.find("line", "l1").unwrap().get("length").unwrap().text,
2302 "2"
2303 );
2304 }
2305
2306 #[test]
2307 fn rpn_value_via_props() {
2308 let raw = parse("New Load.ld kW=(8 1000 /)");
2309 let v = raw.find("load", "ld").unwrap().get("kw").unwrap().clone();
2310 assert_eq!(v.to_f64(None), Ok(0.008));
2311 }
2312
2313 #[test]
2314 fn confined_parsing_refuses_includes_outside_the_case_directory() {
2315 use std::cell::RefCell;
2316 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2319 let mut loader = |p: &Path| {
2320 requested.borrow_mut().push(p.display().to_string());
2321 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
2322 };
2323 let raw = parse_raw_with_confined(
2324 "Redirect ../../secret.dss\nRedirect /etc/passwd\nBuscoords ../up.csv",
2325 "/case/dir/master.dss",
2326 &mut loader,
2327 );
2328 assert!(
2329 requested.borrow().is_empty(),
2330 "escaping include reached the loader: {:?}",
2331 requested.borrow()
2332 );
2333 assert_eq!(
2334 raw.warnings
2335 .iter()
2336 .filter(|w| w.contains("escapes the case directory"))
2337 .count(),
2338 3
2339 );
2340 let refused: Vec<_> = raw
2343 .diagnostics
2344 .iter()
2345 .filter(|d| d.code() == crate::diagnostics::codes::READ_DSS_INCLUDE_REFUSED.code)
2346 .collect();
2347 assert_eq!(refused.len(), 3);
2348 assert!(
2349 refused
2350 .iter()
2351 .all(|d| d.severity() == crate::diagnostics::DiagnosticSeverity::Error)
2352 );
2353 }
2354
2355 #[test]
2356 fn confined_parsing_with_an_empty_root_refuses_absolute_and_climbing_includes() {
2357 use std::cell::RefCell;
2358 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2363 let mut loader = |p: &Path| {
2364 requested.borrow_mut().push(p.display().to_string());
2365 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
2366 };
2367 let raw = parse_raw_with_confined(
2368 "Redirect /etc/passwd\nRedirect ../secret.dss\nRedirect sub/ok.dss",
2369 "master.dss",
2370 &mut loader,
2371 );
2372 assert_eq!(
2373 *requested.borrow(),
2374 vec!["sub/ok.dss".to_string()],
2375 "an absolute or climbing include reached the loader"
2376 );
2377 assert_eq!(
2378 raw.warnings
2379 .iter()
2380 .filter(|w| w.contains("escapes the case directory"))
2381 .count(),
2382 2
2383 );
2384 }
2385
2386 #[test]
2387 fn confined_parsing_allows_includes_under_a_root_that_starts_with_parent_dirs() {
2388 use std::cell::RefCell;
2389 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2392 let mut loader = |p: &Path| {
2393 requested.borrow_mut().push(p.display().to_string());
2394 Ok(String::new())
2395 };
2396 let raw = parse_raw_with_confined(
2397 "Redirect codes.dss\nRedirect ../../outside.dss",
2398 "../case/master.dss",
2399 &mut loader,
2400 );
2401 assert_eq!(*requested.borrow(), vec!["../case/codes.dss".to_string()]);
2402 assert_eq!(
2403 raw.warnings
2404 .iter()
2405 .filter(|w| w.contains("escapes the case directory"))
2406 .count(),
2407 1
2408 );
2409 }
2410
2411 #[cfg(unix)]
2412 #[test]
2413 fn file_parsing_refuses_includes_that_escape_through_a_symlink() {
2414 let root =
2417 std::env::temp_dir().join(format!("powerio-dist-symlink-{}", std::process::id()));
2418 let case = root.join("case");
2419 std::fs::create_dir_all(&case).unwrap();
2420 std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
2421 std::fs::write(case.join("master.dss"), "Redirect linked.dss").unwrap();
2422 std::os::unix::fs::symlink(root.join("secret.dss"), case.join("linked.dss")).unwrap();
2423
2424 let raw = __parse_raw_file(case.join("master.dss")).unwrap();
2425 assert!(raw.find("line", "leaked").is_none());
2426 assert_eq!(
2427 raw.warnings
2428 .iter()
2429 .filter(|w| w.contains("symbolic link"))
2430 .count(),
2431 1,
2432 "warnings: {:?}",
2433 raw.warnings
2434 );
2435
2436 std::fs::remove_dir_all(&root).unwrap();
2437 }
2438
2439 #[test]
2440 fn raw_file_parsing_refuses_includes_outside_the_case_directory() {
2441 let root =
2442 std::env::temp_dir().join(format!("powerio-dist-rawconf-{}", std::process::id()));
2443 std::fs::create_dir_all(root.join("case")).unwrap();
2444 std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
2445 std::fs::write(
2446 root.join("case").join("master.dss"),
2447 "Redirect ../secret.dss",
2448 )
2449 .unwrap();
2450
2451 let raw = __parse_raw_file(root.join("case").join("master.dss")).unwrap();
2452 assert!(raw.find("line", "leaked").is_none());
2453 assert_eq!(
2454 raw.warnings
2455 .iter()
2456 .filter(|w| w.contains("escapes the case directory"))
2457 .count(),
2458 1
2459 );
2460
2461 std::fs::remove_dir_all(&root).unwrap();
2462 }
2463
2464 #[test]
2465 fn confined_parsing_allows_includes_within_the_case_directory() {
2466 use std::cell::RefCell;
2467 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2468 let mut loader = |p: &Path| {
2469 requested.borrow_mut().push(p.display().to_string());
2470 Ok(String::new())
2471 };
2472 parse_raw_with_confined(
2474 "Redirect sub/codes.dss\nRedirect /case/dir/abs.dss",
2475 "/case/dir/master.dss",
2476 &mut loader,
2477 );
2478 assert_eq!(
2479 *requested.borrow(),
2480 vec![
2481 "/case/dir/sub/codes.dss".to_string(),
2482 "/case/dir/abs.dss".to_string(),
2483 ]
2484 );
2485 }
2486
2487 #[test]
2488 fn a_widened_include_root_admits_siblings_and_refuses_escapes_past_it() {
2489 use std::cell::RefCell;
2490 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
2491 let mut loader = |p: &Path| {
2492 requested.borrow_mut().push(p.display().to_string());
2493 Ok(String::new())
2494 };
2495 let raw = parse_raw_confined_under(
2498 "Redirect ../shared/codes.dss\nRedirect ../../outside.dss",
2499 "/root/feeder/master.dss",
2500 Path::new("/root"),
2501 &mut loader,
2502 );
2503 assert_eq!(
2504 *requested.borrow(),
2505 vec!["/root/shared/codes.dss".to_string()]
2506 );
2507 assert_eq!(
2508 raw.warnings
2509 .iter()
2510 .filter(|w| w.contains("escapes the include root"))
2511 .count(),
2512 1,
2513 "warnings: {:?}",
2514 raw.warnings
2515 );
2516 }
2517}