Skip to main content

agentics_contracts/
zip_project.rs

1//! `zip_project` solution submission protocol schema.
2//!
3//! This module defines manifest parsing plus the setup/build/run phase model
4//! that the worker will consume in later execution milestones.
5
6use 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
26/// Validate the ZIP archive envelope before durable storage or extraction.
27pub fn validate_zip_project_archive_envelope(bytes: &[u8]) -> Result<()> {
28    inspect_zip_bytes(bytes, &zip_project_archive_policy())?;
29    Ok(())
30}
31
32/// Shared archive envelope policy for `zip_project` solution ZIPs.
33pub 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/// Parsed `agentics.solution.json` manifest for a ZIP project solution.
43#[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/// Script paths used by the future setup/build/run phase executor.
54#[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/// Ordered phase names in the `zip_project` execution model.
65#[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/// One executable phase after command paths and phase limits are resolved.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
75pub struct ZipProjectResolvedPhase {
76    pub name: ZipProjectPhaseName,
77    pub command: ScriptPath,
78}
79
80/// Structured failure payload for phase-specific execution errors.
81#[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/// Coarse failure classes used by workers when reporting phase outcomes.
94#[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    /// Parse and validate a manifest JSON payload.
106    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    /// Parse and validate `agentics.solution.json` directly from a ZIP artifact.
115    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    /// Validate protocol versioning, submitter metadata, and script paths.
134    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    /// Resolve the ordered setup/build/run plan from commands and phase overrides.
153    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    /// Handles validate for this module.
178    fn validate(&self) -> Result<()> {
179        Ok(())
180    }
181}
182
183impl ZipProjectPhaseFailureReport {
184    /// Validate a future worker failure report before persistence or API output.
185    pub fn validate(&self) -> Result<()> {
186        require_non_empty(&self.message, "phase_failure.message")?;
187
188        Ok(())
189    }
190}
191
192/// Requires non empty and reports a domain error otherwise.
193fn require_non_empty(value: &str, field: &str) -> Result<()> {
194    text::require_non_empty(value, field)
195}
196
197/// Validate submitter-visible note text from `agentics.solution.json`.
198pub 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    /// Builds a test ZIP archive with the supplied stored entries.
216    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    /// Handles valid manifest for this module.
236    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    /// Verifies that accepts valid zip project manifest.
250    #[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    /// Verifies that note defaults to empty when omitted.
271    #[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    /// Verifies that minimal manifest only requires a run command.
286    #[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    /// Verifies that setup and build commands are optional.
306    #[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    /// Verifies that old participant-controlled fields are rejected.
323    #[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    /// Verifies that note rejects too many decoded UTF-8 bytes.
347    #[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    /// Verifies that note allows normal whitespace and rejects non-text controls.
362    #[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    /// Verifies that rejects missing required run script.
379    #[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    /// Verifies that rejects unsupported protocol version.
393    #[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    /// Verifies that rejects unsafe script paths.
404    #[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    /// Verifies that rejects unknown manifest fields.
415    #[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    /// Verifies that validates phase failure report payloads.
426    #[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    /// Verifies archive envelope validation rejects traversal entries.
452    #[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    /// Verifies archive envelope validation rejects duplicate normalized paths.
462    #[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    /// Verifies solution upload validation rejects ZIP symlink entries.
472    #[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}