1use crate::{parser::parse_unscaled, CoreError, Diagnostic};
5use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
6use cooklang::Recipe;
7use cooklang_find::{tree::TreeError, RecipeEntry, RecipeTree};
8use std::collections::BTreeSet;
9
10pub use cooklang_format::REFERENCE_SEPARATOR;
13
14pub fn is_safe_relative_path(path: &str) -> bool {
28 Utf8Path::new(path)
29 .components()
30 .all(|component| match component {
31 Utf8Component::Normal(name) => !(cfg!(windows) && name.contains(':')),
32 _ => false,
33 })
34}
35
36pub fn resolve_reference(from: &Utf8Path, reference: &str) -> Option<Utf8PathBuf> {
62 let reference = reference.replace('\\', "/");
63 let parts: Vec<&str> = reference.split('/').collect();
64
65 let mut resolved: Vec<&str> = if parts.contains(&"..") {
66 from.components()
67 .map(|component| match component {
68 Utf8Component::Normal(name) => Some(name),
69 _ => None,
74 })
75 .collect::<Option<Vec<_>>>()?
76 } else {
77 Vec::new()
78 };
79
80 for part in parts {
81 match part {
82 "" | "." => {}
83 ".." => {
84 resolved.pop()?;
86 }
87 name => resolved.push(name),
88 }
89 }
90
91 if resolved.is_empty() {
92 return None;
93 }
94 let path = resolved.join("/");
95 is_safe_relative_path(&path).then(|| Utf8PathBuf::from(path))
96}
97
98pub fn get_recipe(base_path: &Utf8Path, name: &str) -> Result<RecipeEntry, CoreError> {
112 let clean_name = name.strip_prefix("./").unwrap_or(name);
115
116 cooklang_find::get_recipe(vec![base_path.to_path_buf()], clean_name.into())
117 .map_err(|e| fetch_error(e, Utf8Path::new(clean_name)))
118}
119
120pub(crate) fn fetch_error(
125 error: cooklang_find::fetcher::FetchError,
126 lookup: &Utf8Path,
127) -> CoreError {
128 use cooklang_find::fetcher::FetchError;
129 match error {
130 FetchError::InvalidPath(name) => CoreError::RecipeNotFound {
131 name: name.to_string(),
132 },
133 FetchError::IoError(source) => CoreError::Io {
134 path: lookup.to_owned(),
135 source,
136 },
137 FetchError::RecipeEntryError(source) => CoreError::Io {
138 path: lookup.to_owned(),
139 source: entry_error(source),
140 },
141 }
142}
143
144pub(crate) fn entry_error(error: cooklang_find::RecipeEntryError) -> std::io::Error {
150 match error {
151 cooklang_find::RecipeEntryError::IoError(e) => e,
152 other => std::io::Error::other(other.to_string()),
153 }
154}
155
156pub(crate) fn tree_error(error: TreeError, base_dir: &Utf8Path) -> CoreError {
166 let search = |message: String| CoreError::Search {
167 base_dir: base_dir.to_owned(),
168 message,
169 };
170 match error {
171 TreeError::DirectoryNotFound(_) => search("no such directory".to_string()),
174 TreeError::NotADirectory(_) => search("not a directory".to_string()),
175 TreeError::PatternError(source) => search(source.to_string()),
176 TreeError::StripPrefixError(what) => {
181 search(format!("cannot express {what} relative to it"))
182 }
183 TreeError::GlobError(source) => CoreError::Io {
185 path: Utf8Path::from_path(source.path())
186 .map(Utf8Path::to_owned)
187 .unwrap_or_else(|| base_dir.to_owned()),
188 source: source.into_error(),
189 },
190 TreeError::RecipeEntryError(source) => CoreError::Io {
194 path: base_dir.to_owned(),
195 source: entry_error(source),
196 },
197 TreeError::IoError(source) => CoreError::Io {
199 path: base_dir.to_owned(),
200 source,
201 },
202 }
203}
204
205pub(crate) fn build_tree(base_dir: &Utf8Path) -> Result<RecipeTree, CoreError> {
211 tracing::trace!("walking recipes under {base_dir}");
212 cooklang_find::build_tree(base_dir).map_err(|e| tree_error(e, base_dir))
213}
214
215pub(crate) fn walk(tree: &RecipeTree) -> Vec<&RecipeEntry> {
224 fn collect<'a>(tree: &'a RecipeTree, out: &mut Vec<&'a RecipeEntry>) {
225 if let Some(entry) = &tree.recipe {
226 out.push(entry);
227 }
228 for subtree in tree.children.values() {
229 collect(subtree, out);
230 }
231 }
232
233 let mut entries = Vec::new();
234 collect(tree, &mut entries);
235 entries.sort_by_key(|entry| (entry.path().cloned(), entry.name().clone()));
236 entries
237}
238
239pub(crate) fn parse_or_skip(
251 entry: &RecipeEntry,
252 diagnostics: &mut Vec<Diagnostic>,
253) -> Option<Recipe> {
254 let display = entry
255 .path()
256 .map(ToString::to_string)
257 .or_else(|| entry.name().clone())
258 .unwrap_or_else(|| "unknown".to_string());
259
260 let mut skipped = |reason: &str| {
261 let diagnostic = Diagnostic::warning(format!(
262 "could not {reason} {display}, so it was not considered"
263 ));
264 diagnostics.push(match entry.path() {
265 Some(path) => diagnostic.at_file(path),
266 None => diagnostic,
267 });
268 None
269 };
270
271 let content = match entry.content() {
272 Ok(content) => content,
273 Err(_) => return skipped("read"),
274 };
275
276 match parse_unscaled(&content, &display, entry.path().map(Utf8PathBuf::as_path)) {
279 Ok(outcome) => Some(outcome.value),
280 Err(_) => skipped("parse"),
281 }
282}
283
284pub(crate) fn listed_ingredients(recipe: &Recipe) -> BTreeSet<String> {
295 recipe
296 .ingredients
297 .iter()
298 .filter(|ingredient| ingredient.reference.is_none())
299 .filter(|ingredient| ingredient.modifiers().should_be_listed())
300 .map(|ingredient| ingredient.display_name().to_string())
301 .collect()
302}
303
304pub(crate) fn required_ingredients(recipe: &Recipe) -> BTreeSet<String> {
310 recipe
311 .ingredients
312 .iter()
313 .filter(|ingredient| ingredient.reference.is_none())
314 .filter(|ingredient| ingredient.modifiers().should_be_listed())
315 .filter(|ingredient| !ingredient.modifiers().is_optional())
316 .map(|ingredient| ingredient.display_name().to_string())
317 .collect()
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 fn fixture() -> tempfile::TempDir {
325 let dir = tempfile::TempDir::new().unwrap();
326 std::fs::create_dir(dir.path().join("sub")).unwrap();
327 std::fs::write(dir.path().join("soup.cook"), "Boil @water{1%l}.\n").unwrap();
328 std::fs::write(
329 dir.path().join("sub").join("stew.cook"),
330 "Boil @water{1%l}.\n",
331 )
332 .unwrap();
333 dir
334 }
335
336 fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
337 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
338 }
339
340 #[test]
341 fn plain_relative_paths_are_safe() {
342 for path in [
343 "Pancakes",
344 "Breakfast/Easy Pancakes",
345 "Breakfast/Pancakes.cook",
346 "Crème brûlée",
347 ] {
348 assert!(is_safe_relative_path(path), "{path:?} must be accepted");
349 }
350 }
351
352 #[test]
353 fn paths_that_leave_the_directory_are_not_safe() {
354 for path in [
355 "..",
356 "../Secret",
357 "Breakfast/../../Secret",
358 "/etc/passwd",
359 "./Pancakes",
360 ] {
361 assert!(!is_safe_relative_path(path), "{path:?} must be refused");
362 }
363 }
364
365 #[cfg(windows)]
368 #[test]
369 fn windows_prefixes_and_streams_are_not_safe() {
370 for path in [
371 "C:",
372 "C:/",
373 "C:Windows/win.ini",
374 r"..\Secret",
375 r"\\attacker\share\x.cook",
376 "//attacker/share/x.cook",
377 r"\\?\C:\Windows",
378 "Breakfast/C:/Windows/win.ini",
379 "Pancakes.cook::$DATA",
380 ] {
381 assert!(!is_safe_relative_path(path), "{path:?} must be refused");
382 }
383 }
384
385 fn resolved(from: &str, reference: &str) -> Option<String> {
386 resolve_reference(Utf8Path::new(from), reference).map(String::from)
387 }
388
389 #[test]
392 fn a_reference_that_does_not_step_up_is_read_from_the_root() {
393 assert_eq!(
394 resolved("Breakfast", "./Shared/Red Beans"),
395 Some("Shared/Red Beans".to_string())
396 );
397 assert_eq!(resolved("", "./lamb-chops"), Some("lamb-chops".to_string()));
398 assert_eq!(
399 resolved("a/b/c", "./Risotto"),
400 Some("Risotto".to_string()),
401 "however deep the writer sits"
402 );
403 }
404
405 #[test]
406 fn a_reference_that_steps_up_does_so_from_the_writer() {
407 assert_eq!(
408 resolved("Breakfast", "../Shared/Vinaigrette"),
409 Some("Shared/Vinaigrette".to_string())
410 );
411 assert_eq!(
412 resolved("Menus/Autumn", "../../Risotto"),
413 Some("Risotto".to_string())
414 );
415 assert_eq!(
416 resolved("Menus/Autumn", "../Sunday"),
417 Some("Menus/Sunday".to_string())
418 );
419 }
420
421 #[test]
424 fn a_reference_climbing_above_the_root_resolves_to_nothing() {
425 assert_eq!(resolved("", "../Secret"), None);
426 assert_eq!(resolved("Breakfast", "../../Secret"), None);
427 assert_eq!(resolved("Menus/Autumn", "../../../Secret"), None);
428 assert_eq!(resolved("", "./../Secret"), None);
429 }
430
431 #[test]
434 fn the_result_is_normalised() {
435 assert_eq!(
436 resolved("", "./Shared/./Sauce"),
437 Some("Shared/Sauce".to_string())
438 );
439 assert_eq!(
440 resolved("Breakfast", "../Shared/Extra/../Sauce"),
441 Some("Shared/Sauce".to_string())
442 );
443 assert_eq!(resolved("", "./Shared/.."), None, "normalises to the root");
444 }
445
446 #[test]
450 fn a_dot_in_front_of_a_step_up_does_not_re_anchor_it() {
451 assert_eq!(resolved("Breakfast", "./../Secret"), Some("Secret".into()));
452 }
453
454 #[test]
457 fn a_reference_naming_something_outside_the_collection_resolves_to_nothing() {
458 assert_eq!(resolved("", "./"), None);
459 if cfg!(windows) {
460 assert_eq!(resolved("", "./C:/Windows/win.ini"), None);
461 assert_eq!(resolved("Breakfast", "../C:/Windows/win.ini"), None);
462 assert_eq!(resolved("", r".\Sauce.cook::$DATA"), None);
463 }
464 }
465
466 #[test]
470 fn backslashes_in_a_reference_are_separators() {
471 assert_eq!(
472 resolved("Breakfast", r"..\Shared\Vinaigrette"),
473 Some("Shared/Vinaigrette".to_string())
474 );
475 assert_eq!(resolved("", r"..\Secret"), None);
476 }
477
478 #[test]
479 fn finds_a_recipe_by_name_path_and_extension() {
480 let dir = fixture();
481 for name in ["soup", "soup.cook", "./soup.cook"] {
482 let entry = get_recipe(&base(&dir), name).unwrap_or_else(|e| panic!("{name}: {e}"));
483 assert_eq!(entry.path().and_then(|p| p.file_name()), Some("soup.cook"));
484 }
485 }
486
487 #[test]
489 fn a_leading_dot_slash_is_stripped_from_nested_paths() {
490 let dir = fixture();
491 let entry = get_recipe(&base(&dir), "./sub/stew.cook").expect("resolves");
492 assert_eq!(entry.path().and_then(|p| p.file_name()), Some("stew.cook"));
493 }
494
495 #[test]
496 fn a_missing_recipe_is_not_found() {
497 let dir = fixture();
498 match get_recipe(&base(&dir), "./absent.cook") {
499 Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
502 other => panic!("expected RecipeNotFound, got {other:?}"),
503 }
504 }
505
506 #[test]
508 fn a_directory_named_like_a_recipe_is_an_io_error() {
509 let dir = tempfile::TempDir::new().unwrap();
510 std::fs::create_dir(dir.path().join("adir.cook")).unwrap();
511 match get_recipe(&base(&dir), "adir.cook") {
512 Err(CoreError::Io { path, .. }) => assert_eq!(path.file_name(), Some("adir.cook")),
513 other => panic!("expected CoreError::Io, got {other:?}"),
514 }
515 }
516
517 #[test]
520 fn only_a_missing_file_maps_to_recipe_not_found() {
521 use cooklang_find::fetcher::FetchError;
522 let lookup = Utf8Path::new("recipes/pancakes.cook");
523
524 let absent = fetch_error(
525 FetchError::InvalidPath(Utf8PathBuf::from("pancakes.cook")),
526 lookup,
527 );
528 assert!(
529 matches!(absent, CoreError::RecipeNotFound { ref name } if name == "pancakes.cook"),
530 "an absent file is not found, got {absent:?}"
531 );
532
533 let unreadable = fetch_error(
534 FetchError::IoError(std::io::Error::new(
535 std::io::ErrorKind::PermissionDenied,
536 "denied",
537 )),
538 lookup,
539 );
540 match unreadable {
541 CoreError::Io { path, source } => {
542 assert_eq!(path, lookup);
543 assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
544 }
545 other => panic!("an unreadable file is an I/O error, got {other:?}"),
546 }
547
548 let unusable = fetch_error(
549 FetchError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
550 "bad front matter".to_string(),
551 )),
552 lookup,
553 );
554 match unusable {
555 CoreError::Io { path, source } => {
556 assert_eq!(path, lookup);
557 assert!(source.to_string().contains("bad front matter"));
558 }
559 other => panic!("an unusable file is an I/O error, got {other:?}"),
560 }
561 }
562
563 #[test]
568 fn entry_errors_keep_their_io_kind_and_never_lose_their_message() {
569 let io = entry_error(cooklang_find::RecipeEntryError::IoError(
570 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
571 ));
572 assert_eq!(
573 io.kind(),
574 std::io::ErrorKind::PermissionDenied,
575 "an I/O cause must keep its kind, so callers can match on it"
576 );
577
578 let other = entry_error(cooklang_find::RecipeEntryError::MetadataError(
579 "bad front matter".to_string(),
580 ));
581 assert!(
582 other.to_string().contains("bad front matter"),
583 "a non-I/O cause must keep its message: {other}"
584 );
585 }
586}