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#[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 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}