Skip to main content

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