Skip to content

Commit f605684

Browse files
committed
feat: manage static assets properly in Unix and Windows (#229)
* feat: manage static assets properly in Unix and Windows * feat: add more e2e tests to validate static assets
1 parent f1f68c3 commit f605684

File tree

7 files changed

+391
-30
lines changed

7 files changed

+391
-30
lines changed

crates/server/src/handlers/assets.rs

Lines changed: 263 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,47 @@ use actix_files::NamedFile;
55
use actix_web::{web::Data, HttpRequest};
66
use std::{
77
io::{Error, ErrorKind},
8-
path::PathBuf,
8+
path::{Component, Path, PathBuf},
99
};
1010

11+
/// Clean up invalid components in the paths and returns it. For a file
12+
/// in the public folder, only "normal" components are valid.
13+
fn clean_up_path(uri: &str) -> PathBuf {
14+
// First split the URI as it always uses the /.
15+
let path = PathBuf::from_iter(uri.split('/'));
16+
17+
let valid_components: Vec<Component<'_>> = path
18+
.components()
19+
// Keep only normal components. The relative components should be
20+
// strip by actix, but we're double checking it in case of weird encodings
21+
// that can be interpreted as parent paths. Note this is a path that will
22+
// be appended later to the public folder.
23+
.filter(|c| matches!(c, Component::Normal(_)))
24+
.collect();
25+
26+
// Build a new PathBuf based only on valid components
27+
PathBuf::from_iter(valid_components)
28+
}
29+
30+
/// Build the file path to retrieve and check if it exists. To build, it takes the project
31+
/// root and the parsed path. You can set it the index_folder flag to manage the
32+
/// parsed_path as a folder an look for an index.html inside it.
33+
fn retrieve_asset_path(root_path: &Path, file_path: &Path, index_folder: bool) -> Option<PathBuf> {
34+
let public_folder = root_path.join("public");
35+
let asset_path = if index_folder {
36+
public_folder.join(file_path).join("index.html")
37+
} else {
38+
public_folder.join(file_path)
39+
};
40+
41+
// Checks the output path is a child of public folder
42+
if asset_path.starts_with(public_folder) && asset_path.exists() && asset_path.is_file() {
43+
Some(asset_path)
44+
} else {
45+
None
46+
}
47+
}
48+
1149
/// Find a static HTML file in the `public` folder. This function is used
1250
/// when there's no direct file to be served. It will look for certain patterns
1351
/// like "public/{uri}/index.html" and "public/{uri}.html".
@@ -17,20 +55,234 @@ pub async fn handle_assets(req: &HttpRequest) -> Result<NamedFile, Error> {
1755
let root_path = req.app_data::<Data<PathBuf>>().unwrap();
1856
let uri_path = req.path();
1957

20-
// File path. This is required for the wasm_handler as dynamic routes may capture static files
21-
let file_path = root_path.join(format!("public{uri_path}"));
22-
// A.k.a pretty urls. We may access /about and this matches to /about/index.html
23-
let index_folder_path = root_path.join(format!("public{uri_path}/index.html"));
24-
// Same as before, but the file is located at ./about.html
25-
let html_ext_path = root_path.join(format!("public{uri_path}.html"));
58+
// Double-check the given path path does not contain any unexpected value.
59+
// It was previously sanitized, but this is a double check.
60+
let parsed_path = clean_up_path(uri_path);
2661

27-
if file_path.exists() {
62+
if let Some(file_path) = retrieve_asset_path(root_path, &parsed_path, false) {
63+
// File path. This is required for the wasm_handler as dynamic routes may capture static files
2864
NamedFile::open_async(file_path).await
29-
} else if uri_path.ends_with('/') && index_folder_path.exists() {
65+
} else if let Some(index_folder_path) = retrieve_asset_path(root_path, &parsed_path, true) {
66+
// A.k.a pretty urls. We may access /about and this matches to /about/index.html
3067
NamedFile::open_async(index_folder_path).await
31-
} else if !uri_path.ends_with('/') && html_ext_path.exists() {
32-
NamedFile::open_async(html_ext_path).await
3368
} else {
3469
Err(Error::new(ErrorKind::NotFound, "The file is not present"))
3570
}
3671
}
72+
73+
#[cfg(test)]
74+
mod tests {
75+
use super::*;
76+
77+
#[test]
78+
fn test_clean_up_path() {
79+
let tests = if cfg!(target_os = "windows") {
80+
Vec::from([
81+
("/", PathBuf::new()),
82+
("/index.js", PathBuf::from("index.js")),
83+
("/my-folder/index.js", PathBuf::from("my-folder\\index.js")),
84+
// These scenarios are unlikely as actix already filters the
85+
// URI, but let's test them too
86+
("/../index.js", PathBuf::from("index.js")),
87+
("/../../index.js", PathBuf::from("index.js")),
88+
])
89+
} else {
90+
Vec::from([
91+
("/", PathBuf::new()),
92+
("/index.js", PathBuf::from("index.js")),
93+
("////index.js", PathBuf::from("index.js")),
94+
("/my-folder/index.js", PathBuf::from("my-folder/index.js")),
95+
// These scenarios are unlikely as actix already filters the
96+
// URI, but let's test them too
97+
("/../index.js", PathBuf::from("index.js")),
98+
("/../../index.js", PathBuf::from("index.js")),
99+
])
100+
};
101+
102+
for (uri, path) in tests {
103+
assert_eq!(clean_up_path(uri), path);
104+
}
105+
}
106+
107+
#[test]
108+
fn relative_asset_path_retrieval() {
109+
let (project_root, tests) = if cfg!(target_os = "windows") {
110+
let project_root = Path::new("..\\..\\tests\\data");
111+
let tests = Vec::from([
112+
// Existing files
113+
(
114+
Path::new("index.html"),
115+
Some(PathBuf::from("..\\..\\tests\\data\\public\\index.html")),
116+
),
117+
(
118+
Path::new("main.css"),
119+
Some(PathBuf::from("..\\..\\tests\\data\\public\\main.css")),
120+
),
121+
// Missing files
122+
(Path::new(""), None),
123+
(Path::new("unknown"), None),
124+
(Path::new("about"), None),
125+
]);
126+
127+
(project_root, tests)
128+
} else {
129+
let project_root = Path::new("../../tests/data");
130+
let tests = Vec::from([
131+
// Existing files
132+
(
133+
Path::new("index.html"),
134+
Some(PathBuf::from("../../tests/data/public/index.html")),
135+
),
136+
(
137+
Path::new("main.css"),
138+
Some(PathBuf::from("../../tests/data/public/main.css")),
139+
),
140+
// Missing files
141+
(Path::new(""), None),
142+
(Path::new("unknown"), None),
143+
(Path::new("about"), None),
144+
]);
145+
146+
(project_root, tests)
147+
};
148+
149+
for (file, asset_path) in tests {
150+
assert_eq!(retrieve_asset_path(project_root, file, false), asset_path);
151+
}
152+
}
153+
154+
#[test]
155+
fn absolute_asset_path_retrieval() {
156+
let (project_root, tests) = if cfg!(target_os = "windows") {
157+
let project_root = Path::new("..\\..\\tests\\data").canonicalize().unwrap();
158+
let tests = Vec::from([
159+
// Existing files
160+
(
161+
Path::new("index.html"),
162+
Some(project_root.join("public\\index.html")),
163+
),
164+
(
165+
Path::new("main.css"),
166+
Some(project_root.join("public\\main.css")),
167+
),
168+
// Missing files
169+
(Path::new(""), None),
170+
(Path::new("unknown"), None),
171+
(Path::new("about"), None),
172+
]);
173+
174+
(project_root, tests)
175+
} else {
176+
let project_root = Path::new("../../tests/data").canonicalize().unwrap();
177+
178+
let tests = Vec::from([
179+
// Existing files
180+
(
181+
Path::new("index.html"),
182+
Some(project_root.join("public/index.html")),
183+
),
184+
(
185+
Path::new("main.css"),
186+
Some(project_root.join("public/main.css")),
187+
),
188+
// Missing files
189+
(Path::new(""), None),
190+
(Path::new("unknown"), None),
191+
(Path::new("about"), None),
192+
]);
193+
194+
(project_root, tests)
195+
};
196+
197+
for (file, asset_path) in tests {
198+
assert_eq!(retrieve_asset_path(&project_root, file, false), asset_path);
199+
}
200+
}
201+
202+
#[test]
203+
fn relative_asset_index_path_retrieval() {
204+
let (project_root, tests) = if cfg!(target_os = "windows") {
205+
let project_root = Path::new("..\\..\\tests\\data");
206+
let tests = Vec::from([
207+
// Existing index files
208+
(
209+
Path::new("about"),
210+
Some(PathBuf::from(
211+
"..\\..\\tests\\data\\public\\about\\index.html",
212+
)),
213+
),
214+
(
215+
Path::new(""),
216+
Some(PathBuf::from("..\\..\\tests\\data\\public\\index.html")),
217+
),
218+
// Missing index files
219+
(Path::new("main.css"), None),
220+
(Path::new("unknown"), None),
221+
]);
222+
223+
(project_root, tests)
224+
} else {
225+
let project_root = Path::new("../../tests/data");
226+
let tests = Vec::from([
227+
// Existing index files
228+
(
229+
Path::new("about"),
230+
Some(PathBuf::from("../../tests/data/public/about/index.html")),
231+
),
232+
(
233+
Path::new(""),
234+
Some(PathBuf::from("../../tests/data/public/index.html")),
235+
),
236+
// Missing index files
237+
(Path::new("main.css"), None),
238+
(Path::new("unknown"), None),
239+
]);
240+
241+
(project_root, tests)
242+
};
243+
244+
for (file, asset_path) in tests {
245+
assert_eq!(retrieve_asset_path(project_root, file, true), asset_path);
246+
}
247+
}
248+
249+
#[test]
250+
fn absolute_asset_index_path_retrieval() {
251+
let (project_root, tests) = if cfg!(target_os = "windows") {
252+
let project_root = Path::new("..\\..\\tests\\data").canonicalize().unwrap();
253+
let tests = Vec::from([
254+
// Existing idnex files
255+
(
256+
Path::new("about"),
257+
Some(project_root.join("public\\about\\index.html")),
258+
),
259+
(Path::new(""), Some(project_root.join("public\\index.html"))),
260+
// Missing index files
261+
(Path::new("main.css"), None),
262+
(Path::new("unknown"), None),
263+
]);
264+
265+
(project_root, tests)
266+
} else {
267+
let project_root = Path::new("../../tests/data").canonicalize().unwrap();
268+
269+
let tests = Vec::from([
270+
// Existing index files
271+
(
272+
Path::new("about"),
273+
Some(project_root.join("public/about/index.html")),
274+
),
275+
(Path::new(""), Some(project_root.join("public/index.html"))),
276+
// Missing index files
277+
(Path::new("main.css"), None),
278+
(Path::new("unknown"), None),
279+
]);
280+
281+
(project_root, tests)
282+
};
283+
284+
for (file, asset_path) in tests {
285+
assert_eq!(retrieve_asset_path(&project_root, file, true), asset_path);
286+
}
287+
}
288+
}

examples/js-json/public/robots.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
User-agent: *
2+
Disallow: /

examples/js-params/public/main.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* This is just a comment for testing purposes */
12
body {
23
max-width: 1000px;
34
}
@@ -25,4 +26,4 @@ pre>code {
2526

2627
p {
2728
margin-top: 2rem;
28-
}
29+
}

tests/data/public/about/index.html

Whitespace-only changes.

tests/data/public/index.html

Whitespace-only changes.

tests/data/public/main.css

Whitespace-only changes.

0 commit comments

Comments
 (0)