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