weavatrix-rust 2.16.0

Protocol-independent Rust repository intelligence: typed evidence graphs for impact, architecture, APIs, Git, search, and memory
Documentation
use super::locations;
use super::model::{
    DomainRecord, WorkflowRecord, calls_workflow, configured_with, handles_error, uses_credential,
};
use super::redaction;
use blazingly_json::Value;
use weavatrix_graph::NodeKind;

pub(super) fn collect(workflow: &mut WorkflowRecord, nodes: &[Value], root: &Value, path: &str) {
    let span = locations::file_span(path);
    if let Some(id) = root
        .get("settings")
        .and_then(|settings| settings.get("errorWorkflow"))
        .and_then(Value::as_str)
        .filter(|id| !id.is_empty())
    {
        workflow.domains.push(DomainRecord {
            owner: workflow.key.clone(),
            name: format!("errorWorkflow:{id}"),
            kind: NodeKind::custom("n8n.workflow").unwrap_or(NodeKind::Module),
            relation: handles_error(),
            span: span.clone(),
        });
    }
    for node in nodes {
        let Some(name) = node.get("name").and_then(Value::as_str) else {
            continue;
        };
        let Some(owner) = workflow.nodes.iter().find(|item| item.name == name) else {
            continue;
        };
        let owner_key = owner.key.clone();
        credentials(workflow, node, &owner_key, &span);
        entry(workflow, node, &owner_key, &span);
        http(workflow, node, &owner_key, &span);
        fields(workflow, node, &owner_key, &span);
        subworkflow(workflow, node, &owner_key, &span);
    }
}

fn credentials(
    workflow: &mut WorkflowRecord,
    node: &Value,
    owner: &str,
    span: &weavatrix_graph::SourceSpan,
) {
    let Some(Value::Object(map)) = node.get("credentials") else {
        return;
    };
    for (kind, value) in map {
        let id = value.get("id").and_then(Value::as_str).unwrap_or("");
        let name = value.get("name").and_then(Value::as_str).unwrap_or(kind);
        workflow.domains.push(DomainRecord {
            owner: owner.to_owned(),
            name: if id.is_empty() {
                format!("credential:{name}")
            } else {
                format!("credential:{kind}:{id}")
            },
            kind: NodeKind::ConfigKey,
            relation: uses_credential(),
            span: span.clone(),
        });
    }
}

fn entry(
    workflow: &mut WorkflowRecord,
    node: &Value,
    owner: &str,
    span: &weavatrix_graph::SourceSpan,
) {
    let type_name = node.get("type").and_then(Value::as_str).unwrap_or("");
    let kind = if type_name.ends_with("manualTrigger") {
        "entry:manual"
    } else if type_name.ends_with("webhook") {
        "entry:webhook"
    } else if type_name.contains("schedule") {
        "entry:schedule"
    } else if type_name.ends_with("executeWorkflowTrigger") {
        "entry:executeWorkflow"
    } else if type_name.ends_with("errorTrigger") {
        "entry:error"
    } else {
        return;
    };
    workflow.domains.push(DomainRecord {
        owner: owner.to_owned(),
        name: kind.to_owned(),
        kind: NodeKind::Endpoint,
        relation: configured_with(),
        span: span.clone(),
    });
    if let Some(path) = node
        .get("parameters")
        .and_then(|parameters| parameters.get("path"))
        .and_then(Value::as_str)
    {
        workflow.domains.push(DomainRecord {
            owner: owner.to_owned(),
            name: format!("webhook:{path}"),
            kind: NodeKind::Endpoint,
            relation: configured_with(),
            span: span.clone(),
        });
    }
}

fn http(
    workflow: &mut WorkflowRecord,
    node: &Value,
    owner: &str,
    span: &weavatrix_graph::SourceSpan,
) {
    let type_name = node.get("type").and_then(Value::as_str).unwrap_or("");
    if !type_name.ends_with("httpRequest") && !type_name.ends_with("respondToWebhook") {
        return;
    }
    let parameters = node.get("parameters").cloned().unwrap_or(Value::Null);
    let method = parameters
        .get("method")
        .and_then(Value::as_str)
        .unwrap_or("GET");
    let url = parameters
        .get("url")
        .and_then(Value::as_str)
        .or_else(|| parameters.get("path").and_then(Value::as_str))
        .unwrap_or("");
    if redaction::looks_secret("/parameters/url", url) {
        workflow.domains.push(DomainRecord {
            owner: owner.to_owned(),
            name: format!("http:{method}:redacted"),
            kind: NodeKind::Endpoint,
            relation: configured_with(),
            span: span.clone(),
        });
        return;
    }
    let (static_url, dynamic) = if url.starts_with('=') || url.contains("{{") {
        ("", true)
    } else {
        (url, false)
    };
    workflow.domains.push(DomainRecord {
        owner: owner.to_owned(),
        name: if dynamic {
            format!("http:{method}:dynamic")
        } else {
            format!("http:{method}:{static_url}")
        },
        kind: NodeKind::Endpoint,
        relation: configured_with(),
        span: span.clone(),
    });
}

fn fields(
    workflow: &mut WorkflowRecord,
    node: &Value,
    owner: &str,
    span: &weavatrix_graph::SourceSpan,
) {
    let type_name = node.get("type").and_then(Value::as_str).unwrap_or("");
    if !type_name.ends_with("set") && !type_name.contains("editFields") {
        return;
    }
    let assignments = node
        .get("parameters")
        .and_then(|parameters| parameters.get("assignments"))
        .and_then(|assignments| assignments.get("assignments"))
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    for assignment in assignments {
        let Some(name) = assignment.get("name").and_then(Value::as_str) else {
            continue;
        };
        if redaction::looks_secret(&format!("/assignments/{name}"), name) {
            continue;
        }
        workflow.domains.push(DomainRecord {
            owner: owner.to_owned(),
            name: format!("assigns:{name}"),
            kind: NodeKind::Column,
            relation: configured_with(),
            span: span.clone(),
        });
    }
}

fn subworkflow(
    workflow: &mut WorkflowRecord,
    node: &Value,
    owner: &str,
    span: &weavatrix_graph::SourceSpan,
) {
    let type_name = node.get("type").and_then(Value::as_str).unwrap_or("");
    if !type_name.ends_with("executeWorkflow") {
        return;
    }
    let parameters = node.get("parameters").cloned().unwrap_or(Value::Null);
    let source = parameters
        .get("source")
        .and_then(Value::as_str)
        .unwrap_or("database");
    let wait = parameters
        .get("options")
        .and_then(|options| options.get("waitForSubWorkflow"))
        .and_then(Value::as_bool)
        .unwrap_or(true);
    let target = match source {
        "database" => parameters
            .get("workflowId")
            .and_then(workflow_id)
            .map(|id| {
                workflow.domains.push(DomainRecord {
                    owner: owner.to_owned(),
                    name: format!("workflow_id:{id}"),
                    kind: NodeKind::custom("n8n.workflow").unwrap_or(NodeKind::Module),
                    relation: calls_workflow(),
                    span: span.clone(),
                });
                format!("subworkflow:database:{id}")
            }),
        "localFile" => Some("subworkflow:localFile:unmapped".to_owned()),
        "parameter" => Some("subworkflow:embedded:classified".to_owned()),
        "url" => parameters
            .get("workflowId")
            .and_then(Value::as_str)
            .map(|url| format!("subworkflow:url:{url}")),
        _ => Some("subworkflow:unresolved".to_owned()),
    };
    if let Some(name) = target {
        workflow.domains.push(DomainRecord {
            owner: owner.to_owned(),
            name,
            kind: NodeKind::custom("n8n.workflow").unwrap_or(NodeKind::Module),
            relation: calls_workflow(),
            span: span.clone(),
        });
    }
    if !wait {
        workflow.domains.push(DomainRecord {
            owner: owner.to_owned(),
            name: "subworkflow:no-wait".to_owned(),
            kind: NodeKind::Unknown,
            relation: configured_with(),
            span: span.clone(),
        });
    }
}

fn workflow_id(value: &Value) -> Option<String> {
    match value {
        Value::String(id) if !id.is_empty() && !id.starts_with('=') => Some(id.clone()),
        Value::Object(fields) => fields
            .get("value")
            .and_then(Value::as_str)
            .filter(|id| !id.is_empty() && !id.starts_with('='))
            .map(str::to_owned),
        _ => None,
    }
}