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 *self = RawDss::default();
264 }
265}
266
267pub trait Loader {
269 fn load(&mut self, path: &Path) -> std::io::Result<String>;
270}
271
272impl<F> Loader for F
273where
274 F: FnMut(&Path) -> std::io::Result<String>,
275{
276 fn load(&mut self, path: &Path) -> std::io::Result<String> {
277 self(path)
278 }
279}
280
281const MAX_REDIRECT_DEPTH: usize = 64;
283
284const MAX_TOTAL_INCLUDES: usize = 4096;
289
290const MAX_TOTAL_INCLUDE_BYTES: usize = 64 << 20;
297
298const MAX_OBJECT_PROPS: usize = 1 << 16;
306
307struct Executor<'l, L: Loader> {
308 raw: RawDss,
309 loader: &'l mut L,
310 dirs: Vec<PathBuf>,
313 root: Option<PathBuf>,
319 includes: usize,
323 include_bytes: usize,
324 budget_spent: bool,
325}
326
327fn lexical_normalize(p: &Path) -> PathBuf {
331 use std::path::Component;
332 let mut out: Vec<Component<'_>> = Vec::new();
333 for comp in p.components() {
334 match comp {
335 Component::CurDir => {}
336 Component::ParentDir if matches!(out.last(), Some(Component::Normal(_))) => {
337 out.pop();
338 }
339 other => out.push(other),
340 }
341 }
342 out.into_iter().collect()
343}
344
345fn command_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
350 let mut in_block = false;
351 text.lines().enumerate().filter_map(move |(i, line)| {
352 if in_block {
353 if line.contains("*/") {
354 in_block = false;
355 }
356 return None;
357 }
358 if line.trim_start().starts_with("/*") {
359 in_block = true;
360 if line.contains("*/") {
361 in_block = false;
362 }
363 return None;
364 }
365 Some((i + 1, line))
366 })
367}
368
369impl<L: Loader> Executor<'_, L> {
370 fn run_script(&mut self, text: &str, file: &str) {
371 for (line_no, line) in command_lines(text) {
372 self.run_command(line, file, line_no);
373 }
374 }
375
376 fn run_command(&mut self, line: &str, file: &str, line_no: usize) {
377 let vars = self.raw.vars.clone();
383 let mut scan = Scanner::new(line, Some(&vars));
384 let ctx = |msg: String| format!("{file}:{line_no}: {msg}");
385 match scan.next_param() {
386 None => {}
387 Some(first) if first.value.text.is_empty() && first.name.is_none() => {}
388 Some(first) => {
389 if let Some(name) = first.name {
390 self.edit_property_reference(&name, first.value, &mut scan, &ctx);
393 } else {
394 self.dispatch(first.value.text, &mut scan, &ctx);
395 }
396 }
397 }
398 }
399
400 fn dispatch(&mut self, verb: String, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
401 match command_index(&verb).map(|i| COMMANDS[i]) {
402 Some("new") => self.do_new(scan, ctx),
403 Some("edit") => self.do_edit(scan, ctx),
404 Some("more" | "m" | "~") => self.do_more(scan, ctx),
405 Some("select") => self.do_select(scan, ctx),
406 Some("set") => self.do_set(scan),
407 Some("redirect") => self.do_redirect(scan, false, ctx),
408 Some("compile") => self.do_redirect(scan, true, ctx),
409 Some("buscoords") => self.do_buscoords(scan, ctx),
410 Some("var") => self.do_var(scan),
411 Some("clear" | "clearall") => self.raw.clear(),
412 Some("//") => {}
413 Some(canonical) => {
414 self.raw.commands.push(RawCommand {
415 verb: canonical.to_string(),
416 args: scan.remainder().to_string(),
417 });
418 }
419 None => {
420 self.raw.warn(ctx(format!(
421 "unknown command `{verb}`; line preserved verbatim"
422 )));
423 self.raw.commands.push(RawCommand {
424 verb,
425 args: scan.remainder().to_string(),
426 });
427 }
428 }
429 }
430
431 fn do_var(&mut self, scan: &mut Scanner) {
436 while let Some(p) = scan.next_param() {
437 if p.value.text.is_empty() && p.name.is_none() {
438 break;
439 }
440 if let Some(name) = p.name {
441 let stored = if p.value.text.starts_with('@') {
442 p.value.text
443 } else {
444 format!("{{{}}}", p.value.text)
445 };
446 self.raw.vars.insert(name.to_ascii_lowercase(), stored);
447 }
448 }
449 }
450
451 fn edit_property_reference(
458 &mut self,
459 spec: &str,
460 value: Value,
461 scan: &mut Scanner,
462 ctx: &dyn Fn(String) -> String,
463 ) {
464 let (object, prop) = match spec.split_once('.') {
465 None => (None, spec),
466 Some((first, rest)) => match rest.split_once('.') {
467 None => (Some((None, first)), rest),
468 Some((name, prop)) => (Some((Some(first), name)), prop),
469 },
470 };
471 let active_or = |raw: &mut RawDss| {
472 let active = raw.active;
473 if active.is_none() {
474 raw.warn(ctx(format!("`{spec}=` with no active object")));
475 }
476 active
477 };
478 let idx = match object {
479 None => match active_or(&mut self.raw) {
480 Some(idx) => idx,
481 None => return,
482 },
483 Some((class, name)) => {
484 let class = match class {
485 Some(c) => c.to_ascii_lowercase(),
486 None => match active_or(&mut self.raw) {
487 Some(idx) => self.raw.objects[idx].class.clone(),
488 None => return,
489 },
490 };
491 if let Some(idx) = self
492 .raw
493 .index
494 .get(&(class.clone(), name.to_ascii_lowercase()))
495 .copied()
496 {
497 idx
498 } else {
499 self.raw.warn(ctx(format!(
500 "property reference to unknown object `{class}.{name}`"
501 )));
502 return;
503 }
504 }
505 };
506 self.raw.active = Some(idx);
507 let table = prop_table(&self.raw.objects[idx].class);
508 let name = match table {
509 Some(c) => {
510 if let Some(i) = c.prop_index(prop) {
511 c.props[i].to_string()
512 } else {
513 self.raw.warn(ctx(format!(
514 "unknown property `{prop}` on {}; kept as written",
515 c.name
516 )));
517 prop.to_ascii_lowercase()
518 }
519 }
520 None => prop.to_ascii_lowercase(),
521 };
522 let mut props = vec![RawProp {
523 name: Some(name),
524 value,
525 }];
526 props.extend(collect_props_for(
527 table,
528 scan,
529 Some(prop),
530 &mut self.raw.warnings,
531 ctx,
532 ));
533 self.apply_props(idx, props, ctx);
534 }
535
536 fn do_new(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
537 let Some((class, name)) = self.object_spec(scan, ctx) else {
538 return;
539 };
540 if class.eq_ignore_ascii_case("circuit") {
541 self.raw.circuit_name = Some(name);
546 let idx = self.make_object("vsource", "source".into());
547 self.consume_and_apply(idx, scan, ctx);
548 return;
549 }
550 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
551 let idx = match self.raw.index.get(&key) {
552 Some(&existing) => {
553 self.raw.warn(ctx(format!(
554 "duplicate `New {class}.{name}`; editing the existing object"
555 )));
556 existing
557 }
558 None => self.make_object(&class, name),
559 };
560 self.consume_and_apply(idx, scan, ctx);
561 }
562
563 fn do_edit(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
564 let Some((class, name)) = self.object_spec(scan, ctx) else {
565 return;
566 };
567 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
568 let Some(&idx) = self.raw.index.get(&key) else {
569 self.raw
570 .warn(ctx(format!("`Edit {class}.{name}` on an unknown object")));
571 return;
572 };
573 self.consume_and_apply(idx, scan, ctx);
574 }
575
576 fn do_more(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
577 let Some(idx) = self.raw.active else {
578 self.raw.warn(ctx("`~` with no active object".into()));
579 return;
580 };
581 self.consume_and_apply(idx, scan, ctx);
582 }
583
584 fn do_select(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
585 let Some((class, name)) = self.object_spec(scan, ctx) else {
586 return;
587 };
588 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
589 match self.raw.index.get(&key) {
590 Some(&idx) => self.raw.active = Some(idx),
591 None => self
592 .raw
593 .warn(ctx(format!("`Select {class}.{name}` on an unknown object"))),
594 }
595 }
596
597 fn do_set(&mut self, scan: &mut Scanner) {
598 while let Some(p) = scan.next_param() {
599 if p.value.text.is_empty() && p.name.is_none() {
600 break;
601 }
602 let name = p.name.unwrap_or_default().to_ascii_lowercase();
603 self.raw.options.push((name, p.value));
604 }
605 }
606
607 fn resolve(&self, file_arg: &str) -> Option<PathBuf> {
618 use std::path::Component;
619 let rel = file_arg.replace('\\', "/");
620 let base = self.dirs.last().cloned().unwrap_or_default();
621 let joined = base.join(&rel);
622 match &self.root {
623 None => Some(joined),
624 Some(root) => {
625 let normalized = lexical_normalize(&joined);
633 normalized
634 .strip_prefix(root)
635 .is_ok_and(|rest| rest.components().all(|c| matches!(c, Component::Normal(_))))
636 .then_some(normalized)
637 }
638 }
639 }
640
641 fn resolve_or_warn(
644 &mut self,
645 verb: &str,
646 file_arg: &str,
647 ctx: &dyn Fn(String) -> String,
648 ) -> Option<PathBuf> {
649 let resolved = self.resolve(file_arg);
650 if resolved.is_none() {
651 let message = ctx(format!(
652 "{verb} {file_arg}: refused; include escapes the case directory"
653 ));
654 self.refuse_escape(message);
655 }
656 resolved
657 }
658
659 fn refuse_escape(&mut self, message: String) {
662 self.refuse(
663 message,
664 crate::diagnostics::READ_DSS_INCLUDE_REFUSED,
665 "place included files inside the case directory, or merge them into the case",
666 );
667 }
668
669 fn refuse(&mut self, message: String, code: &'static str, suggested_action: &'static str) {
672 self.raw.warn(message.clone());
673 self.raw.diagnostics.push(
674 crate::diagnostics::StructuredDiagnostic::new(
675 code,
676 crate::diagnostics::DiagnosticSeverity::Error,
677 crate::diagnostics::DiagnosticStage::Parse,
678 message,
679 )
680 .with_suggested_action(suggested_action),
681 );
682 }
683
684 fn charge_include(&mut self, verb: &str, path: &Path, ctx: &dyn Fn(String) -> String) -> bool {
689 if self.budget_spent {
690 return false;
691 }
692 if self.includes >= MAX_TOTAL_INCLUDES || self.include_bytes >= MAX_TOTAL_INCLUDE_BYTES {
693 self.budget_spent = true;
694 let message = ctx(format!(
695 "{verb} {}: refused; the case exceeded the include budget of {MAX_TOTAL_INCLUDES} \
696 files and {} MiB, so the rest of the includes were not followed",
697 path.display(),
698 MAX_TOTAL_INCLUDE_BYTES >> 20,
699 ));
700 self.refuse(
701 message,
702 crate::diagnostics::READ_DSS_INCLUDE_BUDGET,
703 "check the case for an include cycle; a file that redirects to itself expands \
704 without bound",
705 );
706 return false;
707 }
708 self.includes += 1;
709 true
710 }
711
712 fn warn_load_error(
719 &mut self,
720 verb: &str,
721 path: &Path,
722 e: &std::io::Error,
723 ctx: &dyn Fn(String) -> String,
724 ) {
725 let message = ctx(format!("{verb} {}: {e}", path.display()));
726 if Containment::refused_by_us(e) {
727 self.refuse_escape(message);
728 } else {
729 self.raw.warn(message);
730 }
731 }
732
733 fn do_redirect(&mut self, scan: &mut Scanner, compile: bool, ctx: &dyn Fn(String) -> String) {
734 let Some(p) = scan.next_param() else {
735 self.raw.warn(ctx("redirect with no file".into()));
736 return;
737 };
738 let verb = if compile { "compile" } else { "redirect" };
739 let Some(path) = self.resolve_or_warn(verb, &p.value.text, ctx) else {
740 return;
741 };
742 if self.dirs.len() > MAX_REDIRECT_DEPTH {
743 self.raw
744 .warn(ctx(format!("redirect depth limit at {}", path.display())));
745 return;
746 }
747 if !self.charge_include(verb, &path, ctx) {
748 return;
749 }
750 match self.loader.load(&path) {
751 Ok(text) => {
752 self.include_bytes += text.len();
753 let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
754 self.dirs.push(dir.clone());
755 self.run_script(&text, &path.display().to_string());
756 self.dirs.pop();
757 if compile && let Some(top) = self.dirs.last_mut() {
765 *top = dir;
766 }
767 }
768 Err(e) => self.warn_load_error(verb, &path, &e, ctx),
769 }
770 }
771
772 fn do_buscoords(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
773 let Some(p) = scan.next_param() else {
774 self.raw.warn(ctx("buscoords with no file".into()));
775 return;
776 };
777 let Some(path) = self.resolve_or_warn("buscoords", &p.value.text, ctx) else {
778 return;
779 };
780 if !self.charge_include("buscoords", &path, ctx) {
781 return;
782 }
783 match self.loader.load(&path) {
784 Ok(text) => {
785 self.include_bytes += text.len();
786 for (line_no, line) in text.lines().enumerate() {
787 let mut s = Scanner::new(line, None);
788 let Some(bus) = s.next_param() else { continue };
789 if bus.value.text.is_empty() {
790 continue;
791 }
792 let x = s.next_param().map(|p| p.value).unwrap_or_default();
793 let y = s.next_param().map(|p| p.value).unwrap_or_default();
794 match (x.to_f64(None), y.to_f64(None)) {
795 (Ok(x), Ok(y)) => self.raw.buscoords.push(BusCoord {
796 bus: bus.value.text,
797 x,
798 y,
799 }),
800 _ => self.raw.warn(ctx(format!(
801 "buscoords {}:{}: unparseable coordinates",
802 path.display(),
803 line_no + 1
804 ))),
805 }
806 }
807 }
808 Err(e) => self.warn_load_error("buscoords", &path, &e, ctx),
809 }
810 }
811
812 fn object_spec(
814 &mut self,
815 scan: &mut Scanner,
816 ctx: &dyn Fn(String) -> String,
817 ) -> Option<(String, String)> {
818 let p = scan.next_param()?;
819 if let Some(name) = &p.name {
820 if !name.eq_ignore_ascii_case("object") {
821 self.raw
822 .warn(ctx(format!("expected Class.Name, got `{name}=`")));
823 return None;
824 }
825 }
826 let spec = p.value.text;
827 match spec.split_once('.') {
828 Some((class, name)) if !class.is_empty() && !name.is_empty() => {
829 Some((class.to_string(), name.to_string()))
830 }
831 _ => {
832 self.raw
833 .warn(ctx(format!("malformed object spec `{spec}`")));
834 None
835 }
836 }
837 }
838
839 fn make_object(&mut self, class: &str, name: String) -> usize {
840 let class_lc = class.to_ascii_lowercase();
841 let idx = self.raw.objects.len();
842 self.raw
843 .index
844 .insert((class_lc.clone(), name.to_ascii_lowercase()), idx);
845 self.raw.objects.push(RawObject {
846 class: class_lc,
847 name,
848 props: Vec::new(),
849 edits: Vec::new(),
850 });
851 idx
852 }
853
854 fn consume_and_apply(
855 &mut self,
856 idx: usize,
857 scan: &mut Scanner,
858 ctx: &dyn Fn(String) -> String,
859 ) {
860 let props = collect_props_for(
861 prop_table(&self.raw.objects[idx].class),
862 scan,
863 None,
864 &mut self.raw.warnings,
865 ctx,
866 );
867 self.apply_props(idx, props, ctx);
868 }
869
870 fn apply_props(&mut self, idx: usize, props: Vec<RawProp>, ctx: &dyn Fn(String) -> String) {
871 self.raw.active = Some(idx);
872 for p in props {
873 if p.name.as_deref() == Some("like") {
879 let class = self.raw.objects[idx].class.clone();
880 let key = (class.clone(), p.value.text.to_ascii_lowercase());
881 match self.raw.index.get(&key).copied() {
882 Some(src) => {
883 let base = self.raw.objects[idx].props.len();
884 let src_len = self.raw.objects[src].props.len();
885 if base.saturating_add(src_len) > MAX_OBJECT_PROPS {
891 self.raw.warn(ctx(format!(
892 "like={}: {class} property count would exceed the supported \
893 maximum of {MAX_OBJECT_PROPS}; splice refused",
894 p.value.text
895 )));
896 continue;
897 }
898 let cloned = self.raw.objects[src].props.clone();
899 let bounds: Vec<usize> = self.raw.objects[src]
900 .edit_bounds()
901 .map(|e| base + e)
902 .collect();
903 self.raw.objects[idx].props.extend(cloned);
904 self.raw.objects[idx].edits.extend(bounds);
905 }
906 None => self.raw.warn(ctx(format!(
907 "like={} names an unknown {class}",
908 p.value.text
909 ))),
910 }
911 continue;
912 }
913 if self.raw.objects[idx].props.len() >= MAX_OBJECT_PROPS {
914 self.raw.warn(ctx(format!(
915 "{}: property count exceeds the supported maximum of {MAX_OBJECT_PROPS}; \
916 further assignments dropped",
917 self.raw.objects[idx].class
918 )));
919 continue;
920 }
921 self.raw.objects[idx].props.push(p);
922 }
923 let end = self.raw.objects[idx].props.len();
926 self.raw.objects[idx].edits.push(end);
927 }
928}
929
930fn prop_table(class: &str) -> Option<&'static DssClass> {
931 prop::class_by_name(class)
932}
933
934fn collect_props_for(
939 class: Option<&'static DssClass>,
940 scan: &mut Scanner,
941 after: Option<&str>,
942 warnings: &mut Vec<String>,
943 ctx: &dyn Fn(String) -> String,
944) -> Vec<RawProp> {
945 let mut out = Vec::new();
946 let mut pointer: Option<usize> = class.zip(after).and_then(|(c, name)| c.prop_index(name));
947 while let Some(p) = scan.next_param() {
948 if p.value.text.is_empty() && p.name.is_none() {
949 break;
950 }
951 let name = match (&p.name, class) {
952 (Some(written), Some(c)) => {
953 if let Some(i) = c.prop_index(written) {
954 pointer = Some(i);
955 Some(c.props[i].to_string())
956 } else {
957 pointer = None;
961 warnings.push(ctx(format!(
962 "unknown property `{written}` on {}; kept as written",
963 c.name
964 )));
965 Some(written.to_ascii_lowercase())
966 }
967 }
968 (Some(written), None) => Some(written.to_ascii_lowercase()),
969 (None, Some(c)) => {
970 let next = pointer.map_or(0, |i| i + 1);
971 pointer = Some(next);
972 if let Some(canon) = c.props.get(next) {
973 Some((*canon).to_string())
974 } else {
975 warnings.push(ctx(format!(
976 "positional value `{}` beyond the last {} property",
977 p.value.text, c.name
978 )));
979 None
980 }
981 }
982 (None, None) => None,
983 };
984 out.push(RawProp {
985 name,
986 value: p.value,
987 });
988 }
989 out
990}
991
992pub fn parse_raw_with(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1003 run_executor(text, path, None, loader)
1004}
1005
1006pub(crate) fn canonical_case_root(path: &Path) -> Option<PathBuf> {
1011 let dir = path.parent().unwrap_or_else(|| Path::new(""));
1012 let dir = if dir.as_os_str().is_empty() {
1013 Path::new(".")
1014 } else {
1015 dir
1016 };
1017 dir.canonicalize().ok()
1018}
1019
1020pub(crate) fn confined_fs_read(
1026 canonical_root: Option<&Path>,
1027 path: &Path,
1028) -> std::io::Result<String> {
1029 let Some(root) = canonical_root else {
1030 return Err(Containment::refused(
1031 "case directory cannot be resolved; includes are disabled",
1032 ));
1033 };
1034 let real = path.canonicalize()?;
1035 if !real.starts_with(root) {
1036 return Err(Containment::refused(
1037 "resolves outside the case directory through a symbolic link",
1038 ));
1039 }
1040 std::fs::read_to_string(&real)
1041}
1042
1043#[derive(Debug)]
1048pub(crate) struct Containment(&'static str);
1049
1050impl Containment {
1051 fn refused(reason: &'static str) -> std::io::Error {
1052 std::io::Error::new(std::io::ErrorKind::PermissionDenied, Containment(reason))
1053 }
1054
1055 pub(crate) fn refused_by_us(e: &std::io::Error) -> bool {
1057 e.get_ref()
1058 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<Self>)
1059 }
1060}
1061
1062impl std::fmt::Display for Containment {
1063 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1064 f.write_str(self.0)
1065 }
1066}
1067
1068impl std::error::Error for Containment {}
1069
1070pub(crate) fn parse_raw_with_confined(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
1076 let root = lexical_normalize(
1077 &Path::new(path)
1078 .parent()
1079 .map(Path::to_path_buf)
1080 .unwrap_or_default(),
1081 );
1082 run_executor(text, path, Some(root), loader)
1083}
1084
1085fn run_executor(text: &str, path: &str, root: Option<PathBuf>, loader: &mut impl Loader) -> RawDss {
1086 let mut exec = Executor {
1087 raw: RawDss::default(),
1088 loader,
1089 dirs: vec![
1090 Path::new(path)
1091 .parent()
1092 .map(Path::to_path_buf)
1093 .unwrap_or_default(),
1094 ],
1095 root,
1096 includes: 0,
1097 include_bytes: 0,
1098 budget_spent: false,
1099 };
1100 exec.run_script(text, path);
1101 exec.raw
1102}
1103
1104pub fn parse_raw_file(path: impl AsRef<Path>) -> Result<RawDss> {
1111 let path = path.as_ref();
1112 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
1113 path: path.display().to_string(),
1114 source,
1115 })?;
1116 let root = canonical_case_root(path);
1117 Ok(parse_raw_with_confined(
1118 &text,
1119 &path.display().to_string(),
1120 &mut |p: &Path| confined_fs_read(root.as_deref(), p),
1121 ))
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127
1128 fn no_files(_: &Path) -> std::io::Result<String> {
1129 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test"))
1130 }
1131
1132 fn parse(text: &str) -> RawDss {
1133 parse_raw_with(text, "test.dss", &mut no_files)
1134 }
1135
1136 #[test]
1137 fn new_object_with_positional_and_named() {
1138 let raw = parse("New Line.l1 b1 b2 lc 0.3 phases=2 r1=0.1");
1139 let l = raw.find("line", "l1").unwrap();
1140 assert_eq!(l.get("bus1").unwrap().text, "b1");
1141 assert_eq!(l.get("bus2").unwrap().text, "b2");
1142 assert_eq!(l.get("linecode").unwrap().text, "lc");
1143 assert_eq!(l.get("length").unwrap().text, "0.3");
1144 assert_eq!(l.get("phases").unwrap().text, "2");
1145 assert_eq!(l.get("r1").unwrap().text, "0.1");
1146 assert!(raw.warnings.is_empty());
1147 }
1148
1149 #[test]
1150 fn positional_continues_after_named() {
1151 let raw = parse("New Line.l1 r1=0.1 0.2");
1153 let l = raw.find("line", "l1").unwrap();
1154 assert_eq!(l.get("x1").unwrap().text, "0.2");
1155 }
1156
1157 #[test]
1158 fn unknown_property_resets_the_positional_pointer() {
1159 let raw = parse("New Line.l1 r1=0.1 bogus=2 0.5");
1162 let l = raw.find("line", "l1").unwrap();
1163 assert_eq!(l.get("bus1").unwrap().text, "0.5");
1164 assert!(l.get("x1").is_none());
1165 assert_eq!(raw.warnings.len(), 1);
1166 }
1167
1168 #[test]
1169 fn tilde_continues_the_active_object() {
1170 let raw = parse("New Load.ld bus1=b1\n~ kW=15 kvar=3\nMore pf=0.9");
1171 let ld = raw.find("load", "ld").unwrap();
1172 assert_eq!(ld.get("kw").unwrap().text, "15");
1173 assert_eq!(ld.get("kvar").unwrap().text, "3");
1174 assert_eq!(ld.get("pf").unwrap().text, "0.9");
1175 }
1176
1177 #[test]
1178 fn abbreviated_property_names() {
1179 let raw = parse("New Line.l1 ph=3 len=2 rm=(1 | 0 1)");
1180 let l = raw.find("line", "l1").unwrap();
1181 assert_eq!(l.get("phases").unwrap().text, "3");
1182 assert_eq!(l.get("length").unwrap().text, "2");
1183 assert!(l.get("rmatrix").unwrap().quoted);
1184 }
1185
1186 #[test]
1187 fn new_circuit_creates_the_source() {
1188 let raw = parse("New Circuit.test basekv=115 pu=1.05\n~ angle=30");
1189 assert_eq!(raw.circuit_name.as_deref(), Some("test"));
1190 let vs = raw.find("vsource", "source").unwrap();
1191 assert_eq!(vs.get("basekv").unwrap().text, "115");
1192 assert_eq!(vs.get("angle").unwrap().text, "30");
1193 assert!(vs.get("bus1").is_none());
1196 }
1197
1198 #[test]
1199 fn edit_and_property_reference() {
1200 let raw = parse("New Line.l1 length=1\nEdit Line.l1 length=2\nLine.l1.Length=3 phases=2");
1201 let l = raw.find("line", "l1").unwrap();
1202 assert_eq!(l.get("length").unwrap().text, "3");
1203 assert_eq!(l.get("phases").unwrap().text, "2");
1204 }
1205
1206 #[test]
1207 fn property_reference_resolves_abbreviations() {
1208 let raw = parse("New Line.l1 bus1=a\nLine.l1.Len=2.5");
1209 let l = raw.find("line", "l1").unwrap();
1210 assert_eq!(l.get("length").unwrap().text, "2.5");
1211 assert!(raw.warnings.is_empty());
1212 }
1213
1214 #[test]
1215 fn bare_property_edits_the_active_object() {
1216 let raw = parse("New Line.l1 bus1=a bus2=b\nlength=2.5");
1217 let l = raw.find("line", "l1").unwrap();
1218 assert_eq!(l.get("length").unwrap().text, "2.5");
1219 assert!(raw.warnings.is_empty());
1220 }
1221
1222 #[test]
1223 fn classless_reference_uses_the_active_class() {
1224 let raw = parse("New Line.l1 bus1=a\nNew Line.l2 bus1=b\nl1.length=7 phases=2");
1227 let l1 = raw.find("line", "l1").unwrap();
1228 assert_eq!(l1.get("length").unwrap().text, "7");
1229 assert_eq!(l1.get("phases").unwrap().text, "2");
1230 assert!(raw.find("line", "l2").unwrap().get("length").is_none());
1231 assert!(raw.warnings.is_empty());
1232 }
1233
1234 #[test]
1235 fn like_splices_source_props() {
1236 let raw = parse("New Load.a kW=10 pf=0.9\nNew Load.b like=a kW=20");
1237 let b = raw.find("load", "b").unwrap();
1238 assert_eq!(b.get("kw").unwrap().text, "20");
1239 assert_eq!(b.get("pf").unwrap().text, "0.9");
1240 }
1241
1242 #[test]
1243 fn self_referencing_like_cannot_explode_the_prop_count() {
1244 let mut script = String::from("New Load.a kW=1\n");
1248 for _ in 0..40 {
1249 script.push_str("Edit Load.a like=a\n");
1250 }
1251 let raw = parse(&script);
1252 let a = raw.find("load", "a").unwrap();
1253 assert!(
1254 a.props.len() <= MAX_OBJECT_PROPS,
1255 "prop count {} exceeded the cap",
1256 a.props.len()
1257 );
1258 assert!(
1259 raw.warnings.iter().any(|w| w.contains("splice refused")),
1260 "expected a refusal warning, got {:?}",
1261 raw.warnings
1262 );
1263 }
1264
1265 #[test]
1266 fn unknown_class_is_preserved_raw() {
1267 let raw = parse("New Reactor.r1 bus1=b1 x=3");
1268 let r = raw.find("reactor", "r1").unwrap();
1269 assert_eq!(r.get("bus1").unwrap().text, "b1");
1270 assert_eq!(r.get("x").unwrap().text, "3");
1271 }
1272
1273 #[test]
1274 fn set_options_accumulate() {
1275 let raw = parse("Set VoltageBases=[115, 12.47]\nset mode=snapshot");
1276 assert_eq!(raw.options[0].0, "voltagebases");
1277 assert_eq!(
1278 raw.options[0].1.to_vector(None).unwrap(),
1279 vec![115.0, 12.47]
1280 );
1281 assert_eq!(raw.options[1].0, "mode");
1282 }
1283
1284 #[test]
1285 fn unexecuted_commands_are_preserved() {
1286 let raw = parse("Solve\ncalcv\nShow Voltages LN");
1287 let verbs: Vec<&str> = raw.commands.iter().map(|c| c.verb.as_str()).collect();
1288 assert_eq!(verbs, vec!["solve", "calcvoltagebases", "show"]);
1289 assert_eq!(raw.commands[2].args, "Voltages LN");
1290 }
1291
1292 #[test]
1293 fn clear_resets() {
1294 let raw = parse("New Line.l1 length=1\nClear\nNew Line.l2 length=2");
1295 assert!(raw.find("line", "l1").is_none());
1296 assert!(raw.find("line", "l2").is_some());
1297 }
1298
1299 #[test]
1300 fn block_comments_skip_lines() {
1301 let raw = parse("/* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1302 assert!(raw.find("line", "l1").is_none());
1303 assert!(raw.find("line", "l2").is_some());
1304 }
1305
1306 #[test]
1307 fn indented_block_comments_skip_lines() {
1308 let raw = parse(" /* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
1309 assert!(raw.find("line", "l1").is_none());
1310 assert!(raw.find("line", "l2").is_some());
1311 }
1312
1313 #[test]
1314 fn one_line_block_comment() {
1315 let raw = parse("\t/* x */\nNew Line.l2 length=2");
1316 assert!(raw.find("line", "l2").is_some());
1317 }
1318
1319 #[test]
1320 fn redirect_includes_a_file() {
1321 let mut files = BTreeMap::from([(
1322 PathBuf::from("sub/codes.dss"),
1323 "New Linecode.lc1 nphases=3".to_string(),
1324 )]);
1325 let mut loader = move |p: &Path| {
1326 files
1327 .remove(p)
1328 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1329 };
1330 let raw = parse_raw_with(
1331 "Redirect sub/codes.dss\nNew Line.l1 linecode=lc1",
1332 "test.dss",
1333 &mut loader,
1334 );
1335 assert!(raw.find("linecode", "lc1").is_some());
1336 assert!(raw.warnings.is_empty());
1337 }
1338
1339 fn counting_loader(text: &str, give_up: usize, script: &str) -> (RawDss, usize) {
1343 let mut loads = 0usize;
1344 let raw = {
1345 let mut loader = |_: &Path| {
1346 loads += 1;
1347 if loads > give_up {
1348 Err(std::io::Error::other("loader gave up"))
1349 } else {
1350 Ok(text.to_string())
1351 }
1352 };
1353 parse_raw_with(script, "test.dss", &mut loader)
1354 };
1355 (raw, loads)
1356 }
1357
1358 fn budget_refusals(raw: &RawDss) -> usize {
1359 raw.diagnostics
1360 .iter()
1361 .filter(|d| d.code.as_str() == crate::diagnostics::READ_DSS_INCLUDE_BUDGET)
1362 .count()
1363 }
1364
1365 #[test]
1366 fn a_self_redirecting_include_stops_at_the_file_budget() {
1367 let (raw, loads) = counting_loader(
1371 "Redirect a.dss\nRedirect a.dss",
1372 MAX_TOTAL_INCLUDES * 4,
1373 "Redirect a.dss",
1374 );
1375 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1376 assert_eq!(budget_refusals(&raw), 1, "one refusal, however many follow");
1377 }
1378
1379 #[test]
1380 fn a_large_self_redirecting_include_stops_at_the_byte_budget() {
1381 let chunk = 256 << 10;
1385 let text = format!("Redirect a.dss\nRedirect a.dss\n// {}", "a".repeat(chunk));
1386 let (raw, loads) = counting_loader(&text, MAX_TOTAL_INCLUDES, "Redirect a.dss");
1387 assert!(
1388 loads <= MAX_TOTAL_INCLUDE_BYTES / chunk + 1,
1389 "{loads} includes followed, {} bytes",
1390 loads * chunk
1391 );
1392 assert!(
1393 loads < MAX_TOTAL_INCLUDES,
1394 "the byte budget is what stopped it"
1395 );
1396 assert_eq!(budget_refusals(&raw), 1);
1397 }
1398
1399 #[test]
1400 fn buscoords_is_charged_against_the_include_budget() {
1401 let (raw, loads) = counting_loader(
1405 "Redirect a.dss\nRedirect a.dss\nBuscoords a.csv",
1406 MAX_TOTAL_INCLUDES * 4,
1407 "Redirect a.dss",
1408 );
1409 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1410 assert_eq!(budget_refusals(&raw), 1);
1411 }
1412
1413 #[test]
1414 fn clear_does_not_refund_the_include_budget() {
1415 let (_, loads) = counting_loader(
1418 "Clear\nRedirect a.dss\nRedirect a.dss",
1419 MAX_TOTAL_INCLUDES * 4,
1420 "Redirect a.dss",
1421 );
1422 assert_eq!(loads, MAX_TOTAL_INCLUDES, "{loads} includes followed");
1423 }
1424
1425 #[test]
1426 fn missing_redirect_warns() {
1427 let raw = parse("Redirect nope.dss");
1428 assert_eq!(raw.warnings.len(), 1);
1429 assert!(raw.warnings[0].contains("nope.dss"));
1430 }
1431
1432 #[test]
1433 fn compile_moves_the_directory_redirect_restores_it() {
1434 let root = std::env::temp_dir().join(format!("powerio-dist-raw-{}", std::process::id()));
1439 let sub = root.join("sub");
1440 std::fs::create_dir_all(&sub).unwrap();
1441 std::fs::write(sub.join("feeder.dss"), "New Linecode.lc1 nphases=3").unwrap();
1442 std::fs::write(sub.join("lines.dss"), "New Line.fromsub bus1=a").unwrap();
1443 std::fs::write(root.join("lines.dss"), "New Line.fromroot bus1=a").unwrap();
1444 std::fs::write(
1445 root.join("compile.dss"),
1446 "Compile sub/feeder.dss\nRedirect lines.dss",
1447 )
1448 .unwrap();
1449 std::fs::write(
1450 root.join("redirect.dss"),
1451 "Redirect sub/feeder.dss\nRedirect lines.dss",
1452 )
1453 .unwrap();
1454
1455 let compiled = parse_raw_file(root.join("compile.dss")).unwrap();
1456 assert_eq!(compiled.warnings, Vec::<String>::new());
1457 assert!(compiled.find("line", "fromsub").is_some());
1458
1459 let redirected = parse_raw_file(root.join("redirect.dss")).unwrap();
1460 assert_eq!(redirected.warnings, Vec::<String>::new());
1461 assert!(redirected.find("line", "fromroot").is_some());
1462
1463 std::fs::remove_dir_all(&root).unwrap();
1464 }
1465
1466 #[test]
1467 fn compile_inside_compile_pins_the_compiled_files_directory() {
1468 let root =
1475 std::env::temp_dir().join(format!("powerio-dist-rawnest-{}", std::process::id()));
1476 let sub = root.join("sub");
1477 let inner = sub.join("inner");
1478 std::fs::create_dir_all(&inner).unwrap();
1479 std::fs::write(
1480 root.join("main.dss"),
1481 "Compile sub/a.dss\nRedirect probe.dss",
1482 )
1483 .unwrap();
1484 std::fs::write(sub.join("a.dss"), "Compile inner/b.dss").unwrap();
1485 std::fs::write(inner.join("b.dss"), "New Linecode.lc1 nphases=1").unwrap();
1486 std::fs::write(sub.join("probe.dss"), "New Line.fromsub bus1=a").unwrap();
1487 std::fs::write(inner.join("probe.dss"), "New Line.frominner bus1=a").unwrap();
1488
1489 let raw = parse_raw_file(root.join("main.dss")).unwrap();
1490 assert_eq!(raw.warnings, Vec::<String>::new());
1491 assert!(raw.find("linecode", "lc1").is_some());
1492 assert!(raw.find("line", "fromsub").is_some());
1493 assert!(raw.find("line", "frominner").is_none());
1494
1495 std::fs::remove_dir_all(&root).unwrap();
1496 }
1497
1498 #[test]
1499 fn edit_boundaries_are_recorded() {
1500 let raw = parse("New Load.a kW=10 pf=0.9\n~ kvar=5\nNew Load.b like=a kw=20");
1503 let a = raw.find("load", "a").unwrap();
1504 assert_eq!(a.edits, vec![2, 3]);
1505 let b = raw.find("load", "b").unwrap();
1506 assert_eq!(b.props.len(), 4);
1507 assert_eq!(b.edits, vec![2, 3, 4]);
1508 assert_eq!(b.edit_bounds().collect::<Vec<_>>(), vec![2, 3, 4]);
1509 }
1510
1511 #[test]
1512 fn var_definition_and_use() {
1513 let raw = parse("var @kv=12.47\nNew Load.ld kv=@kv");
1514 let ld = raw.find("load", "ld").unwrap();
1515 assert_eq!(ld.get("kv").unwrap().text, "12.47");
1516 }
1517
1518 #[test]
1519 fn quoted_var_value_stays_rpn() {
1520 let raw = parse("var @z=(8 1000 /)\nNew Load.ld kW=@z");
1524 let v = raw.find("load", "ld").unwrap().get("kw").unwrap();
1525 assert!(v.quoted);
1526 assert_eq!(v.to_f64(None), Ok(0.008));
1527 }
1528
1529 #[test]
1530 fn vars_cross_redirect_boundaries() {
1531 let mut loader = |p: &Path| {
1534 if p == Path::new("inc.dss") {
1535 Ok("New Load.inner kv=@kv\nvar @kw=42".to_string())
1536 } else {
1537 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1538 }
1539 };
1540 let raw = parse_raw_with(
1541 "var @kv=12.47\nRedirect inc.dss\nNew Load.outer kW=@kw",
1542 "test.dss",
1543 &mut loader,
1544 );
1545 assert_eq!(raw.warnings, Vec::<String>::new());
1546 assert_eq!(
1547 raw.find("load", "inner").unwrap().get("kv").unwrap().text,
1548 "12.47"
1549 );
1550 assert_eq!(
1551 raw.find("load", "outer").unwrap().get("kw").unwrap().text,
1552 "42"
1553 );
1554 }
1555
1556 #[test]
1557 fn duplicate_new_warns_and_edits() {
1558 let raw = parse("New Line.l1 length=1\nNew Line.l1 length=2");
1559 assert_eq!(raw.warnings.len(), 1);
1560 assert_eq!(
1561 raw.find("line", "l1").unwrap().get("length").unwrap().text,
1562 "2"
1563 );
1564 }
1565
1566 #[test]
1567 fn rpn_value_via_props() {
1568 let raw = parse("New Load.ld kW=(8 1000 /)");
1569 let v = raw.find("load", "ld").unwrap().get("kw").unwrap().clone();
1570 assert_eq!(v.to_f64(None), Ok(0.008));
1571 }
1572
1573 #[test]
1574 fn confined_parsing_refuses_includes_outside_the_case_directory() {
1575 use std::cell::RefCell;
1576 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1579 let mut loader = |p: &Path| {
1580 requested.borrow_mut().push(p.display().to_string());
1581 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1582 };
1583 let raw = parse_raw_with_confined(
1584 "Redirect ../../secret.dss\nRedirect /etc/passwd\nBuscoords ../up.csv",
1585 "/case/dir/master.dss",
1586 &mut loader,
1587 );
1588 assert!(
1589 requested.borrow().is_empty(),
1590 "escaping include reached the loader: {:?}",
1591 requested.borrow()
1592 );
1593 assert_eq!(
1594 raw.warnings
1595 .iter()
1596 .filter(|w| w.contains("escapes the case directory"))
1597 .count(),
1598 3
1599 );
1600 let refused: Vec<_> = raw
1603 .diagnostics
1604 .iter()
1605 .filter(|d| d.code.as_str() == crate::diagnostics::READ_DSS_INCLUDE_REFUSED)
1606 .collect();
1607 assert_eq!(refused.len(), 3);
1608 assert!(
1609 refused
1610 .iter()
1611 .all(|d| d.severity == crate::diagnostics::DiagnosticSeverity::Error)
1612 );
1613 }
1614
1615 #[test]
1616 fn confined_parsing_with_an_empty_root_refuses_absolute_and_climbing_includes() {
1617 use std::cell::RefCell;
1618 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1623 let mut loader = |p: &Path| {
1624 requested.borrow_mut().push(p.display().to_string());
1625 Err::<String, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1626 };
1627 let raw = parse_raw_with_confined(
1628 "Redirect /etc/passwd\nRedirect ../secret.dss\nRedirect sub/ok.dss",
1629 "master.dss",
1630 &mut loader,
1631 );
1632 assert_eq!(
1633 *requested.borrow(),
1634 vec!["sub/ok.dss".to_string()],
1635 "an absolute or climbing include reached the loader"
1636 );
1637 assert_eq!(
1638 raw.warnings
1639 .iter()
1640 .filter(|w| w.contains("escapes the case directory"))
1641 .count(),
1642 2
1643 );
1644 }
1645
1646 #[test]
1647 fn confined_parsing_allows_includes_under_a_root_that_starts_with_parent_dirs() {
1648 use std::cell::RefCell;
1649 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1652 let mut loader = |p: &Path| {
1653 requested.borrow_mut().push(p.display().to_string());
1654 Ok(String::new())
1655 };
1656 let raw = parse_raw_with_confined(
1657 "Redirect codes.dss\nRedirect ../../outside.dss",
1658 "../case/master.dss",
1659 &mut loader,
1660 );
1661 assert_eq!(*requested.borrow(), vec!["../case/codes.dss".to_string()]);
1662 assert_eq!(
1663 raw.warnings
1664 .iter()
1665 .filter(|w| w.contains("escapes the case directory"))
1666 .count(),
1667 1
1668 );
1669 }
1670
1671 #[cfg(unix)]
1672 #[test]
1673 fn file_parsing_refuses_includes_that_escape_through_a_symlink() {
1674 let root =
1677 std::env::temp_dir().join(format!("powerio-dist-symlink-{}", std::process::id()));
1678 let case = root.join("case");
1679 std::fs::create_dir_all(&case).unwrap();
1680 std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
1681 std::fs::write(case.join("master.dss"), "Redirect linked.dss").unwrap();
1682 std::os::unix::fs::symlink(root.join("secret.dss"), case.join("linked.dss")).unwrap();
1683
1684 let raw = parse_raw_file(case.join("master.dss")).unwrap();
1685 assert!(raw.find("line", "leaked").is_none());
1686 assert_eq!(
1687 raw.warnings
1688 .iter()
1689 .filter(|w| w.contains("outside the case directory"))
1690 .count(),
1691 1,
1692 "warnings: {:?}",
1693 raw.warnings
1694 );
1695
1696 std::fs::remove_dir_all(&root).unwrap();
1697 }
1698
1699 #[test]
1700 fn raw_file_parsing_refuses_includes_outside_the_case_directory() {
1701 let root =
1702 std::env::temp_dir().join(format!("powerio-dist-rawconf-{}", std::process::id()));
1703 std::fs::create_dir_all(root.join("case")).unwrap();
1704 std::fs::write(root.join("secret.dss"), "New Line.leaked bus1=a").unwrap();
1705 std::fs::write(
1706 root.join("case").join("master.dss"),
1707 "Redirect ../secret.dss",
1708 )
1709 .unwrap();
1710
1711 let raw = parse_raw_file(root.join("case").join("master.dss")).unwrap();
1712 assert!(raw.find("line", "leaked").is_none());
1713 assert_eq!(
1714 raw.warnings
1715 .iter()
1716 .filter(|w| w.contains("escapes the case directory"))
1717 .count(),
1718 1
1719 );
1720
1721 std::fs::remove_dir_all(&root).unwrap();
1722 }
1723
1724 #[test]
1725 fn confined_parsing_allows_includes_within_the_case_directory() {
1726 use std::cell::RefCell;
1727 let requested: RefCell<Vec<String>> = RefCell::new(Vec::new());
1728 let mut loader = |p: &Path| {
1729 requested.borrow_mut().push(p.display().to_string());
1730 Ok(String::new())
1731 };
1732 parse_raw_with_confined(
1734 "Redirect sub/codes.dss\nRedirect /case/dir/abs.dss",
1735 "/case/dir/master.dss",
1736 &mut loader,
1737 );
1738 assert_eq!(
1739 *requested.borrow(),
1740 vec![
1741 "/case/dir/sub/codes.dss".to_string(),
1742 "/case/dir/abs.dss".to_string(),
1743 ]
1744 );
1745 }
1746}