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 }
198}
199
200pub(crate) fn build_tree(base_dir: &Utf8Path) -> Result<RecipeTree, CoreError> {
206 tracing::trace!("walking recipes under {base_dir}");
207 cooklang_find::build_tree(base_dir).map_err(|e| tree_error(e, base_dir))
208}
209
210pub(crate) fn walk(tree: &RecipeTree) -> Vec<&RecipeEntry> {
219 fn collect<'a>(tree: &'a RecipeTree, out: &mut Vec<&'a RecipeEntry>) {
220 if let Some(entry) = &tree.recipe {
221 out.push(entry);
222 }
223 for subtree in tree.children.values() {
224 collect(subtree, out);
225 }
226 }
227
228 let mut entries = Vec::new();
229 collect(tree, &mut entries);
230 entries.sort_by_key(|entry| (entry.path().cloned(), entry.name().clone()));
231 entries
232}
233
234pub(crate) fn parse_or_skip(
246 entry: &RecipeEntry,
247 diagnostics: &mut Vec<Diagnostic>,
248) -> Option<Recipe> {
249 let display = entry
250 .path()
251 .map(ToString::to_string)
252 .or_else(|| entry.name().clone())
253 .unwrap_or_else(|| "unknown".to_string());
254
255 let mut skipped = |reason: &str| {
256 let diagnostic = Diagnostic::warning(format!(
257 "could not {reason} {display}, so it was not considered"
258 ));
259 diagnostics.push(match entry.path() {
260 Some(path) => diagnostic.at_file(path),
261 None => diagnostic,
262 });
263 None
264 };
265
266 let content = match entry.content() {
267 Ok(content) => content,
268 Err(_) => return skipped("read"),
269 };
270
271 match parse_unscaled(&content, &display, entry.path().map(Utf8PathBuf::as_path)) {
274 Ok(outcome) => Some(outcome.value),
275 Err(_) => skipped("parse"),
276 }
277}
278
279pub(crate) fn listed_ingredients(recipe: &Recipe) -> BTreeSet<String> {
290 recipe
291 .ingredients
292 .iter()
293 .filter(|ingredient| ingredient.reference.is_none())
294 .filter(|ingredient| ingredient.modifiers().should_be_listed())
295 .map(|ingredient| ingredient.display_name().to_string())
296 .collect()
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 fn fixture() -> tempfile::TempDir {
304 let dir = tempfile::TempDir::new().unwrap();
305 std::fs::create_dir(dir.path().join("sub")).unwrap();
306 std::fs::write(dir.path().join("soup.cook"), "Boil @water{1%l}.\n").unwrap();
307 std::fs::write(
308 dir.path().join("sub").join("stew.cook"),
309 "Boil @water{1%l}.\n",
310 )
311 .unwrap();
312 dir
313 }
314
315 fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
316 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
317 }
318
319 #[test]
320 fn plain_relative_paths_are_safe() {
321 for path in [
322 "Pancakes",
323 "Breakfast/Easy Pancakes",
324 "Breakfast/Pancakes.cook",
325 "Crème brûlée",
326 ] {
327 assert!(is_safe_relative_path(path), "{path:?} must be accepted");
328 }
329 }
330
331 #[test]
332 fn paths_that_leave_the_directory_are_not_safe() {
333 for path in [
334 "..",
335 "../Secret",
336 "Breakfast/../../Secret",
337 "/etc/passwd",
338 "./Pancakes",
339 ] {
340 assert!(!is_safe_relative_path(path), "{path:?} must be refused");
341 }
342 }
343
344 #[cfg(windows)]
347 #[test]
348 fn windows_prefixes_and_streams_are_not_safe() {
349 for path in [
350 "C:",
351 "C:/",
352 "C:Windows/win.ini",
353 r"..\Secret",
354 r"\\attacker\share\x.cook",
355 "//attacker/share/x.cook",
356 r"\\?\C:\Windows",
357 "Breakfast/C:/Windows/win.ini",
358 "Pancakes.cook::$DATA",
359 ] {
360 assert!(!is_safe_relative_path(path), "{path:?} must be refused");
361 }
362 }
363
364 fn resolved(from: &str, reference: &str) -> Option<String> {
365 resolve_reference(Utf8Path::new(from), reference).map(String::from)
366 }
367
368 #[test]
371 fn a_reference_that_does_not_step_up_is_read_from_the_root() {
372 assert_eq!(
373 resolved("Breakfast", "./Shared/Red Beans"),
374 Some("Shared/Red Beans".to_string())
375 );
376 assert_eq!(resolved("", "./lamb-chops"), Some("lamb-chops".to_string()));
377 assert_eq!(
378 resolved("a/b/c", "./Risotto"),
379 Some("Risotto".to_string()),
380 "however deep the writer sits"
381 );
382 }
383
384 #[test]
385 fn a_reference_that_steps_up_does_so_from_the_writer() {
386 assert_eq!(
387 resolved("Breakfast", "../Shared/Vinaigrette"),
388 Some("Shared/Vinaigrette".to_string())
389 );
390 assert_eq!(
391 resolved("Menus/Autumn", "../../Risotto"),
392 Some("Risotto".to_string())
393 );
394 assert_eq!(
395 resolved("Menus/Autumn", "../Sunday"),
396 Some("Menus/Sunday".to_string())
397 );
398 }
399
400 #[test]
403 fn a_reference_climbing_above_the_root_resolves_to_nothing() {
404 assert_eq!(resolved("", "../Secret"), None);
405 assert_eq!(resolved("Breakfast", "../../Secret"), None);
406 assert_eq!(resolved("Menus/Autumn", "../../../Secret"), None);
407 assert_eq!(resolved("", "./../Secret"), None);
408 }
409
410 #[test]
413 fn the_result_is_normalised() {
414 assert_eq!(
415 resolved("", "./Shared/./Sauce"),
416 Some("Shared/Sauce".to_string())
417 );
418 assert_eq!(
419 resolved("Breakfast", "../Shared/Extra/../Sauce"),
420 Some("Shared/Sauce".to_string())
421 );
422 assert_eq!(resolved("", "./Shared/.."), None, "normalises to the root");
423 }
424
425 #[test]
429 fn a_dot_in_front_of_a_step_up_does_not_re_anchor_it() {
430 assert_eq!(resolved("Breakfast", "./../Secret"), Some("Secret".into()));
431 }
432
433 #[test]
436 fn a_reference_naming_something_outside_the_collection_resolves_to_nothing() {
437 assert_eq!(resolved("", "./"), None);
438 if cfg!(windows) {
439 assert_eq!(resolved("", "./C:/Windows/win.ini"), None);
440 assert_eq!(resolved("Breakfast", "../C:/Windows/win.ini"), None);
441 assert_eq!(resolved("", r".\Sauce.cook::$DATA"), None);
442 }
443 }
444
445 #[test]
449 fn backslashes_in_a_reference_are_separators() {
450 assert_eq!(
451 resolved("Breakfast", r"..\Shared\Vinaigrette"),
452 Some("Shared/Vinaigrette".to_string())
453 );
454 assert_eq!(resolved("", r"..\Secret"), None);
455 }
456
457 #[test]
458 fn finds_a_recipe_by_name_path_and_extension() {
459 let dir = fixture();
460 for name in ["soup", "soup.cook", "./soup.cook"] {
461 let entry = get_recipe(&base(&dir), name).unwrap_or_else(|e| panic!("{name}: {e}"));
462 assert_eq!(entry.path().and_then(|p| p.file_name()), Some("soup.cook"));
463 }
464 }
465
466 #[test]
468 fn a_leading_dot_slash_is_stripped_from_nested_paths() {
469 let dir = fixture();
470 let entry = get_recipe(&base(&dir), "./sub/stew.cook").expect("resolves");
471 assert_eq!(entry.path().and_then(|p| p.file_name()), Some("stew.cook"));
472 }
473
474 #[test]
475 fn a_missing_recipe_is_not_found() {
476 let dir = fixture();
477 match get_recipe(&base(&dir), "./absent.cook") {
478 Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
481 other => panic!("expected RecipeNotFound, got {other:?}"),
482 }
483 }
484
485 #[test]
487 fn a_directory_named_like_a_recipe_is_an_io_error() {
488 let dir = tempfile::TempDir::new().unwrap();
489 std::fs::create_dir(dir.path().join("adir.cook")).unwrap();
490 match get_recipe(&base(&dir), "adir.cook") {
491 Err(CoreError::Io { path, .. }) => assert_eq!(path.file_name(), Some("adir.cook")),
492 other => panic!("expected CoreError::Io, got {other:?}"),
493 }
494 }
495
496 #[test]
499 fn only_a_missing_file_maps_to_recipe_not_found() {
500 use cooklang_find::fetcher::FetchError;
501 let lookup = Utf8Path::new("recipes/pancakes.cook");
502
503 let absent = fetch_error(
504 FetchError::InvalidPath(Utf8PathBuf::from("pancakes.cook")),
505 lookup,
506 );
507 assert!(
508 matches!(absent, CoreError::RecipeNotFound { ref name } if name == "pancakes.cook"),
509 "an absent file is not found, got {absent:?}"
510 );
511
512 let unreadable = fetch_error(
513 FetchError::IoError(std::io::Error::new(
514 std::io::ErrorKind::PermissionDenied,
515 "denied",
516 )),
517 lookup,
518 );
519 match unreadable {
520 CoreError::Io { path, source } => {
521 assert_eq!(path, lookup);
522 assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
523 }
524 other => panic!("an unreadable file is an I/O error, got {other:?}"),
525 }
526
527 let unusable = fetch_error(
528 FetchError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
529 "bad front matter".to_string(),
530 )),
531 lookup,
532 );
533 match unusable {
534 CoreError::Io { path, source } => {
535 assert_eq!(path, lookup);
536 assert!(source.to_string().contains("bad front matter"));
537 }
538 other => panic!("an unusable file is an I/O error, got {other:?}"),
539 }
540 }
541
542 #[test]
547 fn entry_errors_keep_their_io_kind_and_never_lose_their_message() {
548 let io = entry_error(cooklang_find::RecipeEntryError::IoError(
549 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
550 ));
551 assert_eq!(
552 io.kind(),
553 std::io::ErrorKind::PermissionDenied,
554 "an I/O cause must keep its kind, so callers can match on it"
555 );
556
557 let other = entry_error(cooklang_find::RecipeEntryError::MetadataError(
558 "bad front matter".to_string(),
559 ));
560 assert!(
561 other.to_string().contains("bad front matter"),
562 "a non-I/O cause must keep its message: {other}"
563 );
564 }
565}