1pub 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
19pub 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
29pub 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
46fn 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 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 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 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
212pub 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
220pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}