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> {
192 flow.nodes
193 .keys()
194 .filter_map(|node| {
195 let id = node.to_string();
196 if doc.nodes.contains_key(id.as_str()) {
197 None
198 } else {
199 Some(id)
200 }
201 })
202 .collect()
203}
204
205fn resolve_flow_path(pack_dir: &Path, flow: &FlowConfig) -> PathBuf {
206 if flow.file.is_absolute() {
207 flow.file.clone()
208 } else {
209 pack_dir.join(&flow.file)
210 }
211}
212
213fn read_or_write_flow_resolve_summary(
214 flow_path: &Path,
215 flow: &FlowConfig,
216) -> Result<FlowResolveSummaryV1> {
217 let summary_path = resolve_summary_path_for_flow(flow_path);
218 if !summary_path.exists() {
219 let sidecar_path = sidecar_path_for_flow(flow_path);
220 let sidecar = read_flow_resolve(&sidecar_path).map_err(|err| {
221 anyhow!(
222 "flow {} requires a resolve sidecar to generate summary; expected {}: {}",
223 flow.id,
224 sidecar_path.display(),
225 err
226 )
227 })?;
228 write_flow_resolve_summary_safe(flow_path, &sidecar).with_context(|| {
229 format!(
230 "failed to generate flow resolve summary for {}",
231 flow_path.display()
232 )
233 })?;
234 }
235
236 read_flow_resolve_summary(&summary_path).map_err(|err| {
237 anyhow!(
238 "failed to read flow resolve summary for {}: {}",
239 flow.id,
240 err
241 )
242 })
243}
244
245fn write_flow_resolve_summary_safe(flow_path: &Path, sidecar: &FlowResolveV1) -> Result<PathBuf> {
246 let result = if tokio::runtime::Handle::try_current().is_ok() {
247 let flow_path = flow_path.to_path_buf();
248 let sidecar = sidecar.clone();
249 let join =
250 std::thread::spawn(move || write_flow_resolve_summary_for_flow(&flow_path, &sidecar));
251 join.join()
252 .map_err(|_| anyhow!("flow resolve summary generation panicked"))?
253 } else {
254 write_flow_resolve_summary_for_flow(flow_path, sidecar)
255 };
256
257 match result {
258 Ok(path) => Ok(path),
259 Err(err) => {
260 if sidecar
261 .nodes
262 .values()
263 .all(|node| matches!(node.source, ComponentSourceRefV1::Local { .. }))
264 {
265 let summary = build_flow_resolve_summary_fallback(flow_path, sidecar)?;
266 let summary_path = resolve_summary_path_for_flow(flow_path);
267 write_flow_resolve_summary(&summary_path, &summary)
268 .map_err(|e| anyhow!(e.to_string()))?;
269 return Ok(summary_path);
270 }
271 Err(err)
272 }
273 }
274}
275
276fn enforce_summary_mappings(
277 flow: &FlowConfig,
278 compiled: &Flow,
279 summary: &FlowResolveSummaryV1,
280 flow_path: &Path,
281) -> Result<()> {
282 let missing = missing_summary_node_mappings(compiled, summary);
283 if !missing.is_empty() {
284 let summary_path = resolve_summary_path_for_flow(flow_path);
285 anyhow::bail!(
286 "flow {} is missing resolve summary entries for nodes {} (summary {}). Regenerate the summary and rerun build.",
287 flow.id,
288 missing.join(", "),
289 summary_path.display()
290 );
291 }
292 Ok(())
293}
294
295fn missing_summary_node_mappings(flow: &Flow, doc: &FlowResolveSummaryV1) -> Vec<String> {
296 flow.nodes
297 .keys()
298 .filter_map(|node| {
299 let id = node.to_string();
300 if doc.nodes.contains_key(id.as_str()) {
301 None
302 } else {
303 Some(id)
304 }
305 })
306 .collect()
307}
308
309fn build_flow_resolve_summary_fallback(
310 flow_path: &Path,
311 sidecar: &FlowResolveV1,
312) -> Result<FlowResolveSummaryV1> {
313 let mut nodes = BTreeMap::new();
314 for (node_id, entry) in &sidecar.nodes {
315 let summary = summarize_node_fallback(flow_path, node_id, &entry.source)?;
316 nodes.insert(node_id.clone(), summary);
317 }
318 Ok(FlowResolveSummaryV1 {
319 schema_version: FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION,
320 flow: flow_name_from_path(flow_path),
321 nodes,
322 })
323}
324
325fn summarize_node_fallback(
326 flow_path: &Path,
327 node_id: &str,
328 source: &ComponentSourceRefV1,
329) -> Result<NodeResolveSummaryV1> {
330 let ComponentSourceRefV1::Local { path, .. } = source else {
331 anyhow::bail!(
332 "flow resolve fallback only supports local sources (node {})",
333 node_id
334 );
335 };
336 let source_ref = FlowResolveSummarySourceRefV1::Local {
337 path: strip_file_uri_prefix(path).to_string(),
338 };
339 let wasm_path = local_path_from_sidecar(path, flow_path);
340 let digest = compute_sha256(&wasm_path)?;
341 let manifest_path = find_manifest_for_wasm_loose(&wasm_path).with_context(|| {
342 format!(
343 "component.manifest.json not found for node '{}' ({})",
344 node_id,
345 wasm_path.display()
346 )
347 })?;
348 let (component_id, manifest) = read_manifest_metadata(&manifest_path).with_context(|| {
349 format!(
350 "failed to read component.manifest.json for node '{}' ({})",
351 node_id,
352 manifest_path.display()
353 )
354 })?;
355
356 Ok(NodeResolveSummaryV1 {
357 component_id,
358 source: source_ref,
359 digest,
360 manifest,
361 })
362}
363
364fn find_manifest_for_wasm_loose(wasm_path: &Path) -> Result<PathBuf> {
365 let wasm_abs = fs::canonicalize(wasm_path)
366 .with_context(|| format!("resolve wasm path {}", wasm_path.display()))?;
367 let mut current = wasm_abs.parent();
368 let mut fallback = None;
369 while let Some(dir) = current {
370 let candidate = dir.join("component.manifest.json");
371 if candidate.exists() {
372 if manifest_matches_wasm_loose(&candidate, &wasm_abs)? {
373 return Ok(candidate);
374 }
375 if fallback.is_none() {
376 fallback = Some(candidate);
377 }
378 }
379 current = dir.parent();
380 }
381
382 if let Some(candidate) = fallback {
383 return Ok(candidate);
384 }
385
386 anyhow::bail!(
387 "component.manifest.json not found for wasm {}",
388 wasm_abs.display()
389 );
390}
391
392fn manifest_matches_wasm_loose(manifest_path: &Path, wasm_abs: &Path) -> Result<bool> {
393 let raw = fs::read_to_string(manifest_path)
394 .with_context(|| format!("read {}", manifest_path.display()))?;
395 let json: serde_json::Value =
396 serde_json::from_str(&raw).context("parse component.manifest.json")?;
397 let Some(rel) = json
398 .get("artifacts")
399 .and_then(|v| v.get("component_wasm"))
400 .and_then(|v| v.as_str())
401 else {
402 return Ok(false);
403 };
404 let manifest_dir = manifest_path
405 .parent()
406 .ok_or_else(|| anyhow!("manifest path {} has no parent", manifest_path.display()))?;
407 let sanitized = strip_file_uri_prefix(rel);
408 let Ok(abs) = fs::canonicalize(manifest_dir.join(sanitized)) else {
409 return Ok(false);
410 };
411 Ok(abs == *wasm_abs)
412}
413
414fn read_manifest_metadata(
415 manifest_path: &Path,
416) -> Result<(ComponentId, Option<FlowResolveSummaryManifestV1>)> {
417 let raw = fs::read_to_string(manifest_path)
418 .with_context(|| format!("read {}", manifest_path.display()))?;
419 let json: serde_json::Value =
420 serde_json::from_str(&raw).context("parse component.manifest.json")?;
421 let id = json
422 .get("id")
423 .and_then(|v| v.as_str())
424 .ok_or_else(|| anyhow!("manifest missing id"))?;
425 let component_id =
426 ComponentId::new(id).with_context(|| format!("invalid component id {}", id))?;
427 let world = json.get("world").and_then(|v| v.as_str());
428 let version = json.get("version").and_then(|v| v.as_str());
429 let manifest = match (world, version) {
430 (Some(world), Some(version)) => {
431 let parsed = Version::parse(version)
432 .with_context(|| format!("invalid semver version {}", version))?;
433 Some(FlowResolveSummaryManifestV1 {
434 world: world.to_string(),
435 version: parsed,
436 })
437 }
438 _ => None,
439 };
440 Ok((component_id, manifest))
441}
442
443fn flow_name_from_path(flow_path: &Path) -> String {
444 flow_path
445 .file_name()
446 .map(|name| name.to_string_lossy().to_string())
447 .unwrap_or_else(|| "flow.ygtc".to_string())
448}
449
450pub(crate) fn strip_file_uri_prefix(path: &str) -> &str {
451 path.strip_prefix("file://")
452 .or_else(|| path.strip_prefix("file:/"))
453 .or_else(|| path.strip_prefix("file:"))
454 .unwrap_or(path)
455}
456
457fn local_path_from_sidecar(path: &str, flow_path: &Path) -> PathBuf {
458 let trimmed = strip_file_uri_prefix(path);
459 let raw = PathBuf::from(trimmed);
460 if raw.is_absolute() {
461 raw
462 } else {
463 flow_path
464 .parent()
465 .unwrap_or_else(|| Path::new("."))
466 .join(raw)
467 }
468}
469
470fn compute_sha256(path: &Path) -> Result<String> {
471 let bytes = fs::read(path).with_context(|| format!("read wasm at {}", path.display()))?;
472 let mut sha = Sha256::new();
473 sha.update(bytes);
474 Ok(format!("sha256:{:x}", sha.finalize()))
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use serde_json::json;
481 use std::fs;
482 use tempfile::tempdir;
483
484 #[test]
485 fn strip_file_uri_prefix_removes_scheme_variants() {
486 assert_eq!(strip_file_uri_prefix("file:///tmp/foo"), "/tmp/foo");
487 assert_eq!(strip_file_uri_prefix("file:/tmp/foo"), "tmp/foo");
488 assert_eq!(strip_file_uri_prefix("file://bar/baz"), "bar/baz");
489 assert_eq!(strip_file_uri_prefix("file:relative/path"), "relative/path");
490 assert_eq!(
491 strip_file_uri_prefix("../components/foo"),
492 "../components/foo"
493 );
494 }
495
496 #[test]
497 fn manifest_matches_wasm_loose_handles_relative_file_uri_paths() {
498 let temp = tempdir().expect("alloc temp dir");
499 let components = temp.path().join("components");
500 fs::create_dir_all(&components).expect("create components dir");
501 let wasm_path = components.join("component.wasm");
502 fs::write(&wasm_path, b"wasm-bytes").expect("write wasm");
503 let manifest_path = components.join("component.manifest.json");
504 let manifest = json!({
505 "artifacts": {
506 "component_wasm": "file://component.wasm"
507 }
508 });
509 fs::write(
510 &manifest_path,
511 serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
512 )
513 .expect("write manifest");
514 let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
515 assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
516
517 let parent_manifest = json!({
518 "artifacts": {
519 "component_wasm": "file://../component.wasm"
520 }
521 });
522 let parent_dir = components.join("child");
523 fs::create_dir_all(&parent_dir).expect("create child dir");
524 let child_manifest_path = parent_dir.join("component.manifest.json");
525 fs::write(
526 &child_manifest_path,
527 serde_json::to_vec_pretty(&parent_manifest).unwrap(),
528 )
529 .expect("write child manifest");
530 assert!(
531 manifest_matches_wasm_loose(&child_manifest_path, &wasm_abs)
532 .expect("manifest matches child")
533 );
534 }
535
536 #[test]
537 fn manifest_matches_wasm_loose_handles_absolute_file_uri_paths() {
538 let temp = tempdir().expect("alloc temp dir");
539 let components = temp.path().join("components");
540 fs::create_dir_all(&components).expect("create components dir");
541 let wasm_path = components.join("component.wasm");
542 fs::write(&wasm_path, b"bytes").expect("write wasm");
543 let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
544 let manifest_path = components.join("component.manifest.json");
545 let manifest = json!({
546 "artifacts": {
547 "component_wasm": format!("file://{}", wasm_abs.display())
548 }
549 });
550 fs::write(
551 &manifest_path,
552 serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
553 )
554 .expect("write manifest");
555 assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
556 }
557}