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
15 changes: 15 additions & 0 deletions docs/source-languages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Source language selection

Directory discovery and explicit source paths use the same extension resolver.
Extensions are case-insensitive, with one conventional exception: `.c` selects C
and `.C` selects C++. A file is scanned only if its parser feature is enabled and
the pattern set contains definitions for its language.

`.ts`, `.mts`, and `.cts` use the TypeScript grammar. `.tsx` uses the dedicated
TSX grammar, represented by `Language::Tsx` in the library API. Both use
`lang-typescript` and the `TypeScript` pattern definitions. Ordinary TypeScript
continues to accept angle-bracket type assertions, which conflict with JSX syntax.

Ambiguous `.h` files prefer C++ when available and fall back to C in C-only builds.
The OpenSSL C fixture family runs whenever `lang-c` is enabled; it does not require
the C++ parser.
41 changes: 2 additions & 39 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use clap::Parser;
use crossbeam_channel as channel;
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use ignore::types::TypesBuilder;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use memmap2::Mmap;
use rayon::prelude::*;
Expand Down Expand Up @@ -181,44 +180,8 @@ fn main() -> Result<()> {
}
}

let mut types_builder = TypesBuilder::new();
types_builder.add("c", "*.c").unwrap();
types_builder.add("c", "*.h").unwrap();
types_builder.add("cpp", "*.cc").unwrap();
types_builder.add("cpp", "*.cpp").unwrap();
types_builder.add("cpp", "*.cxx").unwrap();
types_builder.add("cpp", "*.hpp").unwrap();
types_builder.add("cpp", "*.hh").unwrap();
types_builder.add("cpp", "*.hxx").unwrap();
types_builder.add("java", "*.java").unwrap();
types_builder.add("python", "*.py").unwrap();
types_builder.add("go", "*.go").unwrap();
types_builder.add("swift", "*.swift").unwrap();
types_builder.add("php", "*.php").unwrap();
types_builder.add("php", "*.hack").unwrap();
types_builder.add("objc", "*.m").unwrap();
types_builder.add("objc", "*.mm").unwrap();
types_builder.add("rust", "*.rs").unwrap();
types_builder.add("javascript", "*.js").unwrap();
types_builder.add("javascript", "*.mjs").unwrap();
types_builder.add("javascript", "*.cjs").unwrap();
types_builder.add("javascript", "*.jsx").unwrap();
types_builder.add("typescript", "*.ts").unwrap();
types_builder.add("typescript", "*.mts").unwrap();
types_builder.add("typescript", "*.cts").unwrap();
types_builder.add("typescript", "*.tsx").unwrap();
types_builder.select("c");
types_builder.select("cpp");
types_builder.select("java");
types_builder.select("python");
types_builder.select("go");
types_builder.select("swift");
types_builder.select("php");
types_builder.select("objc");
types_builder.select("rust");
types_builder.select("javascript");
types_builder.select("typescript");
walk_builder.types(types_builder.build()?);
// language_from_path is the single source of truth for supported extensions.
// A second, case-sensitive glob allowlist used to silently drop uppercase files.

if !cli.exclude.is_empty() {
let mut override_builder = OverrideBuilder::new(Path::new("."));
Expand Down
17 changes: 15 additions & 2 deletions src/patterns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pub enum Language {
Rust,
JavaScript,
TypeScript,
/// TypeScript with JSX syntax; uses the TypeScript pattern catalog.
Tsx,
}

impl Language {
Expand All @@ -31,7 +33,14 @@ impl Language {
Self::Objc => cfg!(feature = "lang-objc"),
Self::Rust => cfg!(feature = "lang-rust"),
Self::JavaScript => cfg!(feature = "lang-javascript"),
Self::TypeScript => cfg!(feature = "lang-typescript"),
Self::TypeScript | Self::Tsx => cfg!(feature = "lang-typescript"),
}
}

pub(crate) const fn pattern_language(self) -> Self {
match self {
Self::Tsx => Self::TypeScript,
language => language,
}
}

Expand Down Expand Up @@ -356,7 +365,11 @@ impl PatternSet {
}

pub fn supports_language(&self, lang: Language) -> bool {
lang.is_enabled() && self.libraries.iter().any(|l| l.languages.contains(&lang))
lang.is_enabled()
&& self
.libraries
.iter()
.any(|l| l.languages.contains(&lang.pattern_language()))
}
}

Expand Down
36 changes: 24 additions & 12 deletions src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ define_ts_lang!(
"lang-typescript",
tree_sitter_typescript::LANGUAGE_TYPESCRIPT
);
// Note: For TSX files, we use the TypeScript grammar which handles most crypto detection needs.
// The TSX grammar (tree_sitter_typescript::LANGUAGE_TSX) could be used for JSX-specific parsing
// if needed in the future.
define_ts_lang!(
ts_lang_tsx,
"lang-typescript",
tree_sitter_typescript::LANGUAGE_TSX
);

#[derive(Clone, Copy, Debug)]
pub struct LibraryHit<'a> {
Expand All @@ -58,7 +60,12 @@ pub struct AlgorithmHit<'a> {
}

pub fn language_from_path(path: &std::path::Path) -> Option<Language> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
let extension = path.extension()?.to_str()?;
// Uppercase .C conventionally denotes C++, unlike ordinary case variants.
if extension == "C" {
return Language::Cpp.is_enabled().then_some(Language::Cpp);
}
let ext = extension.to_ascii_lowercase();
let language = match ext.as_str() {
"c" => Language::C,
// Prefer the C++ grammar for ambiguous headers, but keep C-only builds useful.
Expand All @@ -73,7 +80,8 @@ pub fn language_from_path(path: &std::path::Path) -> Option<Language> {
"m" | "mm" => Language::Objc,
"rs" => Language::Rust,
"js" | "mjs" | "cjs" | "jsx" => Language::JavaScript,
"ts" | "mts" | "cts" | "tsx" => Language::TypeScript,
"ts" | "mts" | "cts" => Language::TypeScript,
"tsx" => Language::Tsx,
_ => return None,
};
language.is_enabled().then_some(language)
Expand All @@ -98,6 +106,7 @@ pub fn parse(lang: Language, content: &str) -> Result<Tree> {
Language::Rust => ts_lang_rust()?,
Language::JavaScript => ts_lang_javascript()?,
Language::TypeScript => ts_lang_typescript()?,
Language::Tsx => ts_lang_tsx()?,
};
parser.set_language(&ts_lang).context("set language")?;

Expand Down Expand Up @@ -140,7 +149,7 @@ pub fn find_library_anchors<'a>(

// Handle libraries without include patterns (fallback to api_regexes)
for lib in &patterns.libraries {
if !lib.languages.contains(&lang) {
if !lib.languages.contains(&lang.pattern_language()) {
continue;
}
if lib.include_regexes.is_empty() {
Expand All @@ -156,7 +165,10 @@ pub fn find_library_anchors<'a>(
}

// Use pre-compiled include set with ownership tracking
let Some(include_set_with_owners) = patterns.include_sets_with_owners.get(&lang) else {
let Some(include_set_with_owners) = patterns
.include_sets_with_owners
.get(&lang.pattern_language())
else {
return hits;
};

Expand All @@ -179,13 +191,13 @@ pub fn find_library_anchors<'a>(
}

pub fn has_anchor_hint(lang: Language, content: &str, patterns: &PatternSet) -> bool {
if let Some(include_set) = patterns.include_sets.get(&lang)
if let Some(include_set) = patterns.include_sets.get(&lang.pattern_language())
&& include_set.is_match(content)
{
return true;
}

if let Some(api_set) = patterns.api_sets.get(&lang)
if let Some(api_set) = patterns.api_sets.get(&lang.pattern_language())
&& api_set.is_match(content)
{
return true;
Expand Down Expand Up @@ -474,7 +486,7 @@ fn collect_constants(
) -> HashMap<String, String> {
let mut constants = HashMap::new();

let Some(const_patterns) = patterns.constant_patterns.get(&lang) else {
let Some(const_patterns) = patterns.constant_patterns.get(&lang.pattern_language()) else {
return constants;
};

Expand Down Expand Up @@ -632,7 +644,7 @@ fn import_like_nodes<'a>(lang: Language, root: Node<'a>, content: &[u8]) -> Vec<
}
}
// TypeScript: same as JavaScript (import statements, require calls)
Language::TypeScript => {
Language::TypeScript | Language::Tsx => {
kind == "import_statement"
|| kind == "call_expression" && {
node.child(0)
Expand Down Expand Up @@ -708,7 +720,7 @@ fn code_symbol_nodes<'a>(lang: Language, root: Node<'a>) -> Vec<Node<'a>> {
"call_expression" | "member_expression" | "string" | "template_string"
),
// TypeScript: same as JavaScript
Language::TypeScript => matches!(
Language::TypeScript | Language::Tsx => matches!(
kind,
"call_expression" | "member_expression" | "string" | "template_string"
),
Expand Down
2 changes: 1 addition & 1 deletion tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fn should_skip_family(family: &str) -> bool {
"swift" => !cfg!(feature = "lang-swift"),
"php" => !cfg!(feature = "lang-php"),
"objc" | "objective-c" => !cfg!(feature = "lang-objc"),
"openssl_c" => !cfg!(feature = "lang-c") || !cfg!(feature = "lang-cpp"),
"openssl_c" => !cfg!(feature = "lang-c"),
"cpp" => !cfg!(feature = "lang-cpp"),
"rust" => !cfg!(feature = "lang-rust"),
"javascript" | "js" => !cfg!(feature = "lang-javascript"),
Expand Down
66 changes: 66 additions & 0 deletions tests/language_discovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use cipherscope::{patterns::Language, scan::language_from_path};
use std::path::Path;

#[test]
fn extensions_select_enabled_grammars() {
for (extension, language) in [
("PY", Language::Python),
("JsX", Language::JavaScript),
("TS", Language::TypeScript),
("TSX", Language::Tsx),
("c", Language::C),
("C", Language::Cpp),
] {
assert_eq!(
language_from_path(Path::new(&format!("source.{extension}"))),
language.is_enabled().then_some(language),
);
}
}

#[cfg(feature = "lang-python")]
#[test]
fn directory_discovery_accepts_uppercase_extensions() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("source.PY"), "import cryptography\n").unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_cipherscope"))
.arg("--roots")
.arg(dir.path())
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("PyCA cryptography"));
}

#[cfg(feature = "lang-typescript")]
#[test]
fn tsx_uses_jsx_grammar_and_typescript_patterns() {
let source = "import { createHash } from 'node:crypto';\nexport const View = () => <div>{createHash('sha256').digest('hex')}</div>;\n";
let tree = cipherscope::scan::parse(Language::Tsx, source).unwrap();
assert!(
!tree.root_node().has_error(),
"{}",
tree.root_node().to_sexp()
);
let findings = cipherscope::scan_snippet(source, Language::Tsx, "source.tsx").unwrap();
assert!(findings.iter().any(|hit| hit.identifier == "SHA-256"));
assert!(findings.iter().any(|hit| hit.asset_type == "library"));

let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("source.tsx"), source).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_cipherscope"))
.arg("--roots")
.arg(dir.path())
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("SHA-256"));
}

#[cfg(feature = "lang-typescript")]
#[test]
fn ordinary_typescript_retains_angle_bracket_type_assertions() {
let tree =
cipherscope::scan::parse(Language::TypeScript, "const value = <string>input;\n").unwrap();
assert!(!tree.root_node().has_error());
}
Loading