1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, LazyLock, Mutex};
4
5use bytes::Bytes;
6use kellnr_common::version::Version;
7use kellnr_storage::docs_storage::{DocsName, DocsStorage};
8
9use crate::docs_error::DocsError;
10
11type VersionLock = Arc<tokio::sync::Mutex<()>>;
12
13static UPLOAD_LOCKS: LazyLock<Mutex<HashMap<String, VersionLock>>> = LazyLock::new(Mutex::default);
15
16fn version_lock(prefix: &str) -> VersionLock {
19 let mut locks = UPLOAD_LOCKS.lock().unwrap();
20 locks.retain(|_, lock| Arc::strong_count(lock) > 1);
21 Arc::clone(locks.entry(prefix.to_string()).or_default())
22}
23
24pub async fn upload_dir_and_prune(
41 local_root: &Path,
42 key_prefix: &str,
43 crate_name: &DocsName,
44 version: &Version,
45 docs_storage: &DocsStorage,
46) -> Result<(), DocsError> {
47 let prefix = DocsStorage::version_prefix(crate_name, version);
48 let lock = version_lock(&prefix);
49 let _guard = lock.lock().await;
50
51 let root = local_root.to_path_buf();
52 let files = tokio::task::spawn_blocking(move || regular_files(&root))
53 .await
54 .map_err(|_| DocsError::ExtractFailed)??;
55 let mut uploaded_keys = HashSet::new();
56 for file_path in files {
57 let relative =
58 file_path
59 .strip_prefix(local_root)
60 .map_err(|_| DocsError::PathPrefixMismatch {
61 path: file_path.display().to_string(),
62 root: local_root.display().to_string(),
63 })?;
64 let relative_key = relative.to_string_lossy().replace('\\', "/");
65 let relative_key = if key_prefix.is_empty() {
66 relative_key
67 } else {
68 format!("{key_prefix}/{relative_key}")
69 };
70 let key = DocsStorage::file_key(crate_name, version, &relative_key);
71
72 let data = tokio::fs::read(&file_path).await?;
73 docs_storage.put(&key, Bytes::from(data)).await?;
74 uploaded_keys.insert(key);
75 }
76
77 for existing_key in docs_storage.list(&prefix).await? {
78 if !uploaded_keys.contains(&existing_key) {
79 docs_storage.delete(&existing_key).await?;
80 }
81 }
82
83 Ok(())
84}
85
86fn regular_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
88 if !std::fs::symlink_metadata(root)?.is_dir() {
89 return Err(std::io::Error::new(
90 std::io::ErrorKind::InvalidInput,
91 format!("{} is not a directory", root.display()),
92 ));
93 }
94 let mut files = Vec::new();
95 let mut dirs = vec![root.to_path_buf()];
96 while let Some(dir) = dirs.pop() {
97 for entry in std::fs::read_dir(&dir)? {
98 let entry = entry?;
99 let file_type = entry.file_type()?;
101 if file_type.is_dir() {
102 dirs.push(entry.path());
103 } else if file_type.is_file() {
104 files.push(entry.path());
105 }
106 }
107 }
108 Ok(files)
109}
110
111#[cfg(test)]
112mod tests {
113 use kellnr_storage::fs_storage::FSStorage;
114
115 use super::*;
116
117 fn docs_storage() -> (tempfile::TempDir, DocsStorage) {
118 let dir = tempfile::tempdir().unwrap();
119 let storage = FSStorage::new(dir.path().to_str().unwrap()).unwrap();
120 (dir, DocsStorage::new(Box::new(storage)))
121 }
122
123 #[tokio::test]
124 async fn upload_preserves_directory_structure() {
125 let (_storage_dir, docs) = docs_storage();
126 let local = tempfile::tempdir().unwrap();
127 tokio::fs::write(local.path().join("index.html"), b"root")
128 .await
129 .unwrap();
130 tokio::fs::create_dir(local.path().join("nested"))
131 .await
132 .unwrap();
133 tokio::fs::write(local.path().join("nested/page.html"), b"nested")
134 .await
135 .unwrap();
136
137 upload_dir_and_prune(
138 local.path(),
139 "",
140 &DocsName::from_folder("my-crate".to_string()),
141 &Version::try_from("1.0.0").unwrap(),
142 &docs,
143 )
144 .await
145 .unwrap();
146
147 assert_eq!(
148 docs.get(&DocsStorage::file_key(
149 &DocsName::from_folder("my-crate".to_string()),
150 &Version::try_from("1.0.0").unwrap(),
151 "index.html"
152 ))
153 .await
154 .unwrap(),
155 Bytes::from_static(b"root")
156 );
157 assert_eq!(
158 docs.get(&DocsStorage::file_key(
159 &DocsName::from_folder("my-crate".to_string()),
160 &Version::try_from("1.0.0").unwrap(),
161 "nested/page.html"
162 ))
163 .await
164 .unwrap(),
165 Bytes::from_static(b"nested")
166 );
167 }
168
169 #[tokio::test]
170 async fn upload_prunes_stale_keys_from_previous_build() {
171 let (_storage_dir, docs) = docs_storage();
172 let stale_key = DocsStorage::file_key(
173 &DocsName::from_folder("my-crate".to_string()),
174 &Version::try_from("1.0.0").unwrap(),
175 "removed.html",
176 );
177 docs.put(&stale_key, Bytes::from_static(b"old"))
178 .await
179 .unwrap();
180
181 let local = tempfile::tempdir().unwrap();
182 tokio::fs::write(local.path().join("index.html"), b"new")
183 .await
184 .unwrap();
185
186 upload_dir_and_prune(
187 local.path(),
188 "",
189 &DocsName::from_folder("my-crate".to_string()),
190 &Version::try_from("1.0.0").unwrap(),
191 &docs,
192 )
193 .await
194 .unwrap();
195
196 assert!(!docs.exists(&stale_key).await.unwrap());
197 assert!(
198 docs.exists(&DocsStorage::file_key(
199 &DocsName::from_folder("my-crate".to_string()),
200 &Version::try_from("1.0.0").unwrap(),
201 "index.html"
202 ))
203 .await
204 .unwrap()
205 );
206 }
207
208 #[tokio::test]
209 async fn upload_keeps_files_with_special_characters() {
210 let (_storage_dir, docs) = docs_storage();
211 let local = tempfile::tempdir().unwrap();
212 for file in ["a~b.html", "x%20y.html"] {
213 tokio::fs::write(local.path().join(file), file)
214 .await
215 .unwrap();
216 }
217
218 upload_dir_and_prune(
219 local.path(),
220 "",
221 &DocsName::from_folder("my-crate".to_string()),
222 &Version::try_from("1.0.0").unwrap(),
223 &docs,
224 )
225 .await
226 .unwrap();
227
228 for file in ["a~b.html", "x%20y.html"] {
229 let key = DocsStorage::file_key(
230 &DocsName::from_folder("my-crate".to_string()),
231 &Version::try_from("1.0.0").unwrap(),
232 file,
233 );
234 assert_eq!(docs.get(&key).await.unwrap(), Bytes::from(file), "{file}");
235 }
236 }
237
238 fn local_docs(prefix: &str, files: usize) -> tempfile::TempDir {
239 let local = tempfile::tempdir().unwrap();
240 for i in 0..files {
241 std::fs::write(local.path().join(format!("{prefix}{i}.html")), "").unwrap();
242 }
243 local
244 }
245
246 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
247 async fn concurrent_uploads_of_one_version_keep_the_files_of_one_upload() {
248 let (_storage_dir, docs) = docs_storage();
249 let docs = Arc::new(docs);
250 let name = DocsName::from_folder("my-crate".to_string());
251 let version = Version::try_from("1.0.0").unwrap();
252 let first = local_docs("a", 20);
253 let second = local_docs("b", 20);
254
255 for _ in 0..10 {
256 let uploads = [first.path().to_path_buf(), second.path().to_path_buf()].map(|dir| {
257 let (docs, name, version) = (docs.clone(), name.clone(), version.clone());
258 tokio::spawn(async move {
259 upload_dir_and_prune(&dir, "", &name, &version, &docs)
260 .await
261 .unwrap();
262 })
263 });
264 for upload in uploads {
265 upload.await.unwrap();
266 }
267
268 let keys = docs
269 .list(&DocsStorage::version_prefix(&name, &version))
270 .await
271 .unwrap();
272 assert_eq!(keys.len(), 20, "{keys:?}");
273 let prefix = keys[0]
274 .as_str()
275 .split('/')
276 .nth(2)
277 .unwrap()
278 .chars()
279 .next()
280 .unwrap();
281 assert!(
282 keys.iter()
283 .all(|k| k.as_str().split('/').nth(2).unwrap().starts_with(prefix)),
284 "{keys:?}"
285 );
286 }
287 }
288
289 #[tokio::test]
290 async fn upload_leaves_other_versions_untouched() {
291 let (_storage_dir, docs) = docs_storage();
292 let other_version_key = DocsStorage::file_key(
293 &DocsName::from_folder("my-crate".to_string()),
294 &Version::try_from("2.0.0").unwrap(),
295 "index.html",
296 );
297 docs.put(&other_version_key, Bytes::from_static(b"kept"))
298 .await
299 .unwrap();
300
301 let local = tempfile::tempdir().unwrap();
302 tokio::fs::write(local.path().join("index.html"), b"new")
303 .await
304 .unwrap();
305
306 upload_dir_and_prune(
307 local.path(),
308 "",
309 &DocsName::from_folder("my-crate".to_string()),
310 &Version::try_from("1.0.0").unwrap(),
311 &docs,
312 )
313 .await
314 .unwrap();
315
316 assert!(docs.exists(&other_version_key).await.unwrap());
317 }
318
319 #[tokio::test]
320 async fn upload_nests_files_under_key_prefix() {
321 let (_storage_dir, docs) = docs_storage();
322 let local = tempfile::tempdir().unwrap();
323 tokio::fs::write(local.path().join("index.html"), b"root")
324 .await
325 .unwrap();
326
327 upload_dir_and_prune(
328 local.path(),
329 "doc",
330 &DocsName::from_folder("my-crate".to_string()),
331 &Version::try_from("1.0.0").unwrap(),
332 &docs,
333 )
334 .await
335 .unwrap();
336
337 assert_eq!(
338 docs.get(&DocsStorage::file_key(
339 &DocsName::from_folder("my-crate".to_string()),
340 &Version::try_from("1.0.0").unwrap(),
341 "doc/index.html"
342 ))
343 .await
344 .unwrap(),
345 Bytes::from_static(b"root")
346 );
347 }
348
349 #[cfg(unix)]
350 #[tokio::test]
351 async fn upload_skips_symlinked_files_and_directories() {
352 let (_storage_dir, docs) = docs_storage();
353 let outside = tempfile::tempdir().unwrap();
354 tokio::fs::write(outside.path().join("db.sqlite"), b"secret")
355 .await
356 .unwrap();
357
358 let local = tempfile::tempdir().unwrap();
359 tokio::fs::write(local.path().join("index.html"), b"root")
360 .await
361 .unwrap();
362 std::os::unix::fs::symlink(
363 outside.path().join("db.sqlite"),
364 local.path().join("db.html"),
365 )
366 .unwrap();
367 std::os::unix::fs::symlink(outside.path(), local.path().join("linked_dir")).unwrap();
368
369 upload_dir_and_prune(
370 local.path(),
371 "",
372 &DocsName::from_folder("my-crate".to_string()),
373 &Version::try_from("1.0.0").unwrap(),
374 &docs,
375 )
376 .await
377 .unwrap();
378
379 let keys = docs
380 .list(&DocsStorage::version_prefix(
381 &DocsName::from_folder("my-crate".to_string()),
382 &Version::try_from("1.0.0").unwrap(),
383 ))
384 .await
385 .unwrap();
386 assert_eq!(
387 keys,
388 vec![DocsStorage::file_key(
389 &DocsName::from_folder("my-crate".to_string()),
390 &Version::try_from("1.0.0").unwrap(),
391 "index.html"
392 )]
393 );
394 }
395
396 #[cfg(unix)]
397 #[tokio::test]
398 async fn upload_rejects_symlinked_root() {
399 let (_storage_dir, docs) = docs_storage();
400 let outside = tempfile::tempdir().unwrap();
401 tokio::fs::write(outside.path().join("index.html"), b"secret")
402 .await
403 .unwrap();
404 let local = tempfile::tempdir().unwrap();
405 let root = local.path().join("doc");
406 std::os::unix::fs::symlink(outside.path(), &root).unwrap();
407
408 let result = upload_dir_and_prune(
409 &root,
410 "",
411 &DocsName::from_folder("my-crate".to_string()),
412 &Version::try_from("1.0.0").unwrap(),
413 &docs,
414 )
415 .await;
416
417 assert!(result.is_err());
418 }
419}