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