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