Skip to main content

kellnr_docs/
upload.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use bytes::Bytes;
5use kellnr_storage::docs_storage::DocsStorage;
6
7use crate::docs_error::DocsError;
8
9/// Upload every regular file under `local_root` to `docs_storage` under the
10/// `{crate_name}/{version}/{key_prefix}/` prefix, then delete any pre-existing
11/// key under `{crate_name}/{version}/` that wasn't just uploaded.
12///
13/// Symlinks are never followed, so only files that were actually written into
14/// `local_root` can end up in the docs storage.
15///
16/// Upload-then-prune (not delete-then-upload) so a partial/failed upload
17/// never leaves a doc version with zero pages.
18///
19/// `key_prefix` lets the auto-generation queue (which walks `target/doc`
20/// directly) and the manual-upload path (whose zip already contains a
21/// top-level `doc/` folder) both land under `{crate}/{version}/doc/...`,
22/// matching the layout `compute_doc_url` expects. Pass `""` when `local_root`
23/// already contains the full relative structure.
24pub async fn upload_dir_and_prune(
25    local_root: &Path,
26    key_prefix: &str,
27    crate_name: &str,
28    version: &str,
29    docs_storage: &DocsStorage,
30) -> Result<(), DocsError> {
31    let mut uploaded_keys = HashSet::new();
32
33    for file_path in regular_files(local_root)? {
34        let relative =
35            file_path
36                .strip_prefix(local_root)
37                .map_err(|_| DocsError::PathPrefixMismatch {
38                    path: file_path.display().to_string(),
39                    root: local_root.display().to_string(),
40                })?;
41        let relative_key = relative.to_string_lossy().replace('\\', "/");
42        let relative_key = if key_prefix.is_empty() {
43            relative_key
44        } else {
45            format!("{key_prefix}/{relative_key}")
46        };
47        let key = DocsStorage::file_key(crate_name, version, &relative_key);
48
49        let data = tokio::fs::read(&file_path).await?;
50        docs_storage.put(&key, Bytes::from(data)).await?;
51        uploaded_keys.insert(key);
52    }
53
54    let prefix = DocsStorage::version_prefix(crate_name, version);
55    for existing_key in docs_storage.list(&prefix).await? {
56        if !uploaded_keys.contains(&existing_key) {
57            docs_storage.delete(&existing_key).await?;
58        }
59    }
60
61    Ok(())
62}
63
64/// Collect all regular files below `root` without following symlinks.
65fn regular_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
66    if !std::fs::symlink_metadata(root)?.is_dir() {
67        return Err(std::io::Error::new(
68            std::io::ErrorKind::InvalidInput,
69            format!("{} is not a directory", root.display()),
70        ));
71    }
72    let mut files = Vec::new();
73    let mut dirs = vec![root.to_path_buf()];
74    while let Some(dir) = dirs.pop() {
75        for entry in std::fs::read_dir(&dir)? {
76            let entry = entry?;
77            // `DirEntry::file_type` does not follow symlinks.
78            let file_type = entry.file_type()?;
79            if file_type.is_dir() {
80                dirs.push(entry.path());
81            } else if file_type.is_file() {
82                files.push(entry.path());
83            }
84        }
85    }
86    Ok(files)
87}
88
89#[cfg(test)]
90mod tests {
91    use kellnr_storage::fs_storage::FSStorage;
92
93    use super::*;
94
95    fn docs_storage() -> (tempfile::TempDir, DocsStorage) {
96        let dir = tempfile::tempdir().unwrap();
97        let storage = FSStorage::new(dir.path().to_str().unwrap()).unwrap();
98        (dir, DocsStorage::new(Box::new(storage)))
99    }
100
101    #[tokio::test]
102    async fn upload_preserves_directory_structure() {
103        let (_storage_dir, docs) = docs_storage();
104        let local = tempfile::tempdir().unwrap();
105        tokio::fs::write(local.path().join("index.html"), b"root")
106            .await
107            .unwrap();
108        tokio::fs::create_dir(local.path().join("nested"))
109            .await
110            .unwrap();
111        tokio::fs::write(local.path().join("nested/page.html"), b"nested")
112            .await
113            .unwrap();
114
115        upload_dir_and_prune(local.path(), "", "my-crate", "1.0.0", &docs)
116            .await
117            .unwrap();
118
119        assert_eq!(
120            docs.get(&DocsStorage::file_key("my-crate", "1.0.0", "index.html"))
121                .await
122                .unwrap(),
123            Bytes::from_static(b"root")
124        );
125        assert_eq!(
126            docs.get(&DocsStorage::file_key(
127                "my-crate",
128                "1.0.0",
129                "nested/page.html"
130            ))
131            .await
132            .unwrap(),
133            Bytes::from_static(b"nested")
134        );
135    }
136
137    #[tokio::test]
138    async fn upload_prunes_stale_keys_from_previous_build() {
139        let (_storage_dir, docs) = docs_storage();
140        let stale_key = DocsStorage::file_key("my-crate", "1.0.0", "removed.html");
141        docs.put(&stale_key, Bytes::from_static(b"old"))
142            .await
143            .unwrap();
144
145        let local = tempfile::tempdir().unwrap();
146        tokio::fs::write(local.path().join("index.html"), b"new")
147            .await
148            .unwrap();
149
150        upload_dir_and_prune(local.path(), "", "my-crate", "1.0.0", &docs)
151            .await
152            .unwrap();
153
154        assert!(!docs.exists(&stale_key).await.unwrap());
155        assert!(
156            docs.exists(&DocsStorage::file_key("my-crate", "1.0.0", "index.html"))
157                .await
158                .unwrap()
159        );
160    }
161
162    #[tokio::test]
163    async fn upload_leaves_other_versions_untouched() {
164        let (_storage_dir, docs) = docs_storage();
165        let other_version_key = DocsStorage::file_key("my-crate", "2.0.0", "index.html");
166        docs.put(&other_version_key, Bytes::from_static(b"kept"))
167            .await
168            .unwrap();
169
170        let local = tempfile::tempdir().unwrap();
171        tokio::fs::write(local.path().join("index.html"), b"new")
172            .await
173            .unwrap();
174
175        upload_dir_and_prune(local.path(), "", "my-crate", "1.0.0", &docs)
176            .await
177            .unwrap();
178
179        assert!(docs.exists(&other_version_key).await.unwrap());
180    }
181
182    #[tokio::test]
183    async fn upload_nests_files_under_key_prefix() {
184        let (_storage_dir, docs) = docs_storage();
185        let local = tempfile::tempdir().unwrap();
186        tokio::fs::write(local.path().join("index.html"), b"root")
187            .await
188            .unwrap();
189
190        upload_dir_and_prune(local.path(), "doc", "my-crate", "1.0.0", &docs)
191            .await
192            .unwrap();
193
194        assert_eq!(
195            docs.get(&DocsStorage::file_key(
196                "my-crate",
197                "1.0.0",
198                "doc/index.html"
199            ))
200            .await
201            .unwrap(),
202            Bytes::from_static(b"root")
203        );
204    }
205
206    #[cfg(unix)]
207    #[tokio::test]
208    async fn upload_skips_symlinked_files_and_directories() {
209        let (_storage_dir, docs) = docs_storage();
210        let outside = tempfile::tempdir().unwrap();
211        tokio::fs::write(outside.path().join("db.sqlite"), b"secret")
212            .await
213            .unwrap();
214
215        let local = tempfile::tempdir().unwrap();
216        tokio::fs::write(local.path().join("index.html"), b"root")
217            .await
218            .unwrap();
219        std::os::unix::fs::symlink(
220            outside.path().join("db.sqlite"),
221            local.path().join("db.html"),
222        )
223        .unwrap();
224        std::os::unix::fs::symlink(outside.path(), local.path().join("linked_dir")).unwrap();
225
226        upload_dir_and_prune(local.path(), "", "my-crate", "1.0.0", &docs)
227            .await
228            .unwrap();
229
230        let keys = docs
231            .list(&DocsStorage::version_prefix("my-crate", "1.0.0"))
232            .await
233            .unwrap();
234        assert_eq!(
235            keys,
236            vec![DocsStorage::file_key("my-crate", "1.0.0", "index.html")]
237        );
238    }
239
240    #[cfg(unix)]
241    #[tokio::test]
242    async fn upload_rejects_symlinked_root() {
243        let (_storage_dir, docs) = docs_storage();
244        let outside = tempfile::tempdir().unwrap();
245        tokio::fs::write(outside.path().join("index.html"), b"secret")
246            .await
247            .unwrap();
248        let local = tempfile::tempdir().unwrap();
249        let root = local.path().join("doc");
250        std::os::unix::fs::symlink(outside.path(), &root).unwrap();
251
252        let result = upload_dir_and_prune(&root, "", "my-crate", "1.0.0", &docs).await;
253
254        assert!(result.is_err());
255    }
256}