Skip to main content

licoup_workflow_runtime/
package.rs

1use anyhow::{Context, Result, anyhow, ensure};
2use fs2::FileExt;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeSet;
6use std::fs;
7use std::io::{Read, Write};
8use std::path::{Path, PathBuf};
9use uuid::Uuid;
10use zip::write::SimpleFileOptions;
11
12use licoup_foundation::core::safe_archive::{ZipEntryInfo, ZipExtractionLimits, extract_zip_safe};
13
14use licoup_workflow::{
15    CompiledWorkflow, PreflightDiagnostic, WorkflowDefinition, WorkflowDiagnosticCode,
16    WorkflowDiagnosticExpected, WorkflowDiagnosticRecovery, WorkflowDiagnosticStage,
17    WorkflowValidationFailure, compile_workflow, compile_workflow_source,
18};
19
20const MAX_PACKAGE_BYTES: u64 = 8 * 1024 * 1024;
21const MAX_EXTRACTED_BYTES: u64 = 16 * 1024 * 1024;
22const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
23const MAX_WORKFLOW_BYTES: u64 = 512 * 1024;
24const MAX_PACKAGE_ENTRIES: usize = 128;
25const MAX_PACKAGE_DEPTH: usize = 8;
26const MAX_SCRIPT_FILES: usize = 64;
27const PREPARATION_SCHEMA: &str = "licoup.adaptive-flywheel.preparation.v1";
28const SYNTHETIC_FIXTURE_WORKFLOW: &[u8] = include_bytes!(concat!(
29    env!("CARGO_MANIFEST_DIR"),
30    "/tests/fixtures/workflow_runtime/synthetic-entry-worker.fixture"
31));
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct PreparedPackage {
36    pub preparation_id: String,
37    pub definition_id: String,
38    pub revision_digest: String,
39    pub semantics_digest: String,
40    pub name: String,
41    pub version: String,
42    pub asset_count: usize,
43    pub prepared_at_unix_ms: i64,
44}
45
46#[derive(Clone, Debug)]
47pub(crate) struct CommittedPackage {
48    pub prepared: PreparedPackage,
49    pub workflow: WorkflowDefinition,
50}
51
52#[derive(Clone, Debug)]
53pub struct StrategyPackageImporter {
54    root: PathBuf,
55}
56
57impl StrategyPackageImporter {
58    pub fn open(portable_root: &Path) -> Result<Self> {
59        let flywheel_root = portable_root.join("client-state").join("adaptive-flywheel");
60        licoup_foundation::platform::file_security::ensure_private_dir(
61            &portable_root.join("client-state"),
62        )?;
63        licoup_foundation::platform::file_security::ensure_private_dir(&flywheel_root)?;
64        let root = flywheel_root.join("strategy-packages");
65        licoup_foundation::platform::file_security::ensure_private_dir(&root)?;
66        licoup_foundation::platform::file_security::ensure_private_dir(&root.join("prepared"))?;
67        licoup_foundation::platform::file_security::ensure_private_dir(&root.join("revisions"))?;
68        let root = fs::canonicalize(root)?;
69        Ok(Self { root })
70    }
71
72    pub fn prepare_bytes(&self, bytes: &[u8]) -> Result<PreparedPackage> {
73        ensure!(bytes.len() as u64 <= MAX_PACKAGE_BYTES, "package_too_large");
74        let preparation_id = format!("preparation-{}", Uuid::new_v4());
75        let staging = self.root.join("prepared").join(&preparation_id);
76        ensure!(!staging.exists(), "preparation_identity_conflict");
77        licoup_foundation::platform::file_security::ensure_private_dir(&staging)?;
78        let content = staging.join("content");
79        let prepared = (|| {
80            let entries = extract_zip_safe(
81                bytes,
82                &content,
83                ZipExtractionLimits {
84                    max_archive_bytes: MAX_PACKAGE_BYTES,
85                    max_total_bytes: MAX_EXTRACTED_BYTES,
86                    max_file_bytes: MAX_FILE_BYTES,
87                    max_entries: MAX_PACKAGE_ENTRIES,
88                    max_depth: MAX_PACKAGE_DEPTH,
89                },
90            )
91            .map_err(map_archive_error)?;
92            let inventory = validate_layout(&content, &entries)?;
93            let workflow_path = content.join("workflow.json");
94            let workflow_metadata =
95                fs::metadata(&workflow_path).map_err(|_| anyhow!("package_layout_invalid"))?;
96            ensure!(
97                workflow_metadata.is_file() && workflow_metadata.len() <= MAX_WORKFLOW_BYTES,
98                "workflow_invalid"
99            );
100            let mut source = Vec::with_capacity(workflow_metadata.len() as usize);
101            fs::File::open(&workflow_path)?.read_to_end(&mut source)?;
102            let compiled = compile_workflow_source(&source)?;
103            validate_script_references(&compiled, &inventory)?;
104            let canonical = serde_json::to_vec(compiled.definition())?;
105            licoup_foundation::platform::file_security::atomic_write_private_text(
106                &workflow_path,
107                std::str::from_utf8(&canonical).map_err(|_| anyhow!("workflow_invalid"))?,
108            )?;
109            let semantics_digest = sha256_hex(&canonical);
110            let revision_digest = revision_digest(&content, &inventory, &semantics_digest)?;
111            let prepared = PreparedPackage {
112                preparation_id: preparation_id.clone(),
113                definition_id: compiled.definition().metadata.id.clone(),
114                revision_digest,
115                semantics_digest,
116                name: compiled.definition().metadata.name.clone(),
117                version: compiled.definition().metadata.version.clone(),
118                asset_count: inventory.len(),
119                prepared_at_unix_ms: now_ms(),
120            };
121            let envelope = PreparationEnvelope {
122                schema: PREPARATION_SCHEMA.into(),
123                prepared: prepared.clone(),
124            };
125            let envelope_json = serde_json::to_string(&envelope)?;
126            licoup_foundation::platform::file_security::atomic_write_private_text(
127                &staging.join("preparation.json"),
128                &envelope_json,
129            )?;
130            Ok(prepared)
131        })();
132        if prepared.is_err() {
133            let _ = fs::remove_dir_all(&staging);
134        }
135        prepared
136    }
137
138    pub fn prepared(&self, preparation_id: &str) -> Result<PreparedPackage> {
139        validate_preparation_id(preparation_id)?;
140        let path = self
141            .root
142            .join("prepared")
143            .join(preparation_id)
144            .join("preparation.json");
145        let bytes = read_bounded(&path, 64 * 1024).map_err(|_| anyhow!("preparation_not_found"))?;
146        let envelope: PreparationEnvelope =
147            serde_json::from_slice(&bytes).map_err(|_| anyhow!("preparation_not_found"))?;
148        ensure!(
149            envelope.schema == PREPARATION_SCHEMA
150                && envelope.prepared.preparation_id == preparation_id,
151            "preparation_not_found"
152        );
153        Ok(envelope.prepared)
154    }
155
156    pub(crate) fn commit(
157        &self,
158        preparation_id: &str,
159        expected_revision_digest: &str,
160    ) -> Result<CommittedPackage> {
161        let prepared = self.prepared(preparation_id)?;
162        ensure!(
163            prepared.revision_digest == expected_revision_digest,
164            "revision_conflict"
165        );
166        let commit_lock = licoup_foundation::platform::file_security::open_private_lock_file(
167            &self.root.join("commit.lock"),
168        )?;
169        commit_lock
170            .lock_exclusive()
171            .map_err(|_| anyhow!("strategy_revision_commit_failed"))?;
172        let prepared_directory = self.root.join("prepared").join(preparation_id);
173        let revisions = self.root.join("revisions");
174        let target = revisions.join(&prepared.revision_digest);
175        if target.exists() {
176            self.verified_revision_content(&prepared.revision_digest, &prepared.semantics_digest)?;
177            fs::remove_dir_all(&prepared_directory).ok();
178        } else {
179            fs::rename(&prepared_directory, &target)
180                .with_context(|| "strategy_revision_commit_failed")?;
181            harden_read_only_tree(&target)?;
182        }
183        let content =
184            self.verified_revision_content(&prepared.revision_digest, &prepared.semantics_digest)?;
185        let workflow_bytes = read_bounded(&content.join("workflow.json"), MAX_WORKFLOW_BYTES)?;
186        let compiled = compile_workflow_source(&workflow_bytes)?;
187        ensure!(
188            sha256_hex(&serde_json::to_vec(compiled.definition())?) == prepared.semantics_digest,
189            "revision_conflict"
190        );
191        Ok(CommittedPackage {
192            prepared,
193            workflow: compiled.into_definition(),
194        })
195    }
196
197    pub(crate) fn revision_content(&self, digest: &str) -> Result<PathBuf> {
198        validate_digest(digest)?;
199        let content = self.root.join("revisions").join(digest).join("content");
200        ensure!(content.is_dir(), "definition_not_found");
201        Ok(content)
202    }
203
204    pub(crate) fn verified_revision_content(
205        &self,
206        digest: &str,
207        expected_semantics_digest: &str,
208    ) -> Result<PathBuf> {
209        let content = self.revision_content(digest)?;
210        let inventory = persisted_inventory(&content)?;
211        let workflow_bytes = read_bounded(&content.join("workflow.json"), MAX_WORKFLOW_BYTES)?;
212        let definition: WorkflowDefinition = serde_json::from_slice(&workflow_bytes)
213            .map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
214        let canonical = serde_json::to_vec(&definition)?;
215        ensure!(
216            workflow_bytes == canonical,
217            "strategy_revision_content_drifted"
218        );
219        let semantics_digest = sha256_hex(&canonical);
220        ensure!(
221            semantics_digest == expected_semantics_digest,
222            "strategy_revision_content_drifted"
223        );
224        ensure!(
225            revision_digest(&content, &inventory, &semantics_digest)? == digest,
226            "strategy_revision_content_drifted"
227        );
228        let definition = licoup_workflow_store::normalize_legacy_workflow(definition.clone())
229            .unwrap_or(definition);
230        compile_workflow(definition).map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
231        Ok(content)
232    }
233
234    /// Open the restored package store only when the recovery actually carried one.
235    ///
236    /// A data root whose committed revisions never existed is legitimate; reading it
237    /// back must not create package state merely to observe its absence.
238    pub fn open_restored(portable_root: &Path) -> Result<Option<Self>> {
239        let root = portable_root
240            .join("client-state")
241            .join("adaptive-flywheel")
242            .join("strategy-packages");
243        if !root.is_dir() {
244            return Ok(None);
245        }
246        Ok(Some(Self::open(portable_root)?))
247    }
248
249    /// Re-establish the immutable revision invariant and read every revision back.
250    ///
251    /// A generic archive restore publishes each member under fresh permissions, so the
252    /// read-only hardening applied when a revision was committed is gone even though
253    /// every byte is intact. This re-applies that same hardening through this owner and
254    /// then verifies each revision digest, semantics digest and asset inventory through
255    /// the ordinary `verified_revision_content` readback. No revision byte is rewritten;
256    /// a missing, altered or unexpected revision is refused instead of accepted.
257    pub fn restore_revision_invariants(&self) -> Result<Vec<String>> {
258        let revisions = self.root.join("revisions");
259        let entries = match fs::read_dir(&revisions) {
260            Ok(entries) => entries,
261            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
262            Err(_) => return Err(anyhow!("strategy_revision_content_drifted")),
263        };
264        let mut digests = Vec::new();
265        for entry in entries {
266            let entry = entry.map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
267            let digest = entry
268                .file_name()
269                .into_string()
270                .map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
271            let metadata = fs::symlink_metadata(entry.path())
272                .map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
273            ensure!(
274                metadata.is_dir() && !metadata.file_type().is_symlink(),
275                "strategy_revision_content_drifted"
276            );
277            validate_digest(&digest).map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
278            // The preparation envelope travels with its committed revision; it names
279            // the semantics digest this readback must verify.
280            let prepared_bytes = read_bounded(&entry.path().join("preparation.json"), 64 * 1024)
281                .map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
282            let envelope: PreparationEnvelope = serde_json::from_slice(&prepared_bytes)
283                .map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
284            ensure!(
285                envelope.schema == PREPARATION_SCHEMA
286                    && envelope.prepared.revision_digest == digest,
287                "strategy_revision_content_drifted"
288            );
289            harden_read_only_tree(&entry.path())?;
290            self.verified_revision_content(&digest, &envelope.prepared.semantics_digest)?;
291            digests.push(digest);
292        }
293        digests.sort();
294        Ok(digests)
295    }
296}
297
298#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
299struct PreparationEnvelope {
300    schema: String,
301    prepared: PreparedPackage,
302}
303
304fn validate_layout(content: &Path, entries: &[ZipEntryInfo]) -> Result<BTreeSet<String>> {
305    let files = entries
306        .iter()
307        .filter(|entry| !entry.directory)
308        .map(|entry| entry.path.to_string_lossy().replace('\\', "/"))
309        .collect::<BTreeSet<_>>();
310    ensure!(files.contains("workflow.json"), "package_layout_invalid");
311    let scripts = files
312        .iter()
313        .filter(|path| path.starts_with("scripts/"))
314        .count();
315    ensure!(scripts <= MAX_SCRIPT_FILES, "package_resource_limit");
316    ensure!(
317        files
318            .iter()
319            .all(|path| path == "workflow.json" || path.starts_with("scripts/")),
320        "package_layout_invalid"
321    );
322    ensure!(
323        entries.iter().filter(|entry| entry.directory).all(|entry| {
324            let path = entry.path.to_string_lossy().replace('\\', "/");
325            path == "scripts" || path.starts_with("scripts/")
326        }),
327        "package_layout_invalid"
328    );
329    ensure!(
330        content.join("workflow.json").is_file(),
331        "package_layout_invalid"
332    );
333    Ok(files)
334}
335
336fn validate_script_references(
337    workflow: &CompiledWorkflow,
338    inventory: &BTreeSet<String>,
339) -> std::result::Result<(), WorkflowValidationFailure> {
340    let mut diagnostics = Vec::new();
341    for (index, state) in workflow.definition().states.iter().enumerate() {
342        if let Some(entry) = &state.entry
343            && !inventory.contains(entry)
344        {
345            diagnostics.push(PreflightDiagnostic {
346                code: WorkflowDiagnosticCode::WorkflowScriptEntryInvalid,
347                stage: WorkflowDiagnosticStage::PackageValidate,
348                path: Some(format!("/states/{index}/entry")),
349                related_paths: Vec::new(),
350                membership_id: None,
351                actual: None,
352                limit: None,
353                expected: Some(WorkflowDiagnosticExpected::ExistingReference),
354                actual_kind: None,
355                recovery: Some(WorkflowDiagnosticRecovery::CorrectReference),
356                line: None,
357                column: None,
358            });
359        }
360    }
361    if diagnostics.is_empty() {
362        Ok(())
363    } else {
364        Err(WorkflowValidationFailure { diagnostics })
365    }
366}
367
368fn persisted_inventory(content: &Path) -> Result<BTreeSet<String>> {
369    let root_metadata = fs::symlink_metadata(content)?;
370    ensure!(
371        root_metadata.is_dir()
372            && !root_metadata.file_type().is_symlink()
373            && root_metadata.permissions().readonly(),
374        "strategy_revision_content_drifted"
375    );
376    let mut stack = vec![(content.to_path_buf(), 0usize)];
377    let mut files = BTreeSet::new();
378    let mut entries = 0usize;
379    let mut total_bytes = 0u64;
380    while let Some((directory, depth)) = stack.pop() {
381        ensure!(
382            depth <= MAX_PACKAGE_DEPTH,
383            "strategy_revision_content_drifted"
384        );
385        for entry in fs::read_dir(&directory)? {
386            entries = entries.saturating_add(1);
387            ensure!(
388                entries <= MAX_PACKAGE_ENTRIES,
389                "strategy_revision_content_drifted"
390            );
391            let path = entry?.path();
392            let metadata = fs::symlink_metadata(&path)?;
393            ensure!(
394                !metadata.file_type().is_symlink() && metadata.permissions().readonly(),
395                "strategy_revision_content_drifted"
396            );
397            let relative = path
398                .strip_prefix(content)
399                .map_err(|_| anyhow!("strategy_revision_content_drifted"))?;
400            let normalized = relative
401                .iter()
402                .map(|part| {
403                    part.to_str()
404                        .filter(|value| !value.is_empty())
405                        .ok_or_else(|| anyhow!("strategy_revision_content_drifted"))
406                })
407                .collect::<Result<Vec<_>>>()?
408                .join("/");
409            ensure!(
410                normalized == "workflow.json"
411                    || normalized == "scripts"
412                    || normalized.starts_with("scripts/"),
413                "strategy_revision_content_drifted"
414            );
415            if metadata.is_dir() {
416                stack.push((path, depth + 1));
417            } else {
418                ensure!(metadata.is_file(), "strategy_revision_content_drifted");
419                ensure!(
420                    metadata.len() <= MAX_FILE_BYTES,
421                    "strategy_revision_content_drifted"
422                );
423                total_bytes = total_bytes
424                    .checked_add(metadata.len())
425                    .ok_or_else(|| anyhow!("strategy_revision_content_drifted"))?;
426                ensure!(
427                    total_bytes <= MAX_EXTRACTED_BYTES,
428                    "strategy_revision_content_drifted"
429                );
430                files.insert(normalized);
431            }
432        }
433    }
434    ensure!(
435        files.contains("workflow.json"),
436        "strategy_revision_content_drifted"
437    );
438    ensure!(
439        files
440            .iter()
441            .filter(|path| path.starts_with("scripts/"))
442            .count()
443            <= MAX_SCRIPT_FILES,
444        "strategy_revision_content_drifted"
445    );
446    Ok(files)
447}
448
449fn revision_digest(
450    content: &Path,
451    inventory: &BTreeSet<String>,
452    semantics_digest: &str,
453) -> Result<String> {
454    let mut hasher = revision_hasher(semantics_digest);
455    for path in inventory {
456        let bytes = read_bounded(&content.join(path), MAX_FILE_BYTES)?;
457        hash_revision_asset(&mut hasher, path, &bytes);
458    }
459    Ok(hex_digest(hasher.finalize().as_slice()))
460}
461
462fn revision_hasher(semantics_digest: &str) -> Sha256 {
463    let mut hasher = Sha256::new();
464    hasher.update(b"licoup-adaptive-flywheel-revision-v1\0");
465    hasher.update(semantics_digest.as_bytes());
466    hasher
467}
468
469fn hash_revision_asset(hasher: &mut Sha256, path: &str, bytes: &[u8]) {
470    hasher.update(b"\0");
471    hasher.update(path.as_bytes());
472    hasher.update(b"\0");
473    hasher.update((bytes.len() as u64).to_be_bytes());
474    hasher.update(Sha256::digest(bytes));
475}
476
477fn map_archive_error(error: anyhow::Error) -> anyhow::Error {
478    let message = error.to_string();
479    if message.contains("duplicate") || message.contains("case_collision") {
480        anyhow!("package_duplicate_entry")
481    } else if message.contains("limit") || message.contains("overflow") {
482        anyhow!("package_resource_limit")
483    } else {
484        anyhow!("package_entry_invalid")
485    }
486}
487
488fn read_bounded(path: &Path, maximum: u64) -> Result<Vec<u8>> {
489    let metadata = fs::symlink_metadata(path)?;
490    ensure!(
491        metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() <= maximum,
492        "strategy_revision_file_invalid"
493    );
494    fs::read(path).map_err(Into::into)
495}
496
497fn harden_read_only_tree(root: &Path) -> Result<()> {
498    let mut paths = vec![root.to_path_buf()];
499    let mut ordered = Vec::new();
500    while let Some(path) = paths.pop() {
501        let metadata = fs::symlink_metadata(&path)?;
502        ensure!(
503            !metadata.file_type().is_symlink(),
504            "strategy_revision_symlink"
505        );
506        ordered.push(path.clone());
507        if metadata.is_dir() {
508            for entry in fs::read_dir(&path)? {
509                paths.push(entry?.path());
510            }
511        }
512    }
513    ordered.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
514    for path in ordered {
515        let metadata = fs::symlink_metadata(&path)?;
516        let mut permissions = metadata.permissions();
517        permissions.set_readonly(true);
518        fs::set_permissions(&path, permissions)?;
519    }
520    Ok(())
521}
522
523fn validate_preparation_id(value: &str) -> Result<()> {
524    ensure!(
525        value.starts_with("preparation-")
526            && value.len() <= 64
527            && value
528                .chars()
529                .all(|character| character.is_ascii_alphanumeric() || character == '-'),
530        "preparation_not_found"
531    );
532    Ok(())
533}
534
535fn validate_digest(value: &str) -> Result<()> {
536    ensure!(
537        value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()),
538        "revision_digest_invalid"
539    );
540    Ok(())
541}
542
543fn sha256_hex(bytes: &[u8]) -> String {
544    hex_digest(Sha256::digest(bytes).as_slice())
545}
546
547fn hex_digest(bytes: &[u8]) -> String {
548    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
549}
550
551fn now_ms() -> i64 {
552    std::time::SystemTime::now()
553        .duration_since(std::time::UNIX_EPOCH)
554        .map(|duration| duration.as_millis() as i64)
555        .unwrap_or(0)
556}
557
558pub fn zip_package_files(files: &[(&str, &[u8])]) -> Result<Vec<u8>> {
559    let mut cursor = std::io::Cursor::new(Vec::new());
560    {
561        let mut writer = zip::ZipWriter::new(&mut cursor);
562        let options = SimpleFileOptions::default()
563            .compression_method(zip::CompressionMethod::Deflated)
564            .unix_permissions(0o100600);
565        for (path, bytes) in files {
566            writer.start_file(*path, options)?;
567            writer.write_all(bytes)?;
568        }
569        writer.finish()?;
570    }
571    Ok(cursor.into_inner())
572}
573
574pub fn synthetic_fixture_package_bytes() -> Result<Vec<u8>> {
575    zip_package_files(&[("workflow.json", SYNTHETIC_FIXTURE_WORKFLOW)])
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use std::sync::atomic::{AtomicU64, Ordering};
582
583    fn root() -> PathBuf {
584        static NEXT: AtomicU64 = AtomicU64::new(0);
585        let path = std::env::temp_dir().join(format!(
586            "lico-strategy-package-test-{}",
587            NEXT.fetch_add(1, Ordering::Relaxed)
588        ));
589        let _ = fs::remove_dir_all(&path);
590        fs::create_dir_all(&path).unwrap();
591        path
592    }
593
594    fn remove_root(path: PathBuf) {
595        let mut stack = vec![path.clone()];
596        while let Some(current) = stack.pop() {
597            if let Ok(metadata) = fs::symlink_metadata(&current) {
598                make_writable(&current, metadata.permissions());
599                if metadata.is_dir()
600                    && let Ok(entries) = fs::read_dir(&current)
601                {
602                    stack.extend(entries.flatten().map(|entry| entry.path()));
603                }
604            }
605        }
606        fs::remove_dir_all(path).unwrap();
607    }
608
609    fn make_writable(path: &Path, mut permissions: fs::Permissions) {
610        #[cfg(unix)]
611        {
612            use std::os::unix::fs::PermissionsExt;
613            permissions.set_mode(permissions.mode() | 0o200);
614        }
615        #[cfg(not(unix))]
616        permissions.set_readonly(false);
617        fs::set_permissions(path, permissions).unwrap();
618    }
619
620    #[test]
621    fn a_directory_member_with_a_body_is_refused_before_preparation() {
622        let root = root();
623        let importer = StrategyPackageImporter::open(&root).unwrap();
624        let mut cursor = std::io::Cursor::new(Vec::new());
625        {
626            let mut writer = zip::ZipWriter::new(&mut cursor);
627            // Directory-mode external attributes, so the fixture reaches the directory
628            // body guard instead of being refused as a regular file with a directory name.
629            let options = SimpleFileOptions::default()
630                .compression_method(zip::CompressionMethod::Deflated)
631                .unix_permissions(0o040755);
632            writer.start_file("workflow.json", options).unwrap();
633            writer.write_all(SYNTHETIC_FIXTURE_WORKFLOW).unwrap();
634            writer.start_file("data/d/", options).unwrap();
635            writer.write_all(b"body").unwrap();
636            writer.finish().unwrap();
637        }
638        let mut bytes = cursor.into_inner();
639        let central = {
640            let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
641            archive.by_index(1).unwrap().central_header_start() as usize
642        };
643        // unix_permissions masks file type bits; set and verify a real directory mode.
644        bytes[central + 38..central + 42]
645            .copy_from_slice(&((0o040755_u32 << 16) | 16).to_le_bytes());
646        {
647            let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
648            assert_eq!(
649                archive.by_index(1).unwrap().unix_mode().unwrap() & 0o170000,
650                0o040000
651            );
652        }
653        let error = importer
654            .prepare_bytes(&bytes)
655            .expect_err("a directory member with a body is refused");
656        assert_eq!(error.to_string(), "package_entry_invalid");
657        remove_root(root);
658    }
659
660    #[test]
661    fn multiline_instructions_survive_import_and_revision_reload() {
662        let root = root();
663        let importer = StrategyPackageImporter::open(&root).unwrap();
664        let mut workflow: serde_json::Value =
665            serde_json::from_slice(SYNTHETIC_FIXTURE_WORKFLOW).unwrap();
666        let instruction = "Read the plan.\n\t- 修改 fixture。\r\n- Run tests.";
667        workflow["states"][1]["instruction"] = instruction.into();
668        let source = serde_json::to_vec(&workflow).unwrap();
669        let bytes = zip_package_files(&[("workflow.json", &source)]).unwrap();
670        let prepared = importer.prepare_bytes(&bytes).unwrap();
671        let committed = importer
672            .commit(&prepared.preparation_id, &prepared.revision_digest)
673            .unwrap();
674        assert_eq!(committed.workflow.states[1].instruction, instruction);
675        importer
676            .verified_revision_content(&prepared.revision_digest, &prepared.semantics_digest)
677            .unwrap();
678        remove_root(root);
679    }
680
681    #[test]
682    fn instruction_validation_preserves_resource_and_control_character_limits() {
683        let root = root();
684        let importer = StrategyPackageImporter::open(&root).unwrap();
685        for instruction in [
686            "Read\0plan".to_owned(),
687            "Read\u{1b}[31mplan".to_owned(),
688            "Read\u{7f}plan".to_owned(),
689            "x".repeat(16 * 1024 + 1),
690            " padded ".to_owned(),
691        ] {
692            let mut workflow: serde_json::Value =
693                serde_json::from_slice(SYNTHETIC_FIXTURE_WORKFLOW).unwrap();
694            workflow["states"][1]["instruction"] = instruction.into();
695            let source = serde_json::to_vec(&workflow).unwrap();
696            let bytes = zip_package_files(&[("workflow.json", &source)]).unwrap();
697            let error = importer.prepare_bytes(&bytes).unwrap_err();
698            let failure = error.downcast_ref::<WorkflowValidationFailure>().unwrap();
699            assert!(failure.diagnostics.iter().any(|diagnostic| diagnostic.code
700                == WorkflowDiagnosticCode::WorkflowStateInstructionInvalid));
701            assert!(
702                licoup_workflow::compile_workflow(serde_json::from_value(workflow).unwrap())
703                    .is_err()
704            );
705        }
706        remove_root(root);
707    }
708
709    #[test]
710    fn synthetic_package_round_trips_and_commit_is_idempotent() {
711        let root = root();
712        let importer = StrategyPackageImporter::open(&root).unwrap();
713        let bytes = synthetic_fixture_package_bytes().unwrap();
714        let first = importer.prepare_bytes(&bytes).unwrap();
715        let committed = importer
716            .commit(&first.preparation_id, &first.revision_digest)
717            .unwrap();
718        assert_eq!(committed.workflow.metadata.id, "fixture-entry-worker");
719        assert_eq!(
720            committed
721                .workflow
722                .actor_slots
723                .iter()
724                .filter(|slot| slot.entry)
725                .map(|slot| slot.id.as_str())
726                .collect::<Vec<_>>(),
727            vec!["entry"]
728        );
729        let second = importer.prepare_bytes(&bytes).unwrap();
730        assert_eq!(second.revision_digest, first.revision_digest);
731        let committed_again = importer
732            .commit(&second.preparation_id, &second.revision_digest)
733            .unwrap();
734        assert_eq!(
735            committed_again.prepared.revision_digest,
736            first.revision_digest
737        );
738        let revision_content = importer
739            .revision_content(&committed_again.prepared.revision_digest)
740            .unwrap();
741        assert!(
742            !revision_content
743                .parent()
744                .unwrap()
745                .join("source.zip")
746                .exists()
747        );
748        remove_root(root);
749    }
750
751    #[test]
752    fn local_zip_index_disagreement_is_refused_before_preparation() {
753        let root = root();
754        let importer = StrategyPackageImporter::open(&root).unwrap();
755        let mut bytes = synthetic_fixture_package_bytes().unwrap();
756        bytes[22..26].copy_from_slice(&0_u32.to_le_bytes());
757        let error = importer.prepare_bytes(&bytes).unwrap_err();
758        assert_eq!(error.to_string(), "package_entry_invalid");
759        remove_root(root);
760    }
761
762    #[test]
763    fn persisted_revision_is_rehashed_before_execution() {
764        let root = root();
765        let importer = StrategyPackageImporter::open(&root).unwrap();
766        let prepared = importer
767            .prepare_bytes(&synthetic_fixture_package_bytes().unwrap())
768            .unwrap();
769        importer
770            .commit(&prepared.preparation_id, &prepared.revision_digest)
771            .unwrap();
772        let content = importer
773            .verified_revision_content(&prepared.revision_digest, &prepared.semantics_digest)
774            .unwrap();
775        let workflow = content.join("workflow.json");
776        make_writable(&workflow, fs::metadata(&workflow).unwrap().permissions());
777        fs::write(&workflow, b"{}").unwrap();
778        assert!(
779            importer
780                .verified_revision_content(&prepared.revision_digest, &prepared.semantics_digest)
781                .is_err()
782        );
783        remove_root(root);
784    }
785
786    #[test]
787    fn legacy_revision_verification_preserves_immutable_package_identity() {
788        let root = root();
789        let importer = StrategyPackageImporter::open(&root).unwrap();
790        let mut legacy: WorkflowDefinition =
791            serde_json::from_slice(SYNTHETIC_FIXTURE_WORKFLOW).unwrap();
792        legacy.actor_slots[0].entry = false;
793        legacy
794            .transitions
795            .retain(|transition| transition.event != licoup_workflow::TransitionEvent::Failure);
796        let workflow_bytes = serde_json::to_vec(&legacy).unwrap();
797        assert!(compile_workflow(legacy).is_err());
798
799        let staging = importer.root.join("revisions").join("legacy-staging");
800        let content = staging.join("content");
801        fs::create_dir_all(&content).unwrap();
802        fs::write(content.join("workflow.json"), &workflow_bytes).unwrap();
803        let inventory = BTreeSet::from(["workflow.json".to_owned()]);
804        let semantics = sha256_hex(&workflow_bytes);
805        let digest = revision_digest(&content, &inventory, &semantics).unwrap();
806        let revision = importer.root.join("revisions").join(&digest);
807        fs::rename(&staging, &revision).unwrap();
808        harden_read_only_tree(&revision).unwrap();
809
810        let verified = importer
811            .verified_revision_content(&digest, &semantics)
812            .unwrap();
813        assert_eq!(
814            fs::read(verified.join("workflow.json")).unwrap(),
815            workflow_bytes
816        );
817        assert_eq!(sha256_hex(&workflow_bytes), semantics);
818        assert_eq!(
819            revision_digest(&verified, &inventory, &semantics).unwrap(),
820            digest
821        );
822        remove_root(root);
823    }
824
825    #[test]
826    fn malformed_workflow_package_preserves_only_typed_syntax_location() {
827        let root = root();
828        let importer = StrategyPackageImporter::open(&root).unwrap();
829        let bytes =
830            zip_package_files(&[("workflow.json", br#"{"private-workspace-sentinel":"#)]).unwrap();
831        let error = importer.prepare_bytes(&bytes).unwrap_err();
832        let failure = error.downcast_ref::<WorkflowValidationFailure>().unwrap();
833        assert_eq!(failure.diagnostics.len(), 1);
834        let serialized = serde_json::to_value(&failure.diagnostics).unwrap();
835        assert_eq!(serialized[0]["code"], "workflow_syntax_invalid");
836        assert!(serialized[0]["line"].is_u64());
837        assert!(serialized[0]["column"].is_u64());
838        assert_eq!(serialized[0].as_object().unwrap().len(), 4);
839        assert!(
840            !serde_json::to_string(&serialized)
841                .unwrap()
842                .contains("private-workspace-sentinel")
843        );
844        remove_root(root);
845    }
846}