Skip to main content

kellnr_docs/
upload.rs

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
13/// Locks of the crate versions whose docs are uploaded, by key prefix.
14static UPLOAD_LOCKS: LazyLock<Mutex<HashMap<String, VersionLock>>> = LazyLock::new(Mutex::default);
15
16/// Lock for the uploads of one crate version, so that the prune of one upload
17/// cannot delete the files of a concurrent upload.
18fn 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
24/// Upload every regular file under `local_root` to `docs_storage` under the
25/// `{crate_name}/{version}/{key_prefix}/` prefix, then delete any pre-existing
26/// key under `{crate_name}/{version}/` that wasn't just uploaded.
27///
28/// Symlinks are never followed, so only files that were actually written into
29/// `local_root` can end up in the docs storage.
30///
31/// Upload-then-prune (not delete-then-upload) so a partial/failed upload
32/// never leaves a doc version with zero pages. Concurrent uploads of the same
33/// crate version run one after the other.
34///
35/// `key_prefix` lets the auto-generation queue (which walks `target/doc`
36/// directly) and the manual-upload path (whose zip already contains a
37/// top-level `doc/` folder) both land under `{crate}/{version}/doc/...`,
38/// matching the layout `compute_doc_url` expects. Pass `""` when `local_root`
39/// already contains the full relative structure.
40pub 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
86/// Collect all regular files below `root` without following symlinks.
87fn 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            // `DirEntry::file_type` does not follow symlinks.
100            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}