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