1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::analysis::has_blocking;
16use crate::error::{Diagnostic, Severity};
17use crate::eval::{DenyFs, EnvCap, FileAccess, Interpreter, MemFs, Options, RealFs};
18use crate::source::SourceCache;
19use crate::span::Span;
20use crate::vfs::loader::Loader;
21use crate::vfs::lockfile::Lockfile;
22use crate::vfs::{FileResolver, ImportSpec, LocalFsResolver, MemoryResolver};
23
24#[derive(Debug, Clone, Default)]
25pub struct EvalOptions {
26 pub strict: bool,
27 pub allow_read: Vec<PathBuf>,
29 pub allow_env: EnvCap,
31 pub allow_imports_io: bool,
32 pub registry_dir: Option<PathBuf>,
34 pub frozen: bool,
36 pub hermetic: bool,
41 pub env_overrides: HashMap<String, String>,
44}
45
46#[derive(Debug, Clone)]
48pub struct Report {
49 pub code: &'static str,
50 pub severity: Severity,
51 pub message: String,
52 pub file: String,
53 pub line: u32,
55 pub column: u32,
56 pub help: Option<String>,
57}
58
59impl std::fmt::Display for Report {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 let sev = match self.severity {
62 Severity::Error => "error",
63 Severity::Warning => "warning",
64 };
65 write!(f, "{sev}[{}]: {}", self.code, self.message)?;
66 if self.line > 0 {
67 write!(f, " at {}:{}:{}", self.file, self.line, self.column)?;
68 }
69 if let Some(h) = &self.help {
70 write!(f, "\n help: {h}")?;
71 }
72 Ok(())
73 }
74}
75
76#[derive(Debug)]
77pub struct Evaluated {
78 pub json: serde_json::Value,
79 pub warnings: Vec<Report>,
81 pub updated_lockfile: Option<String>,
83}
84
85pub fn eval_file(path: &Path, opts: &EvalOptions) -> Result<Evaluated, Vec<Report>> {
89 let cache = SourceCache::new();
90 let entry = std::fs::canonicalize(path)
91 .map_err(|e| vec![io_report(format!("cannot open {}: {e}", path.display()))])?;
92 let root = entry.parent().unwrap_or(Path::new(".")).to_path_buf();
93 let registry_dir = opts.registry_dir.clone().unwrap_or_else(|| {
94 std::env::var_os("USERPROFILE")
95 .or_else(|| std::env::var_os("HOME"))
96 .map(PathBuf::from)
97 .unwrap_or_default()
98 .join(".aura")
99 .join("registry")
100 });
101 let resolver = LocalFsResolver {
102 root: root.clone(),
103 registry_dir,
104 };
105
106 let lock = match std::fs::read_to_string(root.join("aura.lock")) {
107 Ok(text) => Lockfile::parse(&text)
108 .map_err(|e| vec![io_report(format!("invalid aura.lock: {e}"))])?,
109 Err(_) => Lockfile::default(),
110 };
111 let fs: Box<dyn FileAccess> = if opts.hermetic || opts.allow_read.is_empty() {
112 Box::new(DenyFs)
113 } else {
114 Box::new(RealFs {
115 allowed: opts.allow_read.clone(),
116 })
117 };
118 let file_name = entry
119 .file_name()
120 .unwrap_or_default()
121 .to_string_lossy()
122 .to_string();
123 run(&cache, &resolver, lock, fs, &file_name, opts)
124}
125
126pub fn eval_source(
135 files: HashMap<String, String>,
136 entry: &str,
137 opts: &EvalOptions,
138) -> Result<Evaluated, Vec<Report>> {
139 let cache = SourceCache::new();
140 let resolver = MemoryResolver {
141 files: files.clone(),
142 };
143 let fs: Box<dyn FileAccess> = if opts.hermetic || opts.allow_read.is_empty() {
144 Box::new(DenyFs)
145 } else {
146 Box::new(MemFs(files))
147 };
148 run(&cache, &resolver, Lockfile::default(), fs, entry, opts)
149}
150
151fn run(
154 cache: &SourceCache,
155 resolver: &dyn FileResolver,
156 lock: Lockfile,
157 fs: Box<dyn FileAccess>,
158 file_name: &str,
159 opts: &EvalOptions,
160) -> Result<Evaluated, Vec<Report>> {
161 let mut loader = Loader::new(cache, resolver);
162 loader.frozen = opts.frozen;
163 loader.hermetic = opts.hermetic;
164 loader.lock = lock;
165
166 let mut interp = Interpreter::new(Options {
167 strict: opts.strict,
168 dry_run: false,
169 });
170 interp.fs = fs;
171 interp.env_cap = if opts.hermetic {
174 EnvCap::Deny
175 } else {
176 opts.allow_env.clone()
177 };
178 interp.allow_imports_io = opts.allow_imports_io;
179 interp.env_overrides = opts.env_overrides.clone();
180
181 let result = interp_eval(&mut loader, &mut interp, file_name);
182
183 let mut reports: Vec<Report> = loader.diags.iter().map(|d| to_report(d, cache)).collect();
184 let value = match result {
185 Ok(v) => v,
186 Err(d) => {
187 reports.insert(0, to_report(&d, cache));
188 return Err(reports);
189 }
190 };
191 if has_blocking(&loader.diags, opts.strict) {
192 return Err(reports);
193 }
194
195 let json = crate::serialize::to_json(&value).map_err(|d| {
196 reports.insert(0, to_report(&d, cache));
197 reports.clone()
198 })?;
199 let updated_lockfile =
200 (loader.lock.dirty && !opts.frozen).then(|| loader.lock.to_toml_string());
201 Ok(Evaluated {
202 json,
203 warnings: reports,
204 updated_lockfile,
205 })
206}
207
208fn interp_eval<'a>(
209 loader: &mut Loader<'a, '_>,
210 interp: &mut Interpreter<'a>,
211 file_name: &str,
212) -> Result<crate::eval::value::Value<'a>, Diagnostic> {
213 loader.eval_entry(interp, &ImportSpec::File(file_name))
214}
215
216fn to_report(d: &Diagnostic, cache: &SourceCache) -> Report {
217 let (file, line, column) = locate(d.primary.0, cache);
218 Report {
219 code: d.code,
220 severity: d.severity,
221 message: d.message.clone(),
222 file,
223 line,
224 column,
225 help: d.help.clone(),
226 }
227}
228
229fn locate(span: Span, cache: &SourceCache) -> (String, u32, u32) {
230 let name = cache.name(span.source).unwrap_or_default();
231 let Some(text) = cache.text(span.source) else {
232 return (name, 0, 0);
233 };
234 let upto = &text[..(span.start as usize).min(text.len())];
235 let line = upto.matches('\n').count() as u32 + 1;
236 let column = upto.rsplit('\n').next().map_or(0, |l| l.chars().count()) as u32 + 1;
237 (name, line, column)
238}
239
240fn io_report(message: String) -> Report {
241 Report {
242 code: "E0001",
243 severity: Severity::Error,
244 message,
245 file: String::new(),
246 line: 0,
247 column: 0,
248 help: None,
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn files(pairs: &[(&str, &str)]) -> HashMap<String, String> {
257 pairs
258 .iter()
259 .map(|(k, v)| (k.to_string(), v.to_string()))
260 .collect()
261 }
262
263 #[test]
265 fn eval_source_resolves_imports_between_in_memory_files() {
266 let fs = files(&[
267 (
268 "main.aura",
269 "import \"lib.aura\" as lib\nport: lib.default_port\nname: lib.label(\"api\")\n",
270 ),
271 (
272 "lib.aura",
273 "pub def label(n)\n service: n\nend\n\ndefault_port: 8080\n",
274 ),
275 ]);
276 let out = eval_source(fs, "main.aura", &EvalOptions::default())
277 .unwrap_or_else(|e| panic!("{e:?}"));
278 assert_eq!(out.json["port"], 8080);
279 assert_eq!(out.json["name"]["service"], "api");
280 }
281
282 #[test]
285 fn read_file_is_gated_and_reads_the_same_buffers() {
286 let fs = files(&[
287 ("main.aura", "data: read_file(\"data.json\").parse_json()\n"),
288 ("data.json", "{\"k\": 1}"),
289 ]);
290
291 let denied = eval_source(fs.clone(), "main.aura", &EvalOptions::default());
292 let reports = denied.expect_err("read_file must be denied without a grant");
293 assert!(
294 reports.iter().any(|r| r.code == "E0310"),
295 "expected E0310, got {reports:?}"
296 );
297
298 let allowed = eval_source(
299 fs,
300 "main.aura",
301 &EvalOptions {
302 allow_read: vec![".".into()],
303 ..Default::default()
304 },
305 )
306 .unwrap_or_else(|e| panic!("{e:?}"));
307 assert_eq!(allowed.json["data"]["k"], 1);
308 }
309
310 #[test]
313 fn errors_point_at_the_buffer_they_came_from() {
314 let fs = files(&[
315 ("main.aura", "import \"lib.aura\" as lib\nx: lib.boom\n"),
316 ("lib.aura", "pub def boom()\n y: undefined_name\nend\n"),
317 ]);
318 let reports = eval_source(fs, "main.aura", &EvalOptions::default())
319 .expect_err("undefined name must fail");
320 let first = &reports[0];
321 assert_eq!(first.code, "E0504", "{reports:?}");
322 assert_eq!(first.file, "lib.aura", "must name the buffer");
323 assert!(first.line > 0, "must carry a line");
324 }
325
326 #[test]
329 fn imports_get_no_file_access_in_memory_either() {
330 let fs = files(&[
331 ("main.aura", "import \"dep.aura\" as dep\nx: dep.data\n"),
332 ("dep.aura", "data: read_file(\"secret.txt\")\n"),
333 ("secret.txt", "s3cr3t"),
334 ]);
335 let reports = eval_source(
336 fs,
337 "main.aura",
338 &EvalOptions {
339 allow_read: vec![".".into()],
340 ..Default::default()
341 },
342 )
343 .expect_err("an import must not read files");
344 assert!(
345 reports.iter().any(|r| r.code == "E0310"),
346 "expected E0310, got {reports:?}"
347 );
348 }
349
350 #[test]
354 fn hermetic_turns_effectful_calls_into_analysis_errors() {
355 for source in ["x: env(\"HOME\", \"/\")\n", "x: read_file(\"data.json\")\n"] {
356 let fs = files(&[("main.aura", source), ("data.json", "{}")]);
357 let reports = eval_source(
358 fs,
359 "main.aura",
360 &EvalOptions {
361 hermetic: true,
362 ..Default::default()
363 },
364 )
365 .expect_err("hermetic mode must refuse effectful calls");
366 assert!(
367 reports.iter().any(|r| r.code == "E0505"),
368 "expected E0505 for {source:?}, got {reports:?}"
369 );
370 }
371 }
372
373 #[test]
377 fn hermetic_wins_over_grants_that_contradict_it() {
378 let fs = files(&[
379 ("main.aura", "data: read_file(\"data.json\")\n"),
380 ("data.json", "{}"),
381 ]);
382 let reports = eval_source(
383 fs,
384 "main.aura",
385 &EvalOptions {
386 hermetic: true,
387 allow_read: vec![".".into()],
388 allow_env: EnvCap::AllowAll,
389 ..Default::default()
390 },
391 )
392 .expect_err("a grant must not re-open a hermetic evaluation");
393 assert!(
394 reports.iter().any(|r| r.code == "E0505"),
395 "expected E0505, got {reports:?}"
396 );
397 }
398
399 #[test]
403 fn hermetic_reaches_imported_modules() {
404 let fs = files(&[
405 ("main.aura", "import \"dep.aura\" as dep\nx: dep.who\n"),
406 ("dep.aura", "who: env(\"USER\", \"nobody\")\n"),
407 ]);
408 let reports = eval_source(
409 fs,
410 "main.aura",
411 &EvalOptions {
412 hermetic: true,
413 ..Default::default()
414 },
415 )
416 .expect_err("an import must not perform I/O in hermetic mode");
417 assert!(
418 reports.iter().any(|r| r.code == "E0505"),
419 "expected E0505, got {reports:?}"
420 );
421 }
422
423 #[test]
426 fn hermetic_leaves_a_pure_manifest_alone() {
427 let fs = files(&[("main.aura", "base = 8000\napi:\n port: base + 80\nend\n")]);
428 let out = eval_source(
429 fs,
430 "main.aura",
431 &EvalOptions {
432 hermetic: true,
433 ..Default::default()
434 },
435 )
436 .unwrap_or_else(|e| panic!("{e:?}"));
437 assert_eq!(out.json["api"]["port"], 8080);
438 }
439}