@bobfrankston/tsfind 0.1.1 → 0.1.2
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.
- package/README.md +41 -23
- package/index.d.ts +19 -0
- package/index.js +330 -281
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# tsfind
|
|
2
2
|
|
|
3
3
|
Find files by name on an NTFS volume, fast. It reads the volume's Master File Table
|
|
4
|
-
([`@bobfrankston/mftwalk`](https://github.com/BobFrankston/mftwalk)) instead of walking, and
|
|
5
|
-
the scan, so repeated searches and analyses of the
|
|
4
|
+
([`@bobfrankston/mftwalk`](https://github.com/BobFrankston/mftwalk)) instead of walking, and saves
|
|
5
|
+
the scan, kept current by the drive's change journal, so repeated searches and analyses of the
|
|
6
|
+
same drive take seconds and always see it as it is now. By default it prints
|
|
6
7
|
bare paths, one per line, so the list can be edited (for example, cut down to the files to
|
|
7
8
|
delete). `-count` tallies the matches by name and by extension instead, and `-hogs` shows just the
|
|
8
9
|
largest.
|
|
9
10
|
|
|
10
|
-
A find-only companion to [tswalk](https://github.com/BobFrankston/tswalk)
|
|
11
|
-
(non-NTFS
|
|
11
|
+
A find-only companion to [tswalk](https://github.com/BobFrankston/tswalk). When the MFT cannot be
|
|
12
|
+
used (off Windows, non-NTFS or network drives, or no admin rights to be had without a UAC prompt),
|
|
13
|
+
tsfind walks the tree instead and says why.
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
@@ -36,28 +38,44 @@ tsfind [dir] [pattern] [options]
|
|
|
36
38
|
| `-sort key` | `-count` order: `size` (default), `count` or `name` |
|
|
37
39
|
| `-top N` | `-count` rows per table (default 50; 0 = all) |
|
|
38
40
|
| `-hogs [name\|ext] [N\|N%]` | Largest totals by name (default) or extension, with each one's share; top N (default 5), or every one taking at least N% |
|
|
39
|
-
| `-age N` | Use a cached scan up to N minutes old (default 60) |
|
|
40
|
-
| `-fresh` | Rescan the MFT even if a cached scan is young enough |
|
|
41
41
|
| `-q` | No progress or summary |
|
|
42
42
|
| `-help`, `-version` | |
|
|
43
43
|
|
|
44
44
|
The list is sorted by path. Progress and the summary go to stderr, so stdout carries only the
|
|
45
45
|
list. Links and junctions are never entered.
|
|
46
46
|
|
|
47
|
-
## The MFT and the
|
|
47
|
+
## The MFT and the saved scan
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
Reading the MFT needs Windows and NTFS. The whole volume is read whatever
|
|
50
|
+
folder is searched: a 15 TB USB backup drive with 8.7M files takes about 5-8 minutes and 1.6 GB of
|
|
51
|
+
memory. Each read is saved through mftwalk in `%TEMP%\tsfind\mft`, one file per volume, keyed by
|
|
52
|
+
volume serial so it follows the drive to any letter. The drive's NTFS **change journal** keeps it
|
|
53
|
+
current (since 2026-11-07; before that tsfind kept its own snapshot, valid for `-age` minutes):
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
- **Unchanged** since the save: the saved scan is used as is. Seconds; no admin rights needed.
|
|
56
|
+
- **Changed**: only the MFT records the journal names are read again (admin rights).
|
|
57
|
+
- **No saved scan, or no journal**: the whole MFT is read (admin rights).
|
|
58
|
+
|
|
59
|
+
### Admin rights (2026-11-09)
|
|
60
|
+
|
|
61
|
+
When a read needs admin rights and tsfind is not elevated, it elevates by itself **only if Windows
|
|
62
|
+
would allow it without asking**: the account is an administrator and UAC is set not to prompt
|
|
63
|
+
(`ConsentPromptBehaviorAdmin` = 0). A hidden elevated copy of tsfind then brings the saved scan up
|
|
64
|
+
to date, and the search runs on it in your own, unelevated process, so pipes and `-o` work as
|
|
65
|
+
usual. Changes made in the seconds after that update are not included, and the summary says so.
|
|
66
|
+
|
|
67
|
+
If Windows would ask, tsfind never raises the popup, because it would stall a script. It says why
|
|
68
|
+
on stderr and walks the tree instead (slow on a big drive). Run it elevated for MFT speed.
|
|
69
|
+
|
|
70
|
+
The walk never enters links or junctions. Entries it cannot read are listed after the summary,
|
|
71
|
+
even with `-q`, since without them the results may be incomplete.
|
|
72
|
+
|
|
73
|
+
A drive without a change journal is read in full on every run; tsfind prints the command that
|
|
74
|
+
turns one on (`fsutil usn createjournal m=33554432 a=8388608 X:`, elevated). Measured cost of a
|
|
75
|
+
journal on a USB backup drive: creating, renaming and deleting many small files is about 10-30%
|
|
76
|
+
slower; large sequential writes are unaffected. The summary always says where the data came
|
|
77
|
+
from: the saved scan, the MFT, or a walk and why. A saved scan takes about 150-200 bytes per MFT record (S: here,
|
|
78
|
+
2.4M files: 455 MB).
|
|
61
79
|
|
|
62
80
|
A file with several hard links is found under one of its names only (mftwalk keeps one name per
|
|
63
81
|
MFT record); cargo's `target` directories are the common case.
|
|
@@ -65,12 +83,12 @@ MFT record); cargo's `target` directories are the common case.
|
|
|
65
83
|
## Examples
|
|
66
84
|
|
|
67
85
|
```bash
|
|
68
|
-
|
|
69
|
-
tsfind U:\ -hogs 2% # from the cache: every name taking at least 2%
|
|
70
|
-
tsfind U:\Aged -count -top 30 # from the cache: by name and extension
|
|
71
|
-
tsfind U:\ telemetrydata.db -o tele.txt # list for editing
|
|
86
|
+
tsfind U:\ telemetrydata.db -o tele.txt # list them to tele.txt for editing
|
|
72
87
|
tsfind U:\ "*telemetry*" -l # with dates and sizes
|
|
73
|
-
|
|
88
|
+
tsfind U:\ -hogs ext # the 5 extensions taking the most space
|
|
89
|
+
tsfind U:\Aged -hogs 2% # every name taking at least 2% of U:\Aged
|
|
90
|
+
tsfind U:\ -count -top 30 # where the space goes, by name and extension
|
|
91
|
+
tsfind "*.bak;*.tmp" -count -sort count # in the current directory
|
|
74
92
|
```
|
|
75
93
|
|
|
76
94
|
## License
|
package/index.d.ts
CHANGED
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
* count them by name and extension, or show where the space goes
|
|
5
5
|
*
|
|
6
6
|
* CHANGE LOG (newest first)
|
|
7
|
+
* 2026-11-11 — Claude Code (Opus 5.5), at Bob's direction: usage text and README brought into
|
|
8
|
+
* line: the same examples in both, and both say a walk lists unreadable entries even with -q.
|
|
9
|
+
* 2026-11-09 — Claude Code (Opus 5.5), at Bob's direction: "if you can elevate without a prompt
|
|
10
|
+
* do so, otherwise fall back", and the fallback is the walk ("bring back the walk"). When the
|
|
11
|
+
* saved MFT scan needs an update that needs admin rights, tsfind checks whether Windows would
|
|
12
|
+
* elevate without asking (an administrator, UAC set not to prompt: ConsentPromptBehaviorAdmin 0).
|
|
13
|
+
* If so, a hidden elevated copy of tsfind (-refreshscan X:) brings the saved scan up to date and
|
|
14
|
+
* the search runs on it, here, unelevated, so pipes and -o are unchanged. If Windows would ask,
|
|
15
|
+
* tsfind does NOT raise the popup (it would stall scripts): it says why and walks the tree.
|
|
16
|
+
* The walk also replaces the old "use tswalk" failures: off Windows, non-NTFS, network paths.
|
|
17
|
+
* A stale saved scan is no longer used as is; Bob chose the walk over it.
|
|
18
|
+
* 2026-11-07 — Claude Code (Opus 5.5), at Bob's direction ("the default should be to use the
|
|
19
|
+
* journal"): tsfind's own cache is gone. It now uses mftwalk's saved scans (ScanOptions.cache),
|
|
20
|
+
* which the NTFS change journal keeps current: an unchanged volume loads the saved scan without
|
|
21
|
+
* elevation, a changed one re-reads only the changed records, a full read happens only with no
|
|
22
|
+
* saved scan or no journal. So results are current, not a snapshot up to an hour old, and -age
|
|
23
|
+
* and -fresh are gone. tsfind searches the MftVolume directly; its preorder index and zstd cache
|
|
24
|
+
* file (TSFINDX1) existed only to be cached, and are removed. If an update needs admin rights
|
|
25
|
+
* that are not there, the saved scan is used as it is and the summary says it is not current.
|
|
7
26
|
* 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
|
|
8
27
|
* Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
|
|
9
28
|
* fast. Having a compressed cache that is valid for a limited time allows me to run multiple
|
package/index.js
CHANGED
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
* count them by name and extension, or show where the space goes
|
|
5
5
|
*
|
|
6
6
|
* CHANGE LOG (newest first)
|
|
7
|
+
* 2026-11-11 — Claude Code (Opus 5.5), at Bob's direction: usage text and README brought into
|
|
8
|
+
* line: the same examples in both, and both say a walk lists unreadable entries even with -q.
|
|
9
|
+
* 2026-11-09 — Claude Code (Opus 5.5), at Bob's direction: "if you can elevate without a prompt
|
|
10
|
+
* do so, otherwise fall back", and the fallback is the walk ("bring back the walk"). When the
|
|
11
|
+
* saved MFT scan needs an update that needs admin rights, tsfind checks whether Windows would
|
|
12
|
+
* elevate without asking (an administrator, UAC set not to prompt: ConsentPromptBehaviorAdmin 0).
|
|
13
|
+
* If so, a hidden elevated copy of tsfind (-refreshscan X:) brings the saved scan up to date and
|
|
14
|
+
* the search runs on it, here, unelevated, so pipes and -o are unchanged. If Windows would ask,
|
|
15
|
+
* tsfind does NOT raise the popup (it would stall scripts): it says why and walks the tree.
|
|
16
|
+
* The walk also replaces the old "use tswalk" failures: off Windows, non-NTFS, network paths.
|
|
17
|
+
* A stale saved scan is no longer used as is; Bob chose the walk over it.
|
|
18
|
+
* 2026-11-07 — Claude Code (Opus 5.5), at Bob's direction ("the default should be to use the
|
|
19
|
+
* journal"): tsfind's own cache is gone. It now uses mftwalk's saved scans (ScanOptions.cache),
|
|
20
|
+
* which the NTFS change journal keeps current: an unchanged volume loads the saved scan without
|
|
21
|
+
* elevation, a changed one re-reads only the changed records, a full read happens only with no
|
|
22
|
+
* saved scan or no journal. So results are current, not a snapshot up to an hour old, and -age
|
|
23
|
+
* and -fresh are gone. tsfind searches the MftVolume directly; its preorder index and zstd cache
|
|
24
|
+
* file (TSFINDX1) existed only to be cached, and are removed. If an update needs admin rights
|
|
25
|
+
* that are not there, the saved scan is used as it is and the summary says it is not current.
|
|
7
26
|
* 2026-10-27 (later) — Claude Code (Opus 5.5), at Bob's direction: MFT only, with a cache.
|
|
8
27
|
* Bob: "Walking is expensive and is already handled by tswalk... the point of tsfind is to be
|
|
9
28
|
* fast. Having a compressed cache that is valid for a limited time allows me to run multiple
|
|
@@ -26,36 +45,39 @@
|
|
|
26
45
|
* telemetrydata.db files on the backup drives.
|
|
27
46
|
*/
|
|
28
47
|
import * as fp from 'node:fs/promises';
|
|
29
|
-
import { createReadStream, createWriteStream } from 'node:fs';
|
|
30
48
|
import * as os from 'node:os';
|
|
31
|
-
import {
|
|
49
|
+
import { getDriveInfo } from '@bobfrankston/dirutil';
|
|
32
50
|
import * as path from 'node:path';
|
|
33
51
|
import { styleText } from 'node:util';
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import { createZstdCompress, createZstdDecompress } from 'node:zlib';
|
|
52
|
+
import { finished } from 'node:stream/promises';
|
|
53
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
37
54
|
import packageJson from './package.json' with { type: 'json' };
|
|
38
55
|
const version = packageJson.version;
|
|
39
|
-
/** A cache younger than this many minutes is used instead of rescanning (-age changes it) */
|
|
40
|
-
const DEFAULT_CACHE_MINUTES = 60;
|
|
41
56
|
/** Default rows per -count table */
|
|
42
57
|
const DEFAULT_TOP = 50;
|
|
43
58
|
/** Default rows for -hogs */
|
|
44
59
|
const DEFAULT_HOGS = 5;
|
|
45
60
|
/** Lines handed to the output stream per write */
|
|
46
61
|
const WRITE_BATCH = 10000;
|
|
47
|
-
/** Names encoded or decoded per piece of the cache's name section (keeps each string well under V8's limit) */
|
|
48
|
-
const NAME_BATCH = 100000;
|
|
49
|
-
/** Bytes of the name section decoded at a time when loading */
|
|
50
|
-
const NAME_DECODE_BYTES = 64 * 1024 * 1024;
|
|
51
62
|
const CLEAR_LINE = '\x1b[2K\r';
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Change journal size suggested when a drive has none (Windows' usual 32 MB, 8 MB steps); it only
|
|
65
|
+
* has to hold the changes between two runs. Same suggestion as tswalk's.
|
|
66
|
+
*/
|
|
67
|
+
const SUGGESTED_JOURNAL = 'm=33554432 a=8388608';
|
|
68
|
+
/** Directories read at once by the walk */
|
|
69
|
+
const WALK_CONCURRENCY = 16;
|
|
70
|
+
/** Unreadable directories named in the walk's summary; the rest are only counted */
|
|
71
|
+
const WALK_ERRORS_SHOWN = 5;
|
|
72
|
+
/** Well-known SIDs in `whoami /groups` */
|
|
73
|
+
const Sids = {
|
|
74
|
+
Administrators: 'S-1-5-32-544', /** BUILTIN\Administrators (deny-only in an unelevated admin token) */
|
|
75
|
+
HighIntegrity: 'S-1-16-12288', /** High Mandatory Level: the process is elevated */
|
|
58
76
|
};
|
|
77
|
+
/** HKLM key holding the UAC settings */
|
|
78
|
+
const UAC_KEY = 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System';
|
|
79
|
+
/** ConsentPromptBehaviorAdmin value meaning "elevate administrators without prompting" */
|
|
80
|
+
const UAC_ELEVATE_WITHOUT_PROMPT = 0;
|
|
59
81
|
function usage() {
|
|
60
82
|
return `tsfind v${version} - find files by name on an NTFS volume, fast, from its Master File Table
|
|
61
83
|
|
|
@@ -79,19 +101,25 @@ Options:
|
|
|
79
101
|
Instead of listing: where the space goes. The largest totals by name
|
|
80
102
|
(default) or by extension, with each one's share of the space matched.
|
|
81
103
|
N = how many (default ${DEFAULT_HOGS}); N% = every one taking at least N%.
|
|
82
|
-
-age N Use a cached scan up to N minutes old (default ${DEFAULT_CACHE_MINUTES})
|
|
83
|
-
-fresh Rescan the MFT even if a cached scan is young enough
|
|
84
104
|
-q No progress or summary
|
|
85
105
|
-h, -help This message
|
|
86
106
|
-version Version
|
|
87
107
|
|
|
88
|
-
How it works: tsfind reads the volume's NTFS Master File Table (Windows, NTFS
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
How it works: tsfind reads the volume's NTFS Master File Table (Windows, NTFS) rather than walking:
|
|
109
|
+
minutes instead of the hours a walk can take on a big drive. Each read is saved, one file per
|
|
110
|
+
volume, in ${cacheDir()}, and the drive's change journal keeps it current:
|
|
111
|
+
- unchanged since the save: the saved scan is used as is (seconds; no admin rights needed)
|
|
112
|
+
- changed: only the changed MFT records are read again (admin rights)
|
|
113
|
+
- no saved scan yet, or no change journal: the whole MFT is read (admin rights)
|
|
114
|
+
So every search sees the drive as it is now. A drive without a change journal is read in full
|
|
115
|
+
every time; tsfind prints the fsutil command that turns one on.
|
|
116
|
+
|
|
117
|
+
Admin rights: when a read needs them and tsfind is not elevated, it elevates by itself only if
|
|
118
|
+
Windows would allow it without asking (you are an administrator and UAC is set not to prompt).
|
|
119
|
+
Otherwise it never raises a UAC popup, which would stall a script: it says why on stderr and
|
|
120
|
+
walks the tree instead (slow on a big drive). It also walks off Windows, on non-NTFS drives and
|
|
121
|
+
on network paths. The summary always says which it did. Entries a walk cannot read are listed
|
|
122
|
+
after the summary, even with -q, since the results may then be incomplete.
|
|
95
123
|
|
|
96
124
|
Progress and the summary go to stderr, so stdout carries only the list.
|
|
97
125
|
|
|
@@ -116,8 +144,8 @@ function parseArgs(args) {
|
|
|
116
144
|
}
|
|
117
145
|
const options = {
|
|
118
146
|
positional: [], patterns: null, top: '.', outFile: '', long: false, dirs: false, count: false,
|
|
119
|
-
sort: 'size', rows: DEFAULT_TOP, hogs: null, hogsRows: DEFAULT_HOGS, hogsPercent: 0,
|
|
120
|
-
|
|
147
|
+
sort: 'size', rows: DEFAULT_TOP, hogs: null, hogsRows: DEFAULT_HOGS, hogsPercent: 0, quiet: false,
|
|
148
|
+
refreshScan: '',
|
|
121
149
|
};
|
|
122
150
|
for (let i = 0; i < args.length; i++) {
|
|
123
151
|
const arg = args[i];
|
|
@@ -183,19 +211,20 @@ function parseArgs(args) {
|
|
|
183
211
|
}
|
|
184
212
|
break;
|
|
185
213
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!/^\d+(\.\d+)?$/.test(n))
|
|
189
|
-
fail(`-age needs a number of minutes, not ${n}`);
|
|
190
|
-
options.cacheMinutes = parseFloat(n);
|
|
191
|
-
break;
|
|
192
|
-
}
|
|
214
|
+
// 2026-11-07: retired with tsfind's own cache; said explicitly rather than "Unknown option".
|
|
215
|
+
case '-age':
|
|
193
216
|
case '-fresh':
|
|
194
|
-
|
|
195
|
-
break;
|
|
217
|
+
fail(`${arg} is gone: the drive's change journal now keeps the saved scan current, so every search is up to date.`);
|
|
196
218
|
case '-q':
|
|
197
219
|
options.quiet = true;
|
|
198
220
|
break;
|
|
221
|
+
case '-refreshscan': {
|
|
222
|
+
const drive = needValue();
|
|
223
|
+
if (!/^[A-Za-z]:$/.test(drive))
|
|
224
|
+
fail(`-refreshscan needs a drive like C:, not ${drive}`);
|
|
225
|
+
options.refreshScan = drive.toUpperCase();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
199
228
|
case '-h':
|
|
200
229
|
case '-help':
|
|
201
230
|
console.log(usage());
|
|
@@ -289,270 +318,272 @@ function clearProgress(options) {
|
|
|
289
318
|
if (!options.quiet && process.stderr.isTTY)
|
|
290
319
|
process.stderr.write(CLEAR_LINE);
|
|
291
320
|
}
|
|
292
|
-
// ──
|
|
321
|
+
// ── Volume: mftwalk's saved scan, kept current by the change journal ──
|
|
293
322
|
function cacheDir() {
|
|
294
|
-
return path.join(os.tmpdir(), 'tsfind');
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Read the MFT of the volume holding `top` and build tsfind's index of it. Throws MftError when
|
|
298
|
-
* the MFT cannot be read (not elevated, not NTFS); the caller decides what to do about that.
|
|
299
|
-
*/
|
|
300
|
-
async function scanToIndex(top, options) {
|
|
301
|
-
const { scanVolume } = await import('@bobfrankston/mftwalk');
|
|
302
|
-
const volume = await scanVolume(top, {
|
|
303
|
-
onProgress: (done, total) => progress(options, `MFT ${done.toLocaleString()} / ${total.toLocaleString()} records`),
|
|
304
|
-
});
|
|
305
|
-
clearProgress(options);
|
|
306
|
-
return indexFromVolume(volume);
|
|
323
|
+
return path.join(os.tmpdir(), 'tsfind', 'mft');
|
|
307
324
|
}
|
|
308
|
-
/**
|
|
309
|
-
function
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
parentOf.push(parent);
|
|
319
|
-
const kids = volume.children(id);
|
|
320
|
-
for (let k = kids.length - 1; k >= 0; k--) {
|
|
321
|
-
// NTFS's own files ($MFT, $Extend, ...) are invisible to readdir; leave them out likewise.
|
|
322
|
-
if (!volume.isMetafile(kids[k]))
|
|
323
|
-
stack.push({ id: kids[k], parent: at });
|
|
324
|
-
}
|
|
325
|
+
/** One line saying where the volume's data came from, for the summary */
|
|
326
|
+
function sourceText(volume) {
|
|
327
|
+
const origin = volume.origin;
|
|
328
|
+
switch (origin.source) {
|
|
329
|
+
case 'cache':
|
|
330
|
+
return origin.changed
|
|
331
|
+
? `saved MFT scan, NOT current: ${origin.reason}`
|
|
332
|
+
: `saved MFT scan: ${origin.reason}`;
|
|
333
|
+
case 'updated':
|
|
334
|
+
return origin.reason;
|
|
325
335
|
}
|
|
326
|
-
|
|
327
|
-
const index = {
|
|
328
|
-
count,
|
|
329
|
-
parent: Int32Array.from(parentOf),
|
|
330
|
-
subtreeEnd: new Int32Array(count),
|
|
331
|
-
flags: new Uint8Array(count),
|
|
332
|
-
size: new Float64Array(count),
|
|
333
|
-
mtime: new Float64Array(count),
|
|
334
|
-
names: new Array(count),
|
|
335
|
-
};
|
|
336
|
-
for (let i = 0; i < count; i++) {
|
|
337
|
-
const id = order[i];
|
|
338
|
-
index.flags[i] = (volume.isDirectory(id) ? IndexFlags.Directory : 0) | (volume.isLink(id) ? IndexFlags.Link : 0);
|
|
339
|
-
index.size[i] = volume.size(id);
|
|
340
|
-
index.mtime[i] = volume.mtime(id).getTime();
|
|
341
|
-
index.names[i] = i === 0 ? '' : volume.name(id);
|
|
342
|
-
index.subtreeEnd[i] = i + 1;
|
|
343
|
-
}
|
|
344
|
-
// In preorder a subtree ends where its last descendant's subtree ends; children come after
|
|
345
|
-
// parents, so one backward pass carries each end up to its parent.
|
|
346
|
-
for (let i = count - 1; i > 0; i--) {
|
|
347
|
-
const p = index.parent[i];
|
|
348
|
-
if (index.subtreeEnd[i] > index.subtreeEnd[p])
|
|
349
|
-
index.subtreeEnd[p] = index.subtreeEnd[i];
|
|
350
|
-
}
|
|
351
|
-
return index;
|
|
336
|
+
return `MFT read in full: ${origin.reason}`;
|
|
352
337
|
}
|
|
353
|
-
/**
|
|
354
|
-
function
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function* cachePieces(index) {
|
|
359
|
-
const header = Buffer.from(JSON.stringify({ count: index.count }));
|
|
360
|
-
const headerLength = Buffer.alloc(4);
|
|
361
|
-
headerLength.writeUInt32LE(header.length);
|
|
362
|
-
yield Buffer.from(CACHE_MAGIC);
|
|
363
|
-
yield headerLength;
|
|
364
|
-
yield header;
|
|
365
|
-
yield bytesOf(index.parent);
|
|
366
|
-
yield bytesOf(index.subtreeEnd);
|
|
367
|
-
yield bytesOf(index.flags);
|
|
368
|
-
yield bytesOf(index.size);
|
|
369
|
-
yield bytesOf(index.mtime);
|
|
370
|
-
// Names end with \0, which no NTFS name contains.
|
|
371
|
-
for (let i = 0; i < index.count; i += NAME_BATCH) {
|
|
372
|
-
yield Buffer.from(index.names.slice(i, i + NAME_BATCH).join('\0') + '\0');
|
|
373
|
-
}
|
|
338
|
+
/** A walk, with the reason it is not the MFT, said now on stderr and again in the summary */
|
|
339
|
+
function walkSource(top, reason, options) {
|
|
340
|
+
if (!options.quiet)
|
|
341
|
+
console.error(styleText(['red'], `Walking ${top} instead of reading the MFT: ${reason}`));
|
|
342
|
+
return { volume: null, text: `directory walk, not the MFT: ${reason}`, stale: false };
|
|
374
343
|
}
|
|
375
344
|
/**
|
|
376
|
-
*
|
|
377
|
-
*
|
|
345
|
+
* Can this process get admin rights without anyone being asked? True only for an administrator
|
|
346
|
+
* (deny-only Administrators in this unelevated token) on a machine whose UAC elevates
|
|
347
|
+
* administrators without prompting. `why` says what stands in the way when it is false.
|
|
378
348
|
*/
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
349
|
+
function silentElevation() {
|
|
350
|
+
const groups = spawnSync('whoami', ['/groups', '/fo', 'csv', '/nh'], { encoding: 'utf8' });
|
|
351
|
+
if (groups.status !== 0) {
|
|
352
|
+
return { possible: false, why: `cannot tell whether elevation would prompt (whoami: ${groups.error?.message ?? groups.stderr.trim()})` };
|
|
353
|
+
}
|
|
354
|
+
if (groups.stdout.includes(Sids.HighIntegrity)) {
|
|
355
|
+
return { possible: false, why: 'Windows refused the MFT even though tsfind is already elevated' };
|
|
356
|
+
}
|
|
357
|
+
if (!groups.stdout.includes(Sids.Administrators)) {
|
|
358
|
+
return { possible: false, why: 'it needs admin rights, and this account is not an administrator' };
|
|
359
|
+
}
|
|
360
|
+
const uac = spawnSync('reg', ['query', UAC_KEY, '/v', 'ConsentPromptBehaviorAdmin'], { encoding: 'utf8' });
|
|
361
|
+
const value = /ConsentPromptBehaviorAdmin\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(uac.stdout ?? '');
|
|
362
|
+
if (!value) {
|
|
363
|
+
return { possible: false, why: `it needs admin rights, and the UAC setting cannot be read (${uac.error?.message ?? (uac.stderr.trim() || 'no ConsentPromptBehaviorAdmin')}), so elevating might raise a prompt` };
|
|
364
|
+
}
|
|
365
|
+
const behavior = parseInt(value[1], 16);
|
|
366
|
+
if (behavior !== UAC_ELEVATE_WITHOUT_PROMPT) {
|
|
367
|
+
return { possible: false, why: `it needs admin rights, and UAC here would ask (ConsentPromptBehaviorAdmin ${behavior}); tsfind will not raise a popup that stalls a script. Run tsfind elevated for MFT speed` };
|
|
368
|
+
}
|
|
369
|
+
return { possible: true, why: '' };
|
|
385
370
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
for await (const chunk of source)
|
|
390
|
-
chunks.push(chunk);
|
|
391
|
-
});
|
|
392
|
-
const data = Buffer.concat(chunks);
|
|
393
|
-
chunks.length = 0;
|
|
394
|
-
if (data.toString('latin1', 0, CACHE_MAGIC.length) !== CACHE_MAGIC)
|
|
395
|
-
throw new Error(`${cachePath} is not a tsfind cache of this version`);
|
|
396
|
-
let at = CACHE_MAGIC.length;
|
|
397
|
-
const headerLength = data.readUInt32LE(at);
|
|
398
|
-
at += 4;
|
|
399
|
-
const { count } = JSON.parse(data.toString('utf8', at, at + headerLength));
|
|
400
|
-
at += headerLength;
|
|
401
|
-
// Copied out rather than viewed in place: the arrays' offsets in the buffer are not aligned.
|
|
402
|
-
const take = (array) => {
|
|
403
|
-
bytesOf(array).set(data.subarray(at, at + array.byteLength));
|
|
404
|
-
at += array.byteLength;
|
|
405
|
-
return array;
|
|
406
|
-
};
|
|
407
|
-
const index = {
|
|
408
|
-
count,
|
|
409
|
-
parent: take(new Int32Array(count)),
|
|
410
|
-
subtreeEnd: take(new Int32Array(count)),
|
|
411
|
-
flags: take(new Uint8Array(count)),
|
|
412
|
-
size: take(new Float64Array(count)),
|
|
413
|
-
mtime: take(new Float64Array(count)),
|
|
414
|
-
names: [],
|
|
415
|
-
};
|
|
416
|
-
while (at < data.length) {
|
|
417
|
-
let end = data.indexOf(0, Math.min(at + NAME_DECODE_BYTES, data.length - 1));
|
|
418
|
-
if (end < 0)
|
|
419
|
-
end = data.length - 1;
|
|
420
|
-
const piece = data.toString('utf8', at, end).split('\0');
|
|
421
|
-
for (const name of piece)
|
|
422
|
-
index.names.push(name);
|
|
423
|
-
at = end + 1;
|
|
424
|
-
}
|
|
425
|
-
if (index.names.length !== count)
|
|
426
|
-
throw new Error(`${cachePath} is damaged: ${index.names.length} names for ${count} entries`);
|
|
427
|
-
return index;
|
|
371
|
+
/** Where the hidden elevated copy leaves its result, since it has no console the caller can see */
|
|
372
|
+
function refreshResultFile(driveName) {
|
|
373
|
+
return path.join(os.tmpdir(), 'tsfind', `refresh-${driveName[0]}.txt`);
|
|
428
374
|
}
|
|
429
375
|
/**
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* That wording is English Windows; on any other the label reads as '' and the cache is named
|
|
434
|
-
* "nolabel-<serial>", which still identifies the drive by its serial.
|
|
376
|
+
* Bring driveName's saved MFT scan up to date from a hidden, elevated copy of tsfind
|
|
377
|
+
* (-refreshscan). Only called when silentElevation() says Windows will not ask; the "runas" verb
|
|
378
|
+
* is Windows' own elevation request. Returns '' on success, else why it failed.
|
|
435
379
|
*/
|
|
436
|
-
function
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
380
|
+
async function refreshElevated(driveName, options) {
|
|
381
|
+
const resultFile = refreshResultFile(driveName);
|
|
382
|
+
await fp.mkdir(path.dirname(resultFile), { recursive: true });
|
|
383
|
+
await fp.rm(resultFile, { force: true });
|
|
384
|
+
const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
385
|
+
const childArgs = `"${import.meta.filename}" -refreshscan ${driveName}`;
|
|
386
|
+
const command = `$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList ${psQuote(childArgs)} ` +
|
|
387
|
+
`-Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode`;
|
|
388
|
+
const started = Date.now();
|
|
389
|
+
const ticker = setInterval(() => progress(options, `Updating ${driveName}'s saved MFT scan, elevated: ${elapsedText(Date.now() - started)}`), 1000);
|
|
390
|
+
const { code, stderr } = await new Promise(resolve => {
|
|
391
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
392
|
+
let errText = '';
|
|
393
|
+
child.stderr.on('data', chunk => errText += chunk);
|
|
394
|
+
child.on('error', error => resolve({ code: -1, stderr: error.message }));
|
|
395
|
+
child.on('close', exitCode => resolve({ code: exitCode, stderr: errText }));
|
|
396
|
+
});
|
|
397
|
+
clearInterval(ticker);
|
|
398
|
+
clearProgress(options);
|
|
399
|
+
let result = '';
|
|
400
|
+
try {
|
|
401
|
+
result = (await fp.readFile(resultFile, 'utf8')).trim();
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
// Not lost: no result file means the elevated copy never ran or died early; the failure
|
|
405
|
+
// below then reports PowerShell's own error and the exit code instead.
|
|
406
|
+
result = '';
|
|
407
|
+
}
|
|
408
|
+
if (code === 0 && result === 'ok')
|
|
409
|
+
return '';
|
|
410
|
+
return result || stderr.trim().split('\n')[0] || `the elevated copy of tsfind exited with code ${code}`;
|
|
442
411
|
}
|
|
443
412
|
/**
|
|
444
|
-
*
|
|
445
|
-
*
|
|
413
|
+
* -refreshscan X: the hidden elevated copy. Brings X:'s saved scan up to date (mftwalk: updated
|
|
414
|
+
* from the journal, or read in full) and leaves 'ok' or the error in refreshResultFile.
|
|
446
415
|
*/
|
|
447
|
-
async function
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
// for; the serial keeps two drives with the same label apart. Either follows the drive to
|
|
451
|
-
// whatever letter it is mounted under. (Briefly the drive letter was used instead of the label.)
|
|
452
|
-
// On Windows fs.stat's dev is the volume serial number, and reading it needs no elevation.
|
|
453
|
-
const serial = (await fp.stat(top)).dev.toString(16).toUpperCase().padStart(8, '0');
|
|
454
|
-
const label = volumeLabel(top);
|
|
455
|
-
const cachePath = path.join(cacheDir(), `${label.replace(/[^\w.-]/g, '_') || 'nolabel'}-${serial}.idx.zst`);
|
|
456
|
-
let cacheAgeMs = -1;
|
|
416
|
+
async function refreshScanAndExit(driveName) {
|
|
417
|
+
const resultFile = refreshResultFile(driveName);
|
|
418
|
+
let result = 'ok';
|
|
457
419
|
try {
|
|
458
|
-
|
|
420
|
+
const { scanVolume } = await import('@bobfrankston/mftwalk');
|
|
421
|
+
await scanVolume(driveName + '\\', { cache: { dir: cacheDir() } });
|
|
459
422
|
}
|
|
460
423
|
catch (error) {
|
|
461
|
-
|
|
462
|
-
if (error.code !== 'ENOENT')
|
|
463
|
-
throw error;
|
|
424
|
+
result = error.message ?? String(error);
|
|
464
425
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
426
|
+
await fp.mkdir(path.dirname(resultFile), { recursive: true });
|
|
427
|
+
await fp.writeFile(resultFile, result);
|
|
428
|
+
process.exit(result === 'ok' ? 0 : 1);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Where to search `top`: the volume from mftwalk's saved scan (see usage: unchanged -> saved scan,
|
|
432
|
+
* changed -> updated from the journal, else a full read). When that needs admin rights tsfind does
|
|
433
|
+
* not have, it elevates only if Windows will not ask; otherwise, and whenever the MFT cannot be
|
|
434
|
+
* used at all, it walks the tree and says why.
|
|
435
|
+
*/
|
|
436
|
+
async function getSource(top, options) {
|
|
437
|
+
if (process.platform !== 'win32')
|
|
438
|
+
return walkSource(top, 'the MFT is read on Windows only', options);
|
|
439
|
+
if (!/^[A-Za-z]:\\/.test(top))
|
|
440
|
+
return walkSource(top, 'not on a drive letter (a network path?)', options);
|
|
441
|
+
const drive = getDriveInfo(top.slice(0, 3));
|
|
442
|
+
if (drive.fileSystem !== 'NTFS')
|
|
443
|
+
return walkSource(top, `${top.slice(0, 2)} is ${drive.fileSystem || 'not a local file system'}, not NTFS`, options);
|
|
444
|
+
const driveName = top.slice(0, 2).toUpperCase();
|
|
445
|
+
const label = drive.volumeName ? `"${drive.volumeName}"` : '(no label)';
|
|
446
|
+
if (!options.quiet)
|
|
447
|
+
console.error(styleText(['blue'], `Reading ${driveName} ${label}: saved MFT scan if current, else from the MFT`));
|
|
448
|
+
const { scanVolume, MftError } = await import('@bobfrankston/mftwalk');
|
|
449
|
+
const scanOptions = {
|
|
450
|
+
onProgress: (done, total) => progress(options, `MFT ${done.toLocaleString()} / ${total.toLocaleString()} records`),
|
|
451
|
+
cache: { dir: cacheDir() },
|
|
452
|
+
log: msg => { if (!options.quiet)
|
|
453
|
+
console.error(styleText(['red'], msg)); },
|
|
476
454
|
};
|
|
477
|
-
if (cacheAgeMs >= 0 && cacheAgeMs < options.cacheMinutes * 60000)
|
|
478
|
-
return load('');
|
|
479
|
-
announce(cacheAgeMs < 0
|
|
480
|
-
? `Scanning the MFT of ${volumeName}; no cached scan yet`
|
|
481
|
-
: `Scanning the MFT of ${volumeName}; the cached scan is ${elapsedText(cacheAgeMs)} old` +
|
|
482
|
-
(options.cacheMinutes === 0 ? ' (-fresh)' : `, older than -age ${options.cacheMinutes} minutes`));
|
|
483
|
-
const { MftError } = await import('@bobfrankston/mftwalk');
|
|
484
|
-
const scannedAt = new Date();
|
|
485
|
-
let index;
|
|
486
455
|
try {
|
|
487
|
-
|
|
456
|
+
const volume = await scanVolume(top, scanOptions);
|
|
457
|
+
clearProgress(options);
|
|
458
|
+
return { volume, text: sourceText(volume), stale: volume.origin.changed };
|
|
488
459
|
}
|
|
489
460
|
catch (error) {
|
|
490
461
|
clearProgress(options);
|
|
491
462
|
if (!(error instanceof MftError))
|
|
492
463
|
throw error;
|
|
493
|
-
// Not swallowed:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
return load(`could not rescan (${error.message})`);
|
|
497
|
-
fail(`Cannot read the MFT: ${error.message}.\ntsfind reads the NTFS MFT (Windows, NTFS, elevated prompt); to walk a tree instead, use tswalk.`);
|
|
464
|
+
// Not swallowed: the reason goes to stderr and into the summary, and the walk takes over.
|
|
465
|
+
if (error.code !== 'EPERM')
|
|
466
|
+
return walkSource(top, `cannot read the MFT of ${driveName}: ${error.message}`, options);
|
|
498
467
|
}
|
|
499
|
-
|
|
468
|
+
const elevation = silentElevation();
|
|
469
|
+
if (!elevation.possible)
|
|
470
|
+
return walkSource(top, elevation.why, options);
|
|
471
|
+
if (!options.quiet)
|
|
472
|
+
console.error(styleText(['blue'], `Updating ${driveName}'s saved MFT scan elevated (UAC here elevates administrators without asking)`));
|
|
473
|
+
const refreshError = await refreshElevated(driveName, options);
|
|
474
|
+
if (refreshError)
|
|
475
|
+
return walkSource(top, `the elevated update of ${driveName}'s saved scan failed: ${refreshError}`, options);
|
|
476
|
+
const updatedAt = new Date();
|
|
500
477
|
try {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
await
|
|
478
|
+
// trust: the saved scan was brought up to date a moment ago; changes since then (a busy
|
|
479
|
+
// C: always has some) would need admin rights again, so they are named, not read.
|
|
480
|
+
const volume = await scanVolume(top, { ...scanOptions, cache: { dir: cacheDir(), trust: true } });
|
|
504
481
|
clearProgress(options);
|
|
482
|
+
const text = volume.origin.changed
|
|
483
|
+
? `saved MFT scan, brought up to date elevated at ${updatedAt.toLocaleTimeString()} (changes after that are not included)`
|
|
484
|
+
: sourceText(volume);
|
|
485
|
+
return { volume, text, stale: false };
|
|
505
486
|
}
|
|
506
487
|
catch (error) {
|
|
507
|
-
// Not lost: the search goes ahead on the scan in memory, and the summary says the cache was not saved.
|
|
508
488
|
clearProgress(options);
|
|
509
|
-
|
|
489
|
+
if (!(error instanceof MftError))
|
|
490
|
+
throw error;
|
|
491
|
+
return walkSource(top, `cannot load ${driveName}'s saved scan after updating it: ${error.message}`, options);
|
|
510
492
|
}
|
|
511
|
-
return { index, source: { scanned: true, ageMs: Date.now() - scannedAt.getTime(), cachePath, note } };
|
|
512
493
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
let
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
494
|
+
// ── Walk: the classic way, when the MFT cannot be used ────────────
|
|
495
|
+
/** Run at most `max` jobs at once; the rest wait their turn */
|
|
496
|
+
function makeLimiter(max) {
|
|
497
|
+
let running = 0;
|
|
498
|
+
const waiting = [];
|
|
499
|
+
return async (job) => {
|
|
500
|
+
if (running >= max)
|
|
501
|
+
await new Promise(resolve => waiting.push(resolve));
|
|
502
|
+
else
|
|
503
|
+
running++;
|
|
504
|
+
try {
|
|
505
|
+
return await job();
|
|
526
506
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
507
|
+
finally {
|
|
508
|
+
// Hand the slot straight to the next waiter, or give it back.
|
|
509
|
+
const next = waiting.shift();
|
|
510
|
+
if (next)
|
|
511
|
+
next();
|
|
512
|
+
else
|
|
513
|
+
running--;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
532
516
|
}
|
|
533
|
-
/**
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
517
|
+
/**
|
|
518
|
+
* Hand each match beneath `top` to onFound, reading WALK_CONCURRENCY directories at a time.
|
|
519
|
+
* Links and junctions are reported like directories but never entered, as in searchVolume.
|
|
520
|
+
* Returns the directories read and those that could not be (or matches that could not be stat'd).
|
|
521
|
+
*/
|
|
522
|
+
async function walkTree(top, regexes, options, onFound) {
|
|
523
|
+
const limit = makeLimiter(WALK_CONCURRENCY);
|
|
524
|
+
const errors = [];
|
|
525
|
+
let dirs = 0;
|
|
526
|
+
const visit = async (dir) => {
|
|
527
|
+
let entries;
|
|
528
|
+
try {
|
|
529
|
+
entries = await limit(() => fp.readdir(dir, { withFileTypes: true }));
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
// Not lost: listed in the summary (count, and the first few by name).
|
|
533
|
+
errors.push(`${dir}: ${error.code ?? error.message}`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
dirs++;
|
|
537
|
+
progress(options, `Walked ${dirs.toLocaleString()} directories`);
|
|
538
|
+
const subdirs = [];
|
|
539
|
+
const stats = [];
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
const full = path.join(dir, entry.name);
|
|
542
|
+
const isLink = entry.isSymbolicLink();
|
|
543
|
+
const isDir = entry.isDirectory() || isLink;
|
|
544
|
+
if (isDir && !isLink)
|
|
545
|
+
subdirs.push(full);
|
|
546
|
+
if (!regexes.some(rx => rx.test(entry.name)))
|
|
547
|
+
continue;
|
|
548
|
+
if (isDir && !options.dirs)
|
|
549
|
+
continue;
|
|
550
|
+
stats.push(limit(() => fp.lstat(full)).then(st => onFound({ path: full, name: entry.name, size: isDir ? 0 : st.size, mtime: st.mtime, isDir }),
|
|
551
|
+
// Not lost: a match that vanished or cannot be read is listed with the summary.
|
|
552
|
+
(error) => { errors.push(`${full}: ${error.code ?? error.message}`); }));
|
|
553
|
+
}
|
|
554
|
+
await Promise.all([...stats, ...subdirs.map(visit)]);
|
|
555
|
+
};
|
|
556
|
+
await visit(top);
|
|
557
|
+
clearProgress(options);
|
|
558
|
+
return { dirs, errors };
|
|
539
559
|
}
|
|
540
|
-
/**
|
|
541
|
-
|
|
560
|
+
/**
|
|
561
|
+
* Hand each match beneath `start` to onFound; returns the number of directories searched.
|
|
562
|
+
* Depth-first through mftwalk's accessors. Links have no children in the volume, so their
|
|
563
|
+
* contents are never searched; NTFS's own files ($MFT, $Extend ...) are skipped, as readdir does.
|
|
564
|
+
*/
|
|
565
|
+
function searchVolume(volume, start, regexes, options, onFound) {
|
|
542
566
|
let dirs = 1;
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
567
|
+
const stack = [start];
|
|
568
|
+
while (stack.length) {
|
|
569
|
+
const kids = volume.children(stack.pop());
|
|
570
|
+
for (let k = kids.length - 1; k >= 0; k--) {
|
|
571
|
+
const id = kids[k];
|
|
572
|
+
if (volume.isMetafile(id))
|
|
573
|
+
continue;
|
|
574
|
+
const isLink = volume.isLink(id);
|
|
575
|
+
const isDir = volume.isDirectory(id) || isLink;
|
|
576
|
+
if (isDir && !isLink) {
|
|
577
|
+
dirs++;
|
|
578
|
+
stack.push(id);
|
|
579
|
+
}
|
|
580
|
+
const name = volume.name(id);
|
|
581
|
+
if (!regexes.some(rx => rx.test(name)))
|
|
582
|
+
continue;
|
|
583
|
+
if (isDir && !options.dirs)
|
|
584
|
+
continue;
|
|
585
|
+
onFound({ path: volume.path(id), name, size: isDir ? 0 : volume.size(id), mtime: volume.mtime(id), isDir });
|
|
586
|
+
}
|
|
556
587
|
}
|
|
557
588
|
return dirs;
|
|
558
589
|
}
|
|
@@ -648,8 +679,8 @@ function hogsLines(table, totalBytes, options) {
|
|
|
648
679
|
}
|
|
649
680
|
async function main() {
|
|
650
681
|
const options = parseArgs(process.argv.slice(2));
|
|
651
|
-
if (
|
|
652
|
-
|
|
682
|
+
if (options.refreshScan)
|
|
683
|
+
await refreshScanAndExit(options.refreshScan);
|
|
653
684
|
await resolvePositional(options);
|
|
654
685
|
const regexes = compilePatterns(options.patterns);
|
|
655
686
|
let top;
|
|
@@ -662,10 +693,6 @@ async function main() {
|
|
|
662
693
|
catch (error) {
|
|
663
694
|
fail(`Cannot search ${options.top}: ${error.code ?? error.message}`);
|
|
664
695
|
}
|
|
665
|
-
const drive = /^([A-Za-z]):\\/.exec(top);
|
|
666
|
-
if (!drive)
|
|
667
|
-
fail(`${top} is not on a drive letter; tsfind reads a local NTFS volume's MFT. For a network path, use tswalk.`);
|
|
668
|
-
const rootPath = `${drive[1].toUpperCase()}:\\`;
|
|
669
696
|
// Open -o's file before searching, so a bad path fails now rather than after a long scan.
|
|
670
697
|
let outHandle;
|
|
671
698
|
if (options.outFile) {
|
|
@@ -683,12 +710,10 @@ async function main() {
|
|
|
683
710
|
throw error;
|
|
684
711
|
});
|
|
685
712
|
const start = Date.now();
|
|
686
|
-
|
|
687
|
-
const entry =
|
|
688
|
-
if (entry < 0)
|
|
689
|
-
|
|
690
|
-
`${source.scanned ? '' : '; -fresh rescans'}`);
|
|
691
|
-
}
|
|
713
|
+
let source = await getSource(top, options);
|
|
714
|
+
const entry = source.volume ? source.volume.lookup(top) : -1;
|
|
715
|
+
if (source.volume && entry < 0)
|
|
716
|
+
source = walkSource(top, `${top} is not in the MFT data (${source.text})`, options);
|
|
692
717
|
const found = [];
|
|
693
718
|
const byName = new Map();
|
|
694
719
|
const byExt = new Map();
|
|
@@ -705,7 +730,14 @@ async function main() {
|
|
|
705
730
|
addTally(byName, f.name, f.size);
|
|
706
731
|
addTally(byExt, f.isDir ? '(directory)' : path.extname(f.name) || '(none)', f.size);
|
|
707
732
|
};
|
|
708
|
-
|
|
733
|
+
let dirs;
|
|
734
|
+
let walkErrors = [];
|
|
735
|
+
if (source.volume) {
|
|
736
|
+
dirs = searchVolume(source.volume, entry, regexes, options, onFound);
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
({ dirs, errors: walkErrors } = await walkTree(top, regexes, options, onFound));
|
|
740
|
+
}
|
|
709
741
|
const elapsed = Date.now() - start;
|
|
710
742
|
let lines;
|
|
711
743
|
if (options.hogs) {
|
|
@@ -729,11 +761,28 @@ async function main() {
|
|
|
729
761
|
}
|
|
730
762
|
if (!options.quiet) {
|
|
731
763
|
const what = options.dirs ? 'matches' : 'files';
|
|
732
|
-
const from = source.scanned
|
|
733
|
-
? `MFT scanned${source.note ? `, ${source.note}` : ''}`
|
|
734
|
-
: `cached scan ${elapsedText(source.ageMs)} old${source.note ? `, ${source.note}` : ''}`;
|
|
735
764
|
console.error(`${matchCount.toLocaleString()} ${what}, ${formatBytes(matchBytes)}; ${dirs.toLocaleString()} directories searched; ` +
|
|
736
|
-
`${elapsedText(elapsed)}
|
|
765
|
+
`${elapsedText(elapsed)}${options.outFile ? `; written to ${path.resolve(options.outFile)}` : ''}`);
|
|
766
|
+
console.error(styleText([source.stale ? 'red' : 'blue'], `From the ${source.text}.`));
|
|
767
|
+
}
|
|
768
|
+
else if (source.stale) {
|
|
769
|
+
console.error(styleText(['red'], `From the ${source.text}.`));
|
|
770
|
+
}
|
|
771
|
+
// Shown even with -q: an incomplete walk is a wrong answer unless it says so.
|
|
772
|
+
if (walkErrors.length) {
|
|
773
|
+
console.error(styleText(['red'], `${walkErrors.length.toLocaleString()} could not be read, so the results may be incomplete:`));
|
|
774
|
+
for (const line of walkErrors.slice(0, WALK_ERRORS_SHOWN))
|
|
775
|
+
console.error(styleText(['red'], ` ${line}`));
|
|
776
|
+
if (walkErrors.length > WALK_ERRORS_SHOWN)
|
|
777
|
+
console.error(styleText(['red'], ` ... and ${(walkErrors.length - WALK_ERRORS_SHOWN).toLocaleString()} more`));
|
|
778
|
+
}
|
|
779
|
+
// 2026-11-07: as in tswalk, a drive without a change journal is worth a visible warning,
|
|
780
|
+
// since every run on it pays for a full MFT read.
|
|
781
|
+
const volume = source.volume;
|
|
782
|
+
if (volume && volume.origin.source === 'disk' && !volume.origin.journal && !options.quiet) {
|
|
783
|
+
const driveName = top.slice(0, 2).toUpperCase();
|
|
784
|
+
console.error(styleText(['red'], `NOTE: ${driveName} has no change journal, so the saved scan cannot be checked and EVERY run reads the whole MFT.\n` +
|
|
785
|
+
` Fix, once, elevated: fsutil usn createjournal ${SUGGESTED_JOURNAL} ${driveName}`));
|
|
737
786
|
}
|
|
738
787
|
}
|
|
739
788
|
if (import.meta.main) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/tsfind",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Find files by name in a directory tree; list them for editing, or count them by name and extension. Optional NTFS MFT reading for big drives.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
"homepage": "https://github.com/BobFrankston/tsfind#readme",
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@bobfrankston/
|
|
35
|
+
"@bobfrankston/dirutil": "^1.6.5",
|
|
36
|
+
"@bobfrankston/mftwalk": "^0.1.4"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/node": "^25.3.0"
|
|
@@ -40,12 +41,18 @@
|
|
|
40
41
|
"publishConfig": {
|
|
41
42
|
"access": "public"
|
|
42
43
|
},
|
|
44
|
+
"allowScripts": {
|
|
45
|
+
"file:../../../../projects/NodeJS/dirutil": true,
|
|
46
|
+
"koffi": true
|
|
47
|
+
},
|
|
43
48
|
".dependencies": {
|
|
49
|
+
"@bobfrankston/dirutil": "file:../../projects/NodeJS/dirutil",
|
|
44
50
|
"@bobfrankston/mftwalk": "file:../mftwalk"
|
|
45
51
|
},
|
|
46
52
|
".transformedSnapshot": {
|
|
47
53
|
"dependencies": {
|
|
48
|
-
"@bobfrankston/
|
|
54
|
+
"@bobfrankston/dirutil": "^1.6.5",
|
|
55
|
+
"@bobfrankston/mftwalk": "^0.1.4"
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
}
|