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
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1", features = ["v4"] }
rmp-serde = "1"
globset = "0.4"

[dev-dependencies]
proptest = "1"
Expand Down
48 changes: 7 additions & 41 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,14 @@ use crate::path_matching;
use crate::schema;
use crate::types::{CapturedRequest, Config};

/// Match a path against a simple glob: `*` matches any chars except `/`,
/// `**` matches any chars including `/`. Used by `--exclude-patterns` to
/// drop static assets (e.g. "/static/**", "*.css") out of discovery.
pub fn glob_match(pattern: &str, path: &str) -> bool {
let p_bytes = pattern.as_bytes();
let s_bytes = path.as_bytes();
glob_match_impl(p_bytes, 0, s_bytes, 0)
}

fn glob_match_impl(pat: &[u8], mut pi: usize, s: &[u8], mut si: usize) -> bool {
while pi < pat.len() {
match pat[pi] {
b'*' => {
let double = pi + 1 < pat.len() && pat[pi + 1] == b'*';
let skip = if double { 2 } else { 1 };
if pi + skip >= pat.len() {
return if double {
true
} else {
!s[si..].contains(&b'/')
};
}
for k in si..=s.len() {
if !double && s[si..k].contains(&b'/') {
break;
}
if glob_match_impl(pat, pi + skip, s, k) {
return true;
}
}
return false;
}
c => {
if si >= s.len() || s[si] != c {
return false;
}
pi += 1;
si += 1;
}
}
}
si == s.len()
let Ok(glob) = globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
else {
return false;
};
glob.compile_matcher().is_match(path)
}

/// Discover unique API paths from captured requests and generate templates.
Expand Down
38 changes: 38 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ pub enum InputFormat {
Mitmproxy,
}

fn parse_byte_size(s: &str) -> Result<u64, String> {
let s = s.trim();
let (num_str, multiplier) = if let Some(n) = s.strip_suffix("GiB") {
(n.trim(), 1024 * 1024 * 1024u64)
} else if let Some(n) = s.strip_suffix("MiB") {
(n.trim(), 1024 * 1024u64)
} else if let Some(n) = s.strip_suffix("KiB") {
(n.trim(), 1024u64)
} else {
(s, 1u64)
};
num_str
.parse::<u64>()
.map(|n| n * multiplier)
.map_err(|e| format!("invalid size: {e}"))
}

#[derive(Parser, Debug)]
pub struct DiscoverArgs {
/// Input file or directory path
Expand Down Expand Up @@ -59,6 +76,12 @@ pub struct DiscoverArgs {
/// `ignore:` so you can review it. Saves a manual sed step.
#[arg(long, value_delimiter = ',')]
pub include_patterns: Vec<String>,

#[arg(long, value_parser = parse_byte_size, default_value = "2GiB")]
pub max_input_size: u64,

#[arg(long, default_value_t = false)]
pub allow_symlinks: bool,
}

#[derive(Parser, Debug)]
Expand Down Expand Up @@ -118,4 +141,19 @@ pub struct GenerateArgs {
/// JSON string for tag overrides
#[arg(long)]
pub tags_overrides: Option<String>,

#[arg(long, value_parser = parse_byte_size, default_value = "2GiB")]
pub max_input_size: u64,

#[arg(long, value_parser = parse_byte_size, default_value = "256MiB")]
pub max_payload_size: u64,

#[arg(long, default_value_t = 256)]
pub max_depth: usize,

#[arg(long, value_parser = parse_byte_size, default_value = "64MiB")]
pub max_body_size: u64,

#[arg(long, default_value_t = false)]
pub allow_symlinks: bool,
}
21 changes: 21 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ pub enum Error {
#[error("TNetString parse error at byte {offset}: {message}")]
TNetParse { offset: usize, message: String },

#[error("TNetString payload too large: {len} bytes exceeds limit of {max} bytes")]
TNetStringPayloadTooLarge { len: usize, max: usize },

#[error("TNetString recursion depth exceeded: depth {depth} exceeds limit of {max}")]
TNetStringDepthExceeded { depth: usize, max: usize },

#[error("Invalid flow state: {0}")]
FlowState(String),

Expand All @@ -20,6 +26,21 @@ pub enum Error {

#[error("Schema error: {0}")]
Schema(String),

#[error("Input too large: {size} bytes exceeds limit of {max} bytes")]
InputTooLarge { size: u64, max: u64 },

#[error("Symlink rejected: {}", path.display())]
SymlinkRejected { path: std::path::PathBuf },

#[error("Not a regular file: {}", path.display())]
NotRegularFile { path: std::path::PathBuf },

#[error("Invalid path parameter identifier: {name:?}")]
InvalidParamIdent { name: String },

#[error("Body too large: {size} bytes exceeds limit of {max} bytes")]
BodyTooLarge { size: usize, max: usize },
}

pub type Result<T> = std::result::Result<T, Error>;
11 changes: 9 additions & 2 deletions src/har_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,17 @@ pub fn har_heuristic(path: &Path) -> bool {
if path.is_dir() {
return false;
}
let Ok(bytes) = std::fs::read(path) else {
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let clean = strip_bom(&bytes);
use std::io::Read;
let mut buf = [0u8; 4096];
let mut reader = std::io::BufReader::new(file);
let n = match reader.read(&mut buf) {
Ok(n) => n,
Err(_) => return false,
};
let clean = strip_bom(&buf[..n]);
clean
.iter()
.find(|b| !b.is_ascii_whitespace())
Expand Down
42 changes: 42 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,45 @@ pub mod path_matching;
pub mod schema;
pub mod tnetstring;
pub mod types;

/// Maximum size of a single input file (2 GiB).
pub const MAX_INPUT_SIZE: u64 = 2 * 1024 * 1024 * 1024;

/// Maximum size of a single TNetString payload (256 MiB).
pub const MAX_PAYLOAD_SIZE: usize = 256 * 1024 * 1024;

/// Maximum recursion depth for TNetString parsing.
pub const MAX_DEPTH: usize = 256;

/// Maximum recursion depth for JSON-to-schema conversion.
pub const MAX_SCHEMA_DEPTH: usize = 64;

/// Maximum body size for response/request bodies (64 MiB).
pub const MAX_BODY_SIZE: usize = 64 * 1024 * 1024;

/// Validate that an input path is a regular file (not a symlink, FIFO, etc.)
/// and within the configured size limit.
pub fn validate_input_path(
path: &std::path::Path,
max_size: u64,
allow_symlinks: bool,
) -> Result<(), error::Error> {
if !allow_symlinks && path.symlink_metadata()?.file_type().is_symlink() {
return Err(error::Error::SymlinkRejected {
path: path.to_path_buf(),
});
}
let meta = std::fs::metadata(path)?;
if !meta.is_file() {
return Err(error::Error::NotRegularFile {
path: path.to_path_buf(),
});
}
if meta.len() > max_size {
return Err(error::Error::InputTooLarge {
size: meta.len(),
max: max_size,
});
}
Ok(())
}
25 changes: 22 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ fn main() -> Result<()> {
Command::Discover(args) => {
info!(input = %args.input.display(), output = %args.output.display(), "Starting discovery");

let requests = read_input(&args.input, &args.format)?;
let requests = read_input(
&args.input,
&args.format,
args.max_input_size,
args.allow_symlinks,
)?;
info!(count = requests.len(), path = %args.input.display(), "Read requests");

let templates = builder::discover_paths(
Expand Down Expand Up @@ -61,7 +66,12 @@ fn main() -> Result<()> {
Command::Generate(args) => {
info!(input = %args.input.display(), output = %args.output.display(), "Starting generation");

let requests = read_input(&args.input, &args.format)?;
let requests = read_input(
&args.input,
&args.format,
args.max_input_size,
args.allow_symlinks,
)?;
info!(count = requests.len(), path = %args.input.display(), "Read requests");

let all_templates = load_templates(&args.templates).with_context(|| {
Expand Down Expand Up @@ -142,7 +152,16 @@ fn detect_format_score(path: &Path) -> (u8, u8) {
(mitmproxy_score, har_score)
}

fn read_input(path: &Path, format: &InputFormat) -> Result<Vec<Box<dyn CapturedRequest>>> {
fn read_input(
path: &Path,
format: &InputFormat,
max_input_size: u64,
allow_symlinks: bool,
) -> Result<Vec<Box<dyn CapturedRequest>>> {
if !path.is_dir() {
mitm2openapi::validate_input_path(path, max_input_size, allow_symlinks)
.context("input file validation failed")?;
}
match format {
InputFormat::Mitmproxy => {
debug!(path = %path.display(), "Reading as mitmproxy format");
Expand Down
29 changes: 29 additions & 0 deletions src/path_matching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ use std::collections::HashSet;
///
/// Escapes special regex chars in literal parts, then inserts named capture groups
/// for `{param}` placeholders. The resulting regex is anchored with `^` and `$`.
fn is_valid_param_ident(name: &str) -> bool {
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit())
}

pub fn path_to_regex(template: &str) -> Result<Regex, crate::error::Error> {
let mut pattern = String::from("^");
let mut remaining = template;
Expand All @@ -18,6 +24,11 @@ pub fn path_to_regex(template: &str) -> Result<Regex, crate::error::Error> {

if let Some(close) = remaining.find('}') {
let param_name = &remaining[..close];
if !is_valid_param_ident(param_name) {
return Err(crate::error::Error::InvalidParamIdent {
name: param_name.to_string(),
});
}
pattern.push_str(&format!("(?P<{}>[^/]+)", param_name));
remaining = &remaining[close + 1..];
} else {
Expand Down Expand Up @@ -333,6 +344,24 @@ mod tests {

// ── path_to_regex edge cases ───────────────────────────────────

#[test]
fn invalid_param_ident_rejected() {
let err = path_to_regex("/users/{foo bar}");
assert!(
matches!(err, Err(crate::error::Error::InvalidParamIdent { .. })),
"expected InvalidParamIdent, got {err:?}"
);
}

#[test]
fn digit_leading_param_rejected() {
let err = path_to_regex("/users/{1abc}");
assert!(matches!(
err,
Err(crate::error::Error::InvalidParamIdent { .. })
));
}

#[test]
fn root_path() {
let re = path_to_regex("/").unwrap();
Expand Down
Loading
Loading