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}