Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

#### Core Engine (flowscope-core)

- **Bounded analysis inputs** — enforced UTF-8 byte limits before templating and parsing for inline and multi-file requests, with a 10 MiB per-source limit, a 100 MiB aggregate limit, and structured source-attributed errors

#### TypeScript API, Web App, and VS Code Extension

- **Stable React store subscriptions** — added selector overloads for lineage state and actions, stabilized the legacy `useLineage()` object references, and prevented prop synchronization effects from rerunning on unrelated store updates
Expand Down
6 changes: 5 additions & 1 deletion crates/flowscope-core/src/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use descriptions::DescriptionKey;
use helpers::{
build_column_schemas_with_constraints, find_identifier_span, find_relation_occurrence_spans,
};
use input::{collect_statements, StatementInput};
use input::{collect_statements, validate_analysis_input_sizes, StatementInput};
use schema_registry::SchemaRegistry;
use statements::{
detect_dbt_model_materialization, extract_model_name, DbtMaterializationDetection,
Expand All @@ -51,6 +51,10 @@ pub(crate) use schema_registry::TableResolution;
/// Main entry point for SQL analysis
#[must_use]
pub fn analyze(request: &AnalyzeRequest) -> AnalyzeResult {
if let Err(issue) = validate_analysis_input_sizes(request) {
return AnalyzeResult::from_issue(*issue);
}

#[cfg(feature = "tracing")]
let _span =
info_span!("analyze_request", statement_count = %request.sql.matches(';').count() + 1)
Expand Down
152 changes: 152 additions & 0 deletions crates/flowscope-core/src/analyzer/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! This module handles the parsing and collection of SQL statements from analysis requests,
//! supporting both file-based and inline SQL inputs.

use crate::limits::{MAX_ANALYSIS_SOURCE_BYTES, MAX_ANALYSIS_TOTAL_BYTES};
use crate::parser::{parse_sql_with_dialect, parse_sql_with_dialect_output};
use crate::types::{issue_codes, AnalyzeRequest, Dialect, Issue, Span};
use sqlparser::ast::Statement;
Expand All @@ -20,6 +21,115 @@ use crate::templater::{template_sql, TemplateMode};
/// on malformed SQL input.
const MAX_MERGE_ITERATIONS: usize = 10_000;

#[derive(Clone, Copy)]
enum AnalysisSource<'a> {
Inline(Option<&'a str>),
File(&'a str),
}

impl<'a> AnalysisSource<'a> {
fn name(self) -> Option<&'a str> {
match self {
Self::Inline(name) => name,
Self::File(name) => Some(name),
}
}

fn description(self) -> String {
match self {
Self::Inline(Some(name)) | Self::File(name) => format!("SQL source \"{name}\""),
Self::Inline(None) => "Inline SQL".to_string(),
}
}
}

#[derive(Clone, Copy)]
struct AnalysisSourceSize<'a> {
source: AnalysisSource<'a>,
bytes: usize,
}

/// Validates raw SQL byte sizes before schema initialization, templating, or parsing.
pub(super) fn validate_analysis_input_sizes(request: &AnalyzeRequest) -> Result<(), Box<Issue>> {
validate_analysis_input_sizes_with_limits(
request,
MAX_ANALYSIS_SOURCE_BYTES,
MAX_ANALYSIS_TOTAL_BYTES,
)
}

fn validate_analysis_input_sizes_with_limits(
request: &AnalyzeRequest,
max_source_bytes: usize,
max_total_bytes: usize,
) -> Result<(), Box<Issue>> {
let inline = std::iter::once(AnalysisSourceSize {
source: AnalysisSource::Inline(request.source_name.as_deref()),
bytes: request.sql.len(),
});
let files = request
.files
.iter()
.flatten()
.map(|file| AnalysisSourceSize {
source: AnalysisSource::File(&file.name),
bytes: file.content.len(),
});

validate_analysis_source_sizes(inline.chain(files), max_source_bytes, max_total_bytes)
}

fn validate_analysis_source_sizes<'a>(
sources: impl IntoIterator<Item = AnalysisSourceSize<'a>>,
max_source_bytes: usize,
max_total_bytes: usize,
) -> Result<(), Box<Issue>> {
let mut total_bytes = 0usize;

for source in sources {
if source.bytes > max_source_bytes {
let mut issue = Issue::error(
issue_codes::INVALID_REQUEST,
format!(
"{} exceeds the maximum analysis source size of {} bytes ({} bytes provided)",
source.source.description(),
max_source_bytes,
source.bytes
),
);
if let Some(name) = source.source.name() {
issue = issue.with_source_name(name);
}
return Err(Box::new(issue));
}

total_bytes = match total_bytes.checked_add(source.bytes) {
Some(total) => total,
None => {
return Err(Box::new(Issue::error(
issue_codes::INVALID_REQUEST,
format!(
"Aggregate SQL input exceeds the maximum analysis size of {} bytes",
max_total_bytes
),
)));
}
};

if total_bytes > max_total_bytes {
return Err(Box::new(Issue::error(
issue_codes::INVALID_REQUEST,
format!(
"Aggregate SQL input exceeds the maximum analysis size of {} bytes ({} bytes provided)",
max_total_bytes, total_bytes
),
)));
}
}

Ok(())
}

/// Creates an issue for a template rendering error.
#[cfg(feature = "templating")]
fn template_error_issue(
Expand Down Expand Up @@ -981,6 +1091,48 @@ mod tests {
}
}

#[test]
fn analysis_source_size_limit_uses_utf8_bytes_and_is_inclusive() {
let mut request = base_request();
request.sql = "é".repeat(5);
assert_eq!(request.sql.chars().count(), 5);
assert_eq!(request.sql.len(), 10);
assert!(validate_analysis_input_sizes_with_limits(&request, 10, 100).is_ok());

request.sql.push('x');
let issue =
validate_analysis_input_sizes_with_limits(&request, 10, 100).expect_err("oversized");
assert_eq!(issue.code, issue_codes::INVALID_REQUEST);
assert!(issue.message.contains("11 bytes provided"));
}

#[test]
fn aggregate_limit_includes_inline_and_multibyte_multi_file_sources() {
let mut request = base_request();
request.sql = "é".repeat(2);
request.files = Some(vec![
crate::types::FileSource {
name: "first.sql".to_string(),
content: "日".repeat(2),
},
crate::types::FileSource {
name: "second.sql".to_string(),
content: "SELECT 1".to_string(),
},
]);
assert_eq!(request.sql.len(), 4);
assert_eq!(request.files.as_ref().unwrap()[0].content.len(), 6);
assert_eq!(request.files.as_ref().unwrap()[1].content.len(), 8);
assert!(validate_analysis_input_sizes_with_limits(&request, 10, 18).is_ok());

request.files.as_mut().unwrap()[1].content.push('é');
let issue =
validate_analysis_input_sizes_with_limits(&request, 10, 18).expect_err("oversized");
assert_eq!(issue.code, issue_codes::INVALID_REQUEST);
assert!(issue.message.contains("Aggregate SQL input"));
assert!(issue.message.contains("20 bytes provided"));
}

#[test]
fn collects_file_and_inline_statements() {
let mut request = base_request();
Expand Down
59 changes: 59 additions & 0 deletions crates/flowscope-core/src/analyzer/tests.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
use super::*;
use crate::test_utils::{load_schema_fixture, load_sql_fixture};
use crate::{
limits::MAX_ANALYSIS_SOURCE_BYTES,
types::{AnalysisOptions, LintConfidence, LintFallbackSource},
LintConfig,
};
use std::collections::{BTreeSet, HashMap, HashSet};

#[cfg(feature = "templating")]
use crate::templater::{TemplateConfig, TemplateMode};

fn make_request(sql: &str) -> AnalyzeRequest {
AnalyzeRequest {
sql: sql.to_string(),
Expand Down Expand Up @@ -33,6 +37,61 @@ fn make_request_with_options(
request
}

#[test]
fn analyze_rejects_oversized_inline_sql_before_templating_or_parsing() {
let mut sql = "{{ unclosed ".to_string();
sql.push_str(&"x".repeat(MAX_ANALYSIS_SOURCE_BYTES + 1 - sql.len()));
let mut request = make_request(&sql);
#[cfg(feature = "templating")]
{
request.template_config = Some(TemplateConfig {
mode: TemplateMode::Jinja,
context: HashMap::new(),
});
}
let result = analyze(&request);

assert!(result.statements.is_empty());
assert!(result.nodes.is_empty());
assert_eq!(result.issues.len(), 1);
assert_eq!(result.issues[0].code, issue_codes::INVALID_REQUEST);
assert!(result.issues[0].message.contains("Inline SQL"));
assert!(result.summary.has_errors);
}

#[test]
fn analyze_uses_utf8_bytes_for_inline_size_limit() {
let sql = "é".repeat(MAX_ANALYSIS_SOURCE_BYTES / "é".len() + 1);
assert!(sql.chars().count() < MAX_ANALYSIS_SOURCE_BYTES);
assert!(sql.len() > MAX_ANALYSIS_SOURCE_BYTES);

let result = analyze(&make_request(&sql));

assert_eq!(result.issues.len(), 1);
assert_eq!(result.issues[0].code, issue_codes::INVALID_REQUEST);
assert!(result.issues[0].message.contains(&sql.len().to_string()));
}

#[test]
fn analyze_rejects_oversized_file_with_source_attribution() {
let mut request = make_request("");
request.files = Some(vec![FileSource {
name: "oversized.sql".to_string(),
content: "x".repeat(MAX_ANALYSIS_SOURCE_BYTES + 1),
}]);

let result = analyze(&request);

assert!(result.statements.is_empty());
assert_eq!(result.issues.len(), 1);
assert_eq!(result.issues[0].code, issue_codes::INVALID_REQUEST);
assert_eq!(
result.issues[0].source_name.as_deref(),
Some("oversized.sql")
);
assert!(result.issues[0].message.contains("oversized.sql"));
}

fn schema_with_known_table() -> SchemaMetadata {
SchemaMetadata {
default_catalog: None,
Expand Down
1 change: 1 addition & 0 deletions crates/flowscope-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod completion;
pub mod error;
pub mod extractors;
pub mod generated;
mod limits;
pub mod linter;
pub mod parser;
#[cfg(feature = "templating")]
Expand Down
12 changes: 12 additions & 0 deletions crates/flowscope-core/src/limits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//! Resource limits for FlowScope analysis requests.

/// Maximum UTF-8 size of one SQL source: 10 MiB.
///
/// An inline `AnalyzeRequest::sql` value and each `FileSource::content` value
/// are separate sources.
pub(crate) const MAX_ANALYSIS_SOURCE_BYTES: usize = 10 * 1024 * 1024;

/// Maximum aggregate UTF-8 size of all SQL sources in one analysis: 100 MiB.
///
/// The total includes inline SQL plus every file's content.
pub(crate) const MAX_ANALYSIS_TOTAL_BYTES: usize = 100 * 1024 * 1024;
14 changes: 12 additions & 2 deletions crates/flowscope-core/src/types/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,21 @@ pub use crate::templater::{TemplateConfig, TemplateError, TemplateMode};
///
/// This is the main entry point for the analysis API. It accepts SQL code along with
/// optional dialect and schema information to produce accurate lineage graphs.
///
/// Raw SQL is limited by UTF-8 byte length before templating or parsing: inline SQL and
/// each file may contain at most 10 MiB (10,485,760 bytes), and all sources combined
/// may contain at most 100 MiB (104,857,600 bytes). Inputs exactly at either limit are
/// accepted.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AnalyzeRequest {
/// The SQL code to analyze (UTF-8 string, multi-statement supported)
/// Inline SQL to analyze (UTF-8 string, multi-statement supported).
///
/// This may be empty when `files` contains at least one source. When both are
/// provided, file statements are analyzed first and inline statements last.
pub sql: String,

/// Optional list of source files to analyze (alternative to single `sql` field)
/// Optional source files to analyze, either alone or together with inline `sql`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files: Option<Vec<FileSource>>,

Expand Down Expand Up @@ -80,7 +88,9 @@ pub struct StatementSplitRequest {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FileSource {
/// Source identifier used for grouping and issue attribution.
pub name: String,
/// UTF-8 SQL content, subject to the per-source and aggregate analysis limits.
pub content: String,
}

Expand Down
7 changes: 6 additions & 1 deletion crates/flowscope-core/src/types/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,16 @@ impl AnalyzeResult {
/// Create an error result with a single issue.
/// Useful for returning errors from WASM boundary or other entry points.
pub fn from_error(code: impl Into<String>, message: impl Into<String>) -> Self {
Self::from_issue(Issue::error(code, message))
}

/// Create an error result from an existing structured error issue.
pub(crate) fn from_issue(issue: Issue) -> Self {
Self {
statements: Vec::new(),
nodes: Vec::new(),
edges: Vec::new(),
issues: vec![Issue::error(code, message)],
issues: vec![issue],
summary: Summary {
statement_count: 0,
table_count: 0,
Expand Down
8 changes: 5 additions & 3 deletions docs/api_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
"AnalyzeRequest": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AnalyzeRequest",
"description": "A request to analyze SQL for data lineage.\n\nThis is the main entry point for the analysis API. It accepts SQL code along with\noptional dialect and schema information to produce accurate lineage graphs.",
"description": "A request to analyze SQL for data lineage.\n\nThis is the main entry point for the analysis API. It accepts SQL code along with\noptional dialect and schema information to produce accurate lineage graphs.\n\nRaw SQL is limited by UTF-8 byte length before templating or parsing: inline SQL and\neach file may contain at most 10 MiB (10,485,760 bytes), and all sources combined\nmay contain at most 100 MiB (104,857,600 bytes). Inputs exactly at either limit are\naccepted.",
"type": "object",
"properties": {
"sql": {
"description": "The SQL code to analyze (UTF-8 string, multi-statement supported)",
"description": "Inline SQL to analyze (UTF-8 string, multi-statement supported).\n\nThis may be empty when `files` contains at least one source. When both are\nprovided, file statements are analyzed first and inline statements last.",
"type": "string"
},
"files": {
"description": "Optional list of source files to analyze (alternative to single `sql` field)",
"description": "Optional source files to analyze, either alone or together with inline `sql`.",
"type": ["array", "null"],
"items": {
"$ref": "#/definitions/FileSource"
Expand Down Expand Up @@ -68,9 +68,11 @@
"type": "object",
"properties": {
"name": {
"description": "Source identifier used for grouping and issue attribution.",
"type": "string"
},
"content": {
"description": "UTF-8 SQL content, subject to the per-source and aggregate analysis limits.",
"type": "string"
}
},
Expand Down
Loading
Loading