snix_castore_http/
lib.rs

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