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
9pub 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
64fn 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 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}