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
32pub fn write_atomic(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
47 use std::io::Write as _;
48 if let Some(parent) = path.parent() {
49 std::fs::create_dir_all(parent)?;
50 }
51 let scratch = path.with_extension(format!("{}.sdd-tmp", path.extension().unwrap_or_default()));
52 let mut file = std::fs::OpenOptions::new()
53 .write(true)
54 .create_new(true)
55 .open(&scratch)
56 .map_err(|error| {
57 std::io::Error::new(
58 error.kind(),
59 format!("{scratch}: {error}; remove the scratch file to retry"),
60 )
61 })?;
62 let written = file.write_all(bytes).and_then(|()| file.sync_all());
63 drop(file);
64 if let Err(error) = written {
65 let _ = std::fs::remove_file(&scratch);
66 return Err(error);
67 }
68 std::fs::rename(&scratch, path).inspect_err(|_| {
69 let _ = std::fs::remove_file(&scratch);
70 })
71}
72
73pub fn write_within(
84 target: &Utf8Path,
85 destination: &Utf8Path,
86 bytes: &[u8],
87) -> std::io::Result<()> {
88 check_destination(target, destination).map_err(|refusal| refused(destination, &refusal))?;
89 write_atomic(&target.join(destination), bytes)
90}
91
92pub fn remove_within(target: &Utf8Path, destination: &Utf8Path) -> std::io::Result<()> {
100 check_destination(target, destination).map_err(|refusal| refused(destination, &refusal))?;
101 std::fs::remove_file(target.join(destination))
102}
103
104fn refused(destination: &Utf8Path, refusal: &DestinationRefusal) -> std::io::Error {
105 std::io::Error::new(
106 std::io::ErrorKind::PermissionDenied,
107 format!("{destination}: {refusal}"),
108 )
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum DestinationRefusal {
114 SymlinkEscape,
116 FileBlocksDirectory(String),
118 NotARegularFile,
120}
121
122impl std::fmt::Display for DestinationRefusal {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 match self {
125 Self::SymlinkEscape => f.write_str("reached through a symlink that leaves the target"),
126 Self::FileBlocksDirectory(blocked) => {
127 write!(f, "a file blocks a directory the write needs: {blocked}")
128 }
129 Self::NotARegularFile => f.write_str("exists and is not a regular file"),
130 }
131 }
132}
133
134pub fn check_destination(
143 target: &Utf8Path,
144 destination: &Utf8Path,
145) -> Result<(), DestinationRefusal> {
146 let mut prefix = target.to_path_buf();
147 let components: Vec<&str> = destination.as_str().split('/').collect();
148 for part in &components[..components.len().saturating_sub(1)] {
149 prefix.push(part);
150 if prefix.is_symlink() {
151 return Err(DestinationRefusal::SymlinkEscape);
152 }
153 if prefix.exists() && !prefix.is_dir() {
154 let blocked = prefix
155 .as_str()
156 .strip_prefix(target.as_str())
157 .map_or(prefix.as_str(), |rest| rest.trim_start_matches('/'));
158 return Err(DestinationRefusal::FileBlocksDirectory(blocked.to_string()));
159 }
160 }
161 let full = target.join(destination);
162 if full.is_symlink() {
163 return Err(DestinationRefusal::SymlinkEscape);
164 }
165 if full.exists() && !full.is_file() {
166 return Err(DestinationRefusal::NotARegularFile);
167 }
168 Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173 use camino::Utf8PathBuf;
174
175 use super::*;
176
177 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
178 Utf8PathBuf::from(dir.path().to_str().unwrap())
179 }
180
181 #[test]
182 fn accepts_a_fresh_and_an_existing_regular_destination() {
183 let dir = tempfile::tempdir().unwrap();
184 let target = root(&dir);
185 assert_eq!(
186 check_destination(&target, Utf8Path::new("a/b/c.md")),
187 Ok(())
188 );
189 write_file(&target.join("a/b/c.md"), b"x").unwrap();
190 assert_eq!(
191 check_destination(&target, Utf8Path::new("a/b/c.md")),
192 Ok(())
193 );
194 }
195
196 #[test]
197 fn refuses_a_symlinked_component_and_a_symlinked_destination() {
198 let dir = tempfile::tempdir().unwrap();
199 let target = root(&dir);
200 let outside = tempfile::tempdir().unwrap();
201 std::os::unix::fs::symlink(outside.path(), target.join("a").as_std_path()).unwrap();
202 assert_eq!(
203 check_destination(&target, Utf8Path::new("a/c.md")),
204 Err(DestinationRefusal::SymlinkEscape)
205 );
206 std::os::unix::fs::symlink("/etc/hosts", target.join("link.md").as_std_path()).unwrap();
207 assert_eq!(
208 check_destination(&target, Utf8Path::new("link.md")),
209 Err(DestinationRefusal::SymlinkEscape)
210 );
211 }
212
213 #[test]
214 fn refuses_a_file_where_a_directory_is_needed_and_a_directory_destination() {
215 let dir = tempfile::tempdir().unwrap();
216 let target = root(&dir);
217 write_file(&target.join("a"), b"file").unwrap();
218 assert_eq!(
219 check_destination(&target, Utf8Path::new("a/c.md")),
220 Err(DestinationRefusal::FileBlocksDirectory("a".to_string()))
221 );
222 std::fs::create_dir(target.join("d.md")).unwrap();
223 assert_eq!(
224 check_destination(&target, Utf8Path::new("d.md")),
225 Err(DestinationRefusal::NotARegularFile)
226 );
227 }
228
229 #[test]
230 fn an_atomic_write_lands_the_bytes_and_leaves_no_scratch_file() {
231 let dir = tempfile::tempdir().unwrap();
232 let path = root(&dir).join("a/debt.yaml");
233 write_atomic(&path, b"first").unwrap();
234 write_atomic(&path, b"second").unwrap();
235 assert_eq!(std::fs::read(&path).unwrap(), b"second");
236 let siblings: Vec<_> = std::fs::read_dir(path.parent().unwrap())
237 .unwrap()
238 .filter_map(Result::ok)
239 .map(|entry| entry.file_name().to_string_lossy().to_string())
240 .collect();
241 assert_eq!(siblings, vec!["debt.yaml".to_string()]);
242 }
243
244 #[test]
245 fn a_pre_existing_scratch_symlink_is_refused_and_nothing_outside_is_touched() {
246 let dir = tempfile::tempdir().unwrap();
247 let target = root(&dir);
248 let outside = tempfile::tempdir().unwrap();
249 let victim = outside.path().join("victim");
250 std::fs::write(&victim, b"keep").unwrap();
251 std::os::unix::fs::symlink(&victim, target.join("debt.yaml.sdd-tmp").as_std_path())
252 .unwrap();
253 let error = write_atomic(&target.join("debt.yaml"), b"new").unwrap_err();
254 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
255 assert_eq!(
256 std::fs::read(&victim).unwrap(),
257 b"keep",
258 "the scratch symlink was followed"
259 );
260 assert!(!target.join("debt.yaml").exists());
261 std::fs::remove_file(target.join("debt.yaml.sdd-tmp")).unwrap();
263 std::fs::write(target.join("debt.yaml.sdd-tmp"), b"stale").unwrap();
264 assert!(write_atomic(&target.join("debt.yaml"), b"new").is_err());
265 }
266
267 #[test]
268 fn a_write_within_refuses_a_symlinked_parent_before_any_byte_lands() {
269 let dir = tempfile::tempdir().unwrap();
270 let target = root(&dir);
271 let outside = tempfile::tempdir().unwrap();
272 std::os::unix::fs::symlink(outside.path(), target.join("docs").as_std_path()).unwrap();
273 let error = write_within(&target, Utf8Path::new("docs/x.md"), b"x").unwrap_err();
274 assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
275 assert!(!outside.path().join("x.md").exists(), "the write escaped");
276 assert!(
277 remove_within(&target, Utf8Path::new("docs/x.md")).is_err(),
278 "the removal followed the symlink"
279 );
280 write_within(&target, Utf8Path::new("inside/x.md"), b"x").unwrap();
281 assert_eq!(std::fs::read(target.join("inside/x.md")).unwrap(), b"x");
282 }
283
284 #[test]
285 fn hashes_match_the_domain_digest() {
286 let dir = tempfile::tempdir().unwrap();
287 let path = root(&dir).join("x");
288 write_file(&path, b"payload").unwrap();
289 assert_eq!(sha256_file(&path).unwrap(), Sha256::of(b"payload"));
290 }
291}