Skip to main content

miwa/
source.rs

1//! Abstraction over "a snapshot of the repository" (working tree or a git rev).
2
3/// Lists and reads files of a repository snapshot. Paths are repo-relative,
4/// `/`-separated, and sorted. Symlinks are not included. Paths under `.git`,
5/// `target`, `.miwa`, `.worktrees`, `node_modules`, `obj`, and `dist` are
6/// excluded (directory names at any depth; files with those names are not).
7/// Further directory names can be excluded via `exclude` in the config, which
8/// is applied in `build_graph`.
9pub trait Source {
10    fn list_files(&self) -> anyhow::Result<Vec<String>>;
11    fn read(&self, path: &str) -> anyhow::Result<Vec<u8>>;
12}
13
14pub struct FsSource {
15    #[allow(dead_code)]
16    root: std::path::PathBuf,
17}
18
19/// Whether any parent-directory segment of `path` (the file name itself is not
20/// considered) equals one of `names`.
21pub fn is_under_dir_named<S: AsRef<str>>(path: &str, names: &[S]) -> bool {
22    let mut segments = path.split('/').collect::<Vec<_>>();
23    segments.pop();
24    segments
25        .iter()
26        .any(|seg| names.iter().any(|n| n.as_ref() == *seg))
27}
28
29/// Directory names always excluded from scanning.
30pub const DEFAULT_EXCLUDED_DIRS: [&str; 7] = [
31    ".git",
32    "target",
33    ".miwa",
34    ".worktrees",
35    "node_modules",
36    "obj",
37    "dist",
38];
39
40impl FsSource {
41    pub fn new(root: impl Into<std::path::PathBuf>) -> FsSource {
42        FsSource { root: root.into() }
43    }
44}
45
46/// Rejects paths that are empty, absolute, or contain a `..` component.
47fn reject_unsafe_path(path: &str) -> anyhow::Result<()> {
48    if path.is_empty() {
49        anyhow::bail!("empty path is not allowed");
50    }
51    let p = std::path::Path::new(path);
52    if p.is_absolute() {
53        anyhow::bail!("absolute path is not allowed: {path}");
54    }
55    if p.components()
56        .any(|c| matches!(c, std::path::Component::ParentDir))
57    {
58        anyhow::bail!("path must not contain '..': {path}");
59    }
60    Ok(())
61}
62
63impl Source for FsSource {
64    fn list_files(&self) -> anyhow::Result<Vec<String>> {
65        let root = &self.root;
66        let mut files = Vec::new();
67        let walker = walkdir::WalkDir::new(root)
68            .follow_links(false)
69            .into_iter()
70            .filter_entry(|entry| {
71                if entry.depth() == 0 {
72                    return true;
73                }
74                if entry.file_type().is_dir() {
75                    if let Some(name) = entry.file_name().to_str() {
76                        if DEFAULT_EXCLUDED_DIRS.contains(&name) {
77                            return false;
78                        }
79                    }
80                }
81                true
82            });
83        for entry in walker {
84            let entry = entry?;
85            if !entry.file_type().is_file() {
86                continue;
87            }
88            let rel = entry.path().strip_prefix(root)?;
89            let rel_str = rel
90                .components()
91                .map(|c| c.as_os_str().to_string_lossy().into_owned())
92                .collect::<Vec<_>>()
93                .join("/");
94            files.push(rel_str);
95        }
96        files.sort();
97        Ok(files)
98    }
99
100    fn read(&self, path: &str) -> anyhow::Result<Vec<u8>> {
101        reject_unsafe_path(path)?;
102        Ok(std::fs::read(self.root.join(path))?)
103    }
104}
105
106pub struct GitTreeSource {
107    repo: std::path::PathBuf,
108    rev: String,
109    /// The full, unfiltered `path -> blob oid` map for `rev` (no directory
110    /// exclusions applied). `list_files` filters this; `read` looks paths up
111    /// in it directly, so files under excluded directories (e.g. `.miwa/`)
112    /// can still be read even though they are not listed.
113    files: std::collections::BTreeMap<String, String>,
114}
115
116pub(crate) fn run_git(
117    repo: &std::path::Path,
118    args: &[&str],
119) -> anyhow::Result<std::process::Output> {
120    let output = std::process::Command::new("git")
121        .args(args)
122        .current_dir(repo)
123        .output()?;
124    Ok(output)
125}
126
127impl GitTreeSource {
128    /// Validates `rev` against `repo` and eagerly lists every blob in its
129    /// tree (unfiltered).
130    pub fn new(repo: impl Into<std::path::PathBuf>, rev: &str) -> anyhow::Result<GitTreeSource> {
131        let repo = repo.into();
132        let output = run_git(
133            &repo,
134            &["rev-parse", "--verify", &format!("{rev}^{{commit}}")],
135        )?;
136        if !output.status.success() {
137            anyhow::bail!(
138                "git rev-parse --verify failed for {rev}: {}",
139                String::from_utf8_lossy(&output.stderr)
140            );
141        }
142
143        let ls_output = run_git(&repo, &["ls-tree", "-r", "-z", "--full-tree", rev])?;
144        if !ls_output.status.success() {
145            anyhow::bail!(
146                "git ls-tree failed: {}",
147                String::from_utf8_lossy(&ls_output.stderr)
148            );
149        }
150        let stdout = String::from_utf8_lossy(&ls_output.stdout);
151        let mut files = std::collections::BTreeMap::new();
152        for entry in stdout.split('\0') {
153            if entry.is_empty() {
154                continue;
155            }
156            let Some((meta, path)) = entry.split_once('\t') else {
157                continue;
158            };
159            let mut parts = meta.split_whitespace();
160            let mode = parts.next().unwrap_or("");
161            let obj_type = parts.next().unwrap_or("");
162            let oid = parts.next().unwrap_or("");
163            if obj_type != "blob" {
164                continue;
165            }
166            // Mode 120000 is a symlink; `Source` never follows symlinks
167            // (matches `FsSource`'s `follow_links(false)`), so exclude them.
168            if mode == "120000" {
169                continue;
170            }
171            files.insert(path.to_string(), oid.to_string());
172        }
173
174        Ok(GitTreeSource {
175            repo,
176            rev: rev.to_string(),
177            files,
178        })
179    }
180}
181
182impl Source for GitTreeSource {
183    fn list_files(&self) -> anyhow::Result<Vec<String>> {
184        let mut files: Vec<String> = self
185            .files
186            .keys()
187            .filter(|path| !is_under_dir_named(path, &DEFAULT_EXCLUDED_DIRS))
188            .cloned()
189            .collect();
190        files.sort();
191        Ok(files)
192    }
193
194    fn read(&self, path: &str) -> anyhow::Result<Vec<u8>> {
195        let Some(oid) = self.files.get(path) else {
196            return Err(anyhow::Error::new(std::io::Error::new(
197                std::io::ErrorKind::NotFound,
198                format!("{path} does not exist at {}", self.rev),
199            )));
200        };
201        let output = run_git(&self.repo, &["cat-file", "blob", oid])?;
202        if !output.status.success() {
203            anyhow::bail!(
204                "git cat-file blob failed for {path}: {}",
205                String::from_utf8_lossy(&output.stderr)
206            );
207        }
208        Ok(output.stdout)
209    }
210}
211
212/// Whether `err` is a `Source::read` failure caused by the path not existing
213/// (as opposed to, say, a `git` invocation failure). Callers use this to
214/// distinguish "file absent" from a real execution error.
215pub fn is_not_found(err: &anyhow::Error) -> bool {
216    err.downcast_ref::<std::io::Error>()
217        .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
218}
219
220/// Returns the first line of `git merge-base <base> HEAD` run in `repo`.
221pub fn merge_base(repo: &std::path::Path, base: &str) -> anyhow::Result<String> {
222    let output = run_git(repo, &["merge-base", base, "HEAD"])?;
223    if !output.status.success() {
224        anyhow::bail!(
225            "git merge-base failed: {}",
226            String::from_utf8_lossy(&output.stderr)
227        );
228    }
229    let stdout = String::from_utf8_lossy(&output.stdout);
230    let first_line = stdout.lines().next().unwrap_or("").trim().to_string();
231    if first_line.is_empty() {
232        anyhow::bail!("git merge-base returned no output");
233    }
234    Ok(first_line)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use std::fs;
241
242    // [FR-018]
243    #[test]
244    fn 除外ディレクトリ配下のファイルは列挙されない() {
245        let dir = tempfile::tempdir().unwrap();
246        let root = dir.path();
247        fs::create_dir_all(root.join("req")).unwrap();
248        fs::write(root.join("req/fr-001.md"), "ok").unwrap();
249        for excluded in [
250            ".git",
251            "target",
252            ".miwa",
253            ".worktrees",
254            "node_modules",
255            "obj",
256            "dist",
257        ] {
258            let sub = root.join(excluded).join("nested");
259            fs::create_dir_all(&sub).unwrap();
260            fs::write(sub.join("file.md"), "should not be listed").unwrap();
261        }
262        fs::create_dir_all(root.join("proj/obj/Debug")).unwrap();
263        fs::write(root.join("proj/obj/Debug/X.cs"), "generated").unwrap();
264        fs::create_dir_all(root.join("src/bin")).unwrap();
265        fs::write(root.join("src/bin/main.rs"), "kept").unwrap();
266
267        let source = FsSource::new(root);
268        let files = source.list_files().unwrap();
269        assert!(files.contains(&"req/fr-001.md".to_string()));
270        assert!(files.contains(&"src/bin/main.rs".to_string()), "{files:?}");
271        assert!(
272            !files.contains(&"proj/obj/Debug/X.cs".to_string()),
273            "nested obj leaked: {files:?}"
274        );
275        for excluded in [
276            ".git",
277            "target",
278            ".miwa",
279            ".worktrees",
280            "node_modules",
281            "obj",
282            "dist",
283        ] {
284            assert!(
285                !files.iter().any(|f| f.starts_with(&format!("{excluded}/"))),
286                "excluded dir {excluded} leaked into: {files:?}"
287            );
288        }
289    }
290
291    // [FR-018]
292    #[cfg(unix)]
293    #[test]
294    fn シンボリックリンクの先は辿らない() {
295        let dir = tempfile::tempdir().unwrap();
296        let root = dir.path();
297        fs::create_dir_all(root.join("req")).unwrap();
298        fs::write(root.join("req/fr-001.md"), "ok").unwrap();
299
300        let outside = tempfile::tempdir().unwrap();
301        fs::write(outside.path().join("secret.md"), "should not be visible").unwrap();
302        std::os::unix::fs::symlink(outside.path(), root.join("req/linked")).unwrap();
303
304        let source = FsSource::new(root);
305        let files = source.list_files().unwrap();
306        assert!(!files.iter().any(|f| f.contains("secret.md")));
307    }
308
309    // [FR-018]
310    #[test]
311    fn fssourceのreadは絶対パスやドットドットや空パスを拒否する() {
312        let dir = tempfile::tempdir().unwrap();
313        let root = dir.path();
314        fs::write(root.join("a.txt"), "ok").unwrap();
315
316        let source = FsSource::new(root);
317        assert!(source.read("").is_err());
318        assert!(source.read("../a.txt").is_err());
319        assert!(source.read("sub/../../a.txt").is_err());
320        #[cfg(unix)]
321        assert!(source.read("/etc/passwd").is_err());
322        assert!(source.read("a.txt").is_ok());
323    }
324
325    fn git(dir: &std::path::Path, args: &[&str]) -> String {
326        let output = std::process::Command::new("git")
327            .args(args)
328            .current_dir(dir)
329            .output()
330            .unwrap_or_else(|e| panic!("failed to run git {args:?}: {e}"));
331        assert!(
332            output.status.success(),
333            "git {args:?} failed:\nstdout: {}\nstderr: {}",
334            String::from_utf8_lossy(&output.stdout),
335            String::from_utf8_lossy(&output.stderr)
336        );
337        String::from_utf8_lossy(&output.stdout).trim().to_string()
338    }
339
340    fn init_repo(root: &std::path::Path) {
341        git(root, &["init", "-q", "-b", "main"]);
342        git(root, &["config", "user.name", "Test User"]);
343        git(root, &["config", "user.email", "test@example.com"]);
344    }
345
346    // [FR-021]
347    #[test]
348    fn gittreesourceは除外ディレクトリ配下もreadでは読める() {
349        let dir = tempfile::tempdir().unwrap();
350        let root = dir.path();
351        init_repo(root);
352        fs::create_dir_all(root.join(".miwa")).unwrap();
353        fs::write(root.join(".miwa/config.yaml"), "docs:\n  - docs\n").unwrap();
354        fs::create_dir_all(root.join("req")).unwrap();
355        fs::write(root.join("req/fr1.md"), "content").unwrap();
356        git(root, &["add", "-A"]);
357        git(root, &["commit", "-q", "-m", "init"]);
358
359        let source = GitTreeSource::new(root, "HEAD").unwrap();
360        let files = source.list_files().unwrap();
361        assert!(
362            !files.iter().any(|f| f.starts_with(".miwa/")),
363            ".miwa should be excluded from list_files: {files:?}"
364        );
365        let content = source.read(".miwa/config.yaml").unwrap();
366        assert_eq!(String::from_utf8(content).unwrap(), "docs:\n  - docs\n");
367    }
368
369    // [FR-021]
370    #[test]
371    fn gittreesourceのreadは存在しないパスでnotfoundエラーを返す() {
372        let dir = tempfile::tempdir().unwrap();
373        let root = dir.path();
374        init_repo(root);
375        fs::write(root.join("a.txt"), "hi").unwrap();
376        git(root, &["add", "-A"]);
377        git(root, &["commit", "-q", "-m", "init"]);
378
379        let source = GitTreeSource::new(root, "HEAD").unwrap();
380        let err = source.read("does/not/exist.md").unwrap_err();
381        let io_err = err
382            .downcast_ref::<std::io::Error>()
383            .expect("error should be a std::io::Error");
384        assert_eq!(io_err.kind(), std::io::ErrorKind::NotFound);
385    }
386
387    // [FR-018]
388    #[test]
389    fn gittreesourceは除外ディレクトリ配下をlist_filesに含めない() {
390        let dir = tempfile::tempdir().unwrap();
391        let root = dir.path();
392        init_repo(root);
393        fs::create_dir_all(root.join("target/nested")).unwrap();
394        fs::write(root.join("target/nested/file.txt"), "x").unwrap();
395        fs::create_dir_all(root.join("obj/nested")).unwrap();
396        fs::write(root.join("obj/nested/file.txt"), "x").unwrap();
397        fs::create_dir_all(root.join("proj/obj/Debug")).unwrap();
398        fs::write(root.join("proj/obj/Debug/X.cs"), "x").unwrap();
399        fs::create_dir_all(root.join("dist/nested")).unwrap();
400        fs::write(root.join("dist/nested/file.txt"), "x").unwrap();
401        fs::create_dir_all(root.join("src/bin")).unwrap();
402        fs::write(root.join("src/bin/main.rs"), "x").unwrap();
403        fs::write(root.join("keep.txt"), "y").unwrap();
404        git(root, &["add", "-A"]);
405        git(root, &["commit", "-q", "-m", "init"]);
406
407        let source = GitTreeSource::new(root, "HEAD").unwrap();
408        let files = source.list_files().unwrap();
409        assert!(files.contains(&"keep.txt".to_string()));
410        assert!(
411            !files.iter().any(|f| f.starts_with("target/")
412                || f.starts_with("obj/")
413                || f.contains("/obj/")
414                || f.starts_with("dist/")),
415            "target/ obj/ dist/ should be excluded: {files:?}"
416        );
417        assert!(files.contains(&"src/bin/main.rs".to_string()), "{files:?}");
418    }
419
420    // [FR-021]
421    #[test]
422    fn gittreesourceのreadはコミット時点の内容を返す() {
423        let dir = tempfile::tempdir().unwrap();
424        let root = dir.path();
425        init_repo(root);
426        fs::write(root.join("a.txt"), "v1").unwrap();
427        git(root, &["add", "-A"]);
428        git(root, &["commit", "-q", "-m", "v1"]);
429        let rev = git(root, &["rev-parse", "HEAD"]);
430
431        fs::write(root.join("a.txt"), "v2").unwrap();
432        git(root, &["add", "-A"]);
433        git(root, &["commit", "-q", "-m", "v2"]);
434
435        let source = GitTreeSource::new(root, &rev).unwrap();
436        let content = source.read("a.txt").unwrap();
437        assert_eq!(String::from_utf8(content).unwrap(), "v1");
438    }
439
440    // [FR-018]
441    #[cfg(unix)]
442    #[test]
443    fn gittreesourceはシンボリックリンクをlist_filesに含めない() {
444        let dir = tempfile::tempdir().unwrap();
445        let root = dir.path();
446        init_repo(root);
447        fs::write(root.join("real.txt"), "content").unwrap();
448        std::os::unix::fs::symlink("real.txt", root.join("linked.txt")).unwrap();
449        git(root, &["add", "-A"]);
450        git(root, &["commit", "-q", "-m", "init"]);
451
452        let source = GitTreeSource::new(root, "HEAD").unwrap();
453        let files = source.list_files().unwrap();
454        assert!(files.contains(&"real.txt".to_string()));
455        assert!(
456            !files.contains(&"linked.txt".to_string()),
457            "symlinks should be excluded: {files:?}"
458        );
459    }
460
461    // [FR-018]
462    #[test]
463    fn 既定除外名と同名のファイルはfssourceで除外されない() {
464        let dir = tempfile::tempdir().unwrap();
465        let root = dir.path();
466        fs::create_dir_all(root.join("tests")).unwrap();
467        fs::write(root.join("tests/dist"), "file named dist").unwrap();
468        fs::create_dir_all(root.join("dist")).unwrap();
469        fs::write(root.join("dist/x.txt"), "excluded").unwrap();
470
471        let files = FsSource::new(root).list_files().unwrap();
472        assert!(files.contains(&"tests/dist".to_string()), "{files:?}");
473        assert!(!files.contains(&"dist/x.txt".to_string()), "{files:?}");
474    }
475
476    // [FR-018]
477    #[test]
478    fn 既定除外名と同名のファイルはgittreesourceで除外されない() {
479        let dir = tempfile::tempdir().unwrap();
480        let root = dir.path();
481        init_repo(root);
482        fs::create_dir_all(root.join("tests")).unwrap();
483        fs::write(root.join("tests/dist"), "file named dist").unwrap();
484        fs::create_dir_all(root.join("dist")).unwrap();
485        fs::write(root.join("dist/x.txt"), "excluded").unwrap();
486        git(root, &["add", "-A"]);
487        git(root, &["commit", "-q", "-m", "init"]);
488
489        let files = GitTreeSource::new(root, "HEAD")
490            .unwrap()
491            .list_files()
492            .unwrap();
493        assert!(files.contains(&"tests/dist".to_string()), "{files:?}");
494        assert!(!files.contains(&"dist/x.txt".to_string()), "{files:?}");
495    }
496}