gnutella 1.0.0
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/CLI.md +189 -0
- package/DEVELOPER.md +193 -0
- package/LICENSE +674 -0
- package/QUICKSTART.md +133 -0
- package/README.md +74 -0
- package/bin/gnutella.ts +15 -0
- package/gnutella.json.example +18 -0
- package/package.json +72 -0
- package/src/cli.ts +692 -0
- package/src/cli_shared.ts +359 -0
- package/src/const.ts +138 -0
- package/src/gwebcache/bootstrap.ts +491 -0
- package/src/gwebcache/response.ts +391 -0
- package/src/gwebcache/shared.ts +116 -0
- package/src/gwebcache/types.ts +187 -0
- package/src/gwebcache_client.ts +13 -0
- package/src/protocol/browse_host.ts +552 -0
- package/src/protocol/client_blocking.ts +29 -0
- package/src/protocol/codec.ts +715 -0
- package/src/protocol/content_urn.ts +170 -0
- package/src/protocol/core_utils.ts +43 -0
- package/src/protocol/file_server.ts +245 -0
- package/src/protocol/ggep.ts +168 -0
- package/src/protocol/handshake.ts +199 -0
- package/src/protocol/http_download_reader.ts +112 -0
- package/src/protocol/magnet.ts +176 -0
- package/src/protocol/node.ts +416 -0
- package/src/protocol/node_handshake.ts +992 -0
- package/src/protocol/node_lifecycle.ts +210 -0
- package/src/protocol/node_protocol_runtime.ts +949 -0
- package/src/protocol/node_qrp_runtime.ts +97 -0
- package/src/protocol/node_query_routing.ts +208 -0
- package/src/protocol/node_state.ts +745 -0
- package/src/protocol/node_tls.ts +257 -0
- package/src/protocol/node_topology.ts +141 -0
- package/src/protocol/node_transfer.ts +455 -0
- package/src/protocol/node_types.ts +106 -0
- package/src/protocol/peer_state.ts +675 -0
- package/src/protocol/qrp.ts +549 -0
- package/src/protocol/query_search.ts +29 -0
- package/src/protocol/share_index.ts +131 -0
- package/src/protocol/share_library.ts +246 -0
- package/src/protocol.ts +36 -0
- package/src/shared.ts +236 -0
- package/src/types.ts +452 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { ensureDir, ts, unique, walkFilesIter } from "../shared";
|
|
5
|
+
import type { ShareFile } from "../types";
|
|
6
|
+
import type { GnutellaServent } from "./node";
|
|
7
|
+
import { sha1File, sha1ToUrn, tokenizeKeywords } from "./qrp";
|
|
8
|
+
import {
|
|
9
|
+
loadShareIndex,
|
|
10
|
+
writeShareIndex,
|
|
11
|
+
type ShareIndexEntry,
|
|
12
|
+
} from "./share_index";
|
|
13
|
+
|
|
14
|
+
function shareKeywords(abs: string, rel: string): string[] {
|
|
15
|
+
return unique([
|
|
16
|
+
...tokenizeKeywords(path.basename(abs)),
|
|
17
|
+
...tokenizeKeywords(rel),
|
|
18
|
+
...tokenizeKeywords(path.parse(abs).name),
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cachedShareHash(
|
|
23
|
+
entry: ShareIndexEntry | undefined,
|
|
24
|
+
size: number,
|
|
25
|
+
mtimeMs: number,
|
|
26
|
+
):
|
|
27
|
+
| {
|
|
28
|
+
sha1: Buffer;
|
|
29
|
+
sha1Urn: string;
|
|
30
|
+
sha1Hex: string;
|
|
31
|
+
}
|
|
32
|
+
| undefined {
|
|
33
|
+
if (!entry || entry.size !== size || entry.mtimeMs !== mtimeMs)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!entry.sha1Hex || !entry.sha1Urn) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
sha1: Buffer.from(entry.sha1Hex, "hex"),
|
|
38
|
+
sha1Urn: entry.sha1Urn,
|
|
39
|
+
sha1Hex: entry.sha1Hex,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rebuildShareState(
|
|
44
|
+
node: GnutellaServent,
|
|
45
|
+
shares: ShareFile[],
|
|
46
|
+
): void {
|
|
47
|
+
node.shares = shares;
|
|
48
|
+
node.sharesByIndex = new Map(
|
|
49
|
+
shares.map((share) => [share.index, share]),
|
|
50
|
+
);
|
|
51
|
+
node.sharesByUrn = new Map(
|
|
52
|
+
shares.flatMap((share) =>
|
|
53
|
+
share.sha1Urn ? [[share.sha1Urn.toLowerCase(), share]] : [],
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
node.qrpTable.rebuildFromShares(shares);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function persistShareIndex(
|
|
60
|
+
node: GnutellaServent,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
await writeShareIndex(node.config().dataDir, node.shareIndexEntries);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function persistShareIndexLater(node: GnutellaServent): void {
|
|
66
|
+
void persistShareIndex(node).catch((e) =>
|
|
67
|
+
node.emitMaintenanceError("SAVE", e),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function loadShareIndexOnce(node: GnutellaServent): Promise<void> {
|
|
72
|
+
if (node.shareIndexLoaded) return;
|
|
73
|
+
node.shareIndexEntries = await loadShareIndex(node.config().dataDir);
|
|
74
|
+
node.shareIndexLoaded = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function staleShareHashPass(
|
|
78
|
+
node: GnutellaServent,
|
|
79
|
+
generation: number,
|
|
80
|
+
): boolean {
|
|
81
|
+
return node.stopped || generation !== node.shareRefreshGeneration;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function shareMatchesIndexEntry(
|
|
85
|
+
share: Pick<ShareFile, "size" | "mtimeMs">,
|
|
86
|
+
entry: ShareIndexEntry | undefined,
|
|
87
|
+
): entry is ShareIndexEntry {
|
|
88
|
+
return (
|
|
89
|
+
!!entry && entry.size === share.size && entry.mtimeMs === share.mtimeMs
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fileStillMatchesShare(
|
|
94
|
+
share: Pick<ShareFile, "abs" | "size" | "mtimeMs">,
|
|
95
|
+
): Promise<boolean> {
|
|
96
|
+
const st = await fsp.stat(share.abs);
|
|
97
|
+
return (
|
|
98
|
+
st.isFile() && st.size === share.size && st.mtimeMs === share.mtimeMs
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function currentIndexedShare(
|
|
103
|
+
node: GnutellaServent,
|
|
104
|
+
pendingShare: ShareFile,
|
|
105
|
+
):
|
|
106
|
+
| {
|
|
107
|
+
share: ShareFile;
|
|
108
|
+
entry: ShareIndexEntry;
|
|
109
|
+
}
|
|
110
|
+
| undefined {
|
|
111
|
+
const share = node.sharesByIndex.get(pendingShare.index);
|
|
112
|
+
const entry = node.shareIndexEntries.get(pendingShare.rel);
|
|
113
|
+
if (!share || share.rel !== pendingShare.rel) return undefined;
|
|
114
|
+
if (!shareMatchesIndexEntry(pendingShare, entry)) return undefined;
|
|
115
|
+
return { share, entry };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function applyShareHash(
|
|
119
|
+
node: GnutellaServent,
|
|
120
|
+
share: ShareFile,
|
|
121
|
+
entry: ShareIndexEntry,
|
|
122
|
+
sha1: Buffer,
|
|
123
|
+
): void {
|
|
124
|
+
const sha1Urn = sha1ToUrn(sha1);
|
|
125
|
+
share.sha1 = sha1;
|
|
126
|
+
share.sha1Urn = sha1Urn;
|
|
127
|
+
entry.sha1Hex = sha1.toString("hex");
|
|
128
|
+
entry.sha1Urn = sha1Urn;
|
|
129
|
+
node.sharesByUrn.set(sha1Urn.toLowerCase(), share);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function hashPendingShare(
|
|
133
|
+
node: GnutellaServent,
|
|
134
|
+
pendingShare: ShareFile,
|
|
135
|
+
generation: number,
|
|
136
|
+
): Promise<"continue" | "stop"> {
|
|
137
|
+
if (staleShareHashPass(node, generation)) return "stop";
|
|
138
|
+
const entry = node.shareIndexEntries.get(pendingShare.rel);
|
|
139
|
+
if (!shareMatchesIndexEntry(pendingShare, entry)) return "continue";
|
|
140
|
+
try {
|
|
141
|
+
if (!(await fileStillMatchesShare(pendingShare))) return "continue";
|
|
142
|
+
const sha1 = await sha1File(pendingShare.abs);
|
|
143
|
+
if (staleShareHashPass(node, generation)) return "stop";
|
|
144
|
+
const current = currentIndexedShare(node, pendingShare);
|
|
145
|
+
if (!current) return "continue";
|
|
146
|
+
applyShareHash(node, current.share, current.entry, sha1);
|
|
147
|
+
} catch {
|
|
148
|
+
// Files can be deleted or rewritten while hashing. The next rescan fixes
|
|
149
|
+
// metadata and retries the hash when appropriate.
|
|
150
|
+
}
|
|
151
|
+
return "continue";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function hashPendingShares(
|
|
155
|
+
node: GnutellaServent,
|
|
156
|
+
pendingShares: ShareFile[],
|
|
157
|
+
generation: number,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
for (const pendingShare of pendingShares) {
|
|
160
|
+
if (
|
|
161
|
+
(await hashPendingShare(node, pendingShare, generation)) === "stop"
|
|
162
|
+
)
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (pendingShares.length === 0 || staleShareHashPass(node, generation)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await persistShareIndex(node);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
node.emitMaintenanceError("SAVE", e);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function refreshShares(node: GnutellaServent): Promise<void> {
|
|
176
|
+
await loadShareIndexOnce(node);
|
|
177
|
+
await ensureDir(node.config().downloadsDir);
|
|
178
|
+
const downloadsDir = node.config().downloadsDir;
|
|
179
|
+
const shares: ShareFile[] = [];
|
|
180
|
+
const nextShareIndexEntries = new Map<string, ShareIndexEntry>();
|
|
181
|
+
const pendingHashes: ShareFile[] = [];
|
|
182
|
+
const generation = node.shareRefreshGeneration + 1;
|
|
183
|
+
node.shareRefreshGeneration = generation;
|
|
184
|
+
let idx = 1;
|
|
185
|
+
for await (const abs of walkFilesIter(downloadsDir)) {
|
|
186
|
+
const st = await fsp.stat(abs);
|
|
187
|
+
const rel = path.relative(downloadsDir, abs).replace(/\\/g, "/");
|
|
188
|
+
const share: ShareFile = {
|
|
189
|
+
index: idx++,
|
|
190
|
+
name: path.basename(abs),
|
|
191
|
+
rel,
|
|
192
|
+
abs,
|
|
193
|
+
size: st.size,
|
|
194
|
+
mtimeMs: st.mtimeMs,
|
|
195
|
+
keywords: shareKeywords(abs, rel),
|
|
196
|
+
};
|
|
197
|
+
const entry: ShareIndexEntry = {
|
|
198
|
+
rel,
|
|
199
|
+
size: st.size,
|
|
200
|
+
mtimeMs: st.mtimeMs,
|
|
201
|
+
};
|
|
202
|
+
const cached = cachedShareHash(
|
|
203
|
+
node.shareIndexEntries.get(rel),
|
|
204
|
+
share.size,
|
|
205
|
+
share.mtimeMs,
|
|
206
|
+
);
|
|
207
|
+
if (cached) {
|
|
208
|
+
share.sha1 = cached.sha1;
|
|
209
|
+
share.sha1Urn = cached.sha1Urn;
|
|
210
|
+
entry.sha1Hex = cached.sha1Hex;
|
|
211
|
+
entry.sha1Urn = cached.sha1Urn;
|
|
212
|
+
} else {
|
|
213
|
+
pendingHashes.push(share);
|
|
214
|
+
}
|
|
215
|
+
shares.push(share);
|
|
216
|
+
nextShareIndexEntries.set(rel, entry);
|
|
217
|
+
}
|
|
218
|
+
node.shareIndexEntries = nextShareIndexEntries;
|
|
219
|
+
rebuildShareState(node, shares);
|
|
220
|
+
node.emitEvent({
|
|
221
|
+
type: "SHARES_REFRESHED",
|
|
222
|
+
at: ts(),
|
|
223
|
+
count: node.shares.length,
|
|
224
|
+
totalKBytes: node.totalSharedKBytes(),
|
|
225
|
+
});
|
|
226
|
+
if (node.config().enableQrp) {
|
|
227
|
+
for (const peer of node.peers.values()) {
|
|
228
|
+
void node.sendQrpTable(peer).catch(() => void 0);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
persistShareIndexLater(node);
|
|
232
|
+
if (pendingHashes.length === 0) {
|
|
233
|
+
node.shareHashTask = null;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const task = hashPendingShares(node, pendingHashes, generation)
|
|
237
|
+
.catch((e) => node.emitMaintenanceError("SHARE_RESCAN", e))
|
|
238
|
+
.finally(() => {
|
|
239
|
+
if (node.shareHashTask === task) node.shareHashTask = null;
|
|
240
|
+
});
|
|
241
|
+
node.shareHashTask = task;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function totalSharedKBytes(node: GnutellaServent): number {
|
|
245
|
+
return Math.ceil(node.shares.reduce((a, x) => a + x.size, 0) / 1024);
|
|
246
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { GnutellaServent } from "./protocol/node";
|
|
2
|
+
export {
|
|
3
|
+
buildGetRequest,
|
|
4
|
+
buildHeader,
|
|
5
|
+
buildUriResRequest,
|
|
6
|
+
encodeBye,
|
|
7
|
+
encodePong,
|
|
8
|
+
encodePush,
|
|
9
|
+
encodeQuery,
|
|
10
|
+
parseBye,
|
|
11
|
+
parseHeader,
|
|
12
|
+
parsePong,
|
|
13
|
+
parsePush,
|
|
14
|
+
parseQuery,
|
|
15
|
+
parseQueryHit,
|
|
16
|
+
parseRouteTableUpdate,
|
|
17
|
+
} from "./protocol/codec";
|
|
18
|
+
export { initialRemoteQrpState, QrpTable } from "./protocol/qrp";
|
|
19
|
+
export { buildMagnetUri, parseMagnetUri } from "./protocol/magnet";
|
|
20
|
+
export { defaultDoc, loadDoc, writeDoc } from "./protocol/peer_state";
|
|
21
|
+
export type { Peer } from "./protocol/node_types";
|
|
22
|
+
export type {
|
|
23
|
+
BlockIpResult,
|
|
24
|
+
ConfigDoc,
|
|
25
|
+
ConnectPeerResult,
|
|
26
|
+
DownloadRecord,
|
|
27
|
+
GnutellaEvent,
|
|
28
|
+
GnutellaEventListener,
|
|
29
|
+
GnutellaServentOptions,
|
|
30
|
+
NodeStatus,
|
|
31
|
+
PeerInfo,
|
|
32
|
+
RuntimeConfig,
|
|
33
|
+
SearchHit,
|
|
34
|
+
ShareFile,
|
|
35
|
+
UnblockIpResult,
|
|
36
|
+
} from "./types";
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export function ts(): string {
|
|
6
|
+
return new Date().toISOString();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function errMsg(e: unknown): string {
|
|
10
|
+
return e instanceof Error ? e.message : String(e);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toBuffer(chunk: string | Buffer): Buffer {
|
|
14
|
+
return typeof chunk === "string" ? Buffer.from(chunk, "latin1") : chunk;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sleep(ms: number): Promise<void> {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function unique<T>(xs: T[]): T[] {
|
|
22
|
+
return [...new Set(xs)];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizePeer(host: string, port: number): string {
|
|
26
|
+
return `${normalizeIpv4(host) || host.trim()}:${port}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parsePeer(
|
|
30
|
+
s: string,
|
|
31
|
+
): { host: string; port: number } | null {
|
|
32
|
+
const t = String(s || "").trim();
|
|
33
|
+
const m = /^(\d+\.\d+\.\d+\.\d+):(\d+)$/.exec(t);
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
const host = normalizeIpv4(m[1]);
|
|
36
|
+
if (!host) return null;
|
|
37
|
+
const port = Number(m[2]);
|
|
38
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
39
|
+
return { host, port };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeIpv4(
|
|
43
|
+
host: string | undefined,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
if (!host) return undefined;
|
|
46
|
+
const trimmed = host.trim();
|
|
47
|
+
if (net.isIPv4(trimmed)) return trimmed;
|
|
48
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(trimmed);
|
|
49
|
+
if (mapped && net.isIPv4(mapped[1])) return mapped[1];
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ipv4Octets(host: string): number[] | null {
|
|
54
|
+
const normalized = normalizeIpv4(host);
|
|
55
|
+
if (!normalized) return null;
|
|
56
|
+
return normalized.split(".").map((part) => Number(part));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Ipv4Matcher = (parts: readonly number[]) => boolean;
|
|
60
|
+
|
|
61
|
+
const NON_ROUTABLE_IPV4_MATCHERS: Ipv4Matcher[] = [
|
|
62
|
+
([a]) => a === 0 || a >= 224,
|
|
63
|
+
([a]) => a === 10 || a === 127,
|
|
64
|
+
([a, b]) => a === 100 && b >= 64 && b <= 127,
|
|
65
|
+
([a, b]) => a === 169 && b === 254,
|
|
66
|
+
([a, b]) => a === 172 && b >= 16 && b <= 31,
|
|
67
|
+
([a, b]) => a === 192 && b === 168,
|
|
68
|
+
([a, b, c]) => a === 192 && b === 0 && c === 0,
|
|
69
|
+
([a, b, c]) => a === 192 && b === 0 && c === 2,
|
|
70
|
+
([a, b]) => a === 198 && (b === 18 || b === 19),
|
|
71
|
+
([a, b, c]) => a === 198 && b === 51 && c === 100,
|
|
72
|
+
([a, b, c]) => a === 203 && b === 0 && c === 113,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function isUnspecifiedIpv4(host: string): boolean {
|
|
76
|
+
return normalizeIpv4(host) === "0.0.0.0";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isRoutableIpv4(host: string): boolean {
|
|
80
|
+
const parts = ipv4Octets(host);
|
|
81
|
+
return (
|
|
82
|
+
!!parts && !NON_ROUTABLE_IPV4_MATCHERS.some((match) => match(parts))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function ipv4Subnet16(host: string): string | undefined {
|
|
87
|
+
const parts = ipv4Octets(host);
|
|
88
|
+
if (!parts) return undefined;
|
|
89
|
+
return `${parts[0]}.${parts[1]}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function fileExists(p: string): Promise<boolean> {
|
|
93
|
+
try {
|
|
94
|
+
await fsp.access(p);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function ensureDir(p: string): Promise<void> {
|
|
102
|
+
try {
|
|
103
|
+
await fsp.mkdir(p, { recursive: true });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
// Bun's compiled Windows runtime can report EEXIST for an already-existing
|
|
106
|
+
// directory on first-run config creation.
|
|
107
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
108
|
+
const st = await fsp.stat(p).catch(() => null);
|
|
109
|
+
if (!st?.isDirectory()) throw e;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function* walkFilesRecursive(
|
|
114
|
+
dir: string,
|
|
115
|
+
): AsyncGenerator<string, void, void> {
|
|
116
|
+
const ents = await fsp.readdir(dir, { withFileTypes: true });
|
|
117
|
+
ents.sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
+
for (const ent of ents) {
|
|
119
|
+
const abs = path.join(dir, ent.name);
|
|
120
|
+
if (ent.isDirectory()) yield* walkFilesRecursive(abs);
|
|
121
|
+
else if (ent.isFile()) yield abs;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function* walkFilesIter(
|
|
126
|
+
root: string,
|
|
127
|
+
): AsyncGenerator<string, void, void> {
|
|
128
|
+
if (!(await fileExists(root))) return;
|
|
129
|
+
yield* walkFilesRecursive(root);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function ipToBytesBE(ip: string): Buffer {
|
|
133
|
+
const parts = ip.split(".").map((x) => Number(x));
|
|
134
|
+
if (
|
|
135
|
+
parts.length !== 4 ||
|
|
136
|
+
parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)
|
|
137
|
+
) {
|
|
138
|
+
throw new Error(`invalid IPv4 address: ${ip}`);
|
|
139
|
+
}
|
|
140
|
+
return Buffer.from(parts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function ipToBytesLE(ip: string): Buffer {
|
|
144
|
+
return Buffer.from([...ipToBytesBE(ip)].reverse());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function bytesToIpBE(buf: Buffer): string {
|
|
148
|
+
if (buf.length !== 4)
|
|
149
|
+
throw new Error(`expected 4 bytes for IPv4, got ${buf.length}`);
|
|
150
|
+
return `${buf[0]}.${buf[1]}.${buf[2]}.${buf[3]}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function bytesToIpLE(buf: Buffer): string {
|
|
154
|
+
if (buf.length !== 4)
|
|
155
|
+
throw new Error(`expected 4 bytes for IPv4, got ${buf.length}`);
|
|
156
|
+
return `${buf[3]}.${buf[2]}.${buf[1]}.${buf[0]}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function safeFileName(name: string): string {
|
|
160
|
+
return name.replace(/[\\/\0]/g, "_").replace(/^\.+$/, "_");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type SplitArgsState = {
|
|
164
|
+
out: string[];
|
|
165
|
+
cur: string;
|
|
166
|
+
quote: string;
|
|
167
|
+
esc: boolean;
|
|
168
|
+
pending: boolean;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function flushSplitArg(state: SplitArgsState): void {
|
|
172
|
+
if (!state.pending) return;
|
|
173
|
+
state.out.push(state.cur);
|
|
174
|
+
state.cur = "";
|
|
175
|
+
state.pending = false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function consumeEscapedArgChar(
|
|
179
|
+
state: SplitArgsState,
|
|
180
|
+
ch: string,
|
|
181
|
+
): boolean {
|
|
182
|
+
if (!state.esc) return false;
|
|
183
|
+
state.cur += ch;
|
|
184
|
+
state.esc = false;
|
|
185
|
+
state.pending = true;
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function consumeEscapeStart(state: SplitArgsState, ch: string): boolean {
|
|
190
|
+
if (ch !== "\\") return false;
|
|
191
|
+
state.esc = true;
|
|
192
|
+
state.pending = true;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function consumeQuotedArgChar(state: SplitArgsState, ch: string): boolean {
|
|
197
|
+
if (!state.quote) return false;
|
|
198
|
+
if (ch === state.quote) state.quote = "";
|
|
199
|
+
else state.cur += ch;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function consumeQuoteStart(state: SplitArgsState, ch: string): boolean {
|
|
204
|
+
if (ch !== '"' && ch !== "'") return false;
|
|
205
|
+
state.quote = ch;
|
|
206
|
+
state.pending = true;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function consumeArgWhitespace(state: SplitArgsState, ch: string): boolean {
|
|
211
|
+
if (!/\s/.test(ch)) return false;
|
|
212
|
+
flushSplitArg(state);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function splitArgs(line: string): string[] {
|
|
217
|
+
const state: SplitArgsState = {
|
|
218
|
+
out: [],
|
|
219
|
+
cur: "",
|
|
220
|
+
quote: "",
|
|
221
|
+
esc: false,
|
|
222
|
+
pending: false,
|
|
223
|
+
};
|
|
224
|
+
for (const ch of line) {
|
|
225
|
+
if (consumeEscapedArgChar(state, ch)) continue;
|
|
226
|
+
if (consumeEscapeStart(state, ch)) continue;
|
|
227
|
+
if (consumeQuotedArgChar(state, ch)) continue;
|
|
228
|
+
if (consumeQuoteStart(state, ch)) continue;
|
|
229
|
+
if (consumeArgWhitespace(state, ch)) continue;
|
|
230
|
+
state.cur += ch;
|
|
231
|
+
state.pending = true;
|
|
232
|
+
}
|
|
233
|
+
if (state.esc) state.cur += "\\";
|
|
234
|
+
flushSplitArg(state);
|
|
235
|
+
return state.out;
|
|
236
|
+
}
|