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 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 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 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(¤t) {
598 make_writable(¤t, metadata.permissions());
599 if metadata.is_dir()
600 && let Ok(entries) = fs::read_dir(¤t)
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 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 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}