Skip to main content

mdtask_core/
discover.rs

1use std::path::{Path, PathBuf};
2
3use crate::model::TaskFile;
4use crate::parse::parse;
5
6/// Search for task files from `start` upward, **nearest first**. In each
7/// directory the first of `tasks.md`, `maskfile.md`, `README.md` that parses to
8/// at least one job is taken.
9///
10/// **The walk stops at the first file found**, unless that file opts in with a
11/// file-level `Opts: include-parent` before its first task heading. A file that
12/// opts in is layered under by its own parent, on the same terms, so a chain
13/// continues only as far as every link agrees.
14///
15/// Inheritance has to be opt-in because the walk previously ran to the
16/// filesystem root, and every file it passed could define or *shadow* a task
17/// name. Running `mdtask build` in a freshly cloned repository could therefore
18/// run a script from a directory above it, chosen by a file the caller never
19/// looked at and quite possibly did not know existed. Stopping at the first file
20/// means what runs is what is written in the file you can see from where you are
21/// standing, and a project that genuinely wants a shared baseline says so.
22///
23/// Where layering does happen, it is child-first: a nearer file shadows a
24/// farther one by job name, like just's `set fallback`.
25///
26/// Embedders with their own project root can ignore this and call [`parse`].
27pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
28    let mut found = Vec::new();
29    for (depth, dir) in start.ancestors().enumerate() {
30        for name in ["tasks.md", "maskfile.md", "README.md"] {
31            let path = dir.join(name);
32            if let Some(src) = read_candidate(&path, depth == 0) {
33                let tf = parse(&src);
34                if !tf.jobs.is_empty() {
35                    let inherits = tf.includes_parent();
36                    found.push((path, tf));
37                    if !inherits {
38                        return found;
39                    }
40                    break; // one file per directory
41                }
42            }
43        }
44    }
45    found
46}
47
48/// The largest candidate we will read. A task file is hand-written markdown;
49/// four mebibytes is orders of magnitude past any real one, and the cap is what
50/// stops a `README.md` that happens to be a multi-gigabyte generated dump from
51/// being pulled into memory by a walk nobody asked for.
52const MAX_TASK_FILE: u64 = 4 * 1024 * 1024;
53
54/// Read a candidate task file, or `None` if it is absent or something we should
55/// not block on.
56///
57/// The walk touches every ancestor directory up to the root, so it reads files
58/// the user never mentioned. That makes an unbounded read the wrong default:
59///
60/// - **Not a regular file.** `read_to_string` on a FIFO blocks until someone
61///   writes to it, which may be never. A `tasks.md` FIFO in any ancestor
62///   directory would hang every `mdtask` invocation run beneath it, and hang an
63///   embedder like gloaming on startup with no way out. Character devices are
64///   the same problem with a worse ending.
65/// - **Too large.** See [`MAX_TASK_FILE`].
66/// - **A cloud placeholder** (macOS). iCloud and Dropbox leave dataless stubs;
67///   reading one triggers an on-demand download and blocks until it lands, or
68///   forever if the provider is offline. `explicit` is the directory the caller
69///   actually named: there, a download is what was asked for. In an ancestor it
70///   is a passive read, and passive reads must not stall or mass-download.
71///
72/// Stat-then-read is a race in principle. It is not a security boundary: anyone
73/// who can swap this path can also write the shell script it contains.
74fn read_candidate(path: &Path, explicit: bool) -> Option<String> {
75    // Follows symlinks deliberately, so a symlink pointing at a FIFO is judged
76    // by what it resolves to rather than by being a link.
77    let meta = std::fs::metadata(path).ok()?;
78    if !meta.is_file() || meta.len() > MAX_TASK_FILE {
79        return None;
80    }
81    #[cfg(target_os = "macos")]
82    if !explicit && is_dataless(&meta) {
83        return None;
84    }
85    let _ = explicit;
86    std::fs::read_to_string(path).ok()
87}
88
89/// Whether a macOS File Provider left this file dataless: present in the
90/// directory listing, with no local blocks behind it. `metadata` reports the
91/// flag without materializing the file, which is the whole point of checking.
92#[cfg(target_os = "macos")]
93fn is_dataless(meta: &std::fs::Metadata) -> bool {
94    use std::os::macos::fs::MetadataExt;
95    const SF_DATALESS: u32 = 0x4000_0000;
96    meta.st_flags() & SF_DATALESS != 0
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn find_task_files_layers_child_over_parent() {
105        // parent/tasks.md defines `base` + `shared`; parent/child/tasks.md
106        // redefines `shared` + adds `only`. Nearest-first, so child wins.
107        let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
108        let child = base.join("child");
109        std::fs::create_dir_all(&child).unwrap();
110        std::fs::write(
111            base.join("tasks.md"),
112            "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
113        )
114        .unwrap();
115        std::fs::write(
116            child.join("tasks.md"),
117            "Opts: include-parent\n\n## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
118        )
119        .unwrap();
120
121        let files = find_task_files(&child);
122        assert_eq!(files.len(), 2, "child and parent files found");
123        // Nearest first: child then parent.
124        assert!(files[0].0.starts_with(&child));
125        assert_eq!(
126            files[0].1.job("shared").unwrap().script.trim(),
127            "echo child"
128        );
129        // The parent still supplies `base` as an inherited baseline.
130        assert!(files[1].1.job("base").is_some());
131        std::fs::remove_dir_all(&base).ok();
132    }
133
134    /// The default, and the reason inheritance became opt-in. The walk used to
135    /// run to the filesystem root, and every file it passed could define or
136    /// *shadow* a task name, so `mdtask build` in a fresh clone could run a
137    /// script from a directory above it that the caller never looked at.
138    #[test]
139    fn the_walk_stops_at_the_first_file_by_default() {
140        let base = std::env::temp_dir().join(format!("mdtask-stop-{}", std::process::id()));
141        let child = base.join("child");
142        std::fs::create_dir_all(&child).unwrap();
143        std::fs::write(
144            base.join("tasks.md"),
145            "## build\n\n```sh\necho hijacked\n```\n",
146        )
147        .unwrap();
148        std::fs::write(child.join("tasks.md"), "## only\n\n```sh\ntrue\n```\n").unwrap();
149
150        let files = find_task_files(&child);
151        std::fs::remove_dir_all(&base).ok();
152
153        assert_eq!(files.len(), 1, "the parent is not consulted");
154        assert!(
155            files[0].1.job("build").is_none(),
156            "and cannot supply a name"
157        );
158    }
159
160    /// A chain continues only as far as every link agrees: the middle file opts
161    /// in, the top one does not, so the walk takes the top file and stops there
162    /// rather than continuing past it.
163    #[test]
164    fn opting_in_is_per_file_all_the_way_up() {
165        let base = std::env::temp_dir().join(format!("mdtask-chain-{}", std::process::id()));
166        let mid = base.join("mid");
167        let leaf = mid.join("leaf");
168        std::fs::create_dir_all(&leaf).unwrap();
169        std::fs::write(base.join("tasks.md"), "## top\n\n```sh\ntrue\n```\n").unwrap();
170        std::fs::write(
171            mid.join("tasks.md"),
172            "Opts: include-parent\n\n## middle\n\n```sh\ntrue\n```\n",
173        )
174        .unwrap();
175        std::fs::write(
176            leaf.join("tasks.md"),
177            "Opts: include-parent\n\n## leaf\n\n```sh\ntrue\n```\n",
178        )
179        .unwrap();
180
181        let files = find_task_files(&leaf);
182        std::fs::remove_dir_all(&base).ok();
183
184        assert_eq!(files.len(), 3, "leaf, mid, top");
185        assert!(files[2].1.job("top").is_some());
186    }
187
188    /// A FIFO named `tasks.md` in an ancestor directory used to hang every
189    /// invocation run beneath it, forever, with no output and no way out: the
190    /// walk read it unconditionally and `read_to_string` on a FIFO blocks until
191    /// someone writes. An embedder loading tasks at startup just never started.
192    ///
193    /// The timeout is the assertion. A regression here does not fail the test,
194    /// it hangs the whole test binary, so the wait has to be bounded and the
195    /// work has to happen somewhere it can be abandoned.
196    #[cfg(unix)]
197    #[test]
198    fn a_fifo_task_file_does_not_hang_the_walk() {
199        let base = std::env::temp_dir().join(format!("mdtask-fifo-{}", std::process::id()));
200        let child = base.join("child");
201        std::fs::create_dir_all(&child).unwrap();
202
203        let fifo = base.join("tasks.md");
204        let made = std::process::Command::new("mkfifo")
205            .arg(&fifo)
206            .status()
207            .map(|s| s.success())
208            .unwrap_or(false);
209        if !made {
210            std::fs::remove_dir_all(&base).ok();
211            return; // no mkfifo here; nothing to prove
212        }
213        // A real file below it, so we can also see the walk carried on.
214        std::fs::write(child.join("tasks.md"), "## only\n\n```sh\ntrue\n```\n").unwrap();
215
216        let (tx, rx) = std::sync::mpsc::channel();
217        let probe = child.clone();
218        std::thread::spawn(move || {
219            let _ = tx.send(find_task_files(&probe).len());
220        });
221        let found = rx.recv_timeout(std::time::Duration::from_secs(10));
222        std::fs::remove_dir_all(&base).ok();
223
224        let found = found.expect("the walk returned instead of blocking on the FIFO");
225        assert_eq!(
226            found, 1,
227            "the FIFO was skipped and the real file still read"
228        );
229    }
230
231    /// A `README.md` is a candidate, and a README can be a generated dump. The
232    /// walk should not pull an arbitrarily large one into memory to discover it
233    /// has no task headings in it.
234    #[test]
235    fn an_oversized_candidate_is_skipped() {
236        let base = std::env::temp_dir().join(format!("mdtask-big-{}", std::process::id()));
237        std::fs::create_dir_all(&base).unwrap();
238        let path = base.join("tasks.md");
239
240        let real = "## only\n\n```sh\ntrue\n```\n";
241        std::fs::write(&path, real).unwrap();
242        assert!(
243            read_candidate(&path, true).is_some(),
244            "an ordinary file reads"
245        );
246
247        let padding = "x".repeat(MAX_TASK_FILE as usize + 1);
248        std::fs::write(&path, padding).unwrap();
249        assert!(
250            read_candidate(&path, true).is_none(),
251            "an oversized one does not"
252        );
253
254        std::fs::remove_dir_all(&base).ok();
255    }
256
257    /// A directory named `tasks.md` is not a task file, and must not stop the
258    /// walk from finding the real one further up.
259    #[test]
260    fn a_directory_named_like_a_task_file_is_skipped() {
261        let base = std::env::temp_dir().join(format!("mdtask-dir-{}", std::process::id()));
262        let child = base.join("child");
263        std::fs::create_dir_all(child.join("tasks.md")).unwrap();
264        std::fs::write(base.join("tasks.md"), "## only\n\n```sh\ntrue\n```\n").unwrap();
265
266        let files = find_task_files(&child);
267        std::fs::remove_dir_all(&base).ok();
268
269        assert_eq!(files.len(), 1);
270        assert!(
271            files[0].1.job("only").is_some(),
272            "the real one, one level up"
273        );
274    }
275}