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
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
29/// Validate the ZIP archive envelope before durable storage or extraction.
30pub fn validate_zip_project_archive_envelope(bytes: &[u8]) -> Result<()> {
31    inspect_zip_bytes(bytes, &zip_project_archive_policy())?;
32    Ok(())
33}
34
35/// Parsed artifact envelope plus the solution manifest from one ZIP artifact.
36#[derive(Debug, Clone)]
37pub struct ZipProjectArtifact {
38    pub manifest: ZipProjectManifest,
39    pub metadata: SolutionArtifactMetadata,
40}
41
42/// Inspect a submitted ZIP project once and return manifest plus stable metadata.
43pub 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
60/// Shared archive envelope policy for `zip_project` solution ZIPs.
61pub 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/// Parsed `agentics.solution.json` manifest for a ZIP project solution.
71#[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/// Script paths used by the future setup/build/run phase executor.
82#[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/// Ordered phase names in the `zip_project` execution model.
93#[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/// One executable phase after command paths and phase limits are resolved.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
103pub struct ZipProjectResolvedPhase {
104    pub name: ZipProjectPhaseName,
105    pub command: ScriptPath,
106}
107
108/// Structured failure payload for phase-specific execution errors.
109#[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/// Coarse failure classes used by workers when reporting phase outcomes.
122#[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    /// Parse and validate a manifest JSON payload.
134    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    /// Parse and validate `agentics.solution.json` directly from a ZIP artifact.
143    pub fn from_zip_bytes(bytes: &[u8]) -> Result<Self> {
144        inspect_zip_project_artifact(bytes).map(|artifact| artifact.manifest)
145    }
146
147    /// Validate protocol versioning, submitter metadata, and script paths.
148    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    /// Resolve the ordered setup/build/run plan from commands and phase overrides.
167    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
190/// Read the manifest from ZIP bytes after the caller has validated the envelope.
191fn 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    /// Handles validate for this module.
210    fn validate(&self) -> Result<()> {
211        Ok(())
212    }
213}
214
215impl ZipProjectPhaseFailureReport {
216    /// Validate a future worker failure report before persistence or API output.
217    pub fn validate(&self) -> Result<()> {
218        require_non_empty(&self.message, "phase_failure.message")?;
219
220        Ok(())
221    }
222}
223
224/// Requires non empty and reports a domain error otherwise.
225fn require_non_empty(value: &str, field: &str) -> Result<()> {
226    text::require_non_empty(value, field)
227}
228
229/// Validate submitter-visible note text from `agentics.solution.json`.
230pub 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    /// Builds a test ZIP archive with the supplied stored entries.
250    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    /// Handles valid manifest for this module.
270    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    /// Verifies that accepts valid zip project manifest.
284    #[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    /// Verifies uploaded solution artifacts produce trusted size and digest metadata.
305    #[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    /// Verifies that note defaults to empty when omitted.
337    #[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    /// Verifies that minimal manifest only requires a run command.
352    #[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    /// Verifies that setup and build commands are optional.
372    #[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    /// Verifies that old participant-controlled fields are rejected.
389    #[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    /// Verifies that note rejects too many decoded UTF-8 bytes.
413    #[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    /// Verifies that note allows normal whitespace and rejects non-text controls.
428    #[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    /// Verifies that rejects missing required run script.
445    #[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    /// Verifies that rejects unsupported protocol version.
459    #[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    /// Verifies that rejects unsafe script paths.
470    #[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    /// Verifies that rejects unknown manifest fields.
481    #[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    /// Verifies that validates phase failure report payloads.
492    #[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    /// Verifies archive envelope validation rejects traversal entries.
518    #[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    /// Verifies archive envelope validation rejects duplicate normalized paths.
528    #[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    /// Verifies solution upload validation rejects ZIP symlink entries.
538    #[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}