timetable_core/
path_safety.rs1use std::fs;
8use std::io::{Error, ErrorKind, Result};
9use std::path::{Component, Path, PathBuf};
10
11fn current_directory() -> Result<PathBuf> {
12 std::env::current_dir()?.canonicalize()
13}
14
15fn path_text(path: &Path) -> Result<&str> {
16 path.to_str().ok_or_else(|| {
17 Error::new(
18 ErrorKind::InvalidInput,
19 format!("path '{}' is not valid UTF-8", path.display()),
20 )
21 })
22}
23
24fn reject_parent_references(path: &Path) -> Result<()> {
25 let path_text = path_text(path)?;
26 if path_text.contains("..") {
27 return Err(Error::new(
28 ErrorKind::PermissionDenied,
29 format!(
30 "path '{}' contains a parent-directory reference",
31 path.display()
32 ),
33 ));
34 }
35 Ok(())
36}
37
38pub fn existing_file(path: &Path) -> Result<PathBuf> {
40 reject_parent_references(path)?;
41 if path.is_absolute() {
42 return Err(Error::new(
43 ErrorKind::PermissionDenied,
44 format!("path '{}' must be relative", path.display()),
45 ));
46 }
47
48 let project_root = current_directory()?;
49 let path = project_root.join(path).canonicalize()?;
50 if !path.starts_with(&project_root) {
51 return Err(Error::new(
52 ErrorKind::PermissionDenied,
53 format!(
54 "path '{}' is outside the current project directory",
55 path.display()
56 ),
57 ));
58 }
59 Ok(path)
60}
61
62pub fn read_project_file(path: &Path) -> Result<String> {
64 let path_text = path_text(path)?;
65 if path_text.contains("..") {
66 return Err(Error::new(
67 ErrorKind::PermissionDenied,
68 format!(
69 "path '{}' contains a parent-directory reference",
70 path.display()
71 ),
72 ));
73 }
74
75 let project_root = current_directory()?;
76 let path = project_root.join(path).canonicalize()?;
77 if !path.starts_with(&project_root) {
78 return Err(Error::new(
79 ErrorKind::PermissionDenied,
80 format!(
81 "path '{}' is outside the current project directory",
82 path.display()
83 ),
84 ));
85 }
86 fs::read_to_string(path)
87}
88
89pub fn output_directory(path: &Path) -> Result<PathBuf> {
91 let path_text = path_text(path)?;
92 if path_text.contains("..") {
93 return Err(Error::new(
94 ErrorKind::PermissionDenied,
95 format!(
96 "path '{}' contains a parent-directory reference",
97 path.display()
98 ),
99 ));
100 }
101
102 if path.is_absolute()
103 || path.components().any(|component| {
104 matches!(
105 component,
106 Component::ParentDir | Component::RootDir | Component::Prefix(_)
107 )
108 })
109 {
110 return Err(Error::new(
111 ErrorKind::PermissionDenied,
112 format!(
113 "output directory '{}' must be relative and project-local",
114 path.display()
115 ),
116 ));
117 }
118
119 let project_root = current_directory()?;
120 let directory = project_root.join(path).canonicalize()?;
121 if !directory.starts_with(&project_root) {
122 return Err(Error::new(
123 ErrorKind::PermissionDenied,
124 format!(
125 "path '{}' is outside the current project directory",
126 directory.display()
127 ),
128 ));
129 }
130 Ok(directory)
131}
132
133pub fn write_project_file(path: &Path, content: String) -> Result<()> {
135 let path_text = path_text(path)?;
136 if path_text.contains("..") {
137 return Err(Error::new(
138 ErrorKind::PermissionDenied,
139 format!(
140 "path '{}' contains a parent-directory reference",
141 path.display()
142 ),
143 ));
144 }
145
146 let project_root = current_directory()?;
147 let parent = path
148 .parent()
149 .unwrap_or_else(|| Path::new("."))
150 .canonicalize()?;
151 if !parent.starts_with(&project_root) {
152 return Err(Error::new(
153 ErrorKind::PermissionDenied,
154 format!(
155 "path '{}' is outside the current project directory",
156 parent.display()
157 ),
158 ));
159 }
160
161 let file_name = path.file_name().ok_or_else(|| {
162 Error::new(
163 ErrorKind::InvalidInput,
164 format!("output path '{}' does not name a file", path.display()),
165 )
166 })?;
167 fs::write(parent.join(file_name), content)
168}
169
170pub fn remove_project_file(path: &Path) -> Result<()> {
172 let path_text = path_text(path)?;
173 if path_text.contains("..") {
174 return Err(Error::new(
175 ErrorKind::PermissionDenied,
176 format!(
177 "path '{}' contains a parent-directory reference",
178 path.display()
179 ),
180 ));
181 }
182
183 let project_root = current_directory()?;
184 let path = path.canonicalize()?;
185 if !path.starts_with(&project_root) {
186 return Err(Error::new(
187 ErrorKind::PermissionDenied,
188 format!(
189 "path '{}' is outside the current project directory",
190 path.display()
191 ),
192 ));
193 }
194 fs::remove_file(path)
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn rejects_parent_references() {
203 assert!(reject_parent_references(Path::new("../outside.svg")).is_err());
204 }
205}