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::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
35/// Validate the ZIP archive envelope before durable storage or extraction.
36pub fn validate_zip_project_archive_envelope(bytes: &[u8]) -> Result<()> {
37    inspect_zip_bytes(bytes, &zip_project_archive_policy())?;
38    Ok(())
39}
40
41/// Parsed artifact envelope plus the solution manifest from one ZIP artifact.
42#[derive(Debug, Clone)]
43pub struct ZipProjectArtifact {
44    pub manifest: ZipProjectManifest,
45    pub metadata: SolutionArtifactMetadata,
46}
47
48/// Inspect a submitted ZIP project once and return manifest plus stable metadata.
49pub 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
66/// Shared archive envelope policy for `zip_project` solution ZIPs.
67pub 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/// Parsed `agentics.solution.json` manifest for a ZIP project solution.
77#[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/// Script paths used by the future setup/build/run phase executor.
88#[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/// Ordered phase names in the `zip_project` execution model.
99#[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/// One executable phase after command paths and phase limits are resolved.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
109pub struct ZipProjectResolvedPhase {
110    pub name: ZipProjectPhaseName,
111    pub command: ScriptPath,
112}
113
114/// Structured failure payload for phase-specific execution errors.
115#[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/// Coarse failure classes used by workers when reporting phase outcomes.
128#[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    /// Parse and validate a manifest JSON payload.
140    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    /// Parse and validate `agentics.solution.json` directly from a ZIP artifact.
149    pub fn from_zip_bytes(bytes: &[u8]) -> Result<Self> {
150        inspect_zip_project_artifact(bytes).map(|artifact| artifact.manifest)
151    }
152
153    /// Validate protocol versioning, submitter metadata, and script paths.
154    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    /// Resolve the ordered setup/build/run plan from commands and phase overrides.
173    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
196/// Read the manifest from ZIP bytes after the caller has validated the envelope.
197fn 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    /// Handles validate for this module.
216    fn validate(&self) -> Result<()> {
217        Ok(())
218    }
219}
220
221impl ZipProjectPhaseFailureReport {
222    /// Validate a future worker failure report before persistence or API output.
223    pub fn validate(&self) -> Result<()> {
224        require_non_empty(&self.message, "phase_failure.message")?;
225
226        Ok(())
227    }
228}
229
230/// Requires non empty and reports a domain error otherwise.
231fn require_non_empty(value: &str, field: &str) -> Result<()> {
232    text::require_non_empty(value, field)
233}
234
235/// Validate submitter-visible note text from `agentics.solution.json`.
236pub 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    /// Builds a test ZIP archive with the supplied stored entries.
256    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    /// Handles valid manifest for this module.
276    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    /// Verifies that accepts valid zip project manifest.
290    #[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    /// Verifies uploaded solution artifacts produce trusted size and digest metadata.
311    #[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    /// Verifies that note defaults to empty when omitted.
343    #[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    /// Verifies that minimal manifest only requires a run command.
358    #[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    /// Verifies that setup and build commands are optional.
378    #[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    /// Verifies that old participant-controlled fields are rejected.
395    #[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    /// Verifies that note rejects too many decoded UTF-8 bytes.
419    #[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    /// Verifies that note allows normal whitespace and rejects non-text controls.
434    #[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    /// Verifies that rejects missing required run script.
451    #[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    /// Verifies that rejects unsupported protocol version.
465    #[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    /// Verifies that rejects unsafe script paths.
476    #[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    /// Verifies that rejects unknown manifest fields.
487    #[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    /// Verifies that validates phase failure report payloads.
498    #[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    /// Verifies archive envelope validation rejects traversal entries.
524    #[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    /// Verifies archive envelope validation rejects duplicate normalized paths.
534    #[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    /// Verifies solution upload validation rejects ZIP symlink entries.
544    #[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}