Skip to content

Commit 3d76f2f

Browse files
author
Andrew Marcuse
committed
Strings utility
1 parent 435631a commit 3d76f2f

4 files changed

Lines changed: 312 additions & 0 deletions

File tree

src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
2121
<a href="/runecount.html" class="btn btn-primary">Rune Count</a>
2222
<div class="ps-2">count which characters are in a file</div>
2323
</div>
24+
<div class="flex flex-row items-center">
25+
<a href="/strings.html" class="btn btn-primary">Strings</a>
26+
<div class="ps-2">extract readable strings from a binary file</div>
27+
</div>
2428
<div class="flex flex-row items-center">
2529
<a href="/upside-down.html" class="btn btn-primary">Upside Down</a>
2630
<div class="ps-2">flip text upside down</div>

src/strings.ts

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import './style.css'
2+
import { renderHeader } from './components/header.ts'
3+
4+
type FoundString = {
5+
value: string
6+
offset: number
7+
length: number
8+
}
9+
10+
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
11+
<main class="min-h-screen bg-base-200" data-theme="light">
12+
<section class="mx-auto flex max-w-5xl flex-col gap-8 px-4 py-8 md:px-8 md:py-12">
13+
${renderHeader()}
14+
15+
<section class="rounded-box border border-base-300 bg-base-100 p-8 shadow-sm">
16+
<h1 class="text-3xl font-bold">Strings</h1>
17+
<p class="mt-3 text-base-content/70">Scan a file for human-readable strings.</p>
18+
19+
<form id="strings-form" class="mt-6 flex flex-col gap-4" action="#" method="post">
20+
<label class="form-control w-full">
21+
<div class="label">
22+
<span class="label-text">Input file</span>
23+
</div>
24+
<input id="input-file" type="file" name="inputFile" class="file-input file-input-bordered w-full" />
25+
</label>
26+
27+
<label class="form-control w-full md:max-w-xs">
28+
<div class="label">
29+
<span class="label-text">Minimum length</span>
30+
</div>
31+
<input id="min-length" type="number" min="1" step="1" value="6" class="input input-bordered w-full" />
32+
</label>
33+
34+
<label class="form-control w-full md:max-w-xs">
35+
<div class="label">
36+
<span class="label-text">Minimum contiguous letters</span>
37+
</div>
38+
<input id="min-contiguous-letters" type="number" min="1" step="1" value="3" class="input input-bordered w-full" />
39+
</label>
40+
41+
<label class="form-control w-full md:max-w-xs">
42+
<div class="label">
43+
<span class="label-text">Minimum letters</span>
44+
</div>
45+
<input id="min-letters" type="number" min="1" step="1" value="4" class="input input-bordered w-full" />
46+
</label>
47+
48+
<div id="form-error" class="alert alert-error hidden" role="alert" aria-live="polite"></div>
49+
50+
<div>
51+
<button id="start-button" type="submit" class="btn btn-primary">Start</button>
52+
</div>
53+
</form>
54+
55+
<section id="results" class="mt-8 hidden">
56+
<h2 class="text-xl font-semibold">Strings</h2>
57+
<p id="results-summary" class="mt-2 text-base-content/70"></p>
58+
59+
<div class="mt-4 overflow-x-auto">
60+
<table class="table table-zebra">
61+
<thead>
62+
<tr>
63+
<th class="text-right">Offset</th>
64+
<th class="text-right">Length</th>
65+
<th>String</th>
66+
</tr>
67+
</thead>
68+
<tbody id="results-body"></tbody>
69+
</table>
70+
</div>
71+
</section>
72+
</section>
73+
</section>
74+
</main>
75+
`
76+
77+
const form = document.querySelector<HTMLFormElement>('#strings-form')
78+
const fileInput = document.querySelector<HTMLInputElement>('#input-file')
79+
const minLengthInput = document.querySelector<HTMLInputElement>('#min-length')
80+
const minContiguousLettersInput = document.querySelector<HTMLInputElement>('#min-contiguous-letters')
81+
const minLettersInput = document.querySelector<HTMLInputElement>('#min-letters')
82+
const formError = document.querySelector<HTMLDivElement>('#form-error')
83+
const startButton = document.querySelector<HTMLButtonElement>('#start-button')
84+
const resultsSection = document.querySelector<HTMLElement>('#results')
85+
const resultsSummary = document.querySelector<HTMLParagraphElement>('#results-summary')
86+
const resultsBody = document.querySelector<HTMLTableSectionElement>('#results-body')
87+
let errorTimeoutId: number | undefined
88+
89+
const hideError = () => {
90+
if (!formError) {
91+
return
92+
}
93+
94+
formError.textContent = ''
95+
formError.classList.add('hidden')
96+
97+
if (errorTimeoutId !== undefined) {
98+
window.clearTimeout(errorTimeoutId)
99+
errorTimeoutId = undefined
100+
}
101+
}
102+
103+
const showError = (message: string) => {
104+
if (!formError) {
105+
return
106+
}
107+
108+
formError.textContent = message
109+
formError.classList.remove('hidden')
110+
111+
if (errorTimeoutId !== undefined) {
112+
window.clearTimeout(errorTimeoutId)
113+
}
114+
115+
errorTimeoutId = window.setTimeout(() => {
116+
hideError()
117+
}, 5000)
118+
119+
document.addEventListener('click', hideError, { once: true })
120+
}
121+
122+
const formatOffsetHex = (offset: number): string => `0x${offset.toString(16).toUpperCase().padStart(4, '0')}`
123+
const formatOffsetTitle = (offset: number): string => `${offset.toLocaleString()} (decimal)`
124+
const formatCount = (count: number): string => count.toLocaleString()
125+
126+
const escapeHtml = (value: string): string =>
127+
value
128+
.replaceAll('&', '&amp;')
129+
.replaceAll('<', '&lt;')
130+
.replaceAll('>', '&gt;')
131+
.replaceAll('"', '&quot;')
132+
.replaceAll("'", '&#39;')
133+
134+
const byteToChar = (byte: number): string => {
135+
if (byte === 9) {
136+
return '\\t'
137+
}
138+
139+
return String.fromCharCode(byte)
140+
}
141+
142+
const isReadableByte = (byte: number): boolean => byte === 9 || (byte >= 32 && byte <= 126)
143+
144+
const isAsciiLetter = (character: string): boolean => /[A-Za-z]/.test(character)
145+
146+
const analyzeLetters = (value: string): { totalLetters: number; maxContiguousLetters: number } => {
147+
let totalLetters = 0
148+
let maxContiguousLetters = 0
149+
let currentRun = 0
150+
151+
for (const character of value) {
152+
if (isAsciiLetter(character)) {
153+
totalLetters += 1
154+
currentRun += 1
155+
if (currentRun > maxContiguousLetters) {
156+
maxContiguousLetters = currentRun
157+
}
158+
continue
159+
}
160+
161+
currentRun = 0
162+
}
163+
164+
return { totalLetters, maxContiguousLetters }
165+
}
166+
167+
const shouldIncludeString = (
168+
value: string,
169+
minLength: number,
170+
minContiguousLetters: number,
171+
minLetters: number,
172+
): boolean => {
173+
if (value.length < minLength) {
174+
return false
175+
}
176+
177+
const { totalLetters, maxContiguousLetters } = analyzeLetters(value)
178+
return totalLetters >= minLetters && maxContiguousLetters >= minContiguousLetters
179+
}
180+
181+
const scanStrings = async (
182+
file: File,
183+
minLength: number,
184+
minContiguousLetters: number,
185+
minLetters: number,
186+
): Promise<FoundString[]> => {
187+
const fileBuffer = await file.arrayBuffer()
188+
const bytes = new Uint8Array(fileBuffer)
189+
const found: FoundString[] = []
190+
191+
let currentOffset = -1
192+
let current = ''
193+
194+
for (let index = 0; index < bytes.length; index += 1) {
195+
const byte = bytes[index]
196+
197+
if (isReadableByte(byte)) {
198+
if (currentOffset === -1) {
199+
currentOffset = index
200+
}
201+
202+
current += byteToChar(byte)
203+
continue
204+
}
205+
206+
if (shouldIncludeString(current, minLength, minContiguousLetters, minLetters)) {
207+
found.push({
208+
value: current,
209+
offset: currentOffset,
210+
length: current.length,
211+
})
212+
}
213+
214+
currentOffset = -1
215+
current = ''
216+
}
217+
218+
if (shouldIncludeString(current, minLength, minContiguousLetters, minLetters)) {
219+
found.push({
220+
value: current,
221+
offset: currentOffset,
222+
length: current.length,
223+
})
224+
}
225+
226+
return found
227+
}
228+
229+
const renderResults = (strings: FoundString[]) => {
230+
if (!resultsBody || !resultsSummary || !resultsSection) {
231+
return
232+
}
233+
234+
const rows = strings.map(
235+
(entry) =>
236+
`<tr><td class="text-right" title="${formatOffsetTitle(entry.offset)}">${formatOffsetHex(entry.offset)}</td><td class="text-right">${formatCount(entry.length)}</td><td>${escapeHtml(entry.value)}</td></tr>`,
237+
)
238+
239+
resultsBody.innerHTML = rows.join('')
240+
resultsSummary.textContent = `${formatCount(strings.length)} strings found.`
241+
resultsSection.classList.remove('hidden')
242+
}
243+
244+
form?.addEventListener('submit', async (event) => {
245+
event.preventDefault()
246+
247+
const selectedFile = fileInput?.files?.[0]
248+
const minLengthValue = Number(minLengthInput?.value ?? '6')
249+
const minContiguousLettersValue = Number(minContiguousLettersInput?.value ?? '3')
250+
const minLettersValue = Number(minLettersInput?.value ?? '4')
251+
252+
if (!selectedFile) {
253+
if (resultsSection) {
254+
resultsSection.classList.add('hidden')
255+
}
256+
257+
showError('Please choose a file before starting.')
258+
return
259+
}
260+
261+
if (!Number.isInteger(minLengthValue) || minLengthValue < 1) {
262+
showError('Minimum length must be a whole number greater than or equal to 1.')
263+
return
264+
}
265+
266+
if (!Number.isInteger(minContiguousLettersValue) || minContiguousLettersValue < 1) {
267+
showError('Minimum contiguous letters must be a whole number greater than or equal to 1.')
268+
return
269+
}
270+
271+
if (!Number.isInteger(minLettersValue) || minLettersValue < 1) {
272+
showError('Minimum letters must be a whole number greater than or equal to 1.')
273+
return
274+
}
275+
276+
hideError()
277+
278+
if (startButton) {
279+
startButton.disabled = true
280+
startButton.textContent = 'Scanning...'
281+
}
282+
283+
try {
284+
const strings = await scanStrings(selectedFile, minLengthValue, minContiguousLettersValue, minLettersValue)
285+
renderResults(strings)
286+
} catch {
287+
showError('Unable to read the selected file.')
288+
} finally {
289+
if (startButton) {
290+
startButton.disabled = false
291+
startButton.textContent = 'Start'
292+
}
293+
}
294+
})

strings.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Strings</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/strings.ts"></script>
12+
</body>
13+
</html>

vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default defineConfig({
1313
asciify: resolve(__dirname, 'asciify.html'),
1414
upsideDown: resolve(__dirname, 'upside-down.html'),
1515
urlencode: resolve(__dirname, 'urlencode.html'),
16+
strings: resolve(__dirname, 'strings.html'),
1617
},
1718
},
1819
},

0 commit comments

Comments
 (0)