use std::fs;
use std::path::Path;
use anyhow::Result;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::Match;
use substrate::{DirReader, TreeEntry};
pub struct FsReader {
gitignores: Vec<Gitignore>,
}
impl FsReader {
pub fn new(root_path: &Path, exclude_names: &[String], follow_rules: &[String]) -> Self {
let mut gitignores = Vec::new();
if !follow_rules.is_empty() {
discover_follow_rules(root_path, exclude_names, follow_rules, &mut gitignores);
}
Self { gitignores }
}
}
impl substrate::DirReader for FsReader {
fn read_dir(&self, path: &Path) -> Result<Vec<substrate::DirEntry>> {
let mut entries = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let file_type = fs::symlink_metadata(&path)?.file_type();
if file_type.is_symlink() || (!file_type.is_file() && !file_type.is_dir()) {
continue;
}
entries.push(substrate::DirEntry {
name,
is_dir: file_type.is_dir(),
is_file: file_type.is_file(),
});
}
Ok(entries)
}
fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
Ok(fs::read(path)?)
}
fn is_executable(&self, path: &Path) -> Result<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(path)?;
Ok(metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
let _ = path;
Ok(false)
}
}
fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
path_is_ignored(&self.gitignores, path, is_dir)
}
fn mtime_ms(&self, path: &Path) -> Result<Option<u64>> {
let meta = fs::metadata(path)?;
Ok(meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64))
}
}
fn path_is_ignored(gitignores: &[Gitignore], path: &Path, is_dir: bool) -> bool {
let mut ignored = false;
for gi in gitignores {
if path.starts_with(gi.path()) {
match gi.matched_path_or_any_parents(path, is_dir) {
Match::Ignore(_) => ignored = true,
Match::Whitelist(_) => ignored = false,
Match::None => {}
}
}
}
ignored
}
fn discover_follow_rules(
dir: &Path,
exclude_names: &[String],
follow_rules: &[String],
acc: &mut Vec<Gitignore>,
) {
let mut builder = GitignoreBuilder::new(dir);
let mut found = false;
for rule_file in follow_rules {
let path = dir.join(rule_file);
if path.is_file() && builder.add(&path).is_none() {
found = true;
}
}
if found {
if let Ok(gi) = builder.build() {
acc.push(gi);
}
}
let read = match fs::read_dir(dir) {
Ok(read) => read,
Err(_) => return,
};
for entry in read.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if substrate::tree::should_exclude(&name, exclude_names) {
continue;
}
let path = entry.path();
let Ok(file_type) = fs::symlink_metadata(&path).map(|m| m.file_type()) else {
continue;
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
if path_is_ignored(acc, &path, true) {
continue;
}
discover_follow_rules(&path, exclude_names, follow_rules, acc);
}
}
pub fn walk_dir(
dir_path: &Path,
exclude_names: &[String],
follow_rules: &[String],
) -> Result<Vec<TreeEntry>> {
let reader = FsReader::new(dir_path, exclude_names, follow_rules);
substrate::walk_dir(&reader, dir_path, exclude_names)
}
pub fn compute_tree_hash(dir_path: &Path, tree: &substrate::SporeTree) -> Result<String> {
let entries = walk_dir(dir_path, &tree.exclude_names, &tree.follow_rules)?;
tree.compute_hash(&entries)
}
pub fn check_no_symlinks(
dir_path: &Path,
exclude_names: &[String],
follow_rules: &[String],
) -> Result<()> {
let reader = FsReader::new(dir_path, exclude_names, follow_rules);
check_no_symlinks_inner(&reader, dir_path, dir_path, exclude_names)
}
fn check_no_symlinks_inner(
reader: &FsReader,
root: &Path,
dir_path: &Path,
exclude_names: &[String],
) -> Result<()> {
for entry in fs::read_dir(dir_path)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if substrate::tree::should_exclude(&name, exclude_names) {
continue;
}
if reader.is_ignored(&path, false) {
continue;
}
let file_type = fs::symlink_metadata(&path)?.file_type();
if file_type.is_symlink() {
let target = fs::read_link(&path)
.map(|t| t.to_string_lossy().into_owned())
.unwrap_or_else(|_| "?".to_string());
let relative = path.strip_prefix(root).unwrap_or(&path);
anyhow::bail!(
"symlink found: {} → {}\n\
Symlinks are not included in spore content.\n \
To include the target content: cp -L \"{0}\" \"{0}.tmp\" && mv \"{0}.tmp\" \"{0}\"\n \
To exclude it: add \"{}\" to exclude_names",
relative.display(), target, name,
);
}
if file_type.is_dir() {
check_no_symlinks_inner(reader, root, &path, exclude_names)?;
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn walk_dir_skips_symlink_entries() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let target = root.join("target.txt");
let regular = root.join("regular.txt");
let symlink_path = root.join("linked.txt");
std::fs::write(&target, "target").unwrap();
std::fs::write(®ular, "regular").unwrap();
symlink(&target, &symlink_path).unwrap();
let entries = walk_dir(root, &[], &[]).unwrap();
let flat = substrate::flatten_entries(&entries);
let names: Vec<String> = flat.into_iter().map(|(path, _, _)| path).collect();
assert!(names.contains(&"regular.txt".to_string()));
assert!(names.contains(&"target.txt".to_string()));
assert!(
!names.contains(&"linked.txt".to_string()),
"symlink entries must be skipped"
);
}
#[cfg(unix)]
#[test]
fn check_no_symlinks_catches_symlink() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::write(root.join("target.txt"), "target").unwrap();
symlink("target.txt", root.join("linked.txt")).unwrap();
let err = check_no_symlinks(root, &[], &[]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("symlink found"),
"error should mention symlink: {}",
msg
);
assert!(
msg.contains("linked.txt"),
"error should name the file: {}",
msg
);
}
#[cfg(unix)]
#[test]
fn check_no_symlinks_respects_exclude_names() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::write(root.join("regular.txt"), "data").unwrap();
symlink("regular.txt", root.join("linked.txt")).unwrap();
assert!(check_no_symlinks(root, &["linked.txt".to_string()], &[]).is_ok());
}
#[cfg(unix)]
#[test]
fn check_no_symlinks_honors_nested_gitignore() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let sub = root.join("typescript");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join(".gitignore"), "node_modules/\n").unwrap();
let bin = sub.join("node_modules").join(".bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("target"), "x").unwrap();
symlink("target", bin.join("tsx")).unwrap();
assert!(check_no_symlinks(root, &[], &[".gitignore".to_string()]).is_ok());
}
#[test]
fn walk_dir_honors_nested_gitignore() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let sub = root.join("pkg");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join(".gitignore"), "build/\n").unwrap();
std::fs::write(sub.join("keep.txt"), "keep").unwrap();
std::fs::create_dir(sub.join("build")).unwrap();
std::fs::write(sub.join("build").join("out.o"), "obj").unwrap();
let entries = walk_dir(root, &[], &[".gitignore".to_string()]).unwrap();
let names: Vec<String> = substrate::flatten_entries(&entries)
.into_iter()
.map(|(path, _, _)| path)
.collect();
assert!(
names.iter().any(|n| n.ends_with("keep.txt")),
"tracked sibling must survive: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains("build")),
"nested-gitignored dir must be excluded: {names:?}"
);
}
#[test]
fn deeper_gitignore_overrides_shallower() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
let sub = root.join("keep");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join(".gitignore"), "!*.log\n").unwrap();
std::fs::write(root.join("root.log"), "r").unwrap();
std::fs::write(sub.join("kept.log"), "k").unwrap();
let entries = walk_dir(root, &[], &[".gitignore".to_string()]).unwrap();
let names: Vec<String> = substrate::flatten_entries(&entries)
.into_iter()
.map(|(path, _, _)| path)
.collect();
assert!(
!names.iter().any(|n| n.ends_with("root.log")),
"root-level *.log stays ignored: {names:?}"
);
assert!(
names.iter().any(|n| n.ends_with("kept.log")),
"deeper !*.log re-includes it: {names:?}"
);
}
}