Skip to main content

cookcli_core/
find.rs

1//! Resolving a recipe name or path to a file on disk, and walking a whole
2//! collection of them.
3
4use 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
10/// Re-exported from [`cooklang_format`], which is where the writers that
11/// depend on this spelling now live.
12pub use cooklang_format::REFERENCE_SEPARATOR;
13
14/// Whether `path`, joined to a directory, is sure to stay inside it.
15///
16/// Every component has to be a plain name. `..` climbs out, and a root, a
17/// drive letter or a UNC share makes `join` drop the directory altogether:
18/// `base.join("C:\\x")` is just `C:\x`. A leading `./` is refused as well;
19/// nothing we link to starts with one. On Windows no component may hold a `:`
20/// either. No file name can, so past a drive letter it could only pick an
21/// NTFS alternate data stream.
22///
23/// Meant for paths taken from a request, before they are joined to the recipe
24/// directory. The check is purely lexical on purpose: merely looking up
25/// `\\host\share` on disk makes Windows connect to that host and hand it the
26/// user's NTLM hash.
27pub 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
36/// Resolve a recipe reference to a path relative to the collection root.
37///
38/// `reference` is the reference as the recipe writes it — `./Shared/Sauce`,
39/// `../sauces/tomato`. The parser only produces one from a name starting `./`
40/// or `../`, so it always opens with a dot component and can never be an
41/// absolute path of its own.
42///
43/// **A reference that steps up does so from the directory of the file that
44/// writes it; one that does not is read from the collection root.** That is
45/// not a new rule for the second half: `./` has always meant the root here,
46/// and the shipped seed relies on it — `Breakfast/Mexican Style Burrito.cook`
47/// writes `@./Shared/Red Beans` and means `Shared/Red Beans.cook` at the root,
48/// not one beside itself. `..` had no working meaning at all before this: it
49/// was resolved from the root like everything else, so `../sauces/tomato`
50/// landed *outside* the collection whatever wrote it.
51///
52/// `from` is that writer's directory, relative to the root and empty for a
53/// recipe sitting at it. It is ours, not a request's: an entry path with the
54/// base directory stripped off.
55///
56/// Returns `None` for a reference that still climbs above the root, or that
57/// normalises to something [`is_safe_relative_path`] refuses — a reference is
58/// free to spell `@./C:/Windows/win.ini{}`, and joining that to the recipe
59/// directory would drop the directory. Neither names a recipe in the
60/// collection, so callers report them the way they report one that is missing.
61pub 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                // `from` is built from an entry path, so it holds plain names.
70                // Anything else means a caller passed a path it did not strip
71                // the base directory off; resolving against it would be a
72                // guess, so give up rather than guess.
73                _ => None,
74            })
75            .collect::<Option<Vec<_>>>()?
76    } else {
77        Vec::new()
78    };
79
80    for part in parts {
81        match part {
82            "" | "." => {}
83            ".." => {
84                // Nothing left to step out of: this climbs above the root.
85                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
98/// Look `name` up under `base_path`, returning the file it resolves to.
99///
100/// `name` may be a path or a bare recipe name, with or without an extension —
101/// `cooklang-find` tries `.cook` and `.menu` for the latter. A leading `./` is
102/// stripped first, because `cooklang-find` does not expect it.
103///
104/// # Errors
105///
106/// - [`CoreError::RecipeNotFound`] if nothing matches. Reserved for genuine
107///   absence, so that a caller told "not found" does not go looking for a file
108///   that is sitting right there.
109/// - [`CoreError::Io`] if a match was found but could not be opened or its
110///   front matter could not be understood. The cause is in `source`.
111pub fn get_recipe(base_path: &Utf8Path, name: &str) -> Result<RecipeEntry, CoreError> {
112    // Remove the `./` prefix if present before passing to cooklang_find, which
113    // does not expect it.
114    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
120/// Map a lookup failure onto the error that describes what actually happened.
121///
122/// Only `FetchError::InvalidPath` means the recipe is absent;
123/// the rest mean it was found and could not be opened or understood.
124pub(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
144/// Unwrap a `cooklang-find` entry error to the underlying [`std::io::Error`].
145///
146/// Its other variants are front matter problems rather than I/O; they keep
147/// their message as the source so nothing is lost, and travel as
148/// [`CoreError::Io`] because they too mean "found, but unusable".
149pub(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
156/// Map a tree-building failure onto the error that describes what happened.
157///
158/// Everything except a listing failure is about the root itself: it is missing,
159/// it is a file, or its name cannot be turned into a glob pattern. None of them
160/// mean a recipe was read and rejected.
161///
162/// Shared by every command that walks a collection — `doctor::validate`,
163/// `pantry::recipes` and `pantry::plan` — so that a mistyped root is reported
164/// the same way whichever of them was asked.
165pub(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        // The variants' own `Display` repeats the path, which the `Search`
172        // rendering already names.
173        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        // What a root spelled `./recipes` gets: the walk finds
177        // `recipes/soup.cook`, which does not start with `./recipes`. Reached
178        // by `cook doctor validate -b ./recipes`, so it is worth wording for a
179        // person.
180        TreeError::StripPrefixError(what) => {
181            search(format!("cannot express {what} relative to it"))
182        }
183        // Carries the file it failed on, which is more use than the root.
184        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        // Unreachable through `build_tree`, which skips an entry it cannot
191        // load rather than failing. Mapped rather than ignored so that the
192        // match stops compiling if that changes.
193        TreeError::RecipeEntryError(source) => CoreError::Io {
194            path: base_dir.to_owned(),
195            source: entry_error(source),
196        },
197        // Added by cooklang-find after 0.8.1 (local-path trial only).
198        TreeError::IoError(source) => CoreError::Io {
199            path: base_dir.to_owned(),
200            source,
201        },
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Walking a collection
207// ---------------------------------------------------------------------------
208
209/// Build the recipe tree under `base_dir`, in this crate's error wording.
210pub(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
215/// Every recipe in the tree, depth first, in path order.
216///
217/// Sorted because `cooklang-find` holds a directory's children in a `HashMap`,
218/// so the walk itself yields them differently from run to run. Several callers
219/// sort their own results anyway — `pantry::recipes` does, and `pantry::plan`
220/// breaks its ties alphabetically — but the diagnostics depend on this:
221/// without it, a collection with two unreadable recipes reports them in a
222/// different order each time.
223pub(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
239/// Parse one recipe, or note that it was left out.
240///
241/// Nothing here fails the walk: one unreadable file in a collection must not
242/// cost the caller the answer for the rest of it.
243///
244/// **Only the skip is reported.** A recipe that parses with warnings — the
245/// deprecated `>>` metadata syntax, say — contributes its ingredients and none
246/// of its warnings, because they cannot change any answer a caller of this is
247/// computing. `doctor::validate` is the one command that reports them, and the
248/// reason `cook doctor aisle` and `cook doctor pantry` no longer log a recipe's
249/// parse warnings the way they did before they went through here.
250pub(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    // Unscaled: scaling by one would only re-fit units, and no caller of this
277    // reads a quantity.
278    match parse_unscaled(&content, &display, entry.path().map(Utf8PathBuf::as_path)) {
279        Ok(outcome) => Some(outcome.value),
280        Err(_) => skipped("parse"),
281    }
282}
283
284/// The ingredients a recipe asks the reader to have, as it writes them.
285///
286/// A set, so a recipe using flour twice wants flour once.
287///
288/// References to other recipes are left out: they are a recipe to make, not a
289/// thing to have in. Ingredients the parser marks as not to be listed are left
290/// out too, though with [`PARSER`](crate::PARSER)'s extensions there are none
291/// — `@-salt{}` parses as an ingredient *named* `-salt` rather than a hidden
292/// one, so it counts like any other. The filter is kept for the day that
293/// changes.
294pub(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
304/// The ingredients a recipe cannot be cooked without: [`listed_ingredients`]
305/// less the ones only ever used optionally (`@?chives`).
306///
307/// An ingredient the recipe uses both ways (`@parmesan{100%g}` in the sauce,
308/// `@?parmesan{50%g}` on top) is required.
309pub(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    /// Drive letters, UNC shares, `\` separators and NTFS streams only mean
366    /// something on Windows; anywhere else these are odd but harmless names.
367    #[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    /// What the shipped seed relies on: `Breakfast/Mexican Style Burrito.cook`
390    /// writes `@./Shared/Red Beans` and means the one at the root.
391    #[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    /// The escape this exists to close: from the root there is nothing to step
422    /// out of, and no depth of writer may be over-spent.
423    #[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    /// `.` and `..` are normalised wherever they sit, not just at the front,
432    /// so what comes back is always a plain relative path.
433    #[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    /// It is the `..` anywhere in a reference that makes it writer-relative,
447    /// not only one at the front, so a `./` in front of one does not re-anchor
448    /// it to the root and turn a step up into an escape.
449    #[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    /// A reference is written in a recipe file, which is not always ours: an
455    /// imported or synced one can spell anything the parser accepts.
456    #[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    /// `\` is a separator in a reference — `parse_reference` accepts `.\` and
467    /// `..\` — so it has to be normalised before the components are read, or
468    /// `..\Secret` would arrive as one plain name and be joined verbatim.
469    #[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    /// The `./` strip is what makes a reference like `@./sub/stew{}` resolve.
488    #[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            // The `./` must not survive into the reported name, or the message
500            // names something the caller never asked for.
501            Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
502            other => panic!("expected RecipeNotFound, got {other:?}"),
503        }
504    }
505
506    /// Present but unusable is an I/O error, not absence.
507    #[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    /// Pins all three lookup outcomes, including the one `cooklang-find` does
518    /// not currently produce: only genuine absence may be reported as absence.
519    #[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    /// `entry_error` is the shared mapping used both when the lookup fails and
564    /// when a later read does. Reaching the latter needs the file to become
565    /// unreadable *between* two reads, which cannot be arranged without a race,
566    /// so pin the mapping directly instead.
567    #[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}