Skip to main content

aion_package/project/
assemble.rs

1//! `package_project` pipeline: config → discovery → build → verify-after-write.
2
3use std::{
4    collections::BTreeMap,
5    path::{Path, PathBuf},
6};
7
8use serde::Serialize;
9
10use super::{
11    config::{self, WorkflowConfig},
12    discover,
13    error::PackagingError,
14};
15use crate::{
16    BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest, ManifestVersion,
17    Package, PackageBuilder, WorkflowVersion,
18};
19
20/// Options for packaging an already-built Gleam workflow project.
21///
22/// Construct via [`Default`] and assign fields, so call sites keep compiling
23/// when options are added.
24#[derive(Clone, Debug, Default)]
25pub struct PackageOptions {
26    /// Overrides the single workflow's output path, resolved against the
27    /// project root when relative. Packaging fails with
28    /// [`PackagingError::OutputOverrideAmbiguous`] when the project declares
29    /// more than one workflow.
30    ///
31    /// This is the caller's own path and is intentionally exempt from the
32    /// root confinement applied to `workflow.toml`-declared paths: it may
33    /// point anywhere, including outside the project root (the CLI resolves
34    /// `--out` against the invoker's working directory before passing it
35    /// here).
36    pub output_override: Option<PathBuf>,
37}
38
39/// Result of packaging every workflow a project declares.
40#[derive(Clone, Debug, PartialEq)]
41pub struct ProjectReport {
42    /// One built package per `[[workflow]]` entry, in declaration order.
43    pub packages: Vec<PackagedWorkflow>,
44    /// Modules excluded by the SDK test filter or the dependency-closure filter.
45    pub excluded: Vec<ExcludedModule>,
46}
47
48/// One workflow archive written and verified by [`package_project`].
49#[derive(Clone, Debug, PartialEq)]
50pub struct PackagedWorkflow {
51    /// Workflow type, identical to the manifest entry module.
52    pub workflow_type: String,
53    /// Absolute path of the written `.aion` archive.
54    pub output_path: PathBuf,
55    /// The archive re-loaded from disk after writing, proving integrity.
56    pub package: Package,
57    /// Canonical version record of the verified package.
58    pub version: WorkflowVersion,
59}
60
61/// A compiled module excluded from packaging, with provenance.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
63pub struct ExcludedModule {
64    /// Logical module name that was excluded.
65    pub module: String,
66    /// Gleam package whose ebin provided the module.
67    pub package: String,
68    /// Why the module was excluded.
69    pub reason: ExcludedReason,
70}
71
72/// Reason a discovered compiled module was excluded from packaging.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ExcludedReason {
76    /// SDK test machinery from the `aion_flow` package's ebin.
77    SdkTestOnly,
78    /// Module of a package outside the production dependency closure.
79    DevDependency,
80}
81
82/// Packages every workflow declared by `<root>/workflow.toml`.
83///
84/// The project must already be built (`gleam build`); this function never
85/// spawns processes. The pipeline parses and validates the descriptor,
86/// discovers the production-closure compiled modules once, then writes one
87/// deterministic `.aion` archive per `[[workflow]]` entry. Every written
88/// archive is re-loaded through [`Package::load_from_path`] before this
89/// function returns, so the full read-path validation (integrity hash, format
90/// version, entry module) gates success.
91///
92/// All archives from one project share a single content hash (it covers beams
93/// only), while deployed entry names remain distinct per entry module. First
94/// party sources ship by default and never affect the hash.
95///
96/// Pure with respect to the environment: reads only under `root` (which is
97/// made absolute against the current directory once, up front), writes only
98/// the declared outputs, reads no environment variables, never prints, and
99/// blocks on synchronous filesystem I/O — async callers should wrap it in a
100/// blocking task.
101///
102/// The confinement is enforced, not assumed: every `workflow.toml`-declared
103/// path (`output`, `input_schema`, `output_schema`) is lexically normalized
104/// and must resolve inside `root` — absolute paths and `..` traversal that
105/// escapes the root fail with [`PackagingError::PathEscapesRoot`] before any
106/// file is touched. The sole exception is
107/// [`PackageOptions::output_override`]: that path belongs to the caller and
108/// may point anywhere, including outside the root.
109///
110/// # Errors
111///
112/// Returns [`PackagingError`] variants for missing or invalid `workflow.toml`
113/// descriptors, descriptor paths that are absolute or escape the project
114/// root, unreadable or non-JSON schema files, unbuilt projects, broken Gleam
115/// metadata, unresolved dependencies, duplicate or unreadable compiled
116/// modules, missing entry modules, output conflicts, ambiguous output
117/// overrides, and archive write (path-carrying) or verify-after-write
118/// failures.
119pub fn package_project(
120    root: &Path,
121    options: &PackageOptions,
122) -> Result<ProjectReport, PackagingError> {
123    let root = std::path::absolute(root).map_err(|source| PackagingError::ConfigRead {
124        path: root.to_path_buf(),
125        source,
126    })?;
127
128    let mut config = config::load_config(&root)?;
129    apply_output_override(&root, options, &mut config.workflows)?;
130
131    let discovered = discover::discover_modules(&root)?;
132    let beams = BeamSet::new(discovered.modules)?;
133    let source = if config.include_source {
134        discover::discover_sources(&root)?
135    } else {
136        BTreeMap::new()
137    };
138
139    let mut packages = Vec::with_capacity(config.workflows.len());
140    for workflow in &config.workflows {
141        if beams.get(&workflow.entry_module).is_none() {
142            return Err(PackagingError::EntryModuleNotFound {
143                module: workflow.entry_module.clone(),
144                searched: discovered.searched.clone(),
145            });
146        }
147        packages.push(build_workflow_package(workflow, &beams, &source)?);
148    }
149
150    Ok(ProjectReport {
151        packages,
152        excluded: discovered.excluded,
153    })
154}
155
156fn apply_output_override(
157    root: &Path,
158    options: &PackageOptions,
159    workflows: &mut [WorkflowConfig],
160) -> Result<(), PackagingError> {
161    let Some(output_override) = &options.output_override else {
162        return Ok(());
163    };
164    match workflows {
165        [workflow] => {
166            workflow.output_path = root.join(output_override);
167            Ok(())
168        }
169        _ => Err(PackagingError::OutputOverrideAmbiguous {
170            count: workflows.len(),
171        }),
172    }
173}
174
175fn build_workflow_package(
176    workflow: &WorkflowConfig,
177    beams: &BeamSet,
178    source: &BTreeMap<String, Vec<u8>>,
179) -> Result<PackagedWorkflow, PackagingError> {
180    let manifest = Manifest {
181        entry_module: workflow.entry_module.clone(),
182        entry_function: workflow.entry_function.clone(),
183        input_schema: workflow.input_schema.clone(),
184        output_schema: workflow.output_schema.clone(),
185        timeout: workflow.timeout,
186        activities: workflow
187            .activities
188            .iter()
189            .map(|activity_type| DeclaredActivity {
190                activity_type: activity_type.clone(),
191            })
192            .collect(),
193        version: ManifestVersion::new("unstamped"),
194        format_version: CURRENT_FORMAT_VERSION,
195    };
196
197    PackageBuilder::with_source(manifest, beams.clone(), source.clone())
198        .write_to_path(&workflow.output_path)
199        .map_err(|source| PackagingError::OutputWrite {
200            path: workflow.output_path.clone(),
201            source,
202        })?;
203    // Trusted local input: the archive was written by this process moments
204    // ago, so extraction runs unbounded.
205    let package = Package::load_from_path(&workflow.output_path, ExtractionLimits::unbounded())?;
206
207    Ok(PackagedWorkflow {
208        workflow_type: workflow.entry_module.clone(),
209        output_path: workflow.output_path.clone(),
210        version: package.version_record(),
211        package,
212    })
213}
214
215#[cfg(test)]
216mod tests {
217    use std::{fs, path::PathBuf, time::Duration};
218
219    use serde_json::json;
220
221    use super::{ExcludedModule, ExcludedReason, PackageOptions, package_project};
222    use crate::{PackageError, project::error::PackagingError, project::fixture};
223
224    type TestResult = Result<(), Box<dyn std::error::Error>>;
225
226    const TWO_WORKFLOW_TOML: &str = r#"[[workflow]]
227entry_module = "demo"
228entry_function = "run"
229timeout_seconds = 30
230input_schema = "schemas/input.json"
231output_schema = "schemas/output.json"
232activities = ["greet"]
233
234[[workflow]]
235entry_module = "demo@nested"
236entry_function = "start"
237timeout_seconds = 60
238input_schema = "schemas/input.json"
239output_schema = "schemas/output.json"
240activities = []
241"#;
242
243    #[test]
244    fn packaged_workflow_round_trips_manifest_and_hash() -> TestResult {
245        let root = fixture::synthetic_built_project("assemble-happy")?;
246        let report = package_project(&root, &PackageOptions::default());
247        let reloaded = report
248            .as_ref()
249            .ok()
250            .map(|report| report.packages[0].output_path.clone())
251            .map(|path| crate::Package::load_from_path(path, crate::ExtractionLimits::unbounded()));
252        fs::remove_dir_all(&root)?;
253        let report = report?;
254
255        assert_eq!(report.packages.len(), 1);
256        let packaged = &report.packages[0];
257        assert_eq!(packaged.workflow_type, "demo");
258        assert!(packaged.output_path.is_absolute());
259        assert_eq!(
260            packaged
261                .output_path
262                .file_name()
263                .and_then(|name| name.to_str()),
264            Some("demo.aion")
265        );
266        let manifest = packaged.package.manifest();
267        assert_eq!(manifest.entry_module, "demo");
268        assert_eq!(manifest.entry_function, "run");
269        assert_eq!(manifest.timeout, Duration::from_secs(30));
270        assert_eq!(manifest.input_schema, json!({ "type": "object" }));
271        assert_eq!(manifest.output_schema, json!(true));
272        assert_eq!(manifest.activities.len(), 1);
273        assert_eq!(manifest.activities[0].activity_type, "greet");
274        assert_eq!(
275            manifest.version.as_str(),
276            packaged.package.content_hash().to_string()
277        );
278        assert_eq!(packaged.version, packaged.package.version_record());
279        let reloaded = reloaded.ok_or("report failed")??;
280        assert_eq!(&reloaded, &packaged.package);
281        Ok(())
282    }
283
284    #[test]
285    fn exclusions_and_sources_are_reported_and_shipped() -> TestResult {
286        let root = fixture::synthetic_built_project("assemble-exclusions")?;
287        let report = package_project(&root, &PackageOptions::default());
288        fs::remove_dir_all(&root)?;
289        let report = report?;
290
291        let expected_exclusions = vec![
292            ExcludedModule {
293                module: "dev_only".to_owned(),
294                package: "dev_only".to_owned(),
295                reason: ExcludedReason::DevDependency,
296            },
297            ExcludedModule {
298                module: "aion@testing".to_owned(),
299                package: "aion_flow".to_owned(),
300                reason: ExcludedReason::SdkTestOnly,
301            },
302            ExcludedModule {
303                module: "aion@testing@mock".to_owned(),
304                package: "aion_flow".to_owned(),
305                reason: ExcludedReason::SdkTestOnly,
306            },
307            ExcludedModule {
308                module: "aion_flow_ffi".to_owned(),
309                package: "aion_flow".to_owned(),
310                reason: ExcludedReason::SdkTestOnly,
311            },
312        ];
313        assert_eq!(report.excluded, expected_exclusions);
314
315        let package = &report.packages[0].package;
316        let source_names: Vec<&str> = package.source().keys().map(String::as_str).collect();
317        assert_eq!(source_names, vec!["demo", "demo/nested"]);
318        let beam_names: Vec<&str> = package
319            .beams()
320            .iter()
321            .map(crate::BeamModule::name)
322            .collect();
323        assert_eq!(
324            beam_names,
325            vec!["aion_flow", "demo", "demo@nested", "dep_a", "dep_b"]
326        );
327        Ok(())
328    }
329
330    #[test]
331    fn missing_entry_module_returns_entry_module_not_found() -> TestResult {
332        let root = fixture::synthetic_built_project("assemble-ghost-entry")?;
333        let descriptor = fixture::DEMO_WORKFLOW_TOML.replace("\"demo\"", "\"ghost\"");
334        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
335        let result = package_project(&root, &PackageOptions::default());
336        fs::remove_dir_all(&root)?;
337
338        assert!(matches!(
339            result,
340            Err(PackagingError::EntryModuleNotFound { module, searched })
341                if module == "ghost" && searched.ends_with("build/dev/erlang")
342        ));
343        Ok(())
344    }
345
346    #[test]
347    fn explicit_output_field_is_respected() -> TestResult {
348        let root = fixture::synthetic_built_project("assemble-explicit-output")?;
349        let descriptor = format!(
350            "{}output = \"custom-name.aion\"\n",
351            fixture::DEMO_WORKFLOW_TOML
352        );
353        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
354        let report = package_project(&root, &PackageOptions::default());
355        let written = root.join("custom-name.aion").is_file();
356        fs::remove_dir_all(&root)?;
357        let report = report?;
358
359        assert!(written);
360        assert_eq!(
361            report.packages[0]
362                .output_path
363                .file_name()
364                .and_then(|name| name.to_str()),
365            Some("custom-name.aion")
366        );
367        Ok(())
368    }
369
370    #[test]
371    fn output_override_applies_to_single_workflow_project() -> TestResult {
372        let root = fixture::synthetic_built_project("assemble-override")?;
373        let options = PackageOptions {
374            output_override: Some(PathBuf::from("override.aion")),
375        };
376        let report = package_project(&root, &options);
377        let written = root.join("override.aion").is_file();
378        let derived_absent = !root.join("demo.aion").exists();
379        fs::remove_dir_all(&root)?;
380        let report = report?;
381
382        assert!(written);
383        assert!(derived_absent);
384        assert_eq!(
385            report.packages[0]
386                .output_path
387                .file_name()
388                .and_then(|name| name.to_str()),
389            Some("override.aion")
390        );
391        Ok(())
392    }
393
394    #[test]
395    fn output_write_failure_names_the_output_path() -> TestResult {
396        let root = fixture::synthetic_built_project("assemble-missing-dir")?;
397        let descriptor = format!(
398            "{}output = \"missing-dir/demo.aion\"\n",
399            fixture::DEMO_WORKFLOW_TOML
400        );
401        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
402        let result = package_project(&root, &PackageOptions::default());
403        fs::remove_dir_all(&root)?;
404
405        let expected = root.join("missing-dir/demo.aion");
406        let Err(error) = result else {
407            return Err("write into a missing directory unexpectedly succeeded".into());
408        };
409        assert!(
410            matches!(
411                &error,
412                PackagingError::OutputWrite { path, .. } if *path == expected
413            ),
414            "error does not carry the output path: {error:?}"
415        );
416        assert!(
417            error.to_string().contains(&expected.display().to_string()),
418            "message does not name the output path: {error}"
419        );
420        Ok(())
421    }
422
423    #[test]
424    fn output_override_may_point_outside_root_via_dotdot() -> TestResult {
425        // The exemption under test: workflow.toml paths are confined to the
426        // root, but the caller's `output_override` may point anywhere.
427        let root = fixture::synthetic_built_project("assemble-override-outside")?;
428        let outside_name = format!("aion-override-outside-{}.aion", std::process::id());
429        let options = PackageOptions {
430            output_override: Some(PathBuf::from(format!("../{outside_name}"))),
431        };
432        let report = package_project(&root, &options);
433        let outside = std::env::temp_dir().join(&outside_name);
434        let written = outside.is_file();
435        fs::remove_dir_all(&root)?;
436        if written {
437            fs::remove_file(&outside)?;
438        }
439        let report = report?;
440
441        assert!(written, "override outside the root was not written");
442        assert_eq!(
443            report.packages[0]
444                .output_path
445                .file_name()
446                .and_then(|name| name.to_str()),
447            Some(outside_name.as_str())
448        );
449        Ok(())
450    }
451
452    #[test]
453    fn output_override_with_multiple_workflows_is_ambiguous() -> TestResult {
454        let root = fixture::synthetic_built_project("assemble-override-multi")?;
455        fixture::write_file(&root, "workflow.toml", TWO_WORKFLOW_TOML.as_bytes())?;
456        let options = PackageOptions {
457            output_override: Some(PathBuf::from("override.aion")),
458        };
459        let result = package_project(&root, &options);
460        fs::remove_dir_all(&root)?;
461
462        assert!(matches!(
463            result,
464            Err(PackagingError::OutputOverrideAmbiguous { count: 2 })
465        ));
466        Ok(())
467    }
468
469    #[test]
470    fn multi_workflow_project_shares_hash_with_distinct_deployed_entries() -> TestResult {
471        let root = fixture::synthetic_built_project("assemble-multi")?;
472        fixture::write_file(&root, "workflow.toml", TWO_WORKFLOW_TOML.as_bytes())?;
473        let report = package_project(&root, &PackageOptions::default());
474        fs::remove_dir_all(&root)?;
475        let report = report?;
476
477        assert_eq!(report.packages.len(), 2);
478        let first = &report.packages[0];
479        let second = &report.packages[1];
480        assert_eq!(first.workflow_type, "demo");
481        assert_eq!(second.workflow_type, "demo@nested");
482        assert_eq!(first.package.content_hash(), second.package.content_hash());
483        assert_ne!(
484            first.package.deployed_entry_module(),
485            second.package.deployed_entry_module()
486        );
487        assert_ne!(first.output_path, second.output_path);
488        Ok(())
489    }
490
491    #[test]
492    fn user_module_with_reserved_name_fails_typed() -> TestResult {
493        let root = fixture::synthetic_built_project("assemble-reserved")?;
494        fixture::write_file(
495            &root,
496            "build/dev/erlang/demo/ebin/aion_flow_ffi.beam",
497            b"user-owned-bytes",
498        )?;
499        let result = package_project(&root, &PackageOptions::default());
500        fs::remove_dir_all(&root)?;
501
502        assert!(matches!(
503            result,
504            Err(PackagingError::Package(PackageError::ReservedModuleName { module }))
505                if module == "aion_flow_ffi"
506        ));
507        Ok(())
508    }
509
510    #[test]
511    fn repackaging_produces_identical_archive_bytes() -> TestResult {
512        let root = fixture::synthetic_built_project("assemble-det-1")?;
513        let first_report = package_project(&root, &PackageOptions::default());
514        let first_bytes = fs::read(root.join("demo.aion"));
515        let second_report = package_project(&root, &PackageOptions::default());
516        let second_bytes = fs::read(root.join("demo.aion"));
517        fs::remove_dir_all(&root)?;
518        first_report?;
519        second_report?;
520
521        let first_bytes = first_bytes?;
522        assert!(!first_bytes.is_empty());
523        assert_eq!(first_bytes, second_bytes?);
524        Ok(())
525    }
526
527    #[test]
528    fn source_inclusion_changes_bytes_but_never_the_version() -> TestResult {
529        let root = fixture::synthetic_built_project("assemble-det-3")?;
530        let with_source = package_project(&root, &PackageOptions::default());
531        let with_source_bytes = fs::read(root.join("demo.aion"));
532        let descriptor = format!(
533            "[package]\ninclude_source = false\n\n{}",
534            fixture::DEMO_WORKFLOW_TOML
535        );
536        fixture::write_file(&root, "workflow.toml", descriptor.as_bytes())?;
537        let without_source = package_project(&root, &PackageOptions::default());
538        let without_source_bytes = fs::read(root.join("demo.aion"));
539        fs::remove_dir_all(&root)?;
540        let with_source = with_source?;
541        let without_source = without_source?;
542
543        assert!(!with_source.packages[0].package.source().is_empty());
544        assert!(without_source.packages[0].package.source().is_empty());
545        assert_ne!(with_source_bytes?, without_source_bytes?);
546        assert_eq!(
547            with_source.packages[0].package.content_hash(),
548            without_source.packages[0].package.content_hash()
549        );
550        assert_eq!(
551            with_source.packages[0].package.manifest().version,
552            without_source.packages[0].package.manifest().version
553        );
554        Ok(())
555    }
556}