spec_driven_docs/adapters/
fs.rs1use camino::Utf8Path;
8
9use crate::domain::ownership::Sha256;
10
11pub fn sha256_file(path: &Utf8Path) -> std::io::Result<Sha256> {
17 Ok(Sha256::of(&std::fs::read(path)?))
18}
19
20pub fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
26 if let Some(parent) = path.parent() {
27 std::fs::create_dir_all(parent)?;
28 }
29 std::fs::write(path, bytes)
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum DestinationRefusal {
35 SymlinkEscape,
37 FileBlocksDirectory(String),
39 NotARegularFile,
41}
42
43pub fn check_destination(
52 target: &Utf8Path,
53 destination: &Utf8Path,
54) -> Result<(), DestinationRefusal> {
55 let mut prefix = target.to_path_buf();
56 let components: Vec<&str> = destination.as_str().split('/').collect();
57 for part in &components[..components.len().saturating_sub(1)] {
58 prefix.push(part);
59 if prefix.is_symlink() {
60 return Err(DestinationRefusal::SymlinkEscape);
61 }
62 if prefix.exists() && !prefix.is_dir() {
63 let blocked = prefix
64 .as_str()
65 .strip_prefix(target.as_str())
66 .map_or(prefix.as_str(), |rest| rest.trim_start_matches('/'));
67 return Err(DestinationRefusal::FileBlocksDirectory(blocked.to_string()));
68 }
69 }
70 let full = target.join(destination);
71 if full.is_symlink() {
72 return Err(DestinationRefusal::SymlinkEscape);
73 }
74 if full.exists() && !full.is_file() {
75 return Err(DestinationRefusal::NotARegularFile);
76 }
77 Ok(())
78}
79
80#[cfg(test)]
81mod tests {
82 use camino::Utf8PathBuf;
83
84 use super::*;
85
86 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
87 Utf8PathBuf::from(dir.path().to_str().unwrap())
88 }
89
90 #[test]
91 fn accepts_a_fresh_and_an_existing_regular_destination() {
92 let dir = tempfile::tempdir().unwrap();
93 let target = root(&dir);
94 assert_eq!(
95 check_destination(&target, Utf8Path::new("a/b/c.md")),
96 Ok(())
97 );
98 write_file(&target.join("a/b/c.md"), b"x").unwrap();
99 assert_eq!(
100 check_destination(&target, Utf8Path::new("a/b/c.md")),
101 Ok(())
102 );
103 }
104
105 #[test]
106 fn refuses_a_symlinked_component_and_a_symlinked_destination() {
107 let dir = tempfile::tempdir().unwrap();
108 let target = root(&dir);
109 let outside = tempfile::tempdir().unwrap();
110 std::os::unix::fs::symlink(outside.path(), target.join("a").as_std_path()).unwrap();
111 assert_eq!(
112 check_destination(&target, Utf8Path::new("a/c.md")),
113 Err(DestinationRefusal::SymlinkEscape)
114 );
115 std::os::unix::fs::symlink("/etc/hosts", target.join("link.md").as_std_path()).unwrap();
116 assert_eq!(
117 check_destination(&target, Utf8Path::new("link.md")),
118 Err(DestinationRefusal::SymlinkEscape)
119 );
120 }
121
122 #[test]
123 fn refuses_a_file_where_a_directory_is_needed_and_a_directory_destination() {
124 let dir = tempfile::tempdir().unwrap();
125 let target = root(&dir);
126 write_file(&target.join("a"), b"file").unwrap();
127 assert_eq!(
128 check_destination(&target, Utf8Path::new("a/c.md")),
129 Err(DestinationRefusal::FileBlocksDirectory("a".to_string()))
130 );
131 std::fs::create_dir(target.join("d.md")).unwrap();
132 assert_eq!(
133 check_destination(&target, Utf8Path::new("d.md")),
134 Err(DestinationRefusal::NotARegularFile)
135 );
136 }
137
138 #[test]
139 fn hashes_match_the_domain_digest() {
140 let dir = tempfile::tempdir().unwrap();
141 let path = root(&dir).join("x");
142 write_file(&path, b"payload").unwrap();
143 assert_eq!(sha256_file(&path).unwrap(), Sha256::of(b"payload"));
144 }
145}