@landstrip/landstrip 0.18.38 → 0.20.11

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/lib/proxy.js DELETED
@@ -1,304 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- 'use strict';
3
-
4
- const { lookup } = require('node:dns/promises');
5
- const { BlockList, connect, createServer, isIP } = require('node:net');
6
-
7
- const prohibitedProxyAddresses = new BlockList();
8
-
9
- for (const [network, prefix] of [
10
- ['0.0.0.0', 8],
11
- ['10.0.0.0', 8],
12
- ['100.64.0.0', 10],
13
- ['127.0.0.0', 8],
14
- ['169.254.0.0', 16],
15
- ['172.16.0.0', 12],
16
- ['192.0.0.0', 24],
17
- ['192.168.0.0', 16],
18
- ['198.18.0.0', 15],
19
- ['224.0.0.0', 4],
20
- ['240.0.0.0', 4],
21
- ]) {
22
- prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv4');
23
- }
24
- for (const [network, prefix] of [
25
- ['::', 128],
26
- ['::1', 128],
27
- ['fc00::', 7],
28
- ['fe80::', 10],
29
- ['ff00::', 8],
30
- ]) {
31
- prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv6');
32
- }
33
-
34
- function parseProxyPort(value, defaultPort) {
35
- const rawPort = value ?? String(defaultPort);
36
- if (!/^\d+$/.test(rawPort)) return null;
37
-
38
- const port = Number(rawPort);
39
- return port >= 1 && port <= 65535 ? port : null;
40
- }
41
-
42
- function splitHostPort(target, defaultPort) {
43
- const bracketMatch = target.match(/^\[([^\]]+)\](?::(.*))?$/);
44
- const host = bracketMatch?.[1];
45
- if (host) {
46
- const port = parseProxyPort(bracketMatch[2], defaultPort);
47
- return port === null ? null : { host, port };
48
- }
49
-
50
- const lastColon = target.lastIndexOf(':');
51
- if (lastColon > -1 && target.indexOf(':') === lastColon) {
52
- const port = parseProxyPort(target.slice(lastColon + 1), defaultPort);
53
- return port === null ? null : { host: target.slice(0, lastColon), port };
54
- }
55
-
56
- return { host: target, port: defaultPort };
57
- }
58
-
59
- function denyProxyRequest(client, status = '403 Forbidden') {
60
- client.write(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`);
61
- client.end();
62
- }
63
-
64
- function isPublicProxyAddress(address, family = isIP(address)) {
65
- if (family === 4) return !prohibitedProxyAddresses.check(address, 'ipv4');
66
- if (family === 6) return !prohibitedProxyAddresses.check(address, 'ipv6');
67
- return false;
68
- }
69
-
70
- async function resolveProxyEndpoint(host) {
71
- const literalFamily = isIP(host);
72
- if (literalFamily === 4 || literalFamily === 6) {
73
- if (!isPublicProxyAddress(host, literalFamily)) {
74
- throw new Error(`Proxy destination is not public: ${host}`);
75
- }
76
- return { address: host, family: literalFamily };
77
- }
78
-
79
- const addresses = await lookup(host, { all: true, verbatim: true });
80
- if (
81
- addresses.length === 0 ||
82
- addresses.some(({ address, family }) => !isPublicProxyAddress(address, family))
83
- ) {
84
- throw new Error(`Proxy destination resolves to a non-public address: ${host}`);
85
- }
86
-
87
- const endpoint = addresses[0];
88
- if (endpoint.family !== 4 && endpoint.family !== 6) {
89
- throw new Error(`Proxy could not resolve destination: ${host}`);
90
- }
91
- return { address: endpoint.address, family: endpoint.family };
92
- }
93
-
94
- function pipeSockets(client, upstream, initialData) {
95
- upstream.on('error', () => client.destroy());
96
- client.on('error', () => upstream.destroy());
97
-
98
- if (initialData?.length) upstream.write(initialData);
99
-
100
- client.pipe(upstream);
101
- upstream.pipe(client);
102
- }
103
-
104
- function startFilterProxy(options) {
105
- const { isDomainAllowed, authorization, portRange } = options;
106
- const sockets = new Set();
107
-
108
- // Track the upstream socket so stop() tears it down, and abandon a still-
109
- // connecting upstream when its client goes away — otherwise a connect to a
110
- // black-holed host lingers in SYN-retry (~2 min), leaking an fd per request.
111
- function trackUpstream(upstream, client, settled) {
112
- sockets.add(upstream);
113
- upstream.once('close', () => sockets.delete(upstream));
114
- client.once('close', () => {
115
- if (!settled()) upstream.destroy();
116
- });
117
- }
118
-
119
- async function handleConnect(client, target, rest) {
120
- const endpoint = splitHostPort(target, 443);
121
- if (!endpoint) {
122
- denyProxyRequest(client, '400 Bad Request');
123
- return;
124
- }
125
-
126
- if (!(await isDomainAllowed(endpoint.host))) {
127
- denyProxyRequest(client);
128
- return;
129
- }
130
-
131
- const resolved = await resolveProxyEndpoint(endpoint.host);
132
- let settled = false;
133
- const upstream = connect(
134
- { host: resolved.address, port: endpoint.port, family: resolved.family },
135
- () => {
136
- settled = true;
137
- client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
138
- pipeSockets(client, upstream, rest);
139
- },
140
- );
141
- trackUpstream(upstream, client, () => settled);
142
- upstream.once('error', () => {
143
- if (settled) return;
144
- settled = true;
145
- denyProxyRequest(client, '502 Bad Gateway');
146
- });
147
- }
148
-
149
- async function handleHttp(client, headerText, rest) {
150
- const lines = headerText.split(/\r?\n/);
151
- const requestLine = lines[0];
152
- if (!requestLine) {
153
- denyProxyRequest(client, '400 Bad Request');
154
- return;
155
- }
156
-
157
- const [method, rawTarget, version] = requestLine.split(' ');
158
- if (!method || !rawTarget || !version) {
159
- denyProxyRequest(client, '400 Bad Request');
160
- return;
161
- }
162
-
163
- let url;
164
- try {
165
- url = new URL(rawTarget);
166
- } catch {
167
- const host = lines
168
- .find((line) => line.toLowerCase().startsWith('host:'))
169
- ?.slice(5)
170
- .trim();
171
- if (!host) {
172
- denyProxyRequest(client, '400 Bad Request');
173
- return;
174
- }
175
-
176
- try {
177
- url = new URL(`http://${host}${rawTarget}`);
178
- } catch {
179
- denyProxyRequest(client, '400 Bad Request');
180
- return;
181
- }
182
- }
183
-
184
- if (!(await isDomainAllowed(url.hostname))) {
185
- denyProxyRequest(client);
186
- return;
187
- }
188
-
189
- const port = parseProxyPort(url.port || undefined, url.protocol === 'https:' ? 443 : 80);
190
- if (port === null) {
191
- denyProxyRequest(client, '400 Bad Request');
192
- return;
193
- }
194
-
195
- const path = `${url.pathname}${url.search}` || '/';
196
- lines[0] = `${method} ${path} ${version}`;
197
-
198
- const rewrittenHeader = lines
199
- .filter(
200
- (line) =>
201
- !line.toLowerCase().startsWith('proxy-connection:') &&
202
- !line.toLowerCase().startsWith('proxy-authorization:'),
203
- )
204
- .map((line) => (line.toLowerCase().startsWith('host:') ? `Host: ${url.host}` : line))
205
- .join('\r\n');
206
- const resolved = await resolveProxyEndpoint(url.hostname);
207
- let settled = false;
208
- const upstream = connect({ host: resolved.address, port, family: resolved.family }, () => {
209
- settled = true;
210
- upstream.write(`${rewrittenHeader}\r\n\r\n`);
211
- pipeSockets(client, upstream, rest);
212
- });
213
- trackUpstream(upstream, client, () => settled);
214
- upstream.once('error', () => {
215
- if (settled) return;
216
- settled = true;
217
- denyProxyRequest(client, '502 Bad Gateway');
218
- });
219
- }
220
-
221
- function handleClient(client) {
222
- sockets.add(client);
223
- client.on('close', () => sockets.delete(client));
224
- client.on('error', () => sockets.delete(client));
225
-
226
- let buffered = Buffer.alloc(0);
227
-
228
- client.on('data', (chunk) => {
229
- buffered = Buffer.concat([buffered, chunk]);
230
- const headerEnd = buffered.indexOf('\r\n\r\n');
231
- if (headerEnd === -1) {
232
- if (buffered.length > 65536) {
233
- client.removeAllListeners('data');
234
- client.pause();
235
- denyProxyRequest(client, '431 Request Header Fields Too Large');
236
- }
237
- return;
238
- }
239
-
240
- client.pause();
241
- client.removeAllListeners('data');
242
-
243
- const header = buffered.subarray(0, headerEnd).toString('utf-8');
244
- const rest = buffered.subarray(headerEnd + 4);
245
- if (authorization) {
246
- const supplied = header
247
- .split(/\r?\n/)
248
- .find((line) => line.toLowerCase().startsWith('proxy-authorization:'))
249
- ?.slice('proxy-authorization:'.length)
250
- .trim();
251
- if (supplied !== authorization) {
252
- denyProxyRequest(client, '407 Proxy Authentication Required');
253
- return;
254
- }
255
- }
256
- const [method, target] = header.split(/\r?\n/, 1)[0].split(' ');
257
-
258
- const task =
259
- method?.toUpperCase() === 'CONNECT' && target
260
- ? handleConnect(client, target, rest)
261
- : handleHttp(client, header, rest);
262
- task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
263
- });
264
- }
265
-
266
- const server = createServer(handleClient);
267
- let stopped = false;
268
-
269
- return new Promise((resolve, reject) => {
270
- const listen = (port) => {
271
- server.once('error', (error) => {
272
- if (error.code === 'EADDRINUSE' && portRange && port < portRange.high) {
273
- listen(port + 1);
274
- return;
275
- }
276
- reject(error);
277
- });
278
- server.listen(port, '127.0.0.1', () => {
279
- server.removeAllListeners('error');
280
- const address = server.address();
281
-
282
- resolve({
283
- port: address.port,
284
- stop: () =>
285
- new Promise((done) => {
286
- if (stopped) {
287
- done();
288
- return;
289
- }
290
- stopped = true;
291
- for (const socket of sockets) socket.destroy();
292
- server.close(() => done());
293
- }),
294
- });
295
- });
296
- };
297
- listen(portRange ? portRange.low : 0);
298
- });
299
- }
300
-
301
- module.exports = {
302
- isPublicProxyAddress,
303
- startFilterProxy,
304
- };
package/lib/shared.d.ts DELETED
@@ -1,57 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
-
3
- import type {
4
- LandstripControlResponse,
5
- LandstripFilesystemTrap,
6
- LandstripNetworkTrap,
7
- LandstripTrap,
8
- } from './index.js';
9
-
10
- export type LandstripDenialTrap = LandstripFilesystemTrap | LandstripNetworkTrap;
11
-
12
- export function isRecord(value: unknown): value is Record<string, unknown>;
13
-
14
- export function expandHomePath(path: string): string;
15
-
16
- export function expandPath(filePath: string, baseDirectory: string): string;
17
-
18
- export function canonicalizePath(filePath: string, baseDirectory: string): string;
19
-
20
- export function canonicalizeGlobPattern(pattern: string, baseDirectory: string): string;
21
-
22
- export function normalizePathSeparators(path: string): string;
23
-
24
- export function globToRegExp(globPattern: string): RegExp;
25
-
26
- export function pathUnderDirectory(filePath: string, dir: string): boolean;
27
-
28
- export function sessionAllows(prefixes: Set<string>, filePath: string): boolean;
29
-
30
- export function sessionScopeFor(filePath: string, baseDirectory: string): string;
31
-
32
- export function domainMatchesPattern(domain: string, pattern: string): boolean;
33
-
34
- export function domainMatchesAny(domain: string, patterns: string[]): boolean;
35
-
36
- export function allowsAllDomains(allowedDomains: string[]): boolean;
37
-
38
- export function decodeLandstripTrap(value: unknown): LandstripTrap | null;
39
-
40
- export function parseTrapLine(line: string): LandstripTrap | null;
41
-
42
- export function parseLandstripTraps(output: string): LandstripTrap[];
43
-
44
- export function isFilesystemTrap(trap: LandstripTrap): trap is LandstripFilesystemTrap;
45
-
46
- export function isDenialTrap(trap: LandstripTrap): trap is LandstripDenialTrap;
47
-
48
- export function isQueryTrap(trap: LandstripTrap): trap is LandstripDenialTrap;
49
-
50
- export function formatLandstripTrap(trap: LandstripTrap): string;
51
-
52
- export function formatLandstripTraps(traps: LandstripTrap[]): string;
53
-
54
- export function controlResponseLine(
55
- queryId: string,
56
- action: LandstripControlResponse['action'],
57
- ): string;
package/lib/shared.js DELETED
@@ -1,299 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- 'use strict';
3
-
4
- const { lstatSync, readlinkSync, realpathSync } = require('node:fs');
5
- const { homedir } = require('node:os');
6
- const { basename, dirname, isAbsolute, join, relative, resolve, sep } = require('node:path');
7
-
8
- function isRecord(value) {
9
- return typeof value === 'object' && value !== null && !Array.isArray(value);
10
- }
11
-
12
- function expandHomePath(value) {
13
- if (value === '~' || value === '$HOME') return homedir();
14
- if (value.startsWith('~/')) return join(homedir(), value.slice(2));
15
- if (value.startsWith('$HOME/')) return join(homedir(), value.slice(6));
16
- return value;
17
- }
18
-
19
- function pathUnderDirectory(filePath, dir) {
20
- const child = relative(dir, filePath);
21
- return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`));
22
- }
23
-
24
- function sessionAllows(prefixes, filePath) {
25
- for (const prefix of prefixes) {
26
- if (pathUnderDirectory(filePath, prefix)) return true;
27
- }
28
- return false;
29
- }
30
-
31
- // The broadest ancestor worth approving in one action: the immediate child of
32
- // `$HOME` (e.g. `~/.cargo`) for paths under the user's home, the project root
33
- // for paths under it, otherwise the containing directory. A file sitting
34
- // directly on a boundary falls back to the exact file so nothing widens
35
- // silently.
36
- function sessionScopeFor(filePath, baseDirectory) {
37
- const dir = dirname(filePath);
38
- const homeBoundaries = new Set([homedir()]);
39
- try {
40
- homeBoundaries.add(realpathSync.native(homedir()));
41
- } catch {
42
- // $HOME not resolvable — fall back to the raw value only.
43
- }
44
-
45
- for (const boundary of homeBoundaries) {
46
- if (pathUnderDirectory(dir, boundary)) {
47
- const first = relative(boundary, dir).split(sep)[0];
48
- if (!first) return filePath;
49
- return join(boundary, first);
50
- }
51
- }
52
-
53
- const projectBoundaries = new Set([baseDirectory]);
54
- try {
55
- projectBoundaries.add(realpathSync.native(baseDirectory));
56
- } catch {
57
- // Project directory not resolvable — fall back to the raw value only.
58
- }
59
-
60
- for (const boundary of projectBoundaries) {
61
- if (pathUnderDirectory(dir, boundary)) return boundary;
62
- }
63
-
64
- return dir;
65
- }
66
-
67
- function domainMatchesPattern(domain, pattern) {
68
- // A trailing dot ("pastebin.com.") is the same host to DNS but would slip
69
- // past a literal deny entry; strip it from both sides before matching.
70
- const normalizedDomain = domain.toLowerCase().replace(/\.+$/, '');
71
- const normalizedPattern = pattern.toLowerCase().replace(/\.+$/, '');
72
-
73
- if (normalizedPattern === '*') return true;
74
- if (normalizedPattern.startsWith('*.')) {
75
- const base = normalizedPattern.slice(2);
76
- return normalizedDomain === base || normalizedDomain.endsWith(`.${base}`);
77
- }
78
-
79
- return normalizedDomain === normalizedPattern;
80
- }
81
-
82
- function domainMatchesAny(domain, patterns) {
83
- return patterns.some((pattern) => domainMatchesPattern(domain, pattern));
84
- }
85
-
86
- function allowsAllDomains(allowedDomains) {
87
- return allowedDomains.includes('*');
88
- }
89
-
90
- // landstrip emits each trap as a flat JSON record tagged by a `kind`
91
- // discriminant (`filesystem`, `network`, `launch`, `usage`, `internal`)
92
- // alongside a stable `code` and variant-specific fields. The declarations it
93
- // ships are erased at compile time, so validate the fields callers read
94
- // before trusting a decoded line. `state` is deliberately not validated: a
95
- // missing or unknown state degrades to "informational", the safe direction.
96
- // `query_id` must be a string — landstrip < 0.17 sent a number, and a
97
- // numeric id fails its own deserializer when echoed back.
98
- const LANDSTRIP_OPERATIONS = new Set(['read', 'write']);
99
-
100
- function isLandstripTrap(value) {
101
- if (!isRecord(value)) return false;
102
-
103
- switch (value.kind) {
104
- case 'filesystem':
105
- return (
106
- LANDSTRIP_OPERATIONS.has(value.operation) &&
107
- typeof value.path === 'string' &&
108
- typeof value.query_id === 'string'
109
- );
110
- case 'network':
111
- return (
112
- typeof value.operation === 'string' &&
113
- typeof value.target === 'string' &&
114
- typeof value.query_id === 'string'
115
- );
116
- case 'launch':
117
- return typeof value.program === 'string' && typeof value.message === 'string';
118
- case 'usage':
119
- return typeof value.message === 'string';
120
- case 'internal':
121
- return typeof value.code === 'string' && typeof value.message === 'string';
122
- default:
123
- return false;
124
- }
125
- }
126
-
127
- function decodeLandstripTrap(value) {
128
- return isLandstripTrap(value) ? value : null;
129
- }
130
-
131
- function parseTrapLine(line) {
132
- try {
133
- const parsed = JSON.parse(line);
134
- return isLandstripTrap(parsed) ? parsed : null;
135
- } catch {
136
- return null;
137
- }
138
- }
139
-
140
- function parseLandstripTraps(output) {
141
- const traps = [];
142
-
143
- for (const line of output.split('\n')) {
144
- const trimmed = line.trim();
145
- if (trimmed.length === 0 || trimmed[0] !== '{') continue;
146
- const trap = parseTrapLine(trimmed);
147
- if (trap) traps.push(trap);
148
- }
149
-
150
- return traps;
151
- }
152
-
153
- function isFilesystemTrap(trap) {
154
- return trap.kind === 'filesystem';
155
- }
156
-
157
- // filesystem and network traps report an access the policy denied; launch,
158
- // usage and internal traps report that landstrip itself failed.
159
- function isDenialTrap(trap) {
160
- return trap.kind === 'filesystem' || trap.kind === 'network';
161
- }
162
-
163
- // A `state: "query"` trap suspends the child's syscall until the host answers
164
- // it on the trap socket. An `info` trap is terminal.
165
- function isQueryTrap(trap) {
166
- return isDenialTrap(trap) && trap.state === 'query';
167
- }
168
-
169
- function formatLandstripTrap(trap) {
170
- switch (trap.kind) {
171
- case 'filesystem':
172
- return `landstrip: filesystem ${trap.operation} denied (${trap.path})${
173
- trap.mechanism ? ` [${trap.mechanism}]` : ''
174
- }`;
175
- case 'network':
176
- return `landstrip: network ${trap.operation} denied (${trap.target})${
177
- trap.mechanism ? ` [${trap.mechanism}]` : ''
178
- }`;
179
- case 'launch':
180
- return `landstrip: launch failed (${trap.program})${trap.message ? `: ${trap.message}` : ''}`;
181
- case 'usage':
182
- return `landstrip: usage error: ${trap.message}`;
183
- case 'internal': {
184
- const mechanism = trap.mechanism ? ` [${trap.mechanism}]` : '';
185
- return `landstrip: ${trap.code}${mechanism}: ${trap.message}`;
186
- }
187
- }
188
- }
189
-
190
- function formatLandstripTraps(traps) {
191
- return traps.map(formatLandstripTrap).join('\n');
192
- }
193
-
194
- // Relative entries (notably ".") resolve against the caller-supplied base
195
- // directory — the command's working directory that landstrip itself uses as
196
- // its policy base — never the host process's own cwd.
197
- function expandPath(filePath, baseDirectory) {
198
- return resolve(baseDirectory, expandHomePath(filePath));
199
- }
200
-
201
- function canonicalizePath(filePath, baseDirectory, seen = new Set()) {
202
- const abs = expandPath(filePath, baseDirectory);
203
- const missing = [];
204
- let existing = abs;
205
-
206
- for (;;) {
207
- try {
208
- const stat = lstatSync(existing);
209
- if (stat.isSymbolicLink()) {
210
- if (seen.has(existing)) return abs;
211
- seen.add(existing);
212
- const target = resolve(dirname(existing), readlinkSync(existing), ...missing);
213
- return canonicalizePath(target, baseDirectory, seen);
214
- }
215
- break;
216
- } catch {
217
- const parent = dirname(existing);
218
- if (parent === existing) return abs;
219
- missing.unshift(basename(existing));
220
- existing = parent;
221
- }
222
- }
223
-
224
- try {
225
- return resolve(realpathSync.native(existing), ...missing);
226
- } catch {
227
- return abs;
228
- }
229
- }
230
-
231
- function canonicalizeGlobPattern(pattern, baseDirectory) {
232
- const expanded = expandPath(pattern, baseDirectory);
233
- const wildcard = expanded.indexOf('*');
234
- if (wildcard < 0) return canonicalizePath(expanded, baseDirectory);
235
-
236
- const prefix = expanded.slice(0, wildcard);
237
- const base = prefix.endsWith(sep) ? prefix : dirname(prefix);
238
- const suffixOffset = base.endsWith(sep) ? base.length - 1 : base.length;
239
- const canonicalBase = canonicalizePath(base, baseDirectory);
240
- const suffix = expanded.slice(suffixOffset);
241
- return canonicalBase.endsWith(sep) && suffix.startsWith(sep)
242
- ? `${canonicalBase}${suffix.slice(1)}`
243
- : `${canonicalBase}${suffix}`;
244
- }
245
-
246
- function normalizePathSeparators(path) {
247
- return process.platform === 'win32' ? path.replaceAll('\\', '/') : path;
248
- }
249
-
250
- const globRegExpCache = new Map();
251
-
252
- // Translates an absolute glob pattern to a regular expression using standard
253
- // path semantics: `**` crosses directory boundaries (and `**/` may match zero
254
- // segments), while a single `*` is confined to one path segment.
255
- function globToRegExp(globPattern) {
256
- const cached = globRegExpCache.get(globPattern);
257
- if (cached) return cached;
258
-
259
- const escaped = globPattern
260
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
261
- .replace(/\*\*\/|\*\*|\*/g, (token) =>
262
- token === '**/' ? '(?:.*/)?' : token === '**' ? '.*' : '[^/]*',
263
- );
264
- const result = new RegExp(`^${escaped}$`);
265
- globRegExpCache.set(globPattern, result);
266
- return result;
267
- }
268
-
269
- // The broker matches an answer to its query by the exact decimal `query_id`
270
- // string the trap carried. A numeric id fails its deserializer, the line is
271
- // dropped, and the child's syscall stays suspended.
272
- function controlResponseLine(queryId, action) {
273
- return JSON.stringify({ query_id: queryId, action }) + '\n';
274
- }
275
-
276
- module.exports = {
277
- isRecord,
278
- expandHomePath,
279
- expandPath,
280
- canonicalizePath,
281
- canonicalizeGlobPattern,
282
- normalizePathSeparators,
283
- globToRegExp,
284
- pathUnderDirectory,
285
- sessionAllows,
286
- sessionScopeFor,
287
- domainMatchesPattern,
288
- domainMatchesAny,
289
- allowsAllDomains,
290
- decodeLandstripTrap,
291
- parseTrapLine,
292
- parseLandstripTraps,
293
- isFilesystemTrap,
294
- isDenialTrap,
295
- isQueryTrap,
296
- formatLandstripTrap,
297
- formatLandstripTraps,
298
- controlResponseLine,
299
- };
File without changes