turbo_tasks_fs/
util.rs

1use std::{
2    borrow::Cow,
3    io::{self, ErrorKind},
4    path::Path,
5};
6
7use anyhow::{Context, Result, anyhow};
8use turbo_tasks::Vc;
9
10use crate::{DiskFileSystem, FileSystemPath};
11
12/// Joins two /-separated paths into a normalized path.
13/// Paths are concatenated with /.
14///
15/// see also [normalize_path] for normalization.
16pub fn join_path(fs_path: &str, join: &str) -> Option<String> {
17    // Paths that we join are written as source code (eg, `join_path(fs_path,
18    // "foo/bar.js")`) and it's expected that they will never contain a
19    // backslash.
20    debug_assert!(
21        !join.contains('\\'),
22        "joined path {join} must not contain a Windows directory '\\', it must be normalized to \
23         Unix '/'"
24    );
25
26    // TODO: figure out why this freezes the benchmarks.
27    // // an absolute path would leave the file system root
28    // if Path::new(join).is_absolute() {
29    //     return None;
30    // }
31
32    if fs_path.is_empty() {
33        normalize_path(join)
34    } else if join.is_empty() {
35        normalize_path(fs_path)
36    } else {
37        normalize_path(&[fs_path, "/", join].concat())
38    }
39}
40
41/// Converts System paths into Unix paths. This is a noop on Unix systems, and
42/// replaces backslash directory separators with forward slashes on Windows.
43#[inline]
44pub fn sys_to_unix(path: &str) -> Cow<'_, str> {
45    #[cfg(not(target_family = "windows"))]
46    {
47        Cow::from(path)
48    }
49    #[cfg(target_family = "windows")]
50    {
51        Cow::Owned(path.replace(std::path::MAIN_SEPARATOR_STR, "/"))
52    }
53}
54
55/// Converts Unix paths into System paths. This is a noop on Unix systems, and
56/// replaces forward slash directory separators with backslashes on Windows.
57#[inline]
58pub fn unix_to_sys(path: &str) -> Cow<'_, str> {
59    #[cfg(not(target_family = "windows"))]
60    {
61        Cow::from(path)
62    }
63    #[cfg(target_family = "windows")]
64    {
65        Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
66    }
67}
68
69/// Normalizes a /-separated path into a form that contains no leading /, no
70/// double /, no "." segment, no ".." segment.
71///
72/// Returns None if the path would need to start with ".." to be equal.
73pub fn normalize_path(str: &str) -> Option<String> {
74    let mut segments = Vec::new();
75    for segment in str.split('/') {
76        match segment {
77            "." | "" => {}
78            ".." => {
79                segments.pop()?;
80            }
81            segment => {
82                segments.push(segment);
83            }
84        }
85    }
86    Some(segments.join("/"))
87}
88
89/// Normalizes a /-separated request into a form that contains no leading /, no
90/// double /, and no "." or ".." segments in the middle of the request.
91///
92/// A request might only start with a single "." segment and no ".." segments, or
93/// any positive number of ".." segments but no "." segment.
94pub fn normalize_request(str: &str) -> String {
95    let mut segments = vec!["."];
96    // Keeps track of our directory depth so that we can pop directories when
97    // encountering a "..". If this is positive, then we're inside a directory
98    // and we can pop that. If it's 0, then we can't pop the directory and we must
99    // keep the ".." in our segments. This is not the same as the segments.len(),
100    // because we cannot pop a kept ".." when encountering another "..".
101    let mut depth = 0;
102    let mut popped_dot = false;
103    for segment in str.split('/') {
104        match segment {
105            "." => {}
106            ".." => {
107                if depth > 0 {
108                    depth -= 1;
109                    segments.pop();
110                } else {
111                    // The first time we push a "..", we need to remove the "." we include by
112                    // default.
113                    if !popped_dot {
114                        popped_dot = true;
115                        segments.pop();
116                    }
117                    segments.push(segment);
118                }
119            }
120            segment => {
121                segments.push(segment);
122                depth += 1;
123            }
124        }
125    }
126    segments.join("/")
127}
128
129/// Converts a disk access Result<T> into a Result<Some<T>>, where a NotFound
130/// error results in a None value. This is purely to reduce boilerplate code
131/// comparing NotFound errors against all other errors.
132pub fn extract_disk_access<T>(value: io::Result<T>, path: &Path) -> Result<Option<T>> {
133    match value {
134        Ok(v) => Ok(Some(v)),
135        Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::InvalidFilename) => Ok(None),
136        Err(e) => Err(anyhow!(e).context(format!("reading file {}", path.display()))),
137    }
138}
139
140#[cfg(not(target_os = "windows"))]
141pub async fn uri_from_file(root: Vc<FileSystemPath>, path: Option<&str>) -> Result<String> {
142    let root_fs = root.fs();
143    let root_fs = &*Vc::try_resolve_downcast_type::<DiskFileSystem>(root_fs)
144        .await?
145        .context("Expected root to have a DiskFileSystem")?
146        .await?;
147
148    Ok(format!(
149        "file://{}",
150        &sys_to_unix(
151            &root_fs
152                .to_sys_path(match path {
153                    Some(path) => root.join(path.into()),
154                    None => root,
155                })
156                .await?
157                .to_string_lossy()
158        )
159        .split('/')
160        .map(|s| urlencoding::encode(s))
161        .collect::<Vec<_>>()
162        .join("/")
163    ))
164}
165
166#[cfg(target_os = "windows")]
167pub async fn uri_from_file(root: Vc<FileSystemPath>, path: Option<&str>) -> Result<String> {
168    let root_fs = root.fs();
169    let root_fs = &*Vc::try_resolve_downcast_type::<DiskFileSystem>(root_fs)
170        .await?
171        .context("Expected root to have a DiskFileSystem")?
172        .await?;
173
174    let sys_path = root_fs
175        .to_sys_path(match path {
176            Some(path) => root.join(path.into()),
177            None => root,
178        })
179        .await?;
180
181    let raw_path = sys_path.to_string_lossy().to_string();
182    let normalized_path = raw_path.replace('\\', "/");
183
184    let mut segments = normalized_path.split('/');
185
186    let first = segments.next().unwrap_or_default(); // e.g., "C:"
187    let encoded_path = std::iter::once(first.to_string()) // keep "C:" intact
188        .chain(segments.map(|s| urlencoding::encode(s).into_owned()))
189        .collect::<Vec<_>>()
190        .join("/");
191
192    let uri = format!("file:///{}", encoded_path);
193
194    Ok(uri)
195}
196
197#[cfg(test)]
198mod tests {
199
200    use rstest::*;
201
202    use crate::util::normalize_path;
203
204    #[rstest]
205    #[case("file.js")]
206    #[case("a/b/c/d/e/file.js")]
207    fn test_normalize_path_no_op(#[case] path: &str) {
208        assert_eq!(path, normalize_path(path).unwrap());
209    }
210
211    #[rstest]
212    #[case("/file.js", "file.js")]
213    #[case("./file.js", "file.js")]
214    #[case("././file.js", "file.js")]
215    #[case("a/../c/../file.js", "file.js")]
216    fn test_normalize_path(#[case] path: &str, #[case] normalized: &str) {
217        assert_eq!(normalized, normalize_path(path).unwrap());
218    }
219
220    #[rstest]
221    #[case("../file.js")]
222    #[case("a/../../file.js")]
223    fn test_normalize_path_invalid(#[case] path: &str) {
224        assert_eq!(None, normalize_path(path));
225    }
226}