1use std::io::Read;
7
8use serde::{Deserialize, Serialize};
9
10use crate::validation::archive::{ArchiveEnvelopePolicy, inspect_zip_bytes};
11use crate::validation::text;
12use agentics_domain::models::evaluation::SolutionArtifactMetadata;
13use agentics_domain::models::hashes::Sha256Digest;
14use agentics_domain::models::paths::{LogRelativePath, ScriptPath};
15pub use agentics_domain::zip_project::{
16 DockerNetworkMode, ZipProjectNetworkAccess, ZipProjectPhaseLimits,
17};
18use agentics_error::{Result, ServiceError};
19use sha2::{Digest, Sha256};
20
21mod package;
22pub use package::{
23 ZipProjectWorkspacePackage, ZipProjectWorkspacePackageLimits, package_zip_project_workspace,
24 package_zip_project_workspace_with_limits,
25};
26
27pub const ZIP_PROJECT_MANIFEST_FILE: &str = "agentics.solution.json";
28pub const ZIP_PROJECT_PROTOCOL: &str = "zip_project";
29pub const ZIP_PROJECT_PROTOCOL_VERSION: u16 = 1;
30pub const MAX_ZIP_PROJECT_NOTE_BYTES: usize = 1024;
31pub const MAX_ZIP_PROJECT_ARTIFACT_BYTES: u64 = 20 * 1024 * 1024;
32pub const MAX_ZIP_PROJECT_FILE_COUNT: usize = 256;
33pub const MAX_ZIP_PROJECT_UNCOMPRESSED_BYTES: u64 = 50 * 1024 * 1024;
34
35pub fn validate_zip_project_archive_envelope(bytes: &[u8]) -> Result<()> {
37 inspect_zip_bytes(bytes, &zip_project_archive_policy())?;
38 Ok(())
39}
40
41#[derive(Debug, Clone)]
43pub struct ZipProjectArtifact {
44 pub manifest: ZipProjectManifest,
45 pub metadata: SolutionArtifactMetadata,
46}
47
48pub fn inspect_zip_project_artifact(bytes: &[u8]) -> Result<ZipProjectArtifact> {
50 let envelope = inspect_zip_bytes(bytes, &zip_project_archive_policy())?;
51 let mut hasher = Sha256::new();
52 hasher.update(bytes);
53 let artifact_file_count = u64::try_from(envelope.entries().len()).map_err(|_| {
54 ServiceError::Validation("solution archive entry count exceeds supported range".to_string())
55 })?;
56 let metadata = SolutionArtifactMetadata {
57 artifact_zip_bytes: envelope.archive_size(),
58 artifact_uncompressed_bytes: envelope.expanded_size(),
59 artifact_file_count,
60 artifact_sha256: Sha256Digest::from_bytes(hasher.finalize().into()),
61 };
62 let manifest = read_manifest_from_zip_bytes(bytes)?;
63 Ok(ZipProjectArtifact { manifest, metadata })
64}
65
66pub fn zip_project_archive_policy() -> ArchiveEnvelopePolicy {
68 ArchiveEnvelopePolicy::new(
69 "solution archive",
70 MAX_ZIP_PROJECT_ARTIFACT_BYTES,
71 MAX_ZIP_PROJECT_FILE_COUNT,
72 MAX_ZIP_PROJECT_UNCOMPRESSED_BYTES,
73 )
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct ZipProjectManifest {
80 pub protocol: String,
81 pub protocol_version: u16,
82 #[serde(default)]
83 pub note: String,
84 pub commands: ZipProjectCommands,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct ZipProjectCommands {
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub setup: Option<ScriptPath>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub build: Option<ScriptPath>,
95 pub run: ScriptPath,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(rename_all = "snake_case")]
101pub enum ZipProjectPhaseName {
102 Setup,
103 Build,
104 Run,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
109pub struct ZipProjectResolvedPhase {
110 pub name: ZipProjectPhaseName,
111 pub command: ScriptPath,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
116#[serde(deny_unknown_fields)]
117pub struct ZipProjectPhaseFailureReport {
118 pub phase: ZipProjectPhaseName,
119 pub reason: ZipProjectPhaseFailureReason,
120 pub message: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub exit_code: Option<i32>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub log_path: Option<LogRelativePath>,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
129#[serde(rename_all = "snake_case")]
130pub enum ZipProjectPhaseFailureReason {
131 NonZeroExit,
132 TimedOut,
133 ResourceLimit,
134 MissingCommand,
135 RunnerError,
136}
137
138impl ZipProjectManifest {
139 pub fn parse_json(raw: &str) -> Result<Self> {
141 let manifest: Self = serde_json::from_str(raw).map_err(|e| {
142 ServiceError::Validation(format!("invalid {ZIP_PROJECT_MANIFEST_FILE}: {e}"))
143 })?;
144 manifest.validate()?;
145 Ok(manifest)
146 }
147
148 pub fn from_zip_bytes(bytes: &[u8]) -> Result<Self> {
150 inspect_zip_project_artifact(bytes).map(|artifact| artifact.manifest)
151 }
152
153 pub fn validate(&self) -> Result<()> {
155 if self.protocol != ZIP_PROJECT_PROTOCOL {
156 return Err(ServiceError::Validation(format!(
157 "protocol must be {ZIP_PROJECT_PROTOCOL}"
158 )));
159 }
160 if self.protocol_version != ZIP_PROJECT_PROTOCOL_VERSION {
161 return Err(ServiceError::Validation(format!(
162 "protocol_version must be {ZIP_PROJECT_PROTOCOL_VERSION}"
163 )));
164 }
165
166 validate_solution_note(&self.note)?;
167 self.commands.validate()?;
168
169 Ok(())
170 }
171
172 pub fn phase_execution_plan(&self) -> Vec<ZipProjectResolvedPhase> {
174 let mut phases = Vec::new();
175 if let Some(command) = &self.commands.setup {
176 phases.push(ZipProjectResolvedPhase {
177 name: ZipProjectPhaseName::Setup,
178 command: command.clone(),
179 });
180 }
181 if let Some(command) = &self.commands.build {
182 phases.push(ZipProjectResolvedPhase {
183 name: ZipProjectPhaseName::Build,
184 command: command.clone(),
185 });
186 }
187 phases.push(ZipProjectResolvedPhase {
188 name: ZipProjectPhaseName::Run,
189 command: self.commands.run.clone(),
190 });
191
192 phases
193 }
194}
195
196fn read_manifest_from_zip_bytes(bytes: &[u8]) -> Result<ZipProjectManifest> {
198 let reader = std::io::Cursor::new(bytes);
199 let mut archive = zip::ZipArchive::new(reader)?;
200 let mut manifest = archive.by_name(ZIP_PROJECT_MANIFEST_FILE).map_err(|_| {
201 ServiceError::Validation(format!("{ZIP_PROJECT_MANIFEST_FILE} is required"))
202 })?;
203 if manifest.size() > 128 * 1024 {
204 return Err(ServiceError::Validation(format!(
205 "{ZIP_PROJECT_MANIFEST_FILE} must be at most 131072 bytes"
206 )));
207 }
208
209 let mut raw = String::new();
210 manifest.read_to_string(&mut raw)?;
211 ZipProjectManifest::parse_json(&raw)
212}
213
214impl ZipProjectCommands {
215 fn validate(&self) -> Result<()> {
217 Ok(())
218 }
219}
220
221impl ZipProjectPhaseFailureReport {
222 pub fn validate(&self) -> Result<()> {
224 require_non_empty(&self.message, "phase_failure.message")?;
225
226 Ok(())
227 }
228}
229
230fn require_non_empty(value: &str, field: &str) -> Result<()> {
232 text::require_non_empty(value, field)
233}
234
235pub fn validate_solution_note(note: &str) -> Result<()> {
237 text::validate_solution_note(note, MAX_ZIP_PROJECT_NOTE_BYTES)
238}
239
240#[cfg(test)]
241mod tests {
242 use std::io::{Cursor, Write};
243
244 use serde_json::json;
245 use sha2::Digest;
246
247 use agentics_domain::models::paths::LogRelativePath;
248
249 use super::{
250 MAX_ZIP_PROJECT_NOTE_BYTES, ZipProjectManifest, ZipProjectPhaseFailureReason,
251 ZipProjectPhaseFailureReport, ZipProjectPhaseName, inspect_zip_project_artifact,
252 validate_zip_project_archive_envelope,
253 };
254
255 fn zip_with_entries(entries: &[(&str, &[u8])]) -> Vec<u8> {
257 let mut cursor = Cursor::new(Vec::new());
258 {
259 let mut archive = zip::ZipWriter::new(&mut cursor);
260 let options = zip::write::SimpleFileOptions::default()
261 .compression_method(zip::CompressionMethod::Stored);
262 for (path, content) in entries {
263 archive
264 .start_file(path, options)
265 .expect("test ZIP entry should start");
266 archive
267 .write_all(content)
268 .expect("test ZIP entry content should write");
269 }
270 archive.finish().expect("test ZIP should finish");
271 }
272 cursor.into_inner()
273 }
274
275 fn valid_manifest() -> serde_json::Value {
277 json!({
278 "protocol": "zip_project",
279 "protocol_version": 1,
280 "note": "public note\nwith whitespace",
281 "commands": {
282 "setup": "scripts/setup.sh",
283 "build": "scripts/build.sh",
284 "run": "run.sh"
285 }
286 })
287 }
288
289 #[test]
291 fn accepts_valid_zip_project_manifest() {
292 let raw = serde_json::to_string(&valid_manifest()).expect("serialize manifest");
293 let manifest = ZipProjectManifest::parse_json(&raw).expect("manifest should parse");
294
295 assert_eq!(manifest.protocol, "zip_project");
296 assert_eq!(manifest.protocol_version, 1);
297 assert_eq!(manifest.note, "public note\nwith whitespace");
298 assert_eq!(manifest.commands.run.as_str(), "run.sh");
299
300 let phases = manifest.phase_execution_plan();
301 assert_eq!(phases.len(), 3);
302 assert_eq!(phases[0].name, ZipProjectPhaseName::Setup);
303 assert_eq!(phases[0].command.as_str(), "scripts/setup.sh");
304 assert_eq!(phases[1].name, ZipProjectPhaseName::Build);
305 assert_eq!(phases[1].command.as_str(), "scripts/build.sh");
306 assert_eq!(phases[2].name, ZipProjectPhaseName::Run);
307 assert_eq!(phases[2].command.as_str(), "run.sh");
308 }
309
310 #[test]
312 fn inspect_zip_project_artifact_returns_manifest_and_metadata() {
313 let manifest = valid_manifest().to_string();
314 let readme = b"example solution";
315 let bytes = zip_with_entries(&[
316 (super::ZIP_PROJECT_MANIFEST_FILE, manifest.as_bytes()),
317 ("README.md", readme),
318 ]);
319
320 let artifact =
321 inspect_zip_project_artifact(&bytes).expect("artifact inspection should succeed");
322
323 assert_eq!(artifact.manifest.commands.run.as_str(), "run.sh");
324 assert_eq!(
325 artifact.metadata.artifact_zip_bytes,
326 u64::try_from(bytes.len()).expect("test ZIP length fits")
327 );
328 assert_eq!(
329 artifact.metadata.artifact_uncompressed_bytes,
330 u64::try_from(manifest.len() + readme.len()).expect("test size fits")
331 );
332 assert_eq!(artifact.metadata.artifact_file_count, 2);
333
334 let mut hasher = sha2::Sha256::new();
335 hasher.update(&bytes);
336 assert_eq!(
337 artifact.metadata.artifact_sha256.to_string(),
338 hex::encode(hasher.finalize())
339 );
340 }
341
342 #[test]
344 fn note_defaults_to_empty_when_omitted() {
345 let mut value = valid_manifest();
346 value
347 .as_object_mut()
348 .expect("manifest object")
349 .remove("note");
350
351 let manifest =
352 ZipProjectManifest::parse_json(&value.to_string()).expect("manifest should parse");
353
354 assert_eq!(manifest.note, "");
355 }
356
357 #[test]
359 fn accepts_minimal_manifest() {
360 let manifest = ZipProjectManifest::parse_json(
361 &json!({
362 "protocol": "zip_project",
363 "protocol_version": 1,
364 "commands": { "run": "run.sh" }
365 })
366 .to_string(),
367 )
368 .expect("minimal manifest should parse");
369
370 let phases = manifest.phase_execution_plan();
371 assert_eq!(manifest.note, "");
372 assert_eq!(phases.len(), 1);
373 assert_eq!(phases[0].name, ZipProjectPhaseName::Run);
374 assert_eq!(phases[0].command.as_str(), "run.sh");
375 }
376
377 #[test]
379 fn accepts_optional_setup_and_build_commands() {
380 let manifest =
381 ZipProjectManifest::parse_json(&valid_manifest().to_string()).expect("manifest");
382
383 let phases = manifest.phase_execution_plan();
384 assert_eq!(
385 phases.iter().map(|phase| phase.name).collect::<Vec<_>>(),
386 vec![
387 ZipProjectPhaseName::Setup,
388 ZipProjectPhaseName::Build,
389 ZipProjectPhaseName::Run,
390 ]
391 );
392 }
393
394 #[test]
396 fn rejects_old_submitter_controlled_manifest_fields() {
397 let mut value = valid_manifest();
398 value["runtime"] = json!({ "language": "python" });
399
400 let error = ZipProjectManifest::parse_json(&value.to_string())
401 .expect_err("old runtime field should fail");
402 assert!(error.to_string().contains("unknown field `runtime`"));
403
404 for field in ["phases", "interface", "dependencies"] {
405 let mut value = valid_manifest();
406 value[field] = json!({});
407 let error = ZipProjectManifest::parse_json(&value.to_string())
408 .expect_err("old manifest field should fail");
409 assert!(
410 error
411 .to_string()
412 .contains(&format!("unknown field `{field}`")),
413 "unexpected error for {field}: {error}"
414 );
415 }
416 }
417
418 #[test]
420 fn rejects_over_limit_note() {
421 let mut value = valid_manifest();
422 value["note"] = json!("a".repeat(MAX_ZIP_PROJECT_NOTE_BYTES + 1));
423
424 let error = ZipProjectManifest::parse_json(&value.to_string())
425 .expect_err("over-limit note should fail");
426 assert!(
427 error
428 .to_string()
429 .contains("note must be at most 1024 UTF-8 bytes")
430 );
431 }
432
433 #[test]
435 fn validates_note_control_characters() {
436 let mut value = valid_manifest();
437 value["note"] = json!("line one\nline two\tok\r");
438 ZipProjectManifest::parse_json(&value.to_string()).expect("normal whitespace should parse");
439
440 value["note"] = json!("bad\u{0007}bell");
441 let error = ZipProjectManifest::parse_json(&value.to_string())
442 .expect_err("control character should fail");
443 assert!(
444 error
445 .to_string()
446 .contains("note must not contain non-text control characters")
447 );
448 }
449
450 #[test]
452 fn rejects_missing_required_run_script() {
453 let mut value = valid_manifest();
454 value["commands"]
455 .as_object_mut()
456 .expect("commands object")
457 .remove("run");
458
459 let error =
460 ZipProjectManifest::parse_json(&value.to_string()).expect_err("run is required");
461 assert!(error.to_string().contains("missing field `run`"));
462 }
463
464 #[test]
466 fn rejects_unsupported_protocol_version() {
467 let mut value = valid_manifest();
468 value["protocol_version"] = json!(2);
469
470 let error =
471 ZipProjectManifest::parse_json(&value.to_string()).expect_err("version should fail");
472 assert!(error.to_string().contains("protocol_version must be 1"));
473 }
474
475 #[test]
477 fn rejects_unsafe_script_paths() {
478 let mut value = valid_manifest();
479 value["commands"]["run"] = json!("../run.sh");
480
481 let error =
482 ZipProjectManifest::parse_json(&value.to_string()).expect_err("unsafe run path fails");
483 assert!(error.to_string().contains("repo-relative paths"));
484 }
485
486 #[test]
488 fn rejects_unknown_manifest_fields() {
489 let mut value = valid_manifest();
490 value["unexpected"] = json!(true);
491
492 let error = ZipProjectManifest::parse_json(&value.to_string())
493 .expect_err("unknown fields should fail");
494 assert!(error.to_string().contains("unknown field"));
495 }
496
497 #[test]
499 fn validates_phase_failure_report_payloads() {
500 let report = ZipProjectPhaseFailureReport {
501 phase: ZipProjectPhaseName::Build,
502 reason: ZipProjectPhaseFailureReason::NonZeroExit,
503 message: "build script exited with status 1".to_string(),
504 exit_code: Some(1),
505 log_path: Some(
506 LogRelativePath::try_new("logs/build.stderr.txt").expect("test log path is valid"),
507 ),
508 };
509 report.validate().expect("failure report should validate");
510
511 let invalid = json!({
512 "phase": "build",
513 "reason": "non_zero_exit",
514 "message": "build script exited with status 1",
515 "exit_code": 1,
516 "log_path": "../outside.log"
517 });
518 let error = serde_json::from_value::<ZipProjectPhaseFailureReport>(invalid)
519 .expect_err("unsafe log path should fail during deserialization");
520 assert!(error.to_string().contains("repo-relative paths"));
521 }
522
523 #[test]
525 fn archive_envelope_rejects_unsafe_entry_paths() {
526 let bytes = zip_with_entries(&[("../escape.txt", b"escape")]);
527 let error =
528 validate_zip_project_archive_envelope(&bytes).expect_err("unsafe entry should fail");
529
530 assert!(error.to_string().contains("unsafe path"));
531 }
532
533 #[test]
535 fn archive_envelope_rejects_duplicate_entries() {
536 let bytes = zip_with_entries(&[("dir/run.sh", b"one"), ("dir\\run.sh", b"two")]);
537 let error =
538 validate_zip_project_archive_envelope(&bytes).expect_err("duplicate entry should fail");
539
540 assert!(error.to_string().contains("duplicate path"));
541 }
542
543 #[test]
545 fn archive_envelope_rejects_symlink_entries() {
546 let bytes = crate::validation::archive::test_support::raw_stored_zip(vec![(
547 "link.sh", b"run.sh", 0o120777,
548 )]);
549 let error =
550 validate_zip_project_archive_envelope(&bytes).expect_err("symlink entry should fail");
551
552 assert!(error.to_string().contains("must not contain symlinks"));
553 }
554}