snix_castore_http/
lib.rs

1pub mod app_state;
2pub mod cli;
3pub mod router;
4pub mod routes;
5
6use std::path;
7
8use snix_castore::{
9    B3Digest, Directory, Node, Path, SymlinkTarget,
10    blobservice::BlobService,
11    directoryservice::{DirectoryService, descend_to},
12};
13
14use axum::{
15    body::Body,
16    http::{StatusCode, header},
17    response::{AppendHeaders, IntoResponse, Redirect, Response},
18};
19use axum_extra::{TypedHeader, headers::Range, response::Html};
20use axum_range::{KnownSize, Ranged};
21use path_clean::PathClean;
22use std::ffi::OsStr;
23use std::os::unix::ffi::OsStrExt;
24use tokio_util::io::ReaderStream;
25use tracing::{debug, error, instrument, warn};
26
27/// Helper function, descending from the given `root_node` to the `requested_path` specified.
28/// Returns HTTP Responses or Status Codes.
29/// If the path points to a regular file, it serves its contents.
30/// If the path points to a symlink, it sends a redirect to the target (pretending `base_path`, if relative)
31/// If the path points to a directory, files of `index_names` are tried,
32/// if no files matched then a directory listing is returned if `auto_index` is enabled.
33///
34/// Uses the passed [BlobService] and [DirectoryService]
35#[allow(clippy::too_many_arguments)]
36#[instrument(level = "trace", skip_all, fields(base_path, requested_path), err)]
37pub async fn get_root_node_contents<BS: BlobService, DS: DirectoryService, S: AsRef<str>>(
38    blob_service: BS,
39    directory_service: DS,
40    base_path: &path::Path,
41    root_node: &Node,
42    requested_path: &Path,
43    range_header: Option<TypedHeader<Range>>,
44    index_names: &[S],
45    auto_index: bool,
46) -> Result<Response, StatusCode> {
47    match root_node {
48        Node::Directory { .. } => {
49            let requested_node = descend_to(&directory_service, root_node.clone(), requested_path)
50                .await
51                .map_err(|err| {
52                    error!(err=%err, "an error occured descending");
53                    StatusCode::INTERNAL_SERVER_ERROR
54                })?
55                .ok_or_else(|| {
56                    error!("requested path doesn't exist");
57                    StatusCode::NOT_FOUND
58                })?;
59            match requested_node {
60                Node::Directory { digest, .. } => {
61                    let requested_directory = directory_service
62                        .get(&digest)
63                        .await
64                        .map_err(|err| {
65                            error!(err=%err, "an error occured getting the directory");
66                            StatusCode::INTERNAL_SERVER_ERROR
67                        })?
68                        .ok_or_else(|| {
69                            error!("directory doesn't exist");
70                            StatusCode::NOT_FOUND
71                        })?;
72
73                    // If there was one or more index configured, try to find it
74                    // in the directory requested by the client, by comparing the bytes
75                    // of each directories immediate child's path with the bytes of the
76                    // configured index name
77                    for index_name in index_names {
78                        if let Some((found_index_file_path, found_index_node)) = requested_directory
79                            .nodes()
80                            .find(|(path, _node)| index_name.as_ref().as_bytes() == path.as_ref())
81                        {
82                            match found_index_node {
83                                Node::File { digest, size, .. } => {
84                                    let extension = found_index_file_path
85                                        .extension()
86                                        .and_then(|b| std::str::from_utf8(b).ok());
87
88                                    return respond_file(
89                                        blob_service,
90                                        extension,
91                                        range_header,
92                                        digest,
93                                        *size,
94                                    )
95                                    .await;
96                                }
97                                _ => {
98                                    debug!(
99                                        path = %found_index_file_path,
100                                        "One of the configured index names matched with a
101                                        node located in the root node's directory which is
102                                        not a file"
103                                    );
104                                }
105                            }
106                        }
107                    }
108                    if auto_index {
109                        return respond_directory_list(&requested_directory, requested_path).await;
110                    }
111                    Err(StatusCode::FORBIDDEN)
112                }
113                Node::File { digest, size, .. } => {
114                    respond_file(
115                        blob_service,
116                        requested_path
117                            .extension()
118                            .and_then(|b| std::str::from_utf8(b).ok()),
119                        range_header,
120                        &digest,
121                        size,
122                    )
123                    .await
124                }
125                Node::Symlink { target } => {
126                    let requested_path =
127                        path::Path::new(OsStr::from_bytes(requested_path.as_bytes()));
128                    respond_symlink(base_path, &target, Some(requested_path)).await
129                }
130            }
131        }
132        Node::File { digest, size, .. } => {
133            if requested_path.to_string() == "" {
134                respond_file(blob_service, None, range_header, digest, *size).await
135            } else {
136                warn!(
137                    "The client requested a path but the configured root
138                    node being served is a file"
139                );
140                Err(StatusCode::BAD_REQUEST)
141            }
142        }
143        Node::Symlink { target } => {
144            if requested_path.to_string() == "" {
145                respond_symlink(base_path, target, None).await
146            } else {
147                warn!(
148                    "The client requested a path but the configured root
149                    node being served is a symlink"
150                );
151                Err(StatusCode::BAD_REQUEST)
152            }
153        }
154    }
155}
156
157#[instrument(level = "trace", skip_all)]
158pub async fn respond_symlink(
159    base_path: &path::Path,
160    symlink_target: &SymlinkTarget,
161    requested_path: Option<&path::Path>,
162) -> Result<Response, StatusCode> {
163    if symlink_target.as_ref() == b"." {
164        error!("There was a symlink with target '.'");
165        return Err(StatusCode::INTERNAL_SERVER_ERROR);
166    }
167
168    let symlink_target_path = match std::str::from_utf8(symlink_target.as_ref()) {
169        Ok(s) => path::Path::new(s),
170        Err(_) => {
171            error!("Symlink target contains invalid UTF-8");
172            return Err(StatusCode::INTERNAL_SERVER_ERROR);
173        }
174    };
175
176    let symlink_target_path = if symlink_target_path.is_absolute() {
177        symlink_target_path.to_path_buf()
178    } else if let Some(requested_path) = requested_path {
179        let requested_path_parent = requested_path.parent().ok_or_else(|| {
180            error!("failed to retrieve parent path for requested path");
181            StatusCode::INTERNAL_SERVER_ERROR
182        })?;
183        base_path
184            .join(requested_path_parent)
185            .join(symlink_target_path)
186    } else {
187        base_path.join(symlink_target_path)
188    };
189
190    let symlink_target_path = symlink_target_path.clean();
191
192    if symlink_target_path.starts_with(path::Component::ParentDir) {
193        error!("the symlink's target path points to a non-existing path");
194        return Err(StatusCode::INTERNAL_SERVER_ERROR);
195    }
196
197    let symlink_target_path_str = symlink_target_path.to_str().ok_or(StatusCode::NOT_FOUND)?;
198    Ok(Redirect::temporary(symlink_target_path_str).into_response())
199}
200
201#[instrument(level = "trace", skip_all, fields(directory_path, directory))]
202pub async fn respond_directory_list(
203    directory: &Directory,
204    directory_path: &Path,
205) -> Result<Response, StatusCode> {
206    let mut directory_list_html = String::new();
207    for (path_component, _node) in directory.nodes() {
208        let directory_path = directory_path
209            .try_join(path_component.as_ref())
210            .expect("Join path");
211        directory_list_html.push_str(&format!(
212            "<li><a href=\"/{directory_path}\">{path_component}</a></li>"
213        ))
214    }
215    Ok(Html(format!(
216        "<!DOCTYPE html><html><body>{directory_list_html}</body></html>"
217    ))
218    .into_response())
219}
220
221#[instrument(level = "trace", skip_all, fields(digest, size))]
222pub async fn respond_file<BS: BlobService>(
223    blob_service: BS,
224    extension: Option<&str>,
225    range_header: Option<TypedHeader<Range>>,
226    digest: &B3Digest,
227    size: u64,
228) -> Result<Response, StatusCode> {
229    let blob_reader = blob_service
230        .open_read(digest)
231        .await
232        .map_err(|err| {
233            error!(err=%err, "failed to read blob");
234            StatusCode::INTERNAL_SERVER_ERROR
235        })?
236        .ok_or_else(|| {
237            error!("blob doesn't exist");
238            StatusCode::NOT_FOUND
239        })?;
240
241    let mime_type = extension
242        .and_then(|extension| mime_guess::from_ext(extension).first())
243        .unwrap_or(mime::APPLICATION_OCTET_STREAM);
244    match range_header {
245        None => Ok((
246            StatusCode::OK,
247            AppendHeaders([
248                (header::CONTENT_TYPE, mime_type.to_string()),
249                (header::CONTENT_LENGTH, size.to_string()),
250            ]),
251            Body::from_stream(ReaderStream::new(blob_reader)),
252        )
253            .into_response()),
254        Some(TypedHeader(range)) => Ok((
255            StatusCode::OK,
256            AppendHeaders([
257                (header::CONTENT_TYPE, mime_type.to_string()),
258                (header::CONTENT_LENGTH, size.to_string()),
259            ]),
260            Ranged::new(Some(range), KnownSize::sized(blob_reader, size)).into_response(),
261        )
262            .into_response()),
263    }
264}