Skip to main content

agentics_contracts/zip_project/
package.rs

1use std::fs::{self, File};
2use std::io::{Cursor, Seek, Write};
3use std::path::{Path, PathBuf};
4
5use agentics_error::{Result, ServiceError};
6use ignore::{DirEntry, WalkBuilder};
7use zip::CompressionMethod;
8use zip::write::SimpleFileOptions;
9
10use crate::validation::archive::NormalizedArchivePath;
11use crate::zip_project::{
12    MAX_ZIP_PROJECT_ARTIFACT_BYTES, MAX_ZIP_PROJECT_FILE_COUNT, MAX_ZIP_PROJECT_UNCOMPRESSED_BYTES,
13    ZIP_PROJECT_MANIFEST_FILE, ZipProjectManifest, validate_zip_project_archive_envelope,
14};
15
16#[derive(Debug, Clone)]
17/// Packaged `zip_project` workspace bytes plus stable package metadata.
18pub struct ZipProjectWorkspacePackage {
19    pub workspace_dir: PathBuf,
20    pub bytes: Vec<u8>,
21    pub file_count: usize,
22    pub uncompressed_bytes: u64,
23}
24
25#[derive(Debug, Clone, Copy)]
26/// Limits used while packaging a `zip_project` workspace.
27pub struct ZipProjectWorkspacePackageLimits {
28    pub max_zip_bytes: u64,
29    pub max_file_count: usize,
30    pub max_uncompressed_bytes: u64,
31}
32
33impl ZipProjectWorkspacePackageLimits {
34    pub const DEFAULT: Self = Self {
35        max_zip_bytes: MAX_ZIP_PROJECT_ARTIFACT_BYTES,
36        max_file_count: MAX_ZIP_PROJECT_FILE_COUNT,
37        max_uncompressed_bytes: MAX_ZIP_PROJECT_UNCOMPRESSED_BYTES,
38    };
39}
40
41#[derive(Debug, Clone)]
42struct PackageFile {
43    path: PathBuf,
44    archive_name: String,
45    unix_permissions: u32,
46}
47
48#[derive(Debug)]
49struct CollectedPackageFiles {
50    files: Vec<PackageFile>,
51    uncompressed_bytes: u64,
52}
53
54/// Package one local `zip_project` solution workspace using the platform policy.
55pub fn package_zip_project_workspace(workspace_dir: &Path) -> Result<ZipProjectWorkspacePackage> {
56    package_zip_project_workspace_with_limits(
57        workspace_dir,
58        ZipProjectWorkspacePackageLimits::DEFAULT,
59    )
60}
61
62/// Package one local `zip_project` solution workspace with explicit limits.
63pub fn package_zip_project_workspace_with_limits(
64    workspace_dir: &Path,
65    limits: ZipProjectWorkspacePackageLimits,
66) -> Result<ZipProjectWorkspacePackage> {
67    let workspace_dir = workspace_dir.canonicalize().map_err(|error| {
68        ServiceError::Validation(format!(
69            "failed to resolve workspace {}: {error}",
70            workspace_dir.display()
71        ))
72    })?;
73    if !workspace_dir.is_dir() {
74        return Err(ServiceError::Validation(format!(
75            "workspace is not a directory: {}",
76            workspace_dir.display()
77        )));
78    }
79
80    let manifest_path = workspace_dir.join(ZIP_PROJECT_MANIFEST_FILE);
81    if !fs::exists(&manifest_path).map_err(|error| {
82        ServiceError::Validation(format!(
83            "failed to inspect {}: {error}",
84            manifest_path.display()
85        ))
86    })? {
87        return Err(ServiceError::Validation(format!(
88            "{ZIP_PROJECT_MANIFEST_FILE} must exist at the workspace root before packaging a solution submission"
89        )));
90    }
91    if !manifest_path.is_file() {
92        return Err(ServiceError::Validation(format!(
93            "{ZIP_PROJECT_MANIFEST_FILE} must be a file"
94        )));
95    }
96    let manifest_raw = fs::read_to_string(&manifest_path)?;
97    let manifest = ZipProjectManifest::parse_json(&manifest_raw)?;
98
99    let mut required_scripts = Vec::new();
100    if let Some(setup) = &manifest.commands.setup {
101        required_scripts.push(setup);
102    }
103    if let Some(build) = &manifest.commands.build {
104        required_scripts.push(build);
105    }
106    required_scripts.push(&manifest.commands.run);
107    for script in &required_scripts {
108        let script_path = workspace_dir.join(script.as_path());
109        if !fs::exists(&script_path).map_err(|error| {
110            ServiceError::Validation(format!(
111                "failed to inspect {}: {error}",
112                script_path.display()
113            ))
114        })? {
115            return Err(ServiceError::Validation(format!(
116                "{script} must exist before packaging a solution submission"
117            )));
118        }
119        if !script_path.is_file() {
120            return Err(ServiceError::Validation(format!("{script} must be a file")));
121        }
122    }
123
124    let collected = collect_package_files(&workspace_dir, limits)?;
125    let files = collected.files;
126    if !files
127        .iter()
128        .any(|file| file.archive_name == ZIP_PROJECT_MANIFEST_FILE)
129    {
130        return Err(ServiceError::Validation(format!(
131            "{ZIP_PROJECT_MANIFEST_FILE} is excluded by .gitignore or package filters"
132        )));
133    }
134    for script in required_scripts {
135        if !files
136            .iter()
137            .any(|file| file.archive_name == script.as_str())
138        {
139            return Err(ServiceError::Validation(format!(
140                "{script} is excluded by .gitignore or package filters"
141            )));
142        }
143    }
144    if files.is_empty() {
145        return Err(ServiceError::Validation(
146            "workspace contains no packageable files".to_string(),
147        ));
148    }
149
150    let bytes = write_zip_archive(&files, limits)?;
151    let zip_bytes = u64::try_from(bytes.len()).map_err(|_| {
152        ServiceError::Validation("zip archive length exceeds supported range".to_string())
153    })?;
154    if zip_bytes > limits.max_zip_bytes {
155        return Err(ServiceError::Validation(format!(
156            "solution archive must be at most {} bytes after compression",
157            limits.max_zip_bytes
158        )));
159    }
160    validate_zip_project_archive_envelope(&bytes)?;
161
162    Ok(ZipProjectWorkspacePackage {
163        workspace_dir,
164        bytes,
165        file_count: files.len(),
166        uncompressed_bytes: collected.uncompressed_bytes,
167    })
168}
169
170fn collect_package_files(
171    workspace_dir: &Path,
172    limits: ZipProjectWorkspacePackageLimits,
173) -> Result<CollectedPackageFiles> {
174    let mut builder = WalkBuilder::new(workspace_dir);
175    builder
176        .git_ignore(true)
177        .git_global(true)
178        .git_exclude(true)
179        .hidden(true)
180        .parents(true)
181        .require_git(false)
182        .filter_entry(should_descend);
183
184    let mut files = Vec::new();
185    let mut uncompressed_bytes = 0u64;
186    for entry in builder.build() {
187        let entry = entry.map_err(|error| {
188            ServiceError::Validation(format!(
189                "failed to walk workspace while packaging {}: {error}",
190                workspace_dir.display()
191            ))
192        })?;
193        if entry.path() == workspace_dir || !entry.file_type().is_some_and(|kind| kind.is_file()) {
194            continue;
195        }
196
197        let relative = entry.path().strip_prefix(workspace_dir).map_err(|error| {
198            ServiceError::internal(format!(
199                "failed to compute relative path for {}: {error}",
200                entry.path().display()
201            ))
202        })?;
203        let archive_name = archive_name(relative)?;
204        let metadata = entry.metadata().map_err(|error| {
205            ServiceError::Validation(format!(
206                "failed to stat {}: {error}",
207                entry.path().display()
208            ))
209        })?;
210        if files.len() >= limits.max_file_count {
211            return Err(ServiceError::Validation(format!(
212                "solution workspace must contain at most {} packageable files",
213                limits.max_file_count
214            )));
215        }
216        uncompressed_bytes = uncompressed_bytes
217            .checked_add(metadata.len())
218            .ok_or_else(|| {
219                ServiceError::Validation("solution workspace is too large".to_string())
220            })?;
221        if uncompressed_bytes > limits.max_uncompressed_bytes {
222            return Err(ServiceError::Validation(format!(
223                "solution workspace must contain at most {} bytes before compression",
224                limits.max_uncompressed_bytes
225            )));
226        }
227
228        files.push(PackageFile {
229            path: entry.path().to_path_buf(),
230            archive_name,
231            unix_permissions: unix_permissions(&metadata),
232        });
233    }
234
235    files.sort_by(|a, b| a.archive_name.cmp(&b.archive_name));
236    Ok(CollectedPackageFiles {
237        files,
238        uncompressed_bytes,
239    })
240}
241
242fn should_descend(entry: &DirEntry) -> bool {
243    let Some(name) = entry.file_name().to_str() else {
244        return false;
245    };
246
247    !matches!(
248        name,
249        ".git"
250            | "target"
251            | "node_modules"
252            | "__pycache__"
253            | ".pytest_cache"
254            | ".ruff_cache"
255            | ".mypy_cache"
256            | ".venv"
257            | "dist"
258            | "build"
259            | ".next"
260    )
261}
262
263fn write_zip_archive(
264    files: &[PackageFile],
265    limits: ZipProjectWorkspacePackageLimits,
266) -> Result<Vec<u8>> {
267    let cursor = Cursor::new(Vec::new());
268    let mut archive = zip::ZipWriter::new(cursor);
269
270    for file in files {
271        let options = SimpleFileOptions::default()
272            .compression_method(CompressionMethod::Deflated)
273            .unix_permissions(file.unix_permissions);
274        archive.start_file(&file.archive_name, options)?;
275        copy_file_to_archive(file, &mut archive)?;
276        if current_archive_len(&archive)? > limits.max_zip_bytes {
277            return Err(ServiceError::Validation(format!(
278                "solution archive must be at most {} bytes after compression",
279                limits.max_zip_bytes
280            )));
281        }
282    }
283
284    Ok(archive.finish()?.into_inner())
285}
286
287fn current_archive_len(archive: &zip::ZipWriter<Cursor<Vec<u8>>>) -> Result<u64> {
288    let cursor = archive
289        .get_ref()
290        .ok_or_else(|| ServiceError::internal("zip writer closed before package finalization"))?;
291    u64::try_from(cursor.get_ref().len()).map_err(|_| {
292        ServiceError::Validation("zip archive length exceeds supported range".to_string())
293    })
294}
295
296fn copy_file_to_archive<W>(file: &PackageFile, archive: &mut zip::ZipWriter<W>) -> Result<()>
297where
298    W: Write + Seek,
299{
300    let mut input = File::open(&file.path)?;
301    std::io::copy(&mut input, archive)?;
302    Ok(())
303}
304
305fn archive_name(path: &Path) -> Result<String> {
306    NormalizedArchivePath::from_relative_path(path, "solution package path")
307        .map(|path| path.to_string())
308}
309
310#[cfg(unix)]
311fn unix_permissions(metadata: &std::fs::Metadata) -> u32 {
312    use std::os::unix::fs::PermissionsExt;
313    metadata.permissions().mode() & 0o777
314}
315
316#[cfg(not(unix))]
317fn unix_permissions(_metadata: &std::fs::Metadata) -> u32 {
318    0o644
319}
320
321#[cfg(test)]
322mod tests {
323    use std::fs;
324    use std::io::Read;
325
326    use super::{
327        ZipProjectWorkspacePackageLimits, package_zip_project_workspace,
328        package_zip_project_workspace_with_limits,
329    };
330
331    fn write_manifest(root: &std::path::Path) {
332        fs::write(
333            root.join("agentics.solution.json"),
334            serde_json::json!({
335                "protocol": "zip_project",
336                "protocol_version": 1,
337                "note": "",
338                "commands": { "run": "run.sh" }
339            })
340            .to_string(),
341        )
342        .expect("manifest");
343    }
344
345    fn write_manifest_with_setup_build(root: &std::path::Path) {
346        fs::write(
347            root.join("agentics.solution.json"),
348            serde_json::json!({
349                "protocol": "zip_project",
350                "protocol_version": 1,
351                "commands": {
352                    "setup": "scripts/setup.sh",
353                    "build": "scripts/build.sh",
354                    "run": "run.sh"
355                }
356            })
357            .to_string(),
358        )
359        .expect("manifest");
360    }
361
362    #[test]
363    fn package_respects_gitignore_and_excludes_git_directory() {
364        let temp = tempfile::tempdir().expect("tempdir");
365        let root = temp.path();
366        write_manifest(root);
367        fs::write(root.join("run.sh"), "#!/usr/bin/env bash\npython main.py\n").expect("run.sh");
368        fs::write(root.join("main.py"), "print('ok')\n").expect("main.py");
369        fs::write(root.join("ignored.txt"), "ignored").expect("ignored");
370        fs::write(root.join(".gitignore"), "ignored.txt\n").expect("gitignore");
371        fs::create_dir(root.join(".git")).expect("git dir");
372        fs::write(root.join(".git/config"), "private").expect("git config");
373
374        let package = package_zip_project_workspace(root).expect("workspace should package");
375        let names = zip_file_names(&package.bytes);
376
377        assert_eq!(package.file_count, 3);
378        assert_eq!(
379            names,
380            vec![
381                "agentics.solution.json".to_string(),
382                "main.py".to_string(),
383                "run.sh".to_string()
384            ]
385        );
386    }
387
388    #[test]
389    fn package_rejects_missing_run_script() {
390        let temp = tempfile::tempdir().expect("tempdir");
391        write_manifest(temp.path());
392        fs::write(temp.path().join("main.py"), "print('ok')\n").expect("main.py");
393
394        let error =
395            package_zip_project_workspace(temp.path()).expect_err("run.sh should be required");
396
397        assert!(error.to_string().contains("run.sh must exist"));
398    }
399
400    #[test]
401    fn package_rejects_ignored_run_script() {
402        let temp = tempfile::tempdir().expect("tempdir");
403        write_manifest(temp.path());
404        fs::write(temp.path().join("run.sh"), "#!/usr/bin/env bash\n").expect("run.sh");
405        fs::write(temp.path().join(".gitignore"), "run.sh\n").expect("gitignore");
406
407        let error =
408            package_zip_project_workspace(temp.path()).expect_err("ignored run.sh should fail");
409
410        assert!(error.to_string().contains("run.sh is excluded"));
411    }
412
413    #[test]
414    fn package_requires_declared_setup_and_build_scripts() {
415        let temp = tempfile::tempdir().expect("tempdir");
416        write_manifest_with_setup_build(temp.path());
417        fs::write(temp.path().join("run.sh"), "#!/usr/bin/env bash\n").expect("run.sh");
418
419        let error = package_zip_project_workspace(temp.path())
420            .expect_err("setup script should be required");
421
422        assert!(error.to_string().contains("scripts/setup.sh must exist"));
423    }
424
425    #[test]
426    fn package_includes_declared_setup_and_build_scripts() {
427        let temp = tempfile::tempdir().expect("tempdir");
428        write_manifest_with_setup_build(temp.path());
429        fs::create_dir(temp.path().join("scripts")).expect("scripts dir");
430        fs::write(
431            temp.path().join("scripts/setup.sh"),
432            "#!/usr/bin/env bash\n",
433        )
434        .expect("setup");
435        fs::write(
436            temp.path().join("scripts/build.sh"),
437            "#!/usr/bin/env bash\n",
438        )
439        .expect("build");
440        fs::write(temp.path().join("run.sh"), "#!/usr/bin/env bash\n").expect("run.sh");
441
442        let package = package_zip_project_workspace(temp.path()).expect("workspace should package");
443        let names = zip_file_names(&package.bytes);
444
445        assert!(names.contains(&"scripts/setup.sh".to_string()));
446        assert!(names.contains(&"scripts/build.sh".to_string()));
447    }
448
449    #[test]
450    fn package_rejects_too_many_files() {
451        let temp = tempfile::tempdir().expect("tempdir");
452        write_manifest(temp.path());
453        fs::write(temp.path().join("run.sh"), "#!/usr/bin/env bash\n").expect("run.sh");
454        fs::write(temp.path().join("extra.txt"), "x").expect("extra");
455
456        let error = package_zip_project_workspace_with_limits(
457            temp.path(),
458            ZipProjectWorkspacePackageLimits {
459                max_file_count: 2,
460                ..ZipProjectWorkspacePackageLimits::DEFAULT
461            },
462        )
463        .expect_err("file count should fail");
464
465        assert!(error.to_string().contains("at most 2 packageable files"));
466    }
467
468    #[test]
469    fn package_rejects_too_many_uncompressed_bytes() {
470        let temp = tempfile::tempdir().expect("tempdir");
471        write_manifest(temp.path());
472        fs::write(temp.path().join("run.sh"), "#!/usr/bin/env bash\n").expect("run.sh");
473        fs::write(temp.path().join("main.py"), "print('hello')\n").expect("main.py");
474
475        let error = package_zip_project_workspace_with_limits(
476            temp.path(),
477            ZipProjectWorkspacePackageLimits {
478                max_uncompressed_bytes: 16,
479                ..ZipProjectWorkspacePackageLimits::DEFAULT
480            },
481        )
482        .expect_err("uncompressed size should fail");
483
484        assert!(error.to_string().contains("bytes before compression"));
485    }
486
487    #[test]
488    fn package_rejects_zip_bytes_limit() {
489        let temp = tempfile::tempdir().expect("tempdir");
490        write_manifest(temp.path());
491        fs::write(temp.path().join("run.sh"), "#!/usr/bin/env bash\n").expect("run.sh");
492
493        let error = package_zip_project_workspace_with_limits(
494            temp.path(),
495            ZipProjectWorkspacePackageLimits {
496                max_zip_bytes: 32,
497                ..ZipProjectWorkspacePackageLimits::DEFAULT
498            },
499        )
500        .expect_err("zip size should fail");
501
502        assert!(error.to_string().contains("bytes after compression"));
503    }
504
505    fn zip_file_names(bytes: &[u8]) -> Vec<String> {
506        let cursor = std::io::Cursor::new(bytes);
507        let mut archive = zip::ZipArchive::new(cursor).expect("zip archive");
508        let mut names = Vec::new();
509        for index in 0..archive.len() {
510            let mut file = archive.by_index(index).expect("zip file");
511            let mut contents = String::new();
512            drop(file.read_to_string(&mut contents));
513            names.push(file.name().to_string());
514        }
515        names
516    }
517}