1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::resolve_summary::write_flow_resolve_summary_for_flow;
9use greentic_types::ComponentId;
10use greentic_types::Flow;
11use greentic_types::error::ErrorCode;
12use greentic_types::flow_resolve::{
13 ComponentSourceRefV1, FlowResolveV1, read_flow_resolve, sidecar_path_for_flow,
14 write_flow_resolve,
15};
16use greentic_types::flow_resolve_summary::{
17 FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION, FlowResolveSummaryManifestV1,
18 FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, NodeResolveSummaryV1,
19 read_flow_resolve_summary, resolve_summary_path_for_flow, write_flow_resolve_summary,
20};
21use semver::Version;
22use sha2::{Digest, Sha256};
23
24use crate::config::FlowConfig;
25
26#[derive(Clone, Debug)]
27pub struct FlowResolveSidecar {
28 pub flow_id: String,
29 pub flow_path: PathBuf,
30 pub sidecar_path: PathBuf,
31 pub document: Option<FlowResolveV1>,
32 pub warning: Option<String>,
33}
34
35pub fn discover_flow_resolves(pack_dir: &Path, flows: &[FlowConfig]) -> Vec<FlowResolveSidecar> {
39 flows
40 .iter()
41 .map(|flow| {
42 let flow_path = if flow.file.is_absolute() {
43 flow.file.clone()
44 } else {
45 pack_dir.join(&flow.file)
46 };
47 let sidecar_path = sidecar_path_for_flow(&flow_path);
48
49 let (document, warning) = match read_flow_resolve(&sidecar_path) {
50 Ok(doc) => (Some(doc), None),
51 Err(err) if err.code == ErrorCode::NotFound => (
52 None,
53 Some(format!(
54 "flow resolve sidecar missing for {} ({})",
55 flow.id,
56 sidecar_path.display()
57 )),
58 ),
59 Err(err) => (
60 None,
61 Some(format!(
62 "failed to read flow resolve sidecar for {}: {}",
63 flow.id, err
64 )),
65 ),
66 };
67
68 FlowResolveSidecar {
69 flow_id: flow.id.clone(),
70 flow_path,
71 sidecar_path,
72 document,
73 warning,
74 }
75 })
76 .collect()
77}
78
79pub fn load_flow_resolve_summary(
81 pack_dir: &Path,
82 flow: &FlowConfig,
83 compiled: &Flow,
84) -> Result<FlowResolveSummaryV1> {
85 let flow_path = resolve_flow_path(pack_dir, flow);
86 let summary = read_or_write_flow_resolve_summary(&flow_path, flow)?;
87 enforce_summary_mappings(flow, compiled, &summary, &flow_path)?;
88 Ok(summary)
89}
90
91pub fn read_flow_resolve_summary_for_flow(
93 pack_dir: &Path,
94 flow: &FlowConfig,
95) -> Result<FlowResolveSummaryV1> {
96 let flow_path = resolve_flow_path(pack_dir, flow);
97 read_or_write_flow_resolve_summary(&flow_path, flow)
98}
99
100pub fn ensure_sidecar_exists(
105 pack_dir: &Path,
106 flow: &FlowConfig,
107 compiled: &Flow,
108 strict: bool,
109) -> Result<()> {
110 let flow_path = if flow.file.is_absolute() {
111 flow.file.clone()
112 } else {
113 pack_dir.join(&flow.file)
114 };
115 let sidecar_path = sidecar_path_for_flow(&flow_path);
116
117 let doc = match read_flow_resolve(&sidecar_path) {
118 Ok(doc) => doc,
119 Err(err) if err.code == ErrorCode::NotFound => {
120 let doc = FlowResolveV1 {
121 schema_version: 1,
122 flow: flow.file.to_string_lossy().into_owned(),
123 nodes: BTreeMap::new(),
124 };
125 if let Some(parent) = sidecar_path.parent() {
126 fs::create_dir_all(parent)
127 .with_context(|| format!("failed to create {}", parent.display()))?;
128 }
129 write_flow_resolve(&sidecar_path, &doc)
130 .with_context(|| format!("failed to write {}", sidecar_path.display()))?;
131 doc
132 }
133 Err(err) => {
134 return Err(anyhow!(
135 "failed to read flow resolve sidecar for {}: {}",
136 flow.id,
137 err
138 ));
139 }
140 };
141
142 let missing = missing_node_mappings(compiled, &doc);
143 if !missing.is_empty() {
144 if strict {
145 anyhow::bail!(
146 "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
147 flow.id,
148 missing.join(", "),
149 sidecar_path.display()
150 );
151 } else {
152 eprintln!(
153 "warning: flow {} has no resolve entries for nodes {} ({}); add mappings to the sidecar and rerun `greentic-pack resolve`",
154 flow.id,
155 missing.join(", "),
156 sidecar_path.display()
157 );
158 }
159 }
160
161 Ok(())
162}
163
164pub fn enforce_sidecar_mappings(pack_dir: &Path, flow: &FlowConfig, compiled: &Flow) -> Result<()> {
166 let flow_path = resolve_flow_path(pack_dir, flow);
167 let sidecar_path = sidecar_path_for_flow(&flow_path);
168 let doc = read_flow_resolve(&sidecar_path).map_err(|err| {
169 anyhow!(
170 "flow {} requires a resolve sidecar; expected {}: {}",
171 flow.id,
172 sidecar_path.display(),
173 err
174 )
175 })?;
176
177 let missing = missing_node_mappings(compiled, &doc);
178 if !missing.is_empty() {
179 anyhow::bail!(
180 "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
181 flow.id,
182 missing.join(", "),
183 sidecar_path.display()
184 );
185 }
186
187 Ok(())
188}
189
190pub fn missing_node_mappings(flow: &Flow, doc: &FlowResolveV1) -> Vec<String> {
197 flow.nodes
198 .iter()
199 .filter(|(_, def)| !crate::build::node_is_builtin(def))
200 .filter_map(|(node, _)| {
201 let id = node.to_string();
202 if doc.nodes.contains_key(id.as_str()) {
203 None
204 } else {
205 Some(id)
206 }
207 })
208 .collect()
209}
210
211fn resolve_flow_path(pack_dir: &Path, flow: &FlowConfig) -> PathBuf {
212 if flow.file.is_absolute() {
213 flow.file.clone()
214 } else {
215 pack_dir.join(&flow.file)
216 }
217}
218
219fn read_or_write_flow_resolve_summary(
220 flow_path: &Path,
221 flow: &FlowConfig,
222) -> Result<FlowResolveSummaryV1> {
223 let summary_path = resolve_summary_path_for_flow(flow_path);
224 if !summary_path.exists() {
225 let sidecar_path = sidecar_path_for_flow(flow_path);
226 let sidecar = read_flow_resolve(&sidecar_path).map_err(|err| {
227 anyhow!(
228 "flow {} requires a resolve sidecar to generate summary; expected {}: {}",
229 flow.id,
230 sidecar_path.display(),
231 err
232 )
233 })?;
234 write_flow_resolve_summary_safe(flow_path, &sidecar).with_context(|| {
235 format!(
236 "failed to generate flow resolve summary for {}",
237 flow_path.display()
238 )
239 })?;
240 }
241
242 read_flow_resolve_summary(&summary_path).map_err(|err| {
243 anyhow!(
244 "failed to read flow resolve summary for {}: {}",
245 flow.id,
246 err
247 )
248 })
249}
250
251fn write_flow_resolve_summary_safe(flow_path: &Path, sidecar: &FlowResolveV1) -> Result<PathBuf> {
252 let result = if tokio::runtime::Handle::try_current().is_ok() {
253 let flow_path = flow_path.to_path_buf();
254 let sidecar = sidecar.clone();
255 let join =
256 std::thread::spawn(move || write_flow_resolve_summary_for_flow(&flow_path, &sidecar));
257 join.join()
258 .map_err(|_| anyhow!("flow resolve summary generation panicked"))?
259 } else {
260 write_flow_resolve_summary_for_flow(flow_path, sidecar)
261 };
262
263 match result {
264 Ok(path) => Ok(path),
265 Err(err) => {
266 if sidecar
267 .nodes
268 .values()
269 .all(|node| matches!(node.source, ComponentSourceRefV1::Local { .. }))
270 {
271 let summary = build_flow_resolve_summary_fallback(flow_path, sidecar)?;
272 let summary_path = resolve_summary_path_for_flow(flow_path);
273 write_flow_resolve_summary(&summary_path, &summary)
274 .map_err(|e| anyhow!(e.to_string()))?;
275 return Ok(summary_path);
276 }
277 Err(err)
278 }
279 }
280}
281
282fn enforce_summary_mappings(
283 flow: &FlowConfig,
284 compiled: &Flow,
285 summary: &FlowResolveSummaryV1,
286 flow_path: &Path,
287) -> Result<()> {
288 let missing = missing_summary_node_mappings(compiled, summary);
289 if !missing.is_empty() {
290 let summary_path = resolve_summary_path_for_flow(flow_path);
291 anyhow::bail!(
292 "flow {} is missing resolve summary entries for nodes {} (summary {}). Regenerate the summary and rerun build.",
293 flow.id,
294 missing.join(", "),
295 summary_path.display()
296 );
297 }
298 Ok(())
299}
300
301fn missing_summary_node_mappings(flow: &Flow, doc: &FlowResolveSummaryV1) -> Vec<String> {
302 flow.nodes
305 .iter()
306 .filter(|(_, def)| !crate::build::node_is_builtin(def))
307 .filter_map(|(node, _)| {
308 let id = node.to_string();
309 if doc.nodes.contains_key(id.as_str()) {
310 None
311 } else {
312 Some(id)
313 }
314 })
315 .collect()
316}
317
318fn build_flow_resolve_summary_fallback(
319 flow_path: &Path,
320 sidecar: &FlowResolveV1,
321) -> Result<FlowResolveSummaryV1> {
322 let mut nodes = BTreeMap::new();
323 for (node_id, entry) in &sidecar.nodes {
324 let summary = summarize_node_fallback(flow_path, node_id, &entry.source)?;
325 nodes.insert(node_id.clone(), summary);
326 }
327 Ok(FlowResolveSummaryV1 {
328 schema_version: FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION,
329 flow: flow_name_from_path(flow_path),
330 nodes,
331 })
332}
333
334fn summarize_node_fallback(
335 flow_path: &Path,
336 node_id: &str,
337 source: &ComponentSourceRefV1,
338) -> Result<NodeResolveSummaryV1> {
339 let ComponentSourceRefV1::Local { path, .. } = source else {
340 anyhow::bail!(
341 "flow resolve fallback only supports local sources (node {})",
342 node_id
343 );
344 };
345 let source_ref = FlowResolveSummarySourceRefV1::Local {
346 path: strip_file_uri_prefix(path).to_string(),
347 };
348 let wasm_path = local_path_from_sidecar(path, flow_path);
349 let digest = compute_sha256(&wasm_path)?;
350 let manifest_path = find_manifest_for_wasm_loose(&wasm_path).with_context(|| {
351 format!(
352 "component.manifest.json not found for node '{}' ({})",
353 node_id,
354 wasm_path.display()
355 )
356 })?;
357 let (component_id, manifest) = read_manifest_metadata(&manifest_path).with_context(|| {
358 format!(
359 "failed to read component.manifest.json for node '{}' ({})",
360 node_id,
361 manifest_path.display()
362 )
363 })?;
364
365 Ok(NodeResolveSummaryV1 {
366 component_id,
367 source: source_ref,
368 digest,
369 manifest,
370 })
371}
372
373fn find_manifest_for_wasm_loose(wasm_path: &Path) -> Result<PathBuf> {
374 let wasm_abs = fs::canonicalize(wasm_path)
375 .with_context(|| format!("resolve wasm path {}", wasm_path.display()))?;
376 let mut current = wasm_abs.parent();
377 let mut fallback = None;
378 while let Some(dir) = current {
379 let candidate = dir.join("component.manifest.json");
380 if candidate.exists() {
381 if manifest_matches_wasm_loose(&candidate, &wasm_abs)? {
382 return Ok(candidate);
383 }
384 if fallback.is_none() {
385 fallback = Some(candidate);
386 }
387 }
388 current = dir.parent();
389 }
390
391 if let Some(candidate) = fallback {
392 return Ok(candidate);
393 }
394
395 anyhow::bail!(
396 "component.manifest.json not found for wasm {}",
397 wasm_abs.display()
398 );
399}
400
401fn manifest_matches_wasm_loose(manifest_path: &Path, wasm_abs: &Path) -> Result<bool> {
402 let raw = fs::read_to_string(manifest_path)
403 .with_context(|| format!("read {}", manifest_path.display()))?;
404 let json: serde_json::Value =
405 serde_json::from_str(&raw).context("parse component.manifest.json")?;
406 let Some(rel) = json
407 .get("artifacts")
408 .and_then(|v| v.get("component_wasm"))
409 .and_then(|v| v.as_str())
410 else {
411 return Ok(false);
412 };
413 let manifest_dir = manifest_path
414 .parent()
415 .ok_or_else(|| anyhow!("manifest path {} has no parent", manifest_path.display()))?;
416 let sanitized = strip_file_uri_prefix(rel);
417 let Ok(abs) = fs::canonicalize(manifest_dir.join(sanitized)) else {
418 return Ok(false);
419 };
420 Ok(abs == *wasm_abs)
421}
422
423fn read_manifest_metadata(
424 manifest_path: &Path,
425) -> Result<(ComponentId, Option<FlowResolveSummaryManifestV1>)> {
426 let raw = fs::read_to_string(manifest_path)
427 .with_context(|| format!("read {}", manifest_path.display()))?;
428 let json: serde_json::Value =
429 serde_json::from_str(&raw).context("parse component.manifest.json")?;
430 let id = json
431 .get("id")
432 .and_then(|v| v.as_str())
433 .ok_or_else(|| anyhow!("manifest missing id"))?;
434 let component_id =
435 ComponentId::new(id).with_context(|| format!("invalid component id {}", id))?;
436 let world = json.get("world").and_then(|v| v.as_str());
437 let version = json.get("version").and_then(|v| v.as_str());
438 let manifest = match (world, version) {
439 (Some(world), Some(version)) => {
440 let parsed = Version::parse(version)
441 .with_context(|| format!("invalid semver version {}", version))?;
442 Some(FlowResolveSummaryManifestV1 {
443 world: world.to_string(),
444 version: parsed,
445 })
446 }
447 _ => None,
448 };
449 Ok((component_id, manifest))
450}
451
452fn flow_name_from_path(flow_path: &Path) -> String {
453 flow_path
454 .file_name()
455 .map(|name| name.to_string_lossy().to_string())
456 .unwrap_or_else(|| "flow.ygtc".to_string())
457}
458
459pub(crate) fn strip_file_uri_prefix(path: &str) -> &str {
460 path.strip_prefix("file://")
461 .or_else(|| path.strip_prefix("file:/"))
462 .or_else(|| path.strip_prefix("file:"))
463 .unwrap_or(path)
464}
465
466fn local_path_from_sidecar(path: &str, flow_path: &Path) -> PathBuf {
467 let trimmed = strip_file_uri_prefix(path);
468 let raw = PathBuf::from(trimmed);
469 if raw.is_absolute() {
470 raw
471 } else {
472 flow_path
473 .parent()
474 .unwrap_or_else(|| Path::new("."))
475 .join(raw)
476 }
477}
478
479fn compute_sha256(path: &Path) -> Result<String> {
480 let bytes = fs::read(path).with_context(|| format!("read wasm at {}", path.display()))?;
481 let mut sha = Sha256::new();
482 sha.update(bytes);
483 Ok(format!("sha256:{}", hex::encode(sha.finalize())))
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use serde_json::json;
490 use std::fs;
491 use tempfile::tempdir;
492
493 #[test]
494 fn strip_file_uri_prefix_removes_scheme_variants() {
495 assert_eq!(strip_file_uri_prefix("file:///tmp/foo"), "/tmp/foo");
496 assert_eq!(strip_file_uri_prefix("file:/tmp/foo"), "tmp/foo");
497 assert_eq!(strip_file_uri_prefix("file://bar/baz"), "bar/baz");
498 assert_eq!(strip_file_uri_prefix("file:relative/path"), "relative/path");
499 assert_eq!(
500 strip_file_uri_prefix("../components/foo"),
501 "../components/foo"
502 );
503 }
504
505 #[test]
506 fn manifest_matches_wasm_loose_handles_relative_file_uri_paths() {
507 let temp = tempdir().expect("alloc temp dir");
508 let components = temp.path().join("components");
509 fs::create_dir_all(&components).expect("create components dir");
510 let wasm_path = components.join("component.wasm");
511 fs::write(&wasm_path, b"wasm-bytes").expect("write wasm");
512 let manifest_path = components.join("component.manifest.json");
513 let manifest = json!({
514 "artifacts": {
515 "component_wasm": "file://component.wasm"
516 }
517 });
518 fs::write(
519 &manifest_path,
520 serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
521 )
522 .expect("write manifest");
523 let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
524 assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
525
526 let parent_manifest = json!({
527 "artifacts": {
528 "component_wasm": "file://../component.wasm"
529 }
530 });
531 let parent_dir = components.join("child");
532 fs::create_dir_all(&parent_dir).expect("create child dir");
533 let child_manifest_path = parent_dir.join("component.manifest.json");
534 fs::write(
535 &child_manifest_path,
536 serde_json::to_vec_pretty(&parent_manifest).unwrap(),
537 )
538 .expect("write child manifest");
539 assert!(
540 manifest_matches_wasm_loose(&child_manifest_path, &wasm_abs)
541 .expect("manifest matches child")
542 );
543 }
544
545 #[test]
546 fn manifest_matches_wasm_loose_handles_absolute_file_uri_paths() {
547 let temp = tempdir().expect("alloc temp dir");
548 let components = temp.path().join("components");
549 fs::create_dir_all(&components).expect("create components dir");
550 let wasm_path = components.join("component.wasm");
551 fs::write(&wasm_path, b"bytes").expect("write wasm");
552 let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
553 let manifest_path = components.join("component.manifest.json");
554 let manifest = json!({
555 "artifacts": {
556 "component_wasm": format!("file://{}", wasm_abs.display())
557 }
558 });
559 fs::write(
560 &manifest_path,
561 serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
562 )
563 .expect("write manifest");
564 assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
565 }
566}