sillystrings 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sillystrings-0.1.0/LICENSE +21 -0
- sillystrings-0.1.0/PKG-INFO +113 -0
- sillystrings-0.1.0/README.md +85 -0
- sillystrings-0.1.0/pyproject.toml +126 -0
- sillystrings-0.1.0/pyproject.toml.orig +139 -0
- sillystrings-0.1.0/src/sillystrings/__init__.py +1 -0
- sillystrings-0.1.0/src/sillystrings/__version__.py +3 -0
- sillystrings-0.1.0/src/sillystrings/cli.py +208 -0
- sillystrings-0.1.0/src/sillystrings/encodings.py +93 -0
- sillystrings-0.1.0/src/sillystrings/py.typed +0 -0
- sillystrings-0.1.0/src/sillystrings/scanner.py +106 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kyle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sillystrings
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python reimplementation of the Unix strings utility
|
|
5
|
+
Keywords: strings,binary,reverse engineering,cli
|
|
6
|
+
Author: Kyle O'Malley
|
|
7
|
+
Author-email: Kyle O'Malley <j.kyle.omalley@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Project-URL: Homepage, https://github.com/jkomalley/sillystrings
|
|
24
|
+
Project-URL: Repository, https://github.com/jkomalley/sillystrings
|
|
25
|
+
Project-URL: Issues, https://github.com/jkomalley/sillystrings/issues
|
|
26
|
+
Project-URL: Changelog, https://github.com/jkomalley/sillystrings/blob/main/CHANGELOG.md
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# sillystrings
|
|
30
|
+
|
|
31
|
+
A Python reimplementation of the Unix `strings` utility. Extracts printable character sequences from binary files with support for multiple encodings, offset display, and standard CLI conventions.
|
|
32
|
+
|
|
33
|
+
Pure Python, zero dependencies, comprehensive test coverage.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Requires Python 3.11+.
|
|
38
|
+
|
|
39
|
+
Install from source using [uv](https://docs.astral.sh/uv/):
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
git clone https://github.com/jkomalley/sillystrings.git
|
|
43
|
+
cd sillystrings
|
|
44
|
+
uv sync
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
sillystrings [OPTIONS] [FILE ...]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
With no file arguments, reads from stdin.
|
|
54
|
+
|
|
55
|
+
### Options
|
|
56
|
+
|
|
57
|
+
| Flag | Description |
|
|
58
|
+
|------|-------------|
|
|
59
|
+
| `-n NUM` | Minimum string length (default: 4) |
|
|
60
|
+
| `-e {s,S,l,b}` | Character encoding: `s` = 7-bit ASCII (default), `S` = 8-bit, `l` = UTF-16 LE, `b` = UTF-16 BE |
|
|
61
|
+
| `-t {d,o,x}` | Print byte offset before each string in decimal, octal, or hex |
|
|
62
|
+
| `-w` | Include all whitespace characters (newlines, carriage returns) in strings |
|
|
63
|
+
| `-f` | Print the filename before each string |
|
|
64
|
+
| `-v` | Show version and exit |
|
|
65
|
+
|
|
66
|
+
### Examples
|
|
67
|
+
|
|
68
|
+
Scan a binary for readable strings:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
sillystrings /usr/bin/ls
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Show hex offsets with 8-bit encoding:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
sillystrings -t x -e S firmware.bin
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Read from stdin:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
cat firmware.bin | sillystrings -
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Find wide (UTF-16 LE) strings with a minimum length of 8:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
sillystrings -e l -n 8 program.exe
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Architecture
|
|
93
|
+
|
|
94
|
+
The project is organized into three layers:
|
|
95
|
+
|
|
96
|
+
- **encodings** -- character-level printability checks for ASCII and UTF-16
|
|
97
|
+
- **scanner** -- accumulates printable runs into strings, tracks byte offsets
|
|
98
|
+
- **cli** -- argument parsing, file I/O, output formatting
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
just install # or: uv sync && uv run pre-commit install
|
|
104
|
+
just check # format, lint, type check, and the test suite
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Tests cover all encoding modes, offset calculations, CLI flags, edge cases, and integration via subprocess.
|
|
108
|
+
|
|
109
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project layout, and the conventions this repo follows.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# sillystrings
|
|
2
|
+
|
|
3
|
+
A Python reimplementation of the Unix `strings` utility. Extracts printable character sequences from binary files with support for multiple encodings, offset display, and standard CLI conventions.
|
|
4
|
+
|
|
5
|
+
Pure Python, zero dependencies, comprehensive test coverage.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Requires Python 3.11+.
|
|
10
|
+
|
|
11
|
+
Install from source using [uv](https://docs.astral.sh/uv/):
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
git clone https://github.com/jkomalley/sillystrings.git
|
|
15
|
+
cd sillystrings
|
|
16
|
+
uv sync
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
sillystrings [OPTIONS] [FILE ...]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
With no file arguments, reads from stdin.
|
|
26
|
+
|
|
27
|
+
### Options
|
|
28
|
+
|
|
29
|
+
| Flag | Description |
|
|
30
|
+
|------|-------------|
|
|
31
|
+
| `-n NUM` | Minimum string length (default: 4) |
|
|
32
|
+
| `-e {s,S,l,b}` | Character encoding: `s` = 7-bit ASCII (default), `S` = 8-bit, `l` = UTF-16 LE, `b` = UTF-16 BE |
|
|
33
|
+
| `-t {d,o,x}` | Print byte offset before each string in decimal, octal, or hex |
|
|
34
|
+
| `-w` | Include all whitespace characters (newlines, carriage returns) in strings |
|
|
35
|
+
| `-f` | Print the filename before each string |
|
|
36
|
+
| `-v` | Show version and exit |
|
|
37
|
+
|
|
38
|
+
### Examples
|
|
39
|
+
|
|
40
|
+
Scan a binary for readable strings:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
sillystrings /usr/bin/ls
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Show hex offsets with 8-bit encoding:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
sillystrings -t x -e S firmware.bin
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Read from stdin:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
cat firmware.bin | sillystrings -
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Find wide (UTF-16 LE) strings with a minimum length of 8:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
sillystrings -e l -n 8 program.exe
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Architecture
|
|
65
|
+
|
|
66
|
+
The project is organized into three layers:
|
|
67
|
+
|
|
68
|
+
- **encodings** -- character-level printability checks for ASCII and UTF-16
|
|
69
|
+
- **scanner** -- accumulates printable runs into strings, tracks byte offsets
|
|
70
|
+
- **cli** -- argument parsing, file I/O, output formatting
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
just install # or: uv sync && uv run pre-commit install
|
|
76
|
+
just check # format, lint, type check, and the test suite
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Tests cover all encoding modes, offset calculations, CLI flags, edge cases, and integration via subprocess.
|
|
80
|
+
|
|
81
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project layout, and the conventions this repo follows.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sillystrings"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python reimplementation of the Unix strings utility"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"strings",
|
|
11
|
+
"binary",
|
|
12
|
+
"reverse engineering",
|
|
13
|
+
"cli",
|
|
14
|
+
]
|
|
15
|
+
dependencies = []
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: System Administrators",
|
|
20
|
+
"Topic :: Utilities",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "Kyle O'Malley"
|
|
33
|
+
email = "j.kyle.omalley@gmail.com"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/jkomalley/sillystrings"
|
|
37
|
+
Repository = "https://github.com/jkomalley/sillystrings"
|
|
38
|
+
Issues = "https://github.com/jkomalley/sillystrings/issues"
|
|
39
|
+
Changelog = "https://github.com/jkomalley/sillystrings/blob/main/CHANGELOG.md"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
sillystrings = "sillystrings.cli:main"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pre-commit>=4.6.0",
|
|
47
|
+
"pytest>=9.1.1",
|
|
48
|
+
"pytest-cov>=7.1.0",
|
|
49
|
+
"pytest-mock>=3.15.1",
|
|
50
|
+
"ruff>=0.15.10",
|
|
51
|
+
"ty>=0.0.37",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
56
|
+
build-backend = "uv_build"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
target-version = "py311"
|
|
60
|
+
line-length = 88
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["ALL"]
|
|
64
|
+
ignore = [
|
|
65
|
+
"D203",
|
|
66
|
+
"D213",
|
|
67
|
+
"D100",
|
|
68
|
+
"D104",
|
|
69
|
+
"D107",
|
|
70
|
+
"COM812",
|
|
71
|
+
"ISC001",
|
|
72
|
+
"FIX002",
|
|
73
|
+
"TD002",
|
|
74
|
+
"TD003",
|
|
75
|
+
"ERA001",
|
|
76
|
+
"TRY003",
|
|
77
|
+
"EM101",
|
|
78
|
+
"EM102",
|
|
79
|
+
"S101",
|
|
80
|
+
"PLR2004",
|
|
81
|
+
"CPY001",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.pydocstyle]
|
|
85
|
+
convention = "google"
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.per-file-ignores]
|
|
88
|
+
"tests/**/*.py" = [
|
|
89
|
+
"D",
|
|
90
|
+
"ANN",
|
|
91
|
+
"SLF001",
|
|
92
|
+
"INP001",
|
|
93
|
+
"FBT001",
|
|
94
|
+
]
|
|
95
|
+
"src/sillystrings/cli.py" = ["T201"]
|
|
96
|
+
"tests/test_cli.py" = ["S603"]
|
|
97
|
+
"scripts/**/*.py" = [
|
|
98
|
+
"T201",
|
|
99
|
+
"S603",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[tool.ty.rules]
|
|
103
|
+
possibly-missing-import = "warn"
|
|
104
|
+
|
|
105
|
+
[tool.pytest.ini_options]
|
|
106
|
+
testpaths = ["tests"]
|
|
107
|
+
addopts = [
|
|
108
|
+
"-ra",
|
|
109
|
+
"--strict-markers",
|
|
110
|
+
"--strict-config",
|
|
111
|
+
"--cov",
|
|
112
|
+
"--cov-fail-under=100",
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
[tool.coverage.run]
|
|
116
|
+
source = ["sillystrings"]
|
|
117
|
+
branch = true
|
|
118
|
+
|
|
119
|
+
[tool.coverage.report]
|
|
120
|
+
show_missing = true
|
|
121
|
+
skip_empty = true
|
|
122
|
+
exclude_lines = [
|
|
123
|
+
"pragma: no cover",
|
|
124
|
+
"if TYPE_CHECKING:",
|
|
125
|
+
"if __name__ == .__main__.",
|
|
126
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sillystrings"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python reimplementation of the Unix strings utility"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Kyle O'Malley", email = "j.kyle.omalley@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
keywords = ["strings", "binary", "reverse engineering", "cli"]
|
|
11
|
+
dependencies = []
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Intended Audience :: System Administrators",
|
|
16
|
+
"Topic :: Utilities",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/jkomalley/sillystrings"
|
|
29
|
+
Repository = "https://github.com/jkomalley/sillystrings"
|
|
30
|
+
Issues = "https://github.com/jkomalley/sillystrings/issues"
|
|
31
|
+
Changelog = "https://github.com/jkomalley/sillystrings/blob/main/CHANGELOG.md"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pre-commit>=4.6.0",
|
|
36
|
+
"pytest>=9.1.1",
|
|
37
|
+
"pytest-cov>=7.1.0",
|
|
38
|
+
"pytest-mock>=3.15.1",
|
|
39
|
+
"ruff>=0.15.10",
|
|
40
|
+
"ty>=0.0.37",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
sillystrings = "sillystrings.cli:main"
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
48
|
+
build-backend = "uv_build"
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
# Ruff
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
target-version = "py311"
|
|
55
|
+
line-length = 88
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["ALL"]
|
|
59
|
+
ignore = [
|
|
60
|
+
# --- Formatting / style clashes ---
|
|
61
|
+
"D203", # one-blank-line-before-class (conflicts with D211)
|
|
62
|
+
"D213", # multi-line-summary-second-line (conflicts with D212)
|
|
63
|
+
"D100", # missing docstring in public module
|
|
64
|
+
"D104", # missing docstring in public package
|
|
65
|
+
"D107", # missing docstring in __init__ method
|
|
66
|
+
|
|
67
|
+
# --- Overly pedantic / noisy ---
|
|
68
|
+
"COM812", # missing trailing comma (conflicts with formatter)
|
|
69
|
+
"ISC001", # single-line implicit string concatenation (conflicts with formatter)
|
|
70
|
+
"FIX002", # line contains TODO
|
|
71
|
+
"TD002", # missing author in TODO
|
|
72
|
+
"TD003", # missing issue link in TODO
|
|
73
|
+
"ERA001", # found commented-out code
|
|
74
|
+
"TRY003", # avoid specifying long messages outside the exception class
|
|
75
|
+
"EM101", # exception must not use a string literal
|
|
76
|
+
"EM102", # exception must not use an f-string literal
|
|
77
|
+
|
|
78
|
+
# --- Pragmatic ignores ---
|
|
79
|
+
"S101", # use of assert (needed in tests, fine in internal code)
|
|
80
|
+
"PLR2004",# magic value used in comparison
|
|
81
|
+
"CPY001", # missing copyright notice
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.pydocstyle]
|
|
85
|
+
convention = "google"
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.per-file-ignores]
|
|
88
|
+
"tests/**/*.py" = [
|
|
89
|
+
"D", # no docstring requirements in tests
|
|
90
|
+
"ANN", # no type annotation requirements in tests
|
|
91
|
+
"SLF001", # allow access to private members in tests
|
|
92
|
+
"INP001", # tests/ is a pytest rootdir-based test suite, not an importable package
|
|
93
|
+
"FBT001", # parametrized test signatures take booleans positionally
|
|
94
|
+
]
|
|
95
|
+
"src/sillystrings/cli.py" = [
|
|
96
|
+
"T201", # print() is the primary output mechanism for CLI
|
|
97
|
+
]
|
|
98
|
+
"tests/test_cli.py" = [
|
|
99
|
+
"S603", # subprocess with a fixed, hardcoded argv -- not untrusted input
|
|
100
|
+
]
|
|
101
|
+
"scripts/**/*.py" = [
|
|
102
|
+
"T201", # print() is how a developer script reports progress
|
|
103
|
+
"S603", # subprocess with a fixed, hardcoded argv -- not untrusted input
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
# ty
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
[tool.ty.rules]
|
|
110
|
+
possibly-missing-import = "warn"
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- #
|
|
113
|
+
# Pytest
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
[tool.pytest.ini_options]
|
|
116
|
+
testpaths = ["tests"]
|
|
117
|
+
addopts = [
|
|
118
|
+
"-ra",
|
|
119
|
+
"--strict-markers",
|
|
120
|
+
"--strict-config",
|
|
121
|
+
"--cov",
|
|
122
|
+
"--cov-fail-under=100",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
# Coverage
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
[tool.coverage.run]
|
|
129
|
+
source = ["sillystrings"]
|
|
130
|
+
branch = true
|
|
131
|
+
|
|
132
|
+
[tool.coverage.report]
|
|
133
|
+
show_missing = true
|
|
134
|
+
skip_empty = true
|
|
135
|
+
exclude_lines = [
|
|
136
|
+
"pragma: no cover",
|
|
137
|
+
"if TYPE_CHECKING:",
|
|
138
|
+
"if __name__ == .__main__.",
|
|
139
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# src/sillystrings/__init__.py
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# src/sillystrings/cli.py
|
|
2
|
+
import argparse
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import get_args
|
|
9
|
+
|
|
10
|
+
from sillystrings.__version__ import __version__
|
|
11
|
+
from sillystrings.encodings import Encoding
|
|
12
|
+
from sillystrings.scanner import scan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def positive_int(value: str) -> int:
|
|
16
|
+
"""Parse a string as an integer of at least 1, for use as an argparse type.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
value (str): The raw command-line argument.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
int: The parsed value.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
argparse.ArgumentTypeError: If the value is less than 1.
|
|
26
|
+
"""
|
|
27
|
+
n = int(value)
|
|
28
|
+
if n < 1:
|
|
29
|
+
raise argparse.ArgumentTypeError(f"{value} is not a positive integer")
|
|
30
|
+
return n
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Source:
|
|
35
|
+
"""A named blob of bytes to scan, from a file or from stdin."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
data: bytes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
"""Build the argument parser for the sillystrings command-line interface.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
An instance of argparse.ArgumentParser configured with the appropriate
|
|
46
|
+
arguments and options for the sillystrings CLI
|
|
47
|
+
"""
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="sillystrings",
|
|
50
|
+
description=(
|
|
51
|
+
"sillystrings - find the printable strings in an object,"
|
|
52
|
+
" or other binary, file"
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"files",
|
|
57
|
+
metavar="FILE",
|
|
58
|
+
nargs="*",
|
|
59
|
+
help="the file(s) to search for printable strings (use - for stdin)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"-n",
|
|
63
|
+
"--bytes",
|
|
64
|
+
metavar="NUM",
|
|
65
|
+
dest="min_length",
|
|
66
|
+
type=positive_int,
|
|
67
|
+
default=4,
|
|
68
|
+
help=(
|
|
69
|
+
"Print sequences of displayable characters that are at least"
|
|
70
|
+
" min-len characters long. If not specified a default minimum"
|
|
71
|
+
" length of 4 is used. The distinction between displayable"
|
|
72
|
+
" and non-displayable characters depends upon the setting of"
|
|
73
|
+
" the -e option. Sequences are always terminated at"
|
|
74
|
+
" control characters such as new-line and carriage-return,"
|
|
75
|
+
" but not the tab character."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"-t",
|
|
80
|
+
"--radix",
|
|
81
|
+
choices=("d", "o", "x"),
|
|
82
|
+
help=(
|
|
83
|
+
"Print the offset within the file before each string."
|
|
84
|
+
" The single character argument specifies the radix of"
|
|
85
|
+
" the offset - o for octal, x for hexadecimal, or d"
|
|
86
|
+
" for decimal"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"-e",
|
|
91
|
+
"--encoding",
|
|
92
|
+
choices=get_args(Encoding),
|
|
93
|
+
default="s",
|
|
94
|
+
help=(
|
|
95
|
+
"Select the character encoding of the strings that are to"
|
|
96
|
+
" be found. Possible values for encoding are:"
|
|
97
|
+
" s = single-7-bit-byte characters (default),"
|
|
98
|
+
" S = single-8-bit-byte characters,"
|
|
99
|
+
" b = 16-bit big-endian, l = 16-bit little-endian."
|
|
100
|
+
" Useful for finding wide character strings. (l and b"
|
|
101
|
+
" apply to, for example, Unicode UTF-16/UCS-2 encodings)."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"-w",
|
|
106
|
+
"--include-all-whitespace",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help=(
|
|
109
|
+
"By default tab and space characters are included in the"
|
|
110
|
+
" strings that are displayed, but other whitespace"
|
|
111
|
+
" characters, such a newlines and carriage returns, are"
|
|
112
|
+
" not. The -w option changes this so that all whitespace"
|
|
113
|
+
" characters are considered to be part of a string."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"-f",
|
|
118
|
+
"--print-file-name",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="Print the name of the file before each string.",
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"-v",
|
|
124
|
+
"--version",
|
|
125
|
+
action="version",
|
|
126
|
+
version=f"sillystrings {__version__}",
|
|
127
|
+
help="Shows the version number and exits.",
|
|
128
|
+
)
|
|
129
|
+
return parser
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_offset(offset: int, radix: str | None) -> str:
|
|
133
|
+
"""Format a byte offset for display in the requested radix.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
offset (int): The byte offset of the string.
|
|
137
|
+
radix (str | None): 'd' for decimal, 'o' for octal, 'x' for hex, or
|
|
138
|
+
None to omit the offset.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
str: The padded offset followed by a space, or an empty string when
|
|
142
|
+
no radix was requested.
|
|
143
|
+
"""
|
|
144
|
+
match radix:
|
|
145
|
+
case "d":
|
|
146
|
+
return f"{offset:7d} "
|
|
147
|
+
case "o":
|
|
148
|
+
return f"{offset:7o} "
|
|
149
|
+
case "x":
|
|
150
|
+
return f"{offset:7x} "
|
|
151
|
+
case _:
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main() -> None:
|
|
156
|
+
"""Run the sillystrings command-line interface.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
SystemExit: On a missing file, or when the output pipe closes early.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
_run()
|
|
163
|
+
except BrokenPipeError:
|
|
164
|
+
# A downstream reader went away -- `sillystrings big.bin | head` is the
|
|
165
|
+
# normal case. Rebind stdout to devnull before exiting: the interpreter
|
|
166
|
+
# flushes stdout during shutdown, which would raise a second
|
|
167
|
+
# BrokenPipeError and print "Exception ignored in:" after we are done.
|
|
168
|
+
# If stdout has no underlying fd (captured or wrapped), there is no
|
|
169
|
+
# real pipe to protect, so failing to rebind is not an error.
|
|
170
|
+
with contextlib.suppress(OSError):
|
|
171
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _run() -> None:
|
|
176
|
+
"""Parse arguments, read every source, and print the strings found."""
|
|
177
|
+
args: argparse.Namespace = build_parser().parse_args()
|
|
178
|
+
|
|
179
|
+
sources: list[Source] = []
|
|
180
|
+
|
|
181
|
+
if not args.files:
|
|
182
|
+
sources.append(Source("<stdin>", sys.stdin.buffer.read()))
|
|
183
|
+
else:
|
|
184
|
+
for name in args.files:
|
|
185
|
+
if name == "-":
|
|
186
|
+
sources.append(Source("<stdin>", sys.stdin.buffer.read()))
|
|
187
|
+
else:
|
|
188
|
+
path = Path(name)
|
|
189
|
+
if not path.is_file():
|
|
190
|
+
print(f"sillystrings: {name}: No such file", file=sys.stderr)
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
sources.append(Source(name, path.read_bytes()))
|
|
193
|
+
|
|
194
|
+
multiple: bool = len(sources) > 1
|
|
195
|
+
|
|
196
|
+
for source in sources:
|
|
197
|
+
prefix = f"{source.name}: " if (multiple or args.print_file_name) else ""
|
|
198
|
+
for offset, string in scan(
|
|
199
|
+
source.data,
|
|
200
|
+
min_length=args.min_length,
|
|
201
|
+
encoding=args.encoding,
|
|
202
|
+
include_whitespace=args.include_all_whitespace,
|
|
203
|
+
):
|
|
204
|
+
print(f"{prefix}{format_offset(offset, args.radix)}{string}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
main()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# src/sillystrings/encodings.py
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
Encoding = Literal["s", "S", "l", "b"]
|
|
6
|
+
"""The encodings sillystrings can scan for, in `strings`' own -e vocabulary."""
|
|
7
|
+
|
|
8
|
+
ASCII_ENCODINGS = ("s", "S")
|
|
9
|
+
UTF16_ENCODINGS = ("l", "b")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def unsupported_encoding(encoding: str) -> ValueError:
|
|
13
|
+
"""Build the error for an encoding outside the supported vocabulary.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
encoding (str): The rejected encoding.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
ValueError: The error to raise.
|
|
20
|
+
"""
|
|
21
|
+
return ValueError(f"unsupported encoding: {encoding}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_printable_ascii(
|
|
25
|
+
byte: int, encoding: str = "s", *, include_ws: bool = False
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Check if an ASCII value is printable.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
byte (int): The byte to check.
|
|
31
|
+
encoding (str): The encoding to use for checking. Default is 's'
|
|
32
|
+
(7-bit ASCII).
|
|
33
|
+
include_ws (bool): Whether to include whitespace characters as
|
|
34
|
+
printable. Default is False.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
bool: True if the byte is printable, False otherwise.
|
|
38
|
+
"""
|
|
39
|
+
if encoding not in ("s", "S"):
|
|
40
|
+
return False
|
|
41
|
+
if include_ws and byte in (0x09, 0x0A, 0x0D): # Tab (\t), LF (\n), CR (\r)
|
|
42
|
+
return True
|
|
43
|
+
if encoding == "s": # 7-bit ASCII
|
|
44
|
+
return 0x20 <= byte <= 0x7E
|
|
45
|
+
# "S": 8-bit extended ASCII
|
|
46
|
+
return (0x20 <= byte <= 0x7E) or (0x80 <= byte <= 0xFF)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_printable_utf16(value: int, *, include_ws: bool = False) -> bool:
|
|
50
|
+
"""Check if a UTF-16 value is printable.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
value (int): The UTF-16 value to check.
|
|
54
|
+
include_ws (bool): Whether to include whitespace characters as
|
|
55
|
+
printable. Default is False.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
bool: True if the UTF-16 value is printable, False otherwise.
|
|
59
|
+
"""
|
|
60
|
+
if include_ws and value in (0x0009, 0x000A, 0x000D):
|
|
61
|
+
return True
|
|
62
|
+
return 0x0020 <= value <= 0x007E
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def iter_chars(
|
|
66
|
+
data: bytes | memoryview, encoding: str, *, include_ws: bool = False
|
|
67
|
+
) -> Iterator[tuple[int, bool]]:
|
|
68
|
+
"""Iterate over the chars in a byte sequence, yielding offset and printability.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
data (bytes | memoryview): The byte sequence to iterate over.
|
|
72
|
+
encoding (str): The encoding to use for checking printability.
|
|
73
|
+
include_ws (bool): Whether to include whitespace characters as
|
|
74
|
+
printable. Default is False.
|
|
75
|
+
|
|
76
|
+
Yields:
|
|
77
|
+
tuple[int, bool]: Tuple containing the byte offset and a bool
|
|
78
|
+
indicating if it's printable.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If the encoding is not an Encoding member.
|
|
82
|
+
"""
|
|
83
|
+
if encoding in ASCII_ENCODINGS:
|
|
84
|
+
for i, byte in enumerate(data):
|
|
85
|
+
yield i, is_printable_ascii(byte, encoding, include_ws=include_ws)
|
|
86
|
+
elif encoding in UTF16_ENCODINGS:
|
|
87
|
+
byteorder: Literal["little", "big"] = "little" if encoding == "l" else "big"
|
|
88
|
+
for i in range(0, len(data) - 1, 2):
|
|
89
|
+
char_bytes: bytes | memoryview = data[i : i + 2]
|
|
90
|
+
char_value: int = int.from_bytes(bytes=char_bytes, byteorder=byteorder)
|
|
91
|
+
yield i, is_printable_utf16(char_value, include_ws=include_ws)
|
|
92
|
+
else:
|
|
93
|
+
raise unsupported_encoding(encoding)
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# src/sillystrings/scanner.py
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from sillystrings.encodings import (
|
|
6
|
+
ASCII_ENCODINGS,
|
|
7
|
+
UTF16_ENCODINGS,
|
|
8
|
+
Encoding,
|
|
9
|
+
iter_chars,
|
|
10
|
+
unsupported_encoding,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def scan(
|
|
15
|
+
data: bytes | memoryview,
|
|
16
|
+
*,
|
|
17
|
+
min_length: int = 4,
|
|
18
|
+
encoding: Encoding = "s",
|
|
19
|
+
include_whitespace: bool = False,
|
|
20
|
+
) -> Iterator[tuple[int, str]]:
|
|
21
|
+
"""Scan a byte sequence for printable strings based on the specified encoding.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
data (bytes | memoryview): The byte sequence to scan.
|
|
25
|
+
min_length (int): The minimum length of strings to yield. Default is 4.
|
|
26
|
+
encoding (Encoding): The encoding to use for scanning. Default is 's'.
|
|
27
|
+
- 's' for 7-bit ASCII
|
|
28
|
+
- 'S' for 8-bit extended ASCII
|
|
29
|
+
- 'l' for UTF-16 little-endian
|
|
30
|
+
- 'b' for UTF-16 big-endian
|
|
31
|
+
include_whitespace (bool): Whether to include whitespace characters
|
|
32
|
+
as part of the strings. Default is False.
|
|
33
|
+
|
|
34
|
+
Yields:
|
|
35
|
+
tuple[int, str]: The byte offset of the string's first byte, and the
|
|
36
|
+
string itself.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If the encoding is not an Encoding member.
|
|
40
|
+
"""
|
|
41
|
+
if encoding in ASCII_ENCODINGS:
|
|
42
|
+
yield from _scan_ascii(
|
|
43
|
+
data,
|
|
44
|
+
min_length=min_length,
|
|
45
|
+
encoding=encoding,
|
|
46
|
+
include_whitespace=include_whitespace,
|
|
47
|
+
)
|
|
48
|
+
elif encoding in UTF16_ENCODINGS:
|
|
49
|
+
yield from _scan_utf16(
|
|
50
|
+
data,
|
|
51
|
+
min_length=min_length,
|
|
52
|
+
encoding=encoding,
|
|
53
|
+
include_whitespace=include_whitespace,
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
raise unsupported_encoding(encoding)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _scan_ascii(
|
|
60
|
+
data: bytes | memoryview,
|
|
61
|
+
*,
|
|
62
|
+
min_length: int,
|
|
63
|
+
encoding: str,
|
|
64
|
+
include_whitespace: bool,
|
|
65
|
+
) -> Iterator[tuple[int, str]]:
|
|
66
|
+
codec: Literal["latin-1", "ascii"] = "latin-1" if encoding == "S" else "ascii"
|
|
67
|
+
acc = bytearray()
|
|
68
|
+
acc_start = 0
|
|
69
|
+
|
|
70
|
+
for offset, printable in iter_chars(data, encoding, include_ws=include_whitespace):
|
|
71
|
+
if printable:
|
|
72
|
+
if not acc:
|
|
73
|
+
acc_start = offset
|
|
74
|
+
acc.append(data[offset])
|
|
75
|
+
else:
|
|
76
|
+
if len(acc) >= min_length:
|
|
77
|
+
yield acc_start, acc.decode(codec)
|
|
78
|
+
acc.clear()
|
|
79
|
+
if len(acc) >= min_length:
|
|
80
|
+
yield acc_start, acc.decode(codec)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _scan_utf16(
|
|
84
|
+
data: bytes | memoryview,
|
|
85
|
+
*,
|
|
86
|
+
min_length: int,
|
|
87
|
+
encoding: str,
|
|
88
|
+
include_whitespace: bool,
|
|
89
|
+
) -> Iterator[tuple[int, str]]:
|
|
90
|
+
byteorder: Literal["little", "big"] = "little" if encoding == "l" else "big"
|
|
91
|
+
acc: list[str] = []
|
|
92
|
+
acc_start = 0
|
|
93
|
+
|
|
94
|
+
for offset, printable in iter_chars(data, encoding, include_ws=include_whitespace):
|
|
95
|
+
if printable:
|
|
96
|
+
if not acc:
|
|
97
|
+
acc_start = offset
|
|
98
|
+
ascii_value = int.from_bytes(data[offset : offset + 2], byteorder=byteorder)
|
|
99
|
+
acc.append(chr(ascii_value))
|
|
100
|
+
else:
|
|
101
|
+
if len(acc) >= min_length:
|
|
102
|
+
yield acc_start, "".join(acc)
|
|
103
|
+
acc.clear()
|
|
104
|
+
|
|
105
|
+
if len(acc) >= min_length:
|
|
106
|
+
yield acc_start, "".join(acc)
|