1use super::{agent_architect, dynamic, http, json_commands};
4use std::collections::HashSet;
5use std::fs::{self, OpenOptions};
6use std::io::{self, Cursor, Read};
7use std::path::{Component, Path, PathBuf};
8
9const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
10const MAX_EXPANDED_BYTES: u64 = 80 * 1024 * 1024;
11const MAX_FILES: usize = 512;
12
13pub(super) struct UploadedSource {
15 staging_root: PathBuf,
16 pub(super) app_root: PathBuf,
17}
18
19impl Drop for UploadedSource {
20 fn drop(&mut self) {
22 if let Err(error) = fs::remove_dir_all(&self.staging_root) {
23 eprintln!("Architect import staging cleanup failed: {error}");
24 }
25 }
26}
27
28struct SourceWriter {
30 root: PathBuf,
31 paths: HashSet<PathBuf>,
32 bytes: u64,
33}
34
35impl SourceWriter {
36 fn write(&mut self, name: &str, size: u64, reader: impl Read) -> Result<(), &'static str> {
38 let relative = safe_relative_path(name)?;
39 if !agent_architect::source_export_relative_path_allowed(&relative) {
40 return Err("import contains an unsupported source path\n");
41 }
42 if self.paths.len() >= MAX_FILES
43 || size > MAX_UPLOAD_BYTES as u64
44 || size > MAX_EXPANDED_BYTES.saturating_sub(self.bytes)
45 {
46 return Err("import exceeds source file or expanded size limits\n");
47 }
48 if !self.paths.insert(relative.clone()) {
49 return Err("import contains duplicate source paths\n");
50 }
51 let output = self.root.join(relative);
52 let parent = output.parent().ok_or("invalid source path\n")?;
53 fs::create_dir_all(parent).map_err(|_| "import storage unavailable\n")?;
54 let mut options = OpenOptions::new();
55 options.write(true).create_new(true);
56 #[cfg(unix)]
57 {
58 use std::os::unix::fs::OpenOptionsExt;
59 options.mode(0o640);
60 }
61 let mut file = options
62 .open(output)
63 .map_err(|_| "import source path collision\n")?;
64 let copied = io::copy(&mut reader.take(size + 1), &mut file)
65 .map_err(|_| "import contains unreadable source data\n")?;
66 if copied != size {
67 return Err("import contains an invalid source file size\n");
68 }
69 self.bytes += copied;
70 Ok(())
71 }
72}
73
74fn safe_relative_path(name: &str) -> Result<PathBuf, &'static str> {
76 let normalized = name.replace('\\', "/");
77 if normalized.is_empty()
78 || normalized.len() > 240
79 || normalized.starts_with('/')
80 || normalized.contains(':')
81 || normalized.chars().any(char::is_control)
82 || normalized.split('/').any(|part| part == "..")
83 {
84 return Err("import contains an unsafe source path\n");
85 }
86 let mut relative = PathBuf::new();
87 for component in Path::new(&normalized).components() {
88 match component {
89 Component::Normal(segment) => relative.push(segment),
90 Component::CurDir => {}
91 _ => return Err("import contains an unsafe source path\n"),
92 }
93 }
94 if relative.as_os_str().is_empty() {
95 return Err("import contains an empty source path\n");
96 }
97 Ok(relative)
98}
99
100fn extract_zip(content: &[u8], writer: &mut SourceWriter) -> Result<(), &'static str> {
102 let mut archive =
103 zip::ZipArchive::new(Cursor::new(content)).map_err(|_| "invalid ZIP archive\n")?;
104 if archive.len() > MAX_FILES * 2 {
105 return Err("archive contains too many entries\n");
106 }
107 for index in 0..archive.len() {
108 let mut entry = archive
109 .by_index(index)
110 .map_err(|_| "unreadable ZIP entry\n")?;
111 let kind = entry.unix_mode().unwrap_or(0) & 0o170000;
112 if entry.is_symlink() || !matches!(kind, 0 | 0o100000 | 0o040000) {
113 return Err("archive links and special files are not supported\n");
114 }
115 if entry.is_dir() {
116 if !matches!(entry.name(), "." | "./") {
117 safe_relative_path(entry.name())?;
118 }
119 continue;
120 }
121 safe_relative_path(entry.name())?;
122 let name = entry.name().to_owned();
123 let size = entry.size();
124 writer.write(&name, size, &mut entry)?;
125 }
126 Ok(())
127}
128
129fn extract_tar(reader: impl Read, writer: &mut SourceWriter) -> Result<(), &'static str> {
131 let mut archive = tar::Archive::new(reader.take(MAX_EXPANDED_BYTES + 2 * 1024 * 1024));
132 let entries = archive.entries().map_err(|_| "invalid TAR archive\n")?;
133 for (index, entry) in entries.enumerate() {
134 if index >= MAX_FILES * 2 {
135 return Err("archive contains too many entries\n");
136 }
137 let mut entry = entry.map_err(|_| "unreadable TAR entry\n")?;
138 let path = entry.path().map_err(|_| "invalid TAR source path\n")?;
139 let name = path.to_str().ok_or("invalid TAR source path\n")?.to_owned();
140 let kind = entry.header().entry_type();
141 if kind.is_dir() {
142 if !matches!(name.as_str(), "." | "./") {
143 safe_relative_path(&name)?;
144 }
145 continue;
146 }
147 safe_relative_path(&name)?;
148 if !kind.is_file() {
149 return Err("archive links and special files are not supported\n");
150 }
151 let size = entry.size();
152 writer.write(&name, size, &mut entry)?;
153 }
154 Ok(())
155}
156
157fn find_app_root(root: &Path) -> Result<PathBuf, &'static str> {
159 if root.join("pharos.app.yml").is_file() {
160 return Ok(root.to_path_buf());
161 }
162 let mut directories = Vec::new();
163 for entry in fs::read_dir(root).map_err(|_| "import storage unavailable\n")? {
164 let entry = entry.map_err(|_| "import storage unavailable\n")?;
165 if entry.path().is_dir() {
166 directories.push(entry.path());
167 }
168 }
169 if directories.len() == 1 && directories[0].join("pharos.app.yml").is_file() {
170 return Ok(directories.remove(0));
171 }
172 Err("select one source app folder or archive containing pharos.app.yml\n")
173}
174
175pub(super) fn stage_upload(
177 request: &http::HttpRequest,
178 content_type: &str,
179 state_parent: &Path,
180) -> Result<UploadedSource, &'static str> {
181 if request.body.len() > MAX_UPLOAD_BYTES + 1024 * 1024 {
182 return Err("source upload exceeds 20 MiB\n");
183 }
184 let folders = json_commands::request_multipart_source_file_parts(
185 content_type,
186 &request.body,
187 "source_folder_file",
188 MAX_FILES + 1,
189 request.body.len(),
190 );
191 let archives = json_commands::request_multipart_source_file_parts(
192 content_type,
193 &request.body,
194 "source_archive",
195 MAX_FILES + 1,
196 request.body.len(),
197 );
198 if (folders.is_empty() && archives.len() != 1)
199 || (!folders.is_empty() && !archives.is_empty())
200 || folders.len() > MAX_FILES
201 {
202 return Err("select exactly one source app folder or archive\n");
203 }
204 let upload_bytes: usize = folders
205 .iter()
206 .chain(&archives)
207 .map(|part| part.content.len())
208 .sum();
209 if upload_bytes > MAX_UPLOAD_BYTES {
210 return Err("source upload exceeds 20 MiB\n");
211 }
212 let nonce = dynamic::random_secret_hex(12).map_err(|_| "import storage unavailable\n")?;
213 let parent = state_parent.join("tmp/editor-imports");
214 fs::create_dir_all(&parent).map_err(|_| "import storage unavailable\n")?;
215 let staging_root = parent.join(nonce);
216 fs::create_dir(&staging_root).map_err(|_| "import storage unavailable\n")?;
217 let mut upload = UploadedSource {
218 app_root: staging_root.clone(),
219 staging_root: staging_root.clone(),
220 };
221 let mut writer = SourceWriter {
222 root: staging_root,
223 paths: HashSet::new(),
224 bytes: 0,
225 };
226 if let Some(archive) = archives.first() {
227 let name = archive.filename.to_ascii_lowercase();
228 match name.as_str() {
229 name if name.ends_with(".zip") => extract_zip(&archive.content, &mut writer)?,
230 name if name.ends_with(".tar") => {
231 extract_tar(Cursor::new(&archive.content), &mut writer)?
232 }
233 name if name.ends_with(".tar.gz") || name.ends_with(".tgz") => {
234 extract_tar(
235 flate2::read::GzDecoder::new(Cursor::new(&archive.content)),
236 &mut writer,
237 )?;
238 }
239 _ => return Err("supported archives are ZIP, TAR, TAR.GZ and TGZ\n"),
240 }
241 } else {
242 for part in folders {
243 writer.write(
244 &part.filename,
245 part.content.len() as u64,
246 Cursor::new(part.content),
247 )?;
248 }
249 }
250 upload.app_root = find_app_root(&upload.staging_root)?;
251 Ok(upload)
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use std::io::Write;
258
259 fn test_storage() -> UploadedSource {
261 let parent = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
262 fs::create_dir_all(&parent).unwrap();
263 let root = parent.join(format!(
264 "pharos-architect-import-test.{}",
265 dynamic::random_secret_hex(12).unwrap()
266 ));
267 fs::create_dir(&root).unwrap();
268 UploadedSource {
269 app_root: root.clone(),
270 staging_root: root,
271 }
272 }
273
274 fn upload_request(parts: &[(&str, &str, &[u8])]) -> http::HttpRequest {
276 let mut body = Vec::new();
277 for (field, name, content) in parts {
278 write!(body, "--import-test\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{name}\"\r\n\r\n").unwrap();
279 body.extend_from_slice(content);
280 body.extend_from_slice(b"\r\n");
281 }
282 body.extend_from_slice(b"--import-test--\r\n");
283 http::HttpRequest {
284 method: "POST".to_owned(),
285 target: "/architect/editor/projects/import-source".to_owned(),
286 cookie: None,
287 content_type: Some("multipart/form-data; boundary=import-test".to_owned()),
288 headers: String::new(),
289 body_len: body.len(),
290 body,
291 body_file_path: None,
292 is_multipart: true,
293 }
294 }
295
296 fn zip_content(files: &[(&str, &[u8])]) -> Vec<u8> {
298 let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
299 for (name, content) in files {
300 zip.start_file(
301 *name,
302 zip::write::SimpleFileOptions::default()
303 .compression_method(zip::CompressionMethod::Stored),
304 )
305 .unwrap();
306 zip.write_all(content).unwrap();
307 }
308 zip.finish().unwrap().into_inner()
309 }
310
311 fn tar_content() -> Vec<u8> {
313 let mut tar = tar::Builder::new(Vec::new());
314 let mut directory = tar::Header::new_gnu();
315 directory.set_size(0);
316 directory.set_mode(0o750);
317 directory.set_entry_type(tar::EntryType::Directory);
318 directory.set_cksum();
319 tar.append_data(&mut directory, "./", io::empty()).unwrap();
320 let content = b"app_id: test_app\nname: Test app\n";
321 let mut header = tar::Header::new_gnu();
322 header.set_size(content.len() as u64);
323 header.set_mode(0o640);
324 header.set_cksum();
325 tar.append_data(&mut header, "test_app/pharos.app.yml", &content[..])
326 .unwrap();
327 tar.into_inner().unwrap()
328 }
329
330 #[test]
332 fn source_paths_reject_traversal_and_absolute_names() {
333 for name in ["../x", "app/../x", "/x", "\\x", "C:\\x", "a\0b", "./"] {
334 assert!(safe_relative_path(name).is_err(), "{name:?}");
335 }
336 assert_eq!(
337 safe_relative_path("./app/templates/home.html").unwrap(),
338 Path::new("app/templates/home.html")
339 );
340 }
341
342 #[test]
344 fn malformed_archives_fail_closed() {
345 let mut writer = SourceWriter {
346 root: PathBuf::new(),
347 paths: HashSet::new(),
348 bytes: 0,
349 };
350 assert!(extract_zip(b"not a ZIP", &mut writer).is_err());
351 assert!(extract_tar(Cursor::new(b"not a TAR"), &mut writer).is_err());
352 assert!(writer.paths.is_empty());
353 }
354
355 #[test]
357 fn folder_zip_tar_and_gzip_stage_and_cleanup() {
358 let storage = test_storage();
359 let zip = zip_content(&[("test_app/pharos.app.yml", b"app_id: test_app\n")]);
360 let tar = tar_content();
361 let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
362 gzip.write_all(&tar).unwrap();
363 let gzip = gzip.finish().unwrap();
364 for (field, name, content) in [
365 (
366 "source_folder_file",
367 "pharos.app.yml",
368 &b"app_id: test_app\n"[..],
369 ),
370 ("source_archive", "source.zip", zip.as_slice()),
371 ("source_archive", "source.tar", tar.as_slice()),
372 ("source_archive", "source.tar.gz", gzip.as_slice()),
373 ("source_archive", "source.tgz", gzip.as_slice()),
374 ] {
375 let request = upload_request(&[(field, name, content)]);
376 let upload = stage_upload(
377 &request,
378 request.content_type.as_deref().unwrap(),
379 &storage.app_root,
380 )
381 .unwrap();
382 assert!(upload.app_root.join("pharos.app.yml").is_file());
383 let root = upload.staging_root.clone();
384 drop(upload);
385 assert!(!root.exists());
386 }
387 }
388
389 #[test]
391 fn invalid_uploads_reject_and_cleanup() {
392 let storage = test_storage();
393 let zip = zip_content(&[("pharos.app.yml", b"app_id: test_app\n")]);
394 let unsafe_zip = zip_content(&[("../pharos.app.yml", b"app_id: test_app\n")]);
395 for parts in [
396 vec![
397 ("source_archive", "one.zip", zip.as_slice()),
398 ("source_archive", "two.zip", zip.as_slice()),
399 ],
400 vec![
401 ("source_archive", "one.zip", zip.as_slice()),
402 (
403 "source_folder_file",
404 "pharos.app.yml",
405 &b"app_id: test_app\n"[..],
406 ),
407 ],
408 vec![("source_archive", "app.txt", zip.as_slice())],
409 vec![("source_archive", "unsafe.zip", unsafe_zip.as_slice())],
410 vec![
411 (
412 "source_folder_file",
413 "one/pharos.app.yml",
414 &b"app_id: one\n"[..],
415 ),
416 (
417 "source_folder_file",
418 "two/pharos.app.yml",
419 &b"app_id: two\n"[..],
420 ),
421 ],
422 ] {
423 let request = upload_request(&parts);
424 assert!(
425 stage_upload(
426 &request,
427 request.content_type.as_deref().unwrap(),
428 &storage.app_root
429 )
430 .is_err()
431 );
432 let parent = storage.app_root.join("tmp/editor-imports");
433 if parent.exists() {
434 assert_eq!(fs::read_dir(parent).unwrap().count(), 0);
435 }
436 }
437 }
438
439 #[test]
441 fn large_folder_multipart_upload_stages_all_files() {
442 let storage = test_storage();
443 let names = (0..300)
444 .map(|index| format!("static/file-{index}.txt"))
445 .collect::<Vec<_>>();
446 let content = vec![b'a'; 20_000];
447 let mut parts = vec![(
448 "source_folder_file",
449 "pharos.app.yml",
450 b"app_id: test_app\n".as_slice(),
451 )];
452 parts.extend(
453 names
454 .iter()
455 .map(|name| ("source_folder_file", name.as_str(), content.as_slice())),
456 );
457 let request = upload_request(&parts);
458 let upload = stage_upload(
459 &request,
460 request.content_type.as_deref().unwrap(),
461 &storage.app_root,
462 )
463 .unwrap();
464 assert_eq!(
465 fs::read_dir(upload.app_root.join("static"))
466 .unwrap()
467 .count(),
468 300
469 );
470 assert_eq!(
471 fs::read(upload.app_root.join("static/file-299.txt")).unwrap(),
472 content
473 );
474 }
475
476 #[test]
478 fn excess_ignored_multipart_parts_reject_the_upload() {
479 let storage = test_storage();
480 let mut parts = vec![(
481 "source_folder_file",
482 "pharos.app.yml",
483 b"app_id: test_app\n".as_slice(),
484 )];
485 parts.extend(std::iter::repeat_n(
486 ("ignored", "unused.txt", b"".as_slice()),
487 1024,
488 ));
489 let request = upload_request(&parts);
490 assert!(
491 stage_upload(
492 &request,
493 request.content_type.as_deref().unwrap(),
494 &storage.app_root
495 )
496 .is_err()
497 );
498 assert!(!storage.app_root.join("tmp/editor-imports").exists());
499 }
500
501 #[test]
503 fn folder_import_preserves_empty_files() {
504 let storage = test_storage();
505 let request = upload_request(&[
506 (
507 "source_folder_file",
508 "pharos.app.yml",
509 b"app_id: test_app\n",
510 ),
511 ("source_folder_file", "templates/empty.html", b""),
512 ]);
513 let content_type = request.content_type.as_deref().unwrap();
514 let upload = stage_upload(&request, content_type, &storage.app_root).unwrap();
515 assert_eq!(
516 fs::metadata(upload.app_root.join("templates/empty.html"))
517 .unwrap()
518 .len(),
519 0
520 );
521 assert_eq!(
522 json_commands::request_multipart_file_parts(
523 content_type,
524 &request.body,
525 "source_folder_file",
526 MAX_FILES,
527 MAX_UPLOAD_BYTES
528 )
529 .len(),
530 1
531 );
532 }
533
534 #[test]
536 fn expanded_size_and_file_count_are_bounded() {
537 let storage = test_storage();
538 let mut writer = SourceWriter {
539 root: storage.app_root.clone(),
540 paths: HashSet::new(),
541 bytes: MAX_EXPANDED_BYTES,
542 };
543 assert!(writer.write("large.txt", 1, Cursor::new(b"x")).is_err());
544 writer.bytes = 0;
545 assert!(
546 writer
547 .write("large.txt", MAX_UPLOAD_BYTES as u64 + 1, io::empty())
548 .is_err()
549 );
550 writer.paths = (0..MAX_FILES)
551 .map(|index| PathBuf::from(format!("{index}.txt")))
552 .collect();
553 assert!(writer.write("extra.txt", 1, Cursor::new(b"x")).is_err());
554 assert_eq!(fs::read_dir(&storage.app_root).unwrap().count(), 0);
555 }
556
557 #[test]
559 fn archive_links_are_rejected() {
560 let storage = test_storage();
561 let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
562 zip.add_symlink(
563 "pharos.app.yml",
564 "../outside",
565 zip::write::SimpleFileOptions::default(),
566 )
567 .unwrap();
568 let zip = zip.finish().unwrap().into_inner();
569 let mut writer = SourceWriter {
570 root: storage.app_root.clone(),
571 paths: HashSet::new(),
572 bytes: 0,
573 };
574 assert!(extract_zip(&zip, &mut writer).is_err());
575 let mut tar = tar::Builder::new(Vec::new());
576 let mut header = tar::Header::new_gnu();
577 header.set_entry_type(tar::EntryType::Symlink);
578 header.set_size(0);
579 header.set_mode(0o640);
580 header.set_cksum();
581 tar.append_link(&mut header, "pharos.app.yml", "../outside")
582 .unwrap();
583 assert!(extract_tar(Cursor::new(tar.into_inner().unwrap()), &mut writer).is_err());
584 assert!(writer.paths.is_empty());
585 }
586}