snix_castore_http/
routes.rs

1use crate::app_state::AppState;
2use crate::get_root_node_contents;
3
4use snix_castore::PathBuf;
5
6use axum::{
7    extract::{self, State},
8    http::StatusCode,
9    response::Response,
10};
11use axum_extra::{TypedHeader, headers::Range};
12use std::path;
13use tracing::{debug, instrument};
14
15#[instrument(level = "trace", ret, skip_all, fields(maybe_path))]
16pub async fn root_node_contents(
17    maybe_path: Option<extract::Path<String>>,
18    state: State<AppState>,
19    range_header: Option<TypedHeader<Range>>,
20) -> Result<Response, StatusCode> {
21    let requested_path = maybe_path
22        .map(|extract::Path(path)| PathBuf::from_host_path(path::Path::new(&path), true))
23        .transpose()
24        .map_err(|err| {
25            debug!(%err, "User requested an invalid path");
26            StatusCode::BAD_REQUEST
27        })?;
28    let requested_path = match requested_path.as_ref() {
29        Some(p) => p.as_ref(),
30        None => &PathBuf::new(),
31    };
32
33    get_root_node_contents(
34        state.blob_service.clone(),
35        state.directory_service.clone(),
36        path::Path::new("/"),
37        &state.root_node,
38        requested_path,
39        range_header,
40        &state.index_names,
41        state.auto_index,
42    )
43    .await
44}
45
46#[cfg(test)]
47mod tests {
48    use crate::{app_state::AppConfig, router::app};
49
50    use snix_castore::{
51        B3Digest, Directory, Node,
52        blobservice::{BlobService, MemoryBlobService},
53        directoryservice::DirectoryService,
54        fixtures::{DIRECTORY_COMPLICATED, HELLOWORLD_BLOB_CONTENTS, HELLOWORLD_BLOB_DIGEST},
55        utils::gen_test_directory_service,
56    };
57
58    use axum::http::StatusCode;
59    use std::io::Cursor;
60    use std::sync::{Arc, LazyLock};
61    use tracing_test::traced_test;
62
63    /// Accepts a root node to be served, and returns a [axum_test::TestServer].
64    fn gen_server<S: AsRef<str>>(
65        root_node: Node,
66        index_names: &[S],
67        auto_index: bool,
68    ) -> (
69        axum_test::TestServer,
70        impl BlobService + use<S>,
71        impl DirectoryService + use<S>,
72    ) {
73        let blob_service = Arc::new(MemoryBlobService::default());
74        let directory_service = Arc::new(gen_test_directory_service());
75
76        let app = app(Arc::new(AppConfig {
77            blob_service: blob_service.clone(),
78            directory_service: directory_service.clone(),
79            root_node,
80            index_names: index_names
81                .iter()
82                .map(|index| index.as_ref().to_string())
83                .collect(),
84            auto_index,
85        }));
86
87        (
88            axum_test::TestServer::new(app),
89            blob_service,
90            directory_service,
91        )
92    }
93
94    pub const INDEX_HTML_BLOB_CONTENTS: &[u8] =
95        b"<!DOCTYPE html><html><body>Hello World!</body></html>";
96    pub static INDEX_HTML_BLOB_DIGEST: LazyLock<B3Digest> =
97        LazyLock::new(|| blake3::hash(INDEX_HTML_BLOB_CONTENTS).as_bytes().into());
98
99    pub static DIRECTORY_NESTED_WITH_SYMLINK: LazyLock<Directory> = LazyLock::new(|| {
100        Directory::try_from_iter([
101            (
102                "nested".try_into().unwrap(),
103                Node::Directory {
104                    digest: DIRECTORY_WITH_SYMLINK.digest(),
105                    size: DIRECTORY_WITH_SYMLINK.size(),
106                },
107            ),
108            (
109                "index.htm".try_into().unwrap(),
110                Node::File {
111                    digest: *INDEX_HTML_BLOB_DIGEST,
112                    size: INDEX_HTML_BLOB_CONTENTS.len() as u64,
113                    executable: false,
114                },
115            ),
116            (
117                "out_of_base_path_symlink".try_into().unwrap(),
118                Node::Symlink {
119                    target: "../index.htm".try_into().unwrap(),
120                },
121            ),
122        ])
123        .unwrap()
124    });
125
126    pub static DIRECTORY_WITH_SYMLINK: LazyLock<Directory> = LazyLock::new(|| {
127        Directory::try_from_iter([
128            (
129                "index.html".try_into().unwrap(),
130                Node::File {
131                    digest: *INDEX_HTML_BLOB_DIGEST,
132                    size: INDEX_HTML_BLOB_CONTENTS.len() as u64,
133                    executable: false,
134                },
135            ),
136            (
137                "dot".try_into().unwrap(),
138                Node::Symlink {
139                    target: ".".try_into().unwrap(),
140                },
141            ),
142            (
143                "symlink".try_into().unwrap(),
144                Node::Symlink {
145                    target: "index.html".try_into().unwrap(),
146                },
147            ),
148            (
149                "dot_symlink".try_into().unwrap(),
150                Node::Symlink {
151                    target: "./index.html".try_into().unwrap(),
152                },
153            ),
154            (
155                "dotdot_symlink".try_into().unwrap(),
156                Node::Symlink {
157                    target: "../index.htm".try_into().unwrap(),
158                },
159            ),
160            (
161                "dotdot_same_symlink".try_into().unwrap(),
162                Node::Symlink {
163                    target: "../nested/index.html".try_into().unwrap(),
164                },
165            ),
166        ])
167        .unwrap()
168    });
169
170    #[traced_test]
171    #[tokio::test]
172    async fn test_lists_directory_contents_if_auto_index_enabled() {
173        let root_node = Node::Directory {
174            digest: DIRECTORY_COMPLICATED.digest(),
175            size: DIRECTORY_COMPLICATED.size(),
176        };
177
178        // No index but auto-index is enabled
179        let (server, _blob_service, directory_service) = gen_server::<&str>(root_node, &[], true);
180
181        directory_service
182            .put(DIRECTORY_COMPLICATED.clone())
183            .await
184            .expect("Failed to insert directory");
185
186        server
187            .get("/")
188            .expect_success()
189            .await
190            .assert_text_contains("<html><body><li><a href=\"/.keep\">.keep</a></li><li><a href=\"/aa\">aa</a></li><li><a href=\"/keep\">keep</a></li></body></html>");
191    }
192
193    #[traced_test]
194    #[tokio::test]
195    async fn test_lists_directory_contents_if_auto_index_enabled_for_nested_dir() {
196        let root_node = Node::Directory {
197            digest: DIRECTORY_NESTED_WITH_SYMLINK.digest(),
198            size: DIRECTORY_NESTED_WITH_SYMLINK.size(),
199        };
200
201        // No index but auto-index is enabled
202        let (server, _blob_service, directory_service) = gen_server::<&str>(root_node, &[], true);
203        let mut directory_service_handle = directory_service.put_multiple_start();
204        directory_service_handle
205            .put(DIRECTORY_WITH_SYMLINK.clone())
206            .await
207            .expect("Failed to insert directory");
208        directory_service_handle
209            .put(DIRECTORY_NESTED_WITH_SYMLINK.clone())
210            .await
211            .expect("Failed to insert directory");
212        directory_service_handle
213            .close()
214            .await
215            .expect("Failed to close handle");
216
217        server
218            .get("/nested")
219            .expect_success()
220            .await
221            .assert_text_contains("<!DOCTYPE html><html><body><li><a href=\"/nested/dot\">dot</a></li><li><a href=\"/nested/dot_symlink\">dot_symlink</a></li><li><a href=\"/nested/dotdot_same_symlink\">dotdot_same_symlink</a></li><li><a href=\"/nested/dotdot_symlink\">dotdot_symlink</a></li><li><a href=\"/nested/index.html\">index.html</a></li><li><a href=\"/nested/symlink\">symlink</a></li></body></html>");
222    }
223
224    #[traced_test]
225    #[tokio::test]
226    async fn test_responds_index_file_if_configured() {
227        let root_node = Node::Directory {
228            digest: DIRECTORY_COMPLICATED.digest(),
229            size: DIRECTORY_COMPLICATED.size(),
230        };
231
232        // .keep is a index file in this test scenario, auto-index is off
233        let (server, blob_service, directory_service) =
234            gen_server::<&str>(root_node, &[".keep"], false);
235
236        directory_service
237            .put(DIRECTORY_COMPLICATED.clone())
238            .await
239            .expect("Failed to insert directory");
240
241        let mut blob_writer = blob_service.open_write().await;
242        tokio::io::copy(&mut Cursor::new(vec![]), &mut blob_writer)
243            .await
244            .expect("Failed to copy file to BlobWriter");
245        blob_writer
246            .close()
247            .await
248            .expect("Failed to close the BlobWriter");
249
250        server.get("/").expect_success().await;
251    }
252
253    #[traced_test]
254    #[tokio::test]
255    async fn test_responds_index_file_if_configured_in_nested_dir() {
256        let root_node = Node::Directory {
257            digest: DIRECTORY_NESTED_WITH_SYMLINK.digest(),
258            size: DIRECTORY_NESTED_WITH_SYMLINK.size(),
259        };
260
261        // .keep is a index file in this test scenario, auto-index is off
262        let (server, blob_service, directory_service) =
263            gen_server::<&str>(root_node, &["index.html"], false);
264
265        let mut directory_service_handle = directory_service.put_multiple_start();
266        directory_service_handle
267            .put(DIRECTORY_WITH_SYMLINK.clone())
268            .await
269            .expect("Failed to insert directory");
270        directory_service_handle
271            .put(DIRECTORY_NESTED_WITH_SYMLINK.clone())
272            .await
273            .expect("Failed to insert directory");
274        directory_service_handle
275            .close()
276            .await
277            .expect("Failed to close handle");
278
279        let mut blob_writer = blob_service.open_write().await;
280        tokio::io::copy(&mut Cursor::new(INDEX_HTML_BLOB_CONTENTS), &mut blob_writer)
281            .await
282            .expect("Failed to copy file to BlobWriter");
283        let digest = blob_writer
284            .close()
285            .await
286            .expect("Failed to close the BlobWriter");
287        assert_eq!(digest, *INDEX_HTML_BLOB_DIGEST);
288
289        server.get("/nested").expect_success().await;
290    }
291
292    #[traced_test]
293    #[tokio::test]
294    async fn test_responds_forbidden_if_no_index_configured_nor_auto_index_enabled() {
295        let root_node = Node::Directory {
296            digest: DIRECTORY_COMPLICATED.digest(),
297            size: DIRECTORY_COMPLICATED.size(),
298        };
299
300        // no index configured and auto-index disabled
301        let (server, _blob_service, directory_service) = gen_server::<&str>(root_node, &[], false);
302
303        directory_service
304            .put(DIRECTORY_COMPLICATED.clone())
305            .await
306            .expect("Failed to insert directory");
307
308        let response = server.get("/").expect_failure().await;
309        response.assert_status(StatusCode::FORBIDDEN);
310    }
311
312    #[traced_test]
313    #[tokio::test]
314    async fn test_responds_file() {
315        let root_node = Node::Directory {
316            digest: DIRECTORY_COMPLICATED.digest(),
317            size: DIRECTORY_COMPLICATED.size(),
318        };
319
320        let (server, blob_service, directory_service) = gen_server::<&str>(root_node, &[], false);
321
322        directory_service
323            .put(DIRECTORY_COMPLICATED.clone())
324            .await
325            .expect("Failed to insert directory");
326
327        let mut blob_writer = blob_service.open_write().await;
328        tokio::io::copy(&mut Cursor::new(vec![]), &mut blob_writer)
329            .await
330            .expect("Failed to copy file to BlobWriter");
331        blob_writer
332            .close()
333            .await
334            .expect("Failed to close the BlobWriter");
335
336        server.get("/.keep").expect_success().await;
337    }
338
339    #[traced_test]
340    #[tokio::test]
341    async fn test_responds_file_and_correct_content_type() {
342        let root_node = Node::Directory {
343            digest: DIRECTORY_NESTED_WITH_SYMLINK.digest(),
344            size: DIRECTORY_NESTED_WITH_SYMLINK.size(),
345        };
346
347        let (server, blob_service, directory_service) = gen_server::<&str>(root_node, &[], false);
348
349        let mut directory_service_handle = directory_service.put_multiple_start();
350        directory_service_handle
351            .put(DIRECTORY_WITH_SYMLINK.clone())
352            .await
353            .expect("Failed to insert directory");
354        directory_service_handle
355            .put(DIRECTORY_NESTED_WITH_SYMLINK.clone())
356            .await
357            .expect("Failed to insert directory");
358        directory_service_handle
359            .close()
360            .await
361            .expect("Failed to close handle");
362
363        let mut blob_writer = blob_service.open_write().await;
364        tokio::io::copy(&mut Cursor::new(INDEX_HTML_BLOB_CONTENTS), &mut blob_writer)
365            .await
366            .expect("Failed to copy file to BlobWriter");
367        let digest = blob_writer
368            .close()
369            .await
370            .expect("Failed to close the BlobWriter");
371        assert_eq!(digest, *INDEX_HTML_BLOB_DIGEST);
372
373        let response = server.get("/nested/index.html").expect_success().await;
374        response.assert_header("Content-Type", "text/html");
375    }
376
377    #[traced_test]
378    #[tokio::test]
379    async fn test_responds_redirect_if_symlink() {
380        let root_node = Node::Directory {
381            digest: DIRECTORY_COMPLICATED.digest(),
382            size: DIRECTORY_COMPLICATED.size(),
383        };
384
385        let (server, _blob_service, directory_service) = gen_server::<&str>(root_node, &[], false);
386
387        directory_service
388            .put(DIRECTORY_COMPLICATED.clone())
389            .await
390            .expect("Failed to insert directory");
391
392        let response = server.get("/aa").await;
393        response.assert_status(StatusCode::TEMPORARY_REDIRECT);
394        response.assert_header("Location", "/nix/store/somewhereelse");
395    }
396
397    #[traced_test]
398    #[tokio::test]
399    async fn test_responds_redirect_with_normalized_path_if_symlink() {
400        let root_node = Node::Directory {
401            digest: DIRECTORY_NESTED_WITH_SYMLINK.digest(),
402            size: DIRECTORY_NESTED_WITH_SYMLINK.size(),
403        };
404
405        let (server, _blob_service, directory_service) = gen_server::<&str>(root_node, &[], false);
406
407        let mut directory_service_handle = directory_service.put_multiple_start();
408        directory_service_handle
409            .put(DIRECTORY_WITH_SYMLINK.clone())
410            .await
411            .expect("Failed to insert directory");
412        directory_service_handle
413            .put(DIRECTORY_NESTED_WITH_SYMLINK.clone())
414            .await
415            .expect("Failed to insert directory");
416        directory_service_handle
417            .close()
418            .await
419            .expect("Failed to close handle");
420
421        let response = server.get("/nested/symlink").await;
422        response.assert_status(StatusCode::TEMPORARY_REDIRECT);
423        response.assert_header("Location", "/nested/index.html");
424
425        let response = server.get("/nested/dot_symlink").await;
426        response.assert_status(StatusCode::TEMPORARY_REDIRECT);
427        response.assert_header("Location", "/nested/index.html");
428
429        let response = server.get("/nested/dotdot_symlink").await;
430        response.assert_status(StatusCode::TEMPORARY_REDIRECT);
431        response.assert_header("Location", "/index.htm");
432
433        let response = server.get("/out_of_base_path_symlink").await;
434        response.assert_status(StatusCode::TEMPORARY_REDIRECT);
435        response.assert_header("Location", "/index.htm");
436
437        let response = server.get("/nested/dot").expect_failure().await;
438        response.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
439    }
440
441    #[traced_test]
442    #[tokio::test]
443    async fn test_returns_bad_request_if_not_valid_path() {
444        let root_node = Node::Directory {
445            digest: DIRECTORY_COMPLICATED.digest(),
446            size: DIRECTORY_COMPLICATED.size(),
447        };
448
449        let (server, _blob_service, _directory_service) = gen_server::<&str>(root_node, &[], false);
450
451        // request an invalid path
452        let response = server.get("//aa").expect_failure().await;
453        response.assert_status(StatusCode::BAD_REQUEST);
454    }
455
456    #[traced_test]
457    #[tokio::test]
458    async fn test_returns_bad_request_if_root_node_is_file_and_path_requested() {
459        let root_node = Node::File {
460            digest: *HELLOWORLD_BLOB_DIGEST,
461            size: HELLOWORLD_BLOB_CONTENTS.len() as u64,
462            executable: false,
463        };
464
465        let (server, _blob_service, _directory_service) = gen_server::<&str>(root_node, &[], false);
466
467        // request a path while the root node is a file
468        let response = server.get("/some-path").expect_failure().await;
469        response.assert_status(StatusCode::BAD_REQUEST);
470    }
471
472    #[traced_test]
473    #[tokio::test]
474    async fn test_returns_bad_request_if_root_node_is_symlink_and_path_requested() {
475        let root_node = Node::Symlink {
476            target: "/nix/store/somewhereelse".try_into().unwrap(),
477        };
478
479        let (server, _blob_service, _directory_service) = gen_server::<&str>(root_node, &[], false);
480
481        // request a path while the root node is a symlink
482        let response = server.get("/some-path").expect_failure().await;
483        response.assert_status(StatusCode::BAD_REQUEST);
484    }
485}