agentics_contracts/zip_project/
package.rs1use 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)]
17pub 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)]
26pub 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
54pub 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
62pub 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}