Skip to main content

pharos_native/runtime/
architect_import.rs

1//! Bounded, link-free staging for Architect folder and archive imports.
2
3use 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
13/// Owns disposable staging separately from the selected source app root.
14pub(super) struct UploadedSource {
15    staging_root: PathBuf,
16    pub(super) app_root: PathBuf,
17}
18
19impl Drop for UploadedSource {
20    /// Removes the entire import staging tree on success and failure.
21    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
28/// Tracks expansion limits and duplicate paths across one upload.
29struct SourceWriter {
30    root: PathBuf,
31    paths: HashSet<PathBuf>,
32    bytes: u64,
33}
34
35impl SourceWriter {
36    /// Writes a regular source file without overwriting another archive entry.
37    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
74/// Rejects absolute, parent, Windows-prefixed, and control-character paths.
75fn 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
100/// Extracts ZIP entries through the bounded source writer rather than unpacking paths.
101fn 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
129/// Reads TAR entries with a bound on the entire decompressed stream, including metadata.
130fn 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
157/// Requires one app at the upload root or inside a single enclosing folder.
158fn 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
175/// Stages exactly one folder or supported archive and owns cleanup until import completes.
176pub(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    /// Creates isolated workspace-local staging with automatic cleanup.
260    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    /// Builds a multipart upload containing the exact files under test.
275    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    /// Generates stored ZIP content without requiring an external archive tool.
297    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    /// Generates a TAR app with an enclosing directory, as source exports do.
312    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    /// Covers archive traversal across Unix and Windows path conventions.
331    #[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    /// Rejects malformed archives before creating source output.
343    #[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    /// Stages folders and all advertised archive formats, then removes their enclosing temp tree.
356    #[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    /// Rejects multiple selections, unsupported types, and archive traversal with no staging residue.
390    #[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    /// Parses a realistic many-file folder without repeatedly scanning its remaining payload.
440    #[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    /// Bounds all multipart parts, including fields that do not belong to source uploads.
477    #[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    /// Preserves empty app files without changing the existing attachment parser policy.
502    #[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    /// Bounds file count and expansion before touching the destination filesystem.
535    #[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    /// Rejects ZIP symlinks and TAR links before attempting to resolve their targets.
558    #[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}