Skip to content
Closed
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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ Use idiomatic `clap` derive API patterns. Push validation into clap wherever pos
| `--json` | `-j` | bool | Conflicts with `--yara` |
| `--yara` | | bool | Conflicts with `--json` |
| `--only-tags` | | `Vec<Tag>` | Repeatable, `value_parser = Tag::from_str` |
| `--no-tags` | | `Vec<Tag>` | Repeatable, runtime overlap check with `--only-tags` |
| `--no-tags` | | `Vec<Tag>` | Repeatable, runtime overlap check against the resolved include set (`--only-tags` plus the convenience flags) |
| `--imports` | | bool | Shorthand for `--only-tags import`; conflicts with `--only-tags`/`--raw`, compatible with `--no-tags` |
| `--exports` | | bool | Shorthand for `--only-tags export`; conflicts with `--only-tags`/`--raw`, compatible with `--no-tags` |
| `--symbols` | | bool | Shorthand for `--only-tags demangled` (DemangledSymbol only); conflicts with `--only-tags`/`--raw`, compatible with `--no-tags` |
| `--min-len` | `-m` | `Option<usize>` | Custom parser enforces >= 1 |
| `--top` | `-t` | `Option<usize>` | Custom parser enforces >= 1 |
| `--enc` | | `Option<CliEncoding>` | utf8, utf16, utf16le, utf16be filter by stored encoding; `ascii` is content-based (matches pure-ASCII text that is not UTF-16, since extraction labels ASCII content as UTF-8) |
| `--raw` | | bool | Conflicts with `--only-tags`, `--no-tags`, `--top`, `--debug`, `--yara` |
| `--raw` | | bool | Conflicts with `--only-tags`, `--no-tags`, `--top`, `--debug`, `--yara`, `--imports`, `--exports`, `--symbols` |
| `--summary` | | bool | Conflicts with `--json`, `--yara`; runtime TTY check |
| `--debug` | | bool | Conflicts with `--raw` |

Expand Down
182 changes: 182 additions & 0 deletions docs/plans/2026-07-04-001-feat-cli-convenience-flags-plan.md

Large diffs are not rendered by default.

27 changes: 15 additions & 12 deletions docs/src/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,24 @@ stringy [OPTIONS] - # read from stdin

### Filtering

| Option | Description | Default |
| ----------------- | ------------------------------------------------------------------ | ------- |
| `--min-len N` | Minimum string length (must be >= 1) | 4 |
| `--top N` | Limit to top N strings by score (applied after all filters) | - |
| `--enc ENCODING` | Filter by encoding: `ascii`, `utf8`, `utf16`, `utf16le`, `utf16be` | all |
| `--only-tags TAG` | Include strings with any of these tags (OR); repeatable | all |
| `--no-tags TAG` | Exclude strings with any of these tags; repeatable | none |
| Option | Description | Default |
| ----------------- | --------------------------------------------------------------------------- | ------- |
| `--min-len N` | Minimum string length (must be >= 1) | 4 |
| `--top N` | Limit to top N strings by score (applied after all filters) | - |
| `--enc ENCODING` | Filter by encoding: `ascii`, `utf8`, `utf16`, `utf16le`, `utf16be` | all |
| `--only-tags TAG` | Include strings with any of these tags (OR); repeatable | all |
| `--no-tags TAG` | Exclude strings with any of these tags; repeatable | none |
| `--imports` | Shorthand for `--only-tags import`; conflicts with `--only-tags`/`--raw` | all |
| `--exports` | Shorthand for `--only-tags export`; conflicts with `--only-tags`/`--raw` | all |
| `--symbols` | Shorthand for `--only-tags demangled`; conflicts with `--only-tags`/`--raw` | all |

### Mode Flags

| Option | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--raw` | Extraction-only mode (no tagging, ranking, or scoring); conflicts with `--only-tags`, `--no-tags`, `--top`, `--debug`, `--yara` |
| `--summary` | Append summary block (TTY table mode only); conflicts with `--json`, `--yara` |
| `--debug` | Include score-breakdown fields (`section_weight`, `semantic_boost`, `noise_penalty`) in JSON output; conflicts with `--raw` |
| Option | Description |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--raw` | Extraction-only mode (no tagging, ranking, or scoring); conflicts with `--only-tags`, `--no-tags`, `--top`, `--debug`, `--yara`, `--imports`, `--exports`, `--symbols` |
| `--summary` | Append summary block (TTY table mode only); conflicts with `--json`, `--yara` |
| `--debug` | Include score-breakdown fields (`section_weight`, `semantic_boost`, `noise_penalty`) in JSON output; conflicts with `--raw` |

## Encoding Options

Expand Down
97 changes: 87 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ struct Cli {
#[arg(
long = "only-tags",
action = ArgAction::Append,
value_parser = Tag::from_str,
value_parser = <Tag as FromStr>::from_str,
value_name = "TAG",
long_help = "Include only strings with this tag. Repeat the flag for multiple tags \
(OR logic).\nValid tags: url, domain, ipv4, ipv6, filepath, regpath, guid, email, \
Expand All @@ -116,7 +116,7 @@ struct Cli {
#[arg(
long = "no-tags",
action = ArgAction::Append,
value_parser = Tag::from_str,
value_parser = <Tag as FromStr>::from_str,
value_name = "TAG",
long_help = "Exclude strings with this tag. Repeat the flag for multiple tags \
(OR logic).\nValid tags: url, domain, ipv4, ipv6, filepath, regpath, guid, email, \
Expand All @@ -125,6 +125,18 @@ struct Cli {
)]
no_tags: Vec<Tag>,

/// Shorthand for --only-tags import (see --only-tags for the full tag set)
#[arg(long, conflicts_with_all = ["only_tags", "raw"])]
imports: bool,

/// Shorthand for --only-tags export (see --only-tags for the full tag set)
#[arg(long, conflicts_with_all = ["only_tags", "raw"])]
exports: bool,

/// Shorthand for --only-tags demangled (see --only-tags for the full tag set)
#[arg(long, conflicts_with_all = ["only_tags", "raw"])]
symbols: bool,

/// Minimum string length in bytes (must be >= 1)
#[arg(short = 'm', long = "min-len", value_name = "N", value_parser = parse_positive_usize)]
min_len: Option<usize>,
Expand All @@ -150,18 +162,41 @@ struct Cli {
debug: bool,
}

impl Cli {
/// Effective include-tag set: explicit `--only-tags` plus the convenience
/// flags (`--imports` / `--exports` / `--symbols`). The convenience flags
/// conflict with `--only-tags` at parse time, so the set is populated by
/// exactly one of those two mechanisms -- never both -- though the
/// convenience-flag mechanism itself may combine several flags into a union.
/// Merging is harmless and keeps a single authoritative include list.
fn resolved_include_tags(&self) -> Vec<Tag> {
let mut tags = self.only_tags.clone();
if self.imports {
tags.push(Tag::Import);
}
if self.exports {
tags.push(Tag::Export);
}
if self.symbols {
tags.push(Tag::DemangledSymbol);
}
tags
}
}

fn run(cli: &Cli) -> Result<(), StringyError> {
// Runtime validation: tag overlap between --only-tags and --no-tags
let overlap: Vec<&Tag> = cli
.only_tags
let include_tags = cli.resolved_include_tags();

// Runtime validation: tag overlap between the resolved include set and --no-tags
let overlap: Vec<&Tag> = include_tags
.iter()
.filter(|t| cli.no_tags.contains(t))
.collect();
if !overlap.is_empty() {
let tag_names: Vec<String> = overlap.iter().map(|t| format!("{t:?}")).collect();
let tag_names: Vec<String> = overlap.iter().map(|t| t.to_string()).collect();
return Err(StringyError::ValidationError(format!(
"conflicting tag filters: {} appear in both --only-tags and --no-tags\n\
Remove these tags from one of the filter lists to continue.",
"conflicting tag filters: {} are both included and excluded (--no-tags)\n\
Remove these tags from the include filter or from --no-tags to continue.",
tag_names.join(", ")
)));
}
Expand Down Expand Up @@ -205,8 +240,8 @@ fn run(cli: &Cli) -> Result<(), StringyError> {
if let Some(enc) = cli.enc {
filter_config = filter_config.with_encoding(enc.into());
}
if !cli.only_tags.is_empty() {
filter_config = filter_config.with_include_tags(cli.only_tags.clone());
if !include_tags.is_empty() {
filter_config = filter_config.with_include_tags(include_tags);
}
if !cli.no_tags.is_empty() {
filter_config = filter_config.with_exclude_tags(cli.no_tags.clone());
Expand Down Expand Up @@ -288,3 +323,45 @@ fn main() {
std::process::exit(e.exit_code());
}
}

#[cfg(test)]
mod tests {
use super::*;

// Fixture-independent checks that each convenience flag maps to the exact
// Tag it claims. Integration tests can't falsify the --symbols mapping
// because no fixture yields demangled-tagged rows; these pin it directly.

#[test]
fn resolved_include_tags_imports_is_import_only() {
let cli = Cli::parse_from(["stringy", "f", "--imports"]);
assert_eq!(cli.resolved_include_tags(), vec![Tag::Import]);
}

#[test]
fn resolved_include_tags_exports_is_export_only() {
let cli = Cli::parse_from(["stringy", "f", "--exports"]);
assert_eq!(cli.resolved_include_tags(), vec![Tag::Export]);
}

#[test]
fn resolved_include_tags_symbols_is_demangled_only() {
let cli = Cli::parse_from(["stringy", "f", "--symbols"]);
assert_eq!(cli.resolved_include_tags(), vec![Tag::DemangledSymbol]);
}

#[test]
fn resolved_include_tags_all_three_flags_form_union() {
let cli = Cli::parse_from(["stringy", "f", "--imports", "--exports", "--symbols"]);
assert_eq!(
cli.resolved_include_tags(),
vec![Tag::Import, Tag::Export, Tag::DemangledSymbol]
);
}

#[test]
fn resolved_include_tags_empty_without_convenience_flags() {
let cli = Cli::parse_from(["stringy", "f"]);
assert!(cli.resolved_include_tags().is_empty());
}
}
39 changes: 3 additions & 36 deletions src/output/table/formatting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub fn format_tags(tags: &[Tag]) -> String {
let tag_strings: Vec<String> = tags
.iter()
.filter(|tag| tag_boost_value(tag, config) == max_boost)
.map(tag_to_display_string)
.map(|tag| tag.to_string())
.collect();

let result = tag_strings.join(", ");
Expand All @@ -74,39 +74,6 @@ fn tag_boost_value(tag: &Tag, config: &RankingConfig) -> i32 {
config.tag_boosts.get(tag).copied().unwrap_or(0)
}

/// Convert a single tag to its display string.
///
/// Uses the serde rename value where defined, otherwise uses lowercase Debug format.
pub(crate) fn tag_to_display_string(tag: &Tag) -> String {
match tag {
Tag::Url => "url".to_string(),
Tag::Domain => "domain".to_string(),
Tag::IPv4 => "ipv4".to_string(),
Tag::IPv6 => "ipv6".to_string(),
Tag::FilePath => "filepath".to_string(),
Tag::RegistryPath => "regpath".to_string(),
Tag::Guid => "guid".to_string(),
Tag::Email => "email".to_string(),
Tag::Base64 => "b64".to_string(),
Tag::FormatString => "fmt".to_string(),
Tag::UserAgent => "user-agent-ish".to_string(),
Tag::DemangledSymbol => "demangled".to_string(),
Tag::Import => "import".to_string(),
Tag::Export => "export".to_string(),
Tag::Version => "version".to_string(),
Tag::Manifest => "manifest".to_string(),
Tag::Resource => "resource".to_string(),
Tag::DylibPath => "dylib-path".to_string(),
Tag::Rpath => "rpath".to_string(),
Tag::RpathVariable => "rpath-var".to_string(),
Tag::FrameworkPath => "framework-path".to_string(),
Tag::Crypto => "crypto".to_string(),
Tag::Network => "network".to_string(),
Tag::FileIO => "fileio".to_string(),
Tag::EntryPoint => "entry-point".to_string(),
}
}

/// Truncate a string to the specified maximum length.
///
/// If the string exceeds the maximum length, it is truncated and `...` is appended.
Expand Down Expand Up @@ -254,8 +221,8 @@ mod tests {
];

for tag in all_tags {
let display = tag_to_display_string(&tag);
assert!(!display.is_empty(), "Tag {:?} should have display", tag);
let display = tag.to_string();
assert!(!display.is_empty(), "Tag {tag:?} should have display");
assert!(display.is_ascii(), "Tag display should be ASCII");
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/output/table/tty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ fn format_summary_block(metadata: &OutputMetadata) -> String {
let tag_parts: Vec<String> = metadata
.top_tags
.iter()
.map(|(tag, count)| format!("{tag:?}: {count}"))
.map(|(tag, count)| format!("{tag}: {count}"))
.collect();
block.push_str(&format!("\nTop tags: {}", tag_parts.join(", ")));
}
Expand Down
70 changes: 42 additions & 28 deletions src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod found_string;

pub use error::{Result, StringyError};

use clap::ValueEnum;
use serde::{Deserialize, Serialize};

/// Represents the encoding of an extracted string
Expand All @@ -19,83 +20,96 @@ pub enum Encoding {

/// Semantic tags for classifying strings
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
pub enum Tag {
#[value(name = "url")]
Url,
#[value(name = "domain")]
Domain,
#[serde(rename = "ipv4")]
#[value(name = "ipv4")]
IPv4,
#[serde(rename = "ipv6")]
#[value(name = "ipv6")]
IPv6,
#[serde(rename = "filepath")]
#[value(name = "filepath")]
FilePath,
#[serde(rename = "regpath")]
#[value(name = "regpath")]
RegistryPath,
#[serde(rename = "guid")]
#[value(name = "guid")]
Guid,
#[value(name = "email")]
Email,
#[serde(rename = "b64")]
#[value(name = "b64")]
Base64,
#[serde(rename = "fmt")]
#[value(name = "fmt")]
FormatString,
#[serde(rename = "user-agent-ish")]
#[value(name = "user-agent-ish")]
UserAgent,
#[serde(rename = "demangled")]
#[value(name = "demangled")]
DemangledSymbol,
#[value(name = "import")]
Import,
#[value(name = "export")]
Export,
#[value(name = "version")]
Version,
#[value(name = "manifest")]
Manifest,
#[value(name = "resource")]
Resource,
#[serde(rename = "dylib-path")]
#[value(name = "dylib-path")]
DylibPath,
#[serde(rename = "rpath")]
#[value(name = "rpath")]
Rpath,
#[serde(rename = "rpath-var")]
#[value(name = "rpath-var")]
RpathVariable,
#[serde(rename = "framework-path")]
#[value(name = "framework-path")]
FrameworkPath,
#[serde(rename = "crypto")]
#[value(name = "crypto")]
Crypto,
#[serde(rename = "network")]
#[value(name = "network")]
Network,
#[serde(rename = "fileio")]
#[value(name = "fileio")]
FileIO,
#[serde(rename = "entry-point")]
#[value(name = "entry-point")]
EntryPoint,
}

impl std::str::FromStr for Tag {
type Err = String;

/// Parses a canonical CLI tag name into a `Tag`, delegating to the
/// `ValueEnum` definition so the accepted names have a single source of
/// truth (the `#[value(name = ...)]` attributes above).
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"url" => Ok(Tag::Url),
"domain" => Ok(Tag::Domain),
"ipv4" => Ok(Tag::IPv4),
"ipv6" => Ok(Tag::IPv6),
"filepath" => Ok(Tag::FilePath),
"regpath" => Ok(Tag::RegistryPath),
"guid" => Ok(Tag::Guid),
"email" => Ok(Tag::Email),
"b64" => Ok(Tag::Base64),
"fmt" => Ok(Tag::FormatString),
"user-agent-ish" => Ok(Tag::UserAgent),
"demangled" => Ok(Tag::DemangledSymbol),
"import" => Ok(Tag::Import),
"export" => Ok(Tag::Export),
"version" => Ok(Tag::Version),
"manifest" => Ok(Tag::Manifest),
"resource" => Ok(Tag::Resource),
"dylib-path" => Ok(Tag::DylibPath),
"rpath" => Ok(Tag::Rpath),
"rpath-var" => Ok(Tag::RpathVariable),
"framework-path" => Ok(Tag::FrameworkPath),
"crypto" => Ok(Tag::Crypto),
"network" => Ok(Tag::Network),
"fileio" => Ok(Tag::FileIO),
"entry-point" => Ok(Tag::EntryPoint),
_ => Err(format!("unknown tag: {s}")),
<Self as ValueEnum>::from_str(s, false).map_err(|_| format!("unknown tag: {s}"))
}
}

impl std::fmt::Display for Tag {
/// Renders the canonical CLI tag name -- the form users type and that
/// `--only-tags`/`--no-tags` accept -- sourced from the `ValueEnum`
/// definition rather than the `Debug` variant name (`import`, not `Import`).
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.to_possible_value() {
Some(value) => f.write_str(value.get_name()),
None => f.write_str("<unknown>"),
}
}
}
Expand Down
Loading
Loading