Skip to main content

timetable_core/
path_safety.rs

1//! Path validation for file-system operations.
2//!
3//! The CLI operates on files in the current project directory. These helpers
4//! canonicalize paths and ensure they cannot escape that directory before they
5//! are passed to file-system APIs.
6
7use 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
38/// Resolve an existing relative file only when it is inside the current project directory.
39pub 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
62/// Read a project-local file after validating its path at the file-system boundary.
63pub 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
89/// Resolve an existing relative output directory inside the current project directory.
90pub 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
133/// Write an SVG only when its parent directory is inside the current project directory.
134pub 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
170/// Remove a project-local file after validating its path at the file-system boundary.
171pub 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}