1use 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
17static 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#[derive(Clone, Debug, PartialEq)]
165pub struct RawProp {
166 pub name: Option<String>,
170 pub value: Value,
171}
172
173#[derive(Clone, Debug)]
176pub struct RawObject {
177 pub class: String,
179 pub name: String,
181 pub props: Vec<RawProp>,
182 pub edits: Vec<usize>,
189}
190
191impl RawObject {
192 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 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#[derive(Clone, Debug, PartialEq)]
213pub struct RawCommand {
214 pub verb: String,
216 pub args: String,
218}
219
220#[derive(Clone, Debug, PartialEq)]
222pub struct BusCoord {
223 pub bus: String,
224 pub x: f64,
225 pub y: f64,
226}
227
228#[derive(Debug, Default)]
230pub struct RawDss {
231 pub circuit_name: Option<String>,
232 pub objects: Vec<RawObject>,
233 pub options: Vec<(String, Value)>,
235 pub commands: Vec<RawCommand>,
237 pub buscoords: Vec<BusCoord>,
238 pub vars: VarMap,
239 pub warnings: Vec<String>,
240 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 let (warnings, diagnostics) = (
270 std::mem::take(&mut self.warnings),
271 std::mem::take(&mut self.diagnostics),
272 );
273 *self = RawDss {
274 warnings,
275 diagnostics,
276 ..RawDss::default()
277 };
278 }
279}
280
281pub trait Loader {
283 fn load(&mut self, path: &Path) -> std::io::Result<String>;
284}
285
286impl<F> Loader for F
287where
288 F: FnMut(&Path) -> std::io::Result<String>,
289{
290 fn load(&mut self, path: &Path) -> std::io::Result<String> {
291 self(path)
292 }
293}
294
295const MAX_REDIRECT_DEPTH: usize = 64;
297
298const MAX_TOTAL_INCLUDES: usize = 4096;
303
304const MAX_TOTAL_INCLUDE_BYTES: usize = 64 << 20;
311
312const MAX_OBJECT_PROPS: usize = 1 << 16;
320
321struct Executor<'l, L: Loader> {
322 raw: RawDss,
323 loader: &'l mut L,
324 dirs: Vec<PathBuf>,
327 root: Option<PathBuf>,
333 includes: usize,
337 include_bytes: usize,
338 budget_spent: bool,
339}
340
341fn lexical_normalize(p: &Path) -> PathBuf {
345 use std::path::Component;
346 let mut out: Vec<Component<'_>> = Vec::new();
347 for comp in p.components() {
348 match comp {
349 Component::CurDir => {}
350 Component::ParentDir if matches!(out.last(), Some(Component::Normal(_))) => {
351 out.pop();
352 }
353 other => out.push(other),
354 }
355 }
356 out.into_iter().collect()
357}
358
359fn command_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
364 let mut in_block = false;
365 text.lines().enumerate().filter_map(move |(i, line)| {
366 if in_block {
367 if line.contains("*/") {
368 in_block = false;
369 }
370 return None;
371 }
372 if line.trim_start().starts_with("/*") {
373 in_block = true;
374 if line.contains("*/") {
375 in_block = false;
376 }
377 return None;
378 }
379 Some((i + 1, line))
380 })
381}
382
383impl<L: Loader> Executor<'_, L> {
384 fn run_script(&mut self, text: &str, file: &str) {
385 for (line_no, line) in command_lines(text) {
386 self.run_command(line, file, line_no);
387 }
388 }
389
390 fn run_command(&mut self, line: &str, file: &str, line_no: usize) {
391 let vars = self.raw.vars.clone();
397 let mut scan = Scanner::new(line, Some(&vars));
398 let ctx = |msg: String| format!("{file}:{line_no}: {msg}");
399 match scan.next_param() {
400 None => {}
401 Some(first) if first.value.text.is_empty() && first.name.is_none() => {}
402 Some(first) => {
403 if let Some(name) = first.name {
404 self.edit_property_reference(&name, first.value, &mut scan, &ctx);
407 } else {
408 self.dispatch(first.value.text, &mut scan, &ctx);
409 }
410 }
411 }
412 }
413
414 fn dispatch(&mut self, verb: String, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
415 match command_index(&verb).map(|i| COMMANDS[i]) {
416 Some("new") => self.do_new(scan, ctx),
417 Some("edit") => self.do_edit(scan, ctx),
418 Some("more" | "m" | "~") => self.do_more(scan, ctx),
419 Some("select") => self.do_select(scan, ctx),
420 Some("set") => self.do_set(scan),
421 Some("redirect") => self.do_redirect(scan, false, ctx),
422 Some("compile") => self.do_redirect(scan, true, ctx),
423 Some("buscoords") => self.do_buscoords(scan, ctx),
424 Some("var") => self.do_var(scan),
425 Some("clear" | "clearall") => self.raw.clear(),
426 Some("//") => {}
427 Some(canonical) => {
428 self.raw.commands.push(RawCommand {
429 verb: canonical.to_string(),
430 args: scan.remainder().to_string(),
431 });
432 }
433 None => {
434 self.raw.warn(ctx(format!(
435 "unknown command `{verb}`; line preserved verbatim"
436 )));
437 self.raw.commands.push(RawCommand {
438 verb,
439 args: scan.remainder().to_string(),
440 });
441 }
442 }
443 }
444
445 fn do_var(&mut self, scan: &mut Scanner) {
450 while let Some(p) = scan.next_param() {
451 if p.value.text.is_empty() && p.name.is_none() {
452 break;
453 }
454 if let Some(name) = p.name {
455 let stored = if p.value.text.starts_with('@') {
456 p.value.text
457 } else {
458 format!("{{{}}}", p.value.text)
459 };
460 self.raw.vars.insert(name.to_ascii_lowercase(), stored);
461 }
462 }
463 }
464
465 fn edit_property_reference(
472 &mut self,
473 spec: &str,
474 value: Value,
475 scan: &mut Scanner,
476 ctx: &dyn Fn(String) -> String,
477 ) {
478 let (object, prop) = match spec.split_once('.') {
479 None => (None, spec),
480 Some((first, rest)) => match rest.split_once('.') {
481 None => (Some((None, first)), rest),
482 Some((name, prop)) => (Some((Some(first), name)), prop),
483 },
484 };
485 let active_or = |raw: &mut RawDss| {
486 let active = raw.active;
487 if active.is_none() {
488 raw.warn(ctx(format!("`{spec}=` with no active object")));
489 }
490 active
491 };
492 let idx = match object {
493 None => match active_or(&mut self.raw) {
494 Some(idx) => idx,
495 None => return,
496 },
497 Some((class, name)) => {
498 let class = match class {
499 Some(c) => c.to_ascii_lowercase(),
500 None => match active_or(&mut self.raw) {
501 Some(idx) => self.raw.objects[idx].class.clone(),
502 None => return,
503 },
504 };
505 if let Some(idx) = self
506 .raw
507 .index
508 .get(&(class.clone(), name.to_ascii_lowercase()))
509 .copied()
510 {
511 idx
512 } else {
513 self.raw.warn(ctx(format!(
514 "property reference to unknown object `{class}.{name}`"
515 )));
516 return;
517 }
518 }
519 };
520 self.raw.active = Some(idx);
521 let table = prop_table(&self.raw.objects[idx].class);
522 let name = match table {
523 Some(c) => {
524 if let Some(i) = c.prop_index(prop) {
525 c.props[i].to_string()
526 } else {
527 self.raw.warn(ctx(format!(
528 "unknown property `{prop}` on {}; kept as written",
529 c.name
530 )));
531 prop.to_ascii_lowercase()
532 }
533 }
534 None => prop.to_ascii_lowercase(),
535 };
536 let mut props = vec![RawProp {
537 name: Some(name),
538 value,
539 }];
540 props.extend(collect_props_for(
541 table,
542 scan,
543 Some(prop),
544 &mut self.raw.warnings,
545 ctx,
546 ));
547 self.apply_props(idx, props, ctx);
548 }
549
550 fn do_new(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
551 let Some((class, name)) = self.object_spec(scan, ctx) else {
552 return;
553 };
554 if class.eq_ignore_ascii_case("circuit") {
555 self.raw.circuit_name = Some(name);
560 let idx = self.make_object("vsource", "source".into());
561 self.consume_and_apply(idx, scan, ctx);
562 return;
563 }
564 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
565 let idx = match self.raw.index.get(&key) {
566 Some(&existing) => {
567 self.raw.warn(ctx(format!(
568 "duplicate `New {class}.{name}`; editing the existing object"
569 )));
570 existing
571 }
572 None => self.make_object(&class, name),
573 };
574 self.consume_and_apply(idx, scan, ctx);
575 }
576
577 fn do_edit(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
578 let Some((class, name)) = self.object_spec(scan, ctx) else {
579 return;
580 };
581 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
582 let Some(&idx) = self.raw.index.get(&key) else {
583 self.raw
584 .warn(ctx(format!("`Edit {class}.{name}` on an unknown object")));
585 return;
586 };
587 self.consume_and_apply(idx, scan, ctx);
588 }
589
590 fn do_more(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
591 let Some(idx) = self.raw.active else {
592 self.raw.warn(ctx("`~` with no active object".into()));
593 return;
594 };
595 self.consume_and_apply(idx, scan, ctx);
596 }
597
598 fn do_select(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
599 let Some((class, name)) = self.object_spec(scan, ctx) else {
600 return;
601 };
602 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
603 match self.raw.index.get(&key) {
604 Some(&idx) => self.raw.active = Some(idx),
605 None => self
606 .raw
607 .warn(ctx(format!("`Select {class}.{name}` on an unknown object"))),
608 }
609 }
610
611 fn do_set(&mut self, scan: &mut Scanner) {
612 while let Some(p) = scan.next_param() {
613 if p.value.text.is_empty() && p.name.is_none() {
614 break;
615 }
616 let name = p.name.unwrap_or_default().to_ascii_lowercase();
617 self.raw.options.push((name, p.value));
618 }
619 }
620
621 fn resolve(&self, file_arg: &str) -> Option<PathBuf> {
632 use std::path::Component;
633 let rel = file_arg.replace('\\', "/");
634 let base = self.dirs.last().cloned().unwrap_or_default();
635 let joined = base.join(&rel);
636 match &self.root {
637 None => Some(joined),
638 Some(root) => {
639 let normalized = lexical_normalize(&joined);
647 normalized
648 .strip_prefix(root)
649 .is_ok_and(|rest| rest.components().all(|c| matches!(c, Component::Normal(_))))
650 .then_some(normalized)
651 }
652 }
653 }
654
655 fn resolve_or_warn(
658 &mut self,
659 verb: &str,
660 file_arg: &str,
661 ctx: &dyn Fn(String) -> String,
662 ) -> Option<PathBuf> {
663 let resolved = self.resolve(file_arg);
664 if resolved.is_none() {
665 let message = ctx(format!(
666 "{verb} {file_arg}: refused; include escapes the case directory"
667 ));
668 self.refuse_escape(message);
669 }
670 resolved
671 }
672
673 fn refuse_escape(&mut self, message: String) {
676 self.refuse(
677 message,
678 crate::diagnostics::READ_DSS_INCLUDE_REFUSED,
679 "place included files inside the case directory, or merge them into the case",
680 );
681 }
682
683 fn refuse(&mut self, message: String, code: &'static str, suggested_action: &'static str) {
686 self.raw.warn(message.clone());
687 self.raw.diagnostics.push(
688 crate::diagnostics::StructuredDiagnostic::new(
689 code,
690 crate::diagnostics::DiagnosticSeverity::Error,
691 crate::diagnostics::DiagnosticStage::Parse,
692 message,
693 )
694 .with_suggested_action(suggested_action),
695 );
696 }
697
698 fn charge_include(&mut self, verb: &str, path: &Path, ctx: &dyn Fn(String) -> String) -> bool {
704 let over_budget = self.budget_spent
705 || self.includes >= MAX_TOTAL_INCLUDES
706 || self.include_bytes >= MAX_TOTAL_INCLUDE_BYTES;
707 if !over_budget {
708 self.includes += 1;
709 return true;
710 }
711 if !self.budget_spent {
712 self.budget_spent = true;
713 let message = ctx(format!(
714 "{verb} {}: refused; the case exceeded the include budget of {MAX_TOTAL_INCLUDES} \
715 files and {} MiB, so the rest of the includes were not followed",
716 path.display(),
717 MAX_TOTAL_INCLUDE_BYTES >> 20,
718 ));
719 self.refuse(
720 message,
721 crate::diagnostics::READ_DSS_INCLUDE_BUDGET,
722 "check the case for an include cycle; a file that redirects to itself expands \
723 without bound",
724 );
725 }
726 false
727 }
728
729 fn warn_load_error(
736 &mut self,
737 verb: &str,
738 path: &Path,
739 e: &std::io::Error,
740 ctx: &dyn Fn(String) -> String,
741 ) {
742 let message = ctx(format!("{verb} {}: {e}", path.display()));
743 if Containment::refused_by_us(e) {
744 self.refuse_escape(message);
745 } else {
746 self.raw.warn(message);
747 }
748 }
749
750 fn do_redirect(&mut self, scan: &mut Scanner, compile: bool, ctx: &dyn Fn(String) -> String) {
751 let Some(p) = scan.next_param() else {
752 self.raw.warn(ctx("redirect with no file".into()));
753 return;
754 };
755 let verb = if compile { "compile" } else { "redirect" };
756 let Some(path) = self.resolve_or_warn(verb, &p.value.text, ctx) else {
757 return;
758 };
759 if self.dirs.len() > MAX_REDIRECT_DEPTH {
760 self.raw
761 .warn(ctx(format!("redirect depth limit at {}", path.display())));
762 return;
763 }
764 if !self.charge_include(verb, &path, ctx) {
765 return;
766 }
767 match self.loader.load(&path) {
768 Ok(text) => {
769 self.include_bytes += text.len();
770 let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
771 self.dirs.push(dir.clone());
772 self.run_script(&text, &path.display().to_string());
773 self.dirs.pop();
774 if compile && let Some(top) = self.dirs.last_mut() {
782 *top = dir;
783 }
784 }
785 Err(e) => self.warn_load_error(verb, &path, &e, ctx),
786 }
787 }
788
789 fn do_buscoords(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
790 let Some(p) = scan.next_param() else {
791 self.raw.warn(ctx("buscoords with no file".into()));
792 return;
793 };
794 let Some(path) = self.resolve_or_warn("buscoords", &p.value.text, ctx) else {
795 return;
796 };
797 if !self.charge_include("buscoords", &path, ctx) {
798 return;
799 }
800 match self.loader.load(&path) {
801 Ok(text) => {
802 self.include_bytes += text.len();
803 for (line_no, line) in text.lines().enumerate() {
804 let mut s = Scanner::new(line, None);
805 let Some(bus) = s.next_param() else { continue };
806 if bus.value.text.is_empty() {
807 continue;
808 }
809 let x = s.next_param().map(|p| p.value).unwrap_or_default();
810 let y = s.next_param().map(|p| p.value).unwrap_or_default();
811 match (x.to_f64(None), y.to_f64(None)) {
812 (Ok(x), Ok(y)) => self.raw.buscoords.push(BusCoord {
813 bus: bus.value.text,
814 x,
815 y,
816 }),
817 _ => self.raw.warn(ctx(format!(
818 "buscoords {}:{}: unparseable coordinates",
819 path.display(),
820 line_no + 1
821 ))),
822 }
823 }
824 }
825 Err(e) => self.warn_load_error("buscoords", &path, &e, ctx),
826 }
827 }
828
829 fn object_spec(
831 &mut self,
832 scan: &mut Scanner,
833 ctx: &dyn Fn(String) -> String,
834 ) -> Option<(String, String)> {
835 let p = scan.next_param()?;
836 if let Some(name) = &p.name {
837 if !name.eq_ignore_ascii_case("object") {
838 self.raw
839 .warn(ctx(format!("expected Class.Name, got `{name}=`")));
840 return None;
841 }
842 }
843 let spec = p.value.text;
844 match spec.split_once('.') {
845 Some((class, name)) if !class.is_empty() && !name.is_empty() => {
846 Some((class.to_string(), name.to_string()))
847 }
848 _ => {
849 self.raw
850 .warn(ctx(format!("malformed object spec `{spec}`")));
851 None
852 }
853 }
854 }
855
856 fn make_object(&mut self, class: &str, name: String) -> usize {
857 let class_lc = class.to_ascii_lowercase();
858 let idx = self.raw.objects.len();
859 self.raw
860 .index
861 .insert((class_lc.clone(), name.to_ascii_lowercase()), idx);
862 self.raw.objects.push(RawObject {
863 class: class_lc,
864 name,
865 props: Vec::new(),
866 edits: Vec::new(),
867 });
868 idx
869 }
870
871 fn consume_and_apply(
872 &mut self,
873 idx: usize,
874 scan: &mut Scanner,
875 ctx: &dyn Fn(String) -> String,
876 ) {
877 let props = collect_props_for(
878 prop_table(&self.raw.objects[idx].class),
879 scan,
880 None,
881 &mut self.raw.warnings,
882 ctx,
883 );
884 self.apply_props(idx, props, ctx);
885 }
886
887 fn apply_props(&mut self, idx: usize, props: Vec<RawProp>, ctx: &dyn Fn(String) -> String) {
888 self.raw.active = Some(idx);
889 for p in props {
890 if p.name.as_deref() == Some("like") {
896 let class = self.raw.objects[idx].class.clone();
897 let key = (class.clone(), p.value.text.to_ascii_lowercase());
898 match self.raw.index.get(&key).copied() {
899 Some(src) => {
900 let base = self.raw.objects[idx].props.len();
901 let src_len = self.raw.objects[src].props.len();
902 if base.saturating_add(src_len) > MAX_OBJECT_PROPS {
908 self.raw.warn(ctx(format!(
909 "like={}: {class} property count would exceed the supported \
910 maximum of {MAX_OBJECT_PROPS}; splice refused",
911 p.value.text
912 )));
913 continue;
914 }
915 let cloned = self.raw.objects[src].props.clone();
916 let bounds: Vec<usize> = self.raw.objects[src]
917 .edit_bounds()
918 .map(|e| base + e)
919 .collect();
920 self.raw.objects[idx].props.extend(cloned);
921 self.raw.objects[idx].edits.extend(bounds);
922 }
923 None => self.raw.warn(ctx(format!(
924 "like={} names an unknown {class}",
925 p.value.text
926 ))),
927 }
928 continue;
929 }
930 if self.raw.objects[idx].props.len() >= MAX_OBJECT_PROPS {
931 self.raw.warn(ctx(format!(
932 "{}: property count exceeds the supported maximum of {MAX_OBJECT_PROPS}; \
933 further assignments dropped",
934 self.raw.objects[idx].class
935 )));
936 continue;
937 }
938 self.raw.objects[idx].props.push(p);
939 }
940 let end = self.raw.objects[idx].props.len();
943 self.raw.objects[idx].edits.push(end);
944 }
945}
946
947fn prop_table(class: &str) -> Option<&'static DssClass> {
948 prop::class_by_name(class)
949}
950
951fn collect_props_for(
956 class: Option<&'static DssClass>,
957 scan: &mut Scanner,
958 after: Option<&str>,
959 warnings: &mut Vec<String>,
960 ctx: &dyn Fn(String) -> String,
961) -> Vec<RawProp> {
962 let mut out = Vec::new();
963 let mut pointer: Option<usize> = class.zip(after).and_then(|(c, name)| c.prop_index(name));
964 while let Some(p) = scan.next_param() {
965 if p.value.text.is_empty() && p.name.is_none() {
966 break;
967 }
968 let name = match (&p.name, class) {
969 (Some(written), Some(c)) => {
970 if let Some(i) = c.prop_index(written) {
971 pointer = Some(i);
972 Some(c.props[i].to_string())
973 } else {
974 pointer = None;
978 warnings.push(ctx(format!(
979 "unknown property `{written}` on {}; kept as written",
980 c.name
981 )));
982 Some(written.to_ascii_lowercase())
983 }
984 }
985 (Some(written), None) => Some(written.to_ascii_lowercase()),
986 (None, Some(c)) => {
987 let next = pointer.map_or(0, |i| i + 1);
988 pointer = Some(next);
989 if let Some(canon) = c.props.get(next) {
990 Some((*canon).to_string())
991 } else {
992 warnings.push(ctx(format!(
993 "positional value `{}` beyond the last {} property",
994 p.value.text, c.name
995 )));
996 None
997 }
998 }
999 (None, None) => None,
1000 };
1001 out.push(RawProp {
1002 name,
1003 value: p.value,
1004 });
1005 }
1006 out
1007}
1008
1009pub fn parse_raw_with(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1020 run_executor(text, path, None, loader)
1021}
1022
1023pub(crate) fn canonical_case_root(path: &Path) -> Option<PathBuf> {
1028 let dir = path.parent().unwrap_or_else(|| Path::new(""));
1029 let dir = if dir.as_os_str().is_empty() {
1030 Path::new(".")
1031 } else {
1032 dir
1033 };
1034 dir.canonicalize().ok()
1035}
1036
1037pub(crate) fn confined_fs_read(
1043 canonical_root: Option<&Path>,
1044 path: &Path,
1045) -> std::io::Result<String> {
1046 let Some(root) = canonical_root else {
1047 return Err(Containment::refused(
1048 "case directory cannot be resolved; includes are disabled",
1049 ));
1050 };
1051 let real = path.canonicalize()?;
1052 if !real.starts_with(root) {
1053 return Err(Containment::refused(
1054 "resolves outside the case directory through a symbolic link",
1055 ));
1056 }
1057 std::fs::read_to_string(&real)
1058}
1059
1060#[derive(Debug)]
1065pub(crate) struct Containment(&'static str);
1066
1067impl Containment {
1068 fn refused(reason: &'static str) -> std::io::Error {
1069 std::io::Error::new(std::io::ErrorKind::PermissionDenied, Containment(reason))
1070 }
1071
1072 pub(crate) fn refused_by_us(e: &std::io::Error) -> bool {
1074 e.get_ref()
1075 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<Self>)
1076 }
1077}
1078
1079impl std::fmt::Display for Containment {
1080 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1081 f.write_str(self.0)
1082 }
1083}
1084
1085impl std::error::Error for Containment {}
1086
1087pub(crate) fn parse_raw_with_confined(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1093 let root = lexical_normalize(
1094 &Path::new(path)
1095 .parent()
1096 .map(Path::to_path_buf)
1097 .unwrap_or_default(),
1098 );
1099 run_executor(text, path, Some(root), loader)
1100}
1101
1102fn run_executor(text: &str, path: &str, root: Option<PathBuf>, loader: &mut impl Loader) -> RawDss {
1103 let mut exec = Executor {
1104 raw: RawDss::default(),
1105 loader,
1106 dirs: vec![
1107 Path::new(path)
1108 .parent()
1109 .map(Path::to_path_buf)
1110 .unwrap_or_default(),
1111 ],
1112 root,
1113 includes: 0,
1114 include_bytes: 0,
1115 budget_spent: false,
1116 };
1117 exec.run_script(text, path);
1118 exec.raw
1119}
1120
1121pub fn parse_raw_file(path: impl AsRef<Path>) -> Result<RawDss> {
1128 let path = path.as_ref();
1129 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
1130 path: path.display().to_string(),
1131 source,
1132 })?;
1133 let root = canonical_case_root(path);
1134 Ok(parse_raw_with_confined(
1135 &text,
1136 &path.display().to_string(),
1137 &mut |p: &Path| confined_fs_read(root.as_deref(), p),
1138 ))
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use super::*;
1144
1145 fn no_files(_: &Path) -> std::io::Result<String> {
1146 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test"))
1147 }
1148
1149 fn parse(text: &str) -> RawDss {
1150 parse_raw_with(text, "test.dss", &mut no_files)
1151 }
1152
1153 #[test]
1154 fn new_object_with_positional_and_named() {
1155 let raw = parse("New Line.l1 b1 b2 lc 0.3 phases=2 r1=0.1");
1156 let l = raw.find("line", "l1").unwrap();
1157 assert_eq!(l.get("bus1").unwrap().text, "b1");
1158 assert_eq!(l.get("bus2").unwrap().text, "b2");
1159 assert_eq!(l.get("linecode").unwrap().text, "lc");
1160 assert_eq!(l.get("length").unwrap().text, "0.3");
1161 assert_eq!(l.get("phases").unwrap().text, "2");
1162 assert_eq!(l.get("r1").unwrap().text, "0.1");
1163 assert!(raw.warnings.is_empty());
1164 }
1165
1166 #[test]
1167 fn positional_continues_after_named() {
1168 let raw = parse("New Line.l1 r1=0.1 0.2");
1170 let l = raw.find("line", "l1").unwrap();
1171 assert_eq!(l.get("x1").unwrap().text, "0.2");
1172 }
1173
1174 #[test]
1175 fn unknown_property_resets_the_positional_pointer() {
1176 let raw = parse("New Line.l1 r1=0.1 bogus=2 0.5");
1179 let l = raw.find("line", "l1").unwrap();
1180 assert_eq!(l.get("bus1").unwrap().text, "0.5");
1181 assert!(l.get("x1").is_none());
1182 assert_eq!(raw.warnings.len(), 1);
1183 }
1184
1185 #[test]
1186 fn tilde_continues_the_active_object() {
1187 let raw = parse("New Load.ld bus1=b1\n~ kW=15 kvar=3\nMore pf=0.9");
1188 let ld = raw.find("load", "ld").unwrap();
1189 assert_eq!(ld.get("kw").unwrap().text, "15");
1190 assert_eq!(ld.get("kvar").unwrap().text, "3");
1191 assert_eq!(ld.get("pf").unwrap().text, "0.9");
1192 }
1193
1194 #[test]
1195 fn abbreviated_property_names() {
1196 let raw = parse("New Line.l1 ph=3 len=2 rm=(1 | 0 1)");
1197 let l = raw.find("line", "l1").unwrap();
1198 assert_eq!(l.get("phases").unwrap().text, "3");
1199 assert_eq!(l.get("length").unwrap().text, "2");
1200 assert!(l.get("rmatrix").unwrap().quoted);
1201 }
1202
1203 #[test]
1204 fn new_circuit_creates_the_source() {
1205 let raw = parse("New Circuit.test basekv=115 pu=1.05\n~ angle=30");
1206 assert_eq!(raw.circuit_name.as_deref(), Some("test"));
1207 let vs = raw.find("vsource", "source").unwrap();
1208 assert_eq!(vs.get("basekv").unwrap().text, "115");
1209 assert_eq!(vs.get("angle").unwrap().text, "30");
1210 assert!(vs.get("bus1").is_none());
1213 }
1214
1215 #[test]
1216 fn edit_and_property_reference() {
1217 let raw = parse("New Line.l1 length=1\nEdit Line.l1 length=2\nLine.l1.Length=3 phases=2");
1218 let l = raw.find("line", "l1").unwrap();
1219 assert_eq!(l.get("length").unwrap().text, "3");
1220 assert_eq!(l.get("phases").unwrap().text, "2");
1221 }
1222
1223 #[test]
1224 fn property_reference_resolves_abbreviations() {
1225 let raw = parse("New Line.l1 bus1=a\nLine.l1.Len=2.5");
1226 let l = raw.find("line", "l1").unwrap();
1227 assert_eq!(l.get("length").unwrap().text, "2.5");
1228 assert!(raw.warnings.is_empty());
1229 }
1230
1231 #[test]
1232 fn bare_property_edits_the_active_object() {
1233 let raw = parse("New Line.l1 bus1=a bus2=b\nlength=2.5");
1234 let l = raw.find("line", "l1").unwrap();
1235 assert_eq!(l.get("length").unwrap().text, "2.5");
1236 assert!(raw.warnings.is_empty());
1237 }
1238
1239 #[test]
1240 fn classless_reference_uses_the_active_class() {
1241 let raw = parse("New Line.l1 bus1=a\nNew Line.l2 bus1=b\nl1.length=7 phases=2");
1244 let l1 = raw.find("line", "l1").unwrap();
1245 assert_eq!(l1.get("length").unwrap().text, "7");
1246 assert_eq!(l1.get("phases").unwrap().text, "2");
1247 assert!(raw.find("line", "l2").unwrap().get("length").is_none());
1248 assert!(raw.warnings.is_empty());
1249 }
1250
1251 #[test]
1252 fn like_splices_source_props() {
1253 let raw = parse("New Load.a kW=10 pf=0.9\nNew Load.b like=a kW=20");
1254 let b = raw.find("load", "b").unwrap();
1255 assert_eq!(b.get("kw").unwrap().text, "20");
1256 assert_eq!(b.get("pf").unwrap().text, "0.9");
1257 }
1258
1259 #[test]
1260 fn self_referencing_like_cannot_explode_the_prop_count() {
1261 let mut script = String::from("New Load.a kW=1\n");
1265 for _ in 0..40 {
1266 script.push_str("Edit Load.a like=a\n");
1267 }
1268 let raw = parse(&script);
1269 let a = raw.find("load", "a").unwrap();
1270 assert!(
1271 a.props.len() <= MAX_OBJECT_PROPS,
1272 "prop count {} exceeded the cap",
1273 a.props.len()
1274 );
1275 assert!(
1276 raw.warnings.iter().any(|w| w.contains("splice refused")),
1277 "expected a refusal warning, got {:?}",
1278 raw.warnings
1279 );
1280 }
1281
1282 #[test]
1283 fn unknown_class_is_preserved_raw() {
1284 let raw = parse("New Reactor.r1 bus1=b1 x=3");
1285 let r = raw.find("reactor", "r1").unwrap();
1286 assert_eq!(r.get("bus1").unwrap().text, "b1");
1287 assert_eq!(r.get("x").unwrap().text, "3");
1288 }
1289
1290 #[test]
1291 fn set_options_accumulate() {
1292 let raw = parse("Set VoltageBases=[115, 12.47]\nset mode=snapshot");
1293 assert_eq!(raw.options[0].0, "voltagebases");
1294 assert_eq!(
1295 raw.options[0].1.to_vector(None).unwrap(),
1296 vec![115.0, 12.47]
1297 );
1298 assert_eq!(raw.options[1].0, "mode");
1299 }
1300
1301 #[test]
1302 fn unexecuted_commands_are_preserved() {
1303 let raw = parse("Solve\ncalcv\nShow Voltages LN");
1304 let verbs: Vec<&str> = raw.commands.iter().map(|c| c.verb.as_str()).collect();
1305 assert_eq!(verbs, vec!["solve", "calcvoltagebases", "show"]);
1306 assert_eq!(raw.commands[2].args, "Voltages LN");
1307 }
1308
1309 #[test]
1314 fn clear_does_not_disarm_the_include_containment() {
1315 let requested: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
1316 let mut loader = |p: &Path| {
1317 requested.borrow_mut().push(p.display().to_string());
1318 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1319 };
1320 let raw = parse_raw_with_confined(
1321 "Redirect ../../secret.dss\nClear\nRedirect ../../secret.dss\nRedirect /etc/passwd",
1322 "/case/dir/master.dss",
1323 &mut loader,
1324 );
1325 assert!(
1326 requested.borrow().is_empty(),
1327 "an escaping include reached the loader after Clear: {:?}",
1328 requested.borrow()
1329 );
1330 assert_eq!(
1331 raw.warnings
1332 .iter()
1333 .filter(|w| w.contains("escapes the case directory"))
1334 .count(),
1335 3,
1336 "every refusal is stated, and Clear does not erase the ones before it"
1337 );
1338 }
1339
1340 #[test]
1341 fn clear_resets() {
1342 let raw = parse("New Line.l1 length=1\nClear\nNew Line.l2 length=2");
1343 assert!(raw.find("line", "l1").is_none());
1344 assert!(raw.find("line", "l2").is_some());
1345 }
1346
1347 #[test]
1348 fn block_comments_skip_lines() {
1349 let raw = parse("/* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1350 assert!(raw.find("line", "l1").is_none());
1351 assert!(raw.find("line", "l2").is_some());
1352 }
1353
1354 #[test]
1355 fn indented_block_comments_skip_lines() {
1356 let raw = parse(" /* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1357 assert!(raw.find("line", "l1").is_none());
1358 assert!(raw.find("line", "l2").is_some());
1359 }
1360
1361 #[test]
1362 fn one_line_block_comment() {
1363 let raw = parse("\t/* x */\nNew Line.l2 length=2");
1364 assert!(raw.find("line", "l2").is_some());
1365 }
1366
1367 #[test]
1368 fn redirect_includes_a_file() {
1369 let mut files = BTreeMap::from([(
1370 PathBuf::from("sub/codes.dss"),
1371 "New Linecode.lc1 nphases=3".to_string(),
1372 )]);
1373 let mut loader = move |p: &Path| {
1374 files
1375 .remove(p)
1376 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1377 };
1378 let raw = parse_raw_with(
1379 "Redirect sub/codes.dss\nNew Line.l1 linecode=lc1",
1380 "test.dss",
1381 &mut loader,
1382 );
1383 assert!(raw.find("linecode", "lc1").is_some());
1384 assert!(raw.warnings.is_empty());
1385 }
1386
1387 fn counting_loader(text: &str, give_up: usize, script: &str) -> (RawDss, usize) {
1391 let mut loads = 0usize;
1392 let raw = {
1393 let mut loader = |_: &Path| {
1394 loads += 1;
1395 if loads > give_up {
1396 Err(std::io::Error::other("loader gave up"))
1397 } else {
1398 Ok(text.to_string())
1399 }
1400 };
1401 parse_raw_with(script, "test.dss", &mut loader)
1402 };
1403 (raw, loads)
1404 }
1405
1406 fn budget_refusals(raw: &RawDss) -> usize {
1407 raw.diagnostics
1408 .iter()
1409 .filter(|d| d.code.as_str() == crate::diagnostics::READ_DSS_INCLUDE_BUDGET)
1410 .count()
1411 }
1412
1413 #[test]
1414 fn a_self_redirecting_include_stops_at_the_file_budget() {
1415 let (raw, loads) = counting_loader(
1419 "Redirect a.dss\nRedirect a.dss",
1420 MAX_TOTAL_INCLUDES * 4,
1421 "Redirect a.dss",
1422 );
1423 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1424 assert_eq!(budget_refusals(&raw), 1, "one refusal, however many follow");
1425 }
1426
1427 #[test]
1428 fn a_large_self_redirecting_include_stops_at_the_byte_budget() {
1429 let chunk = 256 << 10;
1433 let text = format!("Redirect a.dss\nRedirect a.dss\n// {}", "a".repeat(chunk));
1434 let (raw, loads) = counting_loader(&text, MAX_TOTAL_INCLUDES, "Redirect a.dss");
1435 assert!(
1436 loads <= MAX_TOTAL_INCLUDE_BYTES / chunk + 1,
1437 "{loads} includes followed, {} bytes",
1438 loads * chunk
1439 );
1440 assert!(
1441 loads < MAX_TOTAL_INCLUDES,
1442 "the byte budget is what stopped it"
1443 );
1444 assert_eq!(budget_refusals(&raw), 1);
1445 }
1446
1447 #[test]
1448 fn buscoords_is_charged_against_the_include_budget() {
1449 let (raw, loads) = counting_loader(
1453 "Redirect a.dss\nRedirect a.dss\nBuscoords a.csv",
1454 MAX_TOTAL_INCLUDES * 4,
1455 "Redirect a.dss",
1456 );
1457 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1458 assert_eq!(budget_refusals(&raw), 1);
1459 }
1460
1461 #[test]
1462 fn clear_does_not_refund_the_include_budget() {
1463 let (_, loads) = counting_loader(
1466 "Clear\nRedirect a.dss\nRedirect a.dss",
1467 MAX_TOTAL_INCLUDES * 4,
1468 "Redirect a.dss",
1469 );
1470 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1471 }
1472
1473 #[test]
1474 fn missing_redirect_warns() {
1475 let raw = parse("Redirect nope.dss");
1476 assert_eq!(raw.warnings.len(), 1);
1477 assert!(raw.warnings[0].contains("nope.dss"));
1478 }
1479
1480 #[test]
1481 fn compile_moves_the_directory_redirect_restores_it() {
1482 let root = std::env::temp_dir().join(format!("powerio-dist-raw-{}", std::process::id()));
1487 let sub = root.join("sub");
1488 std::fs::create_dir_all(&sub).unwrap();
1489 std::fs::write(sub.join("feeder.dss"), "New Linecode.lc1 nphases=3").unwrap();
1490 std::fs::write(sub.join("lines.dss"), "New Line.fromsub bus1=a").unwrap();
1491 std::fs::write(root.join("lines.dss"), "New Line.fromroot bus1=a").unwrap();
1492 std::fs::write(
1493 root.join("compile.dss"),
1494 "Compile sub/feeder.dss\nRedirect lines.dss",
1495 )
1496 .unwrap();
1497 std::fs::write(
1498 root.join("redirect.dss"),
1499 "Redirect sub/feeder.dss\nRedirect lines.dss",
1500 )
1501 .unwrap();
1502
1503 let compiled = parse_raw_file(root.join("compile.dss")).unwrap();
1504 assert_eq!(compiled.warnings, Vec::<String>::new());
1505 assert!(compiled.find("line", "fromsub").is_some());
1506
1507 let redirected = parse_raw_file(root.join("redirect.dss")).unwrap();
1508 assert_eq!(redirected.warnings, Vec::<String>::new());
1509 assert!(redirected.find("line", "fromroot").is_some());
1510
1511 std::fs::remove_dir_all(&root).unwrap();
1512 }
1513
1514 #[test]
1515 fn compile_inside_compile_pins_the_compiled_files_directory() {
1516 let root =
1523 std::env::temp_dir().join(format!("powerio-dist-rawnest-{}", std::process::id()));
1524 let sub = root.join("sub");
1525 let inner = sub.join("inner");
1526 std::fs::create_dir_all(&inner).unwrap();
1527 std::fs::write(
1528 root.join("main.dss"),
1529 "Compile sub/a.dss\nRedirect probe.dss",
1530 )
1531 .unwrap();
1532 std::fs::write(sub.join("a.dss"), "Compile inner/b.dss").unwrap();
1533 std::fs::write(inner.join("b.dss"), "New Linecode.lc1 nphases=1").unwrap();
1534 std::fs::write(sub.join("probe.dss"), "New Line.fromsub bus1=a").unwrap();
1535 std::fs::write(inner.join("probe.dss"), "New Line.frominner bus1=a").unwrap();
1536
1537 let raw = parse_raw_file(root.join("main.dss")).unwrap();
1538 assert_eq!(raw.warnings, Vec::<String>::new());
1539 assert!(raw.find("linecode", "lc1").is_some());
1540 assert!(raw.find("line", "fromsub").is_some());
1541 assert!(raw.find("line", "frominner").is_none());
1542
1543 std::fs::remove_dir_all(&root).unwrap();
1544 }
1545
1546 #[test]
1547 fn edit_boundaries_are_recorded() {
1548 let raw = parse("New Load.a kW=10 pf=0.9\n~ kvar=5\nNew Load.b like=a kw=20");
1551 let a = raw.find("load", "a").unwrap();
1552 assert_eq!(a.edits, vec![2, 3]);
1553 let b = raw.find("load", "b").unwrap();
1554 assert_eq!(b.props.len(), 4);
1555 assert_eq!(b.edits, vec![2, 3, 4]);
1556 assert_eq!(b.edit_bounds().collect::<Vec<_>>(), vec![2, 3, 4]);
1557 }
1558
1559 #[test]
1560 fn var_definition_and_use() {
1561 let raw = parse("var @kv=12.47\nNew Load.ld kv=@kv");
1562 let ld = raw.find("load", "ld").unwrap();
1563 assert_eq!(ld.get("kv").unwrap().text, "12.47");
1564 }
1565
1566 #[test]
1567 fn quoted_var_value_stays_rpn() {
1568 let raw = parse("var @z=(8 1000 /)\nNew Load.ld kW=@z");
1572 let v = raw.find("load", "ld").unwrap().get("kw").unwrap();
1573 assert!(v.quoted);
1574 assert_eq!(v.to_f64(None), Ok(0.008));
1575 }
1576
1577 #[test]
1578 fn vars_cross_redirect_boundaries() {
1579 let mut loader = |p: &Path| {
1582 if p == Path::new("inc.dss") {
1583 Ok("New Load.inner kv=@kv\nvar @kw=42".to_string())
1584 } else {
1585 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1586 }
1587 };
1588 let raw = parse_raw_with(
1589 "var @kv=12.47\nRedirect inc.dss\nNew Load.outer kW=@kw",
1590 "test.dss",
1591 &mut loader,
1592 );
1593 assert_eq!(raw.warnings, Vec::<String>::new());
1594 assert_eq!(
1595 raw.find("load", "inner").unwrap().get("kv").unwrap().text,
1596 "12.47"
1597 );
1598 assert_eq!(
1599 raw.find("load", "outer").unwrap().get("kw").unwrap().text,
1600 "42"
1601 );
1602 }
1603
1604 #[test]
1605 fn duplicate_new_warns_and_edits() {
1606 let raw = parse("New Line.l1 length=1\nNew Line.l1 length=2");
1607 assert_eq!(raw.warnings.len(), 1);
1608 assert_eq!(
1609 raw.find("line", "l1").unwrap().get("length").unwrap().text,
1610 "2"
1611 );
1612 }
1613
1614 #[test]
1615 fn rpn_value_via_props() {
1616 let raw = parse("New Load.ld kW=(8 1000 /)");
1617 let v = raw.find("load", "ld").unwrap().get("kw").unwrap().clone();
1618 assert_eq!(v.to_f64(None), Ok(0.008));
1619 }
1620
1621 #[test]
1622 fn confined_parsing_refuses_includes_outside_the_case_directory() {
1623 use std::cell::RefCell;
1624 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1627 let mut loader = |p: &Path| {
1628 requested.borrow_mut().push(p.display().to_string());
1629 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1630 };
1631 let raw = parse_raw_with_confined(
1632 "Redirect ../../secret.dss\nRedirect /etc/passwd\nBuscoords ../up.csv",
1633 "/case/dir/master.dss",
1634 &mut loader,
1635 );
1636 assert!(
1637 requested.borrow().is_empty(),
1638 "escaping include reached the loader: {:?}",
1639 requested.borrow()
1640 );
1641 assert_eq!(
1642 raw.warnings
1643 .iter()
1644 .filter(|w| w.contains("escapes the case directory"))
1645 .count(),
1646 3
1647 );
1648 let refused: Vec<_> = raw
1651 .diagnostics
1652 .iter()
1653 .filter(|d| d.code.as_str() == crate::diagnostics::READ_DSS_INCLUDE_REFUSED)
1654 .collect();
1655 assert_eq!(refused.len(), 3);
1656 assert!(
1657 refused
1658 .iter()
1659 .all(|d| d.severity == crate::diagnostics::DiagnosticSeverity::Error)
1660 );
1661 }
1662
1663 #[test]
1664 fn confined_parsing_with_an_empty_root_refuses_absolute_and_climbing_includes() {
1665 use std::cell::RefCell;
1666 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1671 let mut loader = |p: &Path| {
1672 requested.borrow_mut().push(p.display().to_string());
1673 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1674 };
1675 let raw = parse_raw_with_confined(
1676 "Redirect /etc/passwd\nRedirect ../secret.dss\nRedirect sub/ok.dss",
1677 "master.dss",
1678 &mut loader,
1679 );
1680 assert_eq!(
1681 *requested.borrow(),
1682 vec!["sub/ok.dss".to_string()],
1683 "an absolute or climbing include reached the loader"
1684 );
1685 assert_eq!(
1686 raw.warnings
1687 .iter()
1688 .filter(|w| w.contains("escapes the case directory"))
1689 .count(),
1690 2
1691 );
1692 }
1693
1694 #[test]
1695 fn confined_parsing_allows_includes_under_a_root_that_starts_with_parent_dirs() {
1696 use std::cell::RefCell;
1697 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1700 let mut loader = |p: &Path| {
1701 requested.borrow_mut().push(p.display().to_string());
1702 Ok(String::new())
1703 };
1704 let raw = parse_raw_with_confined(
1705 "Redirect codes.dss\nRedirect ../../outside.dss",
1706 "../case/master.dss",
1707 &mut loader,
1708 );
1709 assert_eq!(*requested.borrow(), vec!["../case/codes.dss".to_string()]);
1710 assert_eq!(
1711 raw.warnings
1712 .iter()
1713 .filter(|w| w.contains("escapes the case directory"))
1714 .count(),
1715 1
1716 );
1717 }
1718
1719 #[cfg(unix)]
1720 #[test]
1721 fn file_parsing_refuses_includes_that_escape_through_a_symlink() {
1722 let root =
1725 std::env::temp_dir().join(format!("powerio-dist-symlink-{}", std::process::id()));
1726 let case = root.join("case");
1727 std::fs::create_dir_all(&case).unwrap();
1728 std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
1729 std::fs::write(case.join("master.dss"), "Redirect linked.dss").unwrap();
1730 std::os::unix::fs::symlink(root.join("secret.dss"), case.join("linked.dss")).unwrap();
1731
1732 let raw = parse_raw_file(case.join("master.dss")).unwrap();
1733 assert!(raw.find("line", "leaked").is_none());
1734 assert_eq!(
1735 raw.warnings
1736 .iter()
1737 .filter(|w| w.contains("outside the case directory"))
1738 .count(),
1739 1,
1740 "warnings: {:?}",
1741 raw.warnings
1742 );
1743
1744 std::fs::remove_dir_all(&root).unwrap();
1745 }
1746
1747 #[test]
1748 fn raw_file_parsing_refuses_includes_outside_the_case_directory() {
1749 let root =
1750 std::env::temp_dir().join(format!("powerio-dist-rawconf-{}", std::process::id()));
1751 std::fs::create_dir_all(root.join("case")).unwrap();
1752 std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
1753 std::fs::write(
1754 root.join("case").join("master.dss"),
1755 "Redirect ../secret.dss",
1756 )
1757 .unwrap();
1758
1759 let raw = parse_raw_file(root.join("case").join("master.dss")).unwrap();
1760 assert!(raw.find("line", "leaked").is_none());
1761 assert_eq!(
1762 raw.warnings
1763 .iter()
1764 .filter(|w| w.contains("escapes the case directory"))
1765 .count(),
1766 1
1767 );
1768
1769 std::fs::remove_dir_all(&root).unwrap();
1770 }
1771
1772 #[test]
1773 fn confined_parsing_allows_includes_within_the_case_directory() {
1774 use std::cell::RefCell;
1775 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1776 let mut loader = |p: &Path| {
1777 requested.borrow_mut().push(p.display().to_string());
1778 Ok(String::new())
1779 };
1780 parse_raw_with_confined(
1782 "Redirect sub/codes.dss\nRedirect /case/dir/abs.dss",
1783 "/case/dir/master.dss",
1784 &mut loader,
1785 );
1786 assert_eq!(
1787 *requested.borrow(),
1788 vec![
1789 "/case/dir/sub/codes.dss".to_string(),
1790 "/case/dir/abs.dss".to_string(),
1791 ]
1792 );
1793 }
1794}