@tikoci/centrs 0.1.9-next.24 → 0.1.9-next.27

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/GLOSSARY.txt +9 -1
  3. package/README.md +28 -16
  4. package/commands/api/AGENTS.md +3 -2
  5. package/commands/api/README.md +8 -1
  6. package/commands/btest/README.md +9 -0
  7. package/commands/btest/examples.md +7 -0
  8. package/commands/devices/README.md +72 -3
  9. package/commands/devices/examples.md +56 -1
  10. package/commands/execute/README.md +11 -0
  11. package/commands/explain/README.md +223 -3
  12. package/commands/explain/examples.md +8 -1
  13. package/commands/retrieve/README.md +22 -1
  14. package/commands/retrieve/examples.md +84 -0
  15. package/commands/transfer/README.md +25 -0
  16. package/commands/transfer/examples.md +46 -0
  17. package/docs/CLI.md +11 -3
  18. package/docs/MATRIX.md +5 -3
  19. package/docs/errors/cdb/encrypt-failed.md +10 -0
  20. package/docs/errors/cdb/password-required.md +5 -4
  21. package/docs/errors/cdb/plaintext-backups.md +11 -0
  22. package/docs/errors/input/invalid-routeros-path.md +25 -3
  23. package/package.json +8 -4
  24. package/src/api-fanout.ts +2 -0
  25. package/src/api.ts +12 -0
  26. package/src/btest.ts +7 -0
  27. package/src/cli/api.ts +18 -1
  28. package/src/cli/btest.ts +9 -0
  29. package/src/cli/devices.ts +76 -8
  30. package/src/cli/execute.ts +9 -0
  31. package/src/cli/retrieve.ts +9 -0
  32. package/src/cli/terminal.ts +9 -0
  33. package/src/cli/transfer.ts +10 -1
  34. package/src/core/error-catalog.ts +13 -2
  35. package/src/core/menu-path.ts +89 -0
  36. package/src/devices.ts +154 -9
  37. package/src/execute-fanout.ts +2 -0
  38. package/src/execute.ts +7 -0
  39. package/src/explain/blocks.ts +2 -2
  40. package/src/explain/catalog.ts +22 -12
  41. package/src/explain/pasted-prompt.ts +198 -0
  42. package/src/explain/pathresolve.ts +2 -2
  43. package/src/explain/segment.ts +2 -2
  44. package/src/explain/symbols.ts +2 -2
  45. package/src/explain/verbsplit.ts +2 -2
  46. package/src/explain/write.ts +2 -2
  47. package/src/explain.ts +2 -0
  48. package/src/index.ts +4 -0
  49. package/src/protocols/adapter.ts +2 -0
  50. package/src/protocols/btest-session.ts +10 -30
  51. package/src/protocols/ec-srp5.ts +19 -10
  52. package/src/protocols/mac-telnet-console.ts +2 -2
  53. package/src/resolver/cdb.ts +58 -2
  54. package/src/resolver/index.ts +1 -0
  55. package/src/resolver/quickchr-provider.ts +5 -0
  56. package/src/resolver/selection.ts +11 -0
  57. package/src/retrieve-fanout.ts +2 -0
  58. package/src/retrieve.ts +11 -0
  59. package/src/settings.ts +1 -0
  60. package/src/terminal.ts +7 -0
  61. package/src/transfer-fanout.ts +2 -0
  62. package/src/transfer.ts +79 -14
@@ -1,9 +1,31 @@
1
1
  # `input/invalid-routeros-path`
2
2
 
3
- The RouterOS path is malformed (it must be slash-prefixed).
3
+ The RouterOS path is malformed: not slash-prefixed, a segment that is not a
4
+ menu name, or characters a REST URL would reinterpret.
5
+
6
+ ## Typical triggers
7
+
8
+ - A path without a leading slash: `retrieve <router> ip/address`.
9
+ - A `retrieve` or `api` path segment that is not a RouterOS menu name. Menu
10
+ names are letters, digits and hyphens (case folds) (`/interface/6to4`,
11
+ `/ip/dhcp-server`). The check runs on every transport and even with
12
+ `--validate=false`, because a read must only dispatch a read (#586).
13
+ - A REST request path containing `?`, `#`, `\`, a control character, or a
14
+ `.`/`..` segment, including percent-encoded forms such as `%2e%2e`. The
15
+ URL parser would turn these into a query, a fragment
16
+ or a different path, so the request would not reach the path as written.
17
+ Before this guard, `retrieve '/system/reboot#' --no-validate` posted
18
+ `/rest/system/reboot`.
19
+
20
+ `context.path` names the path; `context.invalidSegments` lists the segments
21
+ that failed the menu grammar.
4
22
 
5
23
  ## Fix
6
24
 
25
+ - Write the path as slash-separated menu names: `/ip/address`,
26
+ `/system/resource`.
27
+ - Select rows with `--query` (for example `--query 'name="a#b"'`), not with
28
+ characters in the path.
29
+
7
30
  See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
8
- contract. This stub will be expanded with the typical trigger and remediation
9
- for `input/invalid-routeros-path`.
31
+ contract.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.9-next.24",
3
+ "version": "0.1.9-next.27",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
@@ -12,18 +12,18 @@
12
12
  "packageManager": "bun@1.4.2",
13
13
  "license": "MIT",
14
14
  "devDependencies": {
15
- "@biomejs/biome": "2.5.15",
15
+ "@biomejs/biome": "2.6.3",
16
16
  "@secretlint/secretlint-rule-preset-recommend": "^13.0.7",
17
17
  "@types/bun": "1.4.2",
18
18
  "cloc": "^2.6.0-cloc",
19
19
  "cspell": "^10.3.6",
20
20
  "has-flag": "4.0.0",
21
- "markdownlint-cli2": "^0.23.3",
21
+ "markdownlint-cli2": "^0.24.0",
22
22
  "secretlint": "^13.0.7",
23
23
  "typedoc": "^0.28.20"
24
24
  },
25
25
  "optionalDependencies": {
26
- "@tikoci/quickchr": "^0.4.8"
26
+ "@tikoci/quickchr": "^0.5.2"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "typescript": "^6.0.3"
@@ -118,6 +118,8 @@
118
118
  "explain:operator-readme:check": "bun run scripts/explain-operator-slice.ts --readme --check",
119
119
  "explain:operator-slice": "bun run scripts/explain-operator-slice.ts",
120
120
  "explain:probe:brace-slots": "bun run scripts/probes/explain-brace-slot-sweep.ts",
121
+ "explain:probe:context-matrix": "bun run scripts/probes/explain-context-matrix.ts",
122
+ "explain:probe:corrected-anchors": "bun run scripts/probes/explain-corrected-anchors.ts",
121
123
  "explain:probe:escapes": "bun run scripts/probes/explain-escape-sweep.ts",
122
124
  "explain:probe:highlight-recapture": "bun run scripts/probes/explain-highlight-recapture.ts",
123
125
  "explain:probe:operators": "bun run scripts/probes/explain-operator-sweep.ts",
@@ -125,6 +127,8 @@
125
127
  "explain:probe:symbol-arms-score": "bun run scripts/probes/explain-symbol-arms-score.ts",
126
128
  "explain:probe:symbol-bad-sigil-replay": "bun run scripts/probes/explain-symbol-bad-sigil-replay.ts",
127
129
  "explain:probe:symbol-classes-replay": "bun run scripts/probes/explain-symbol-classes-replay.ts",
130
+ "explain:probe:import-blocker-validity": "bun run scripts/probes/explain-import-blocker-validity.ts",
131
+ "explain:probe:import-dry-run": "bun run scripts/probes/explain-import-dry-run.ts",
128
132
  "explain:probe:version-floors": "bun run scripts/probes/explain-version-floors.ts",
129
133
  "explain:menus:check": "bun run scripts/gen-explain-menus.ts --check",
130
134
  "explain:value-census": "bun run scripts/explain-value-census.ts",
package/src/api-fanout.ts CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  import { promptForWriteConfirmation } from "./execute.ts";
63
63
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
64
64
  import {
65
+ assertNoMatchInFanout,
65
66
  emptyWriteSelectionError,
66
67
  expandSelection,
67
68
  isDefaultRecordTarget,
@@ -213,6 +214,7 @@ export async function apiFanout(
213
214
  // missing endpoint or an invalid `-X` is an outer error regardless of how many
214
215
  // targets the selection resolves to (so an empty selection cannot mask it).
215
216
  validateApiRequestShape(request);
217
+ assertNoMatchInFanout(request.match);
216
218
  const config = await loadEnvFileDefaults(env);
217
219
  const method = parseApiMethod(request.method);
218
220
  const requestSummary = apiRequestSummaryFromRequest(request, env);
package/src/api.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  isCommandNode,
46
46
  pathTokens,
47
47
  } from "./core/inspect.ts";
48
+ import { assertRouterOsMenuPath } from "./core/menu-path.ts";
48
49
  import {
49
50
  compileQueryWords,
50
51
  type ParsedQuery,
@@ -160,6 +161,12 @@ export interface ApiRequest {
160
161
  verbose?: boolean;
161
162
  cdbFile?: string;
162
163
  cdbPassword?: string;
164
+ /**
165
+ * `--match` selector for a `<router>` several CDB records share
166
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
167
+ * `devices show --match` takes. Single-target only (#583).
168
+ */
169
+ match?: string;
163
170
  /** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
164
171
  resolve?: string;
165
172
  stdinIsTty?: boolean;
@@ -937,6 +944,7 @@ export async function resolveApiRequest(
937
944
  targetInput: request.targetInput,
938
945
  cdbFile: request.cdbFile,
939
946
  cdbPassword: request.cdbPassword,
947
+ match: request.match,
940
948
  },
941
949
  env,
942
950
  config,
@@ -2176,6 +2184,10 @@ export function validateApiRequestShape(request: ApiRequest): void {
2176
2184
  "Pass a path with at least one menu segment, such as `ip/address`.",
2177
2185
  });
2178
2186
  }
2187
+ // Before transport choice and regardless of --validate: a `#`/`?` in the
2188
+ // path cut `/print` off the REST URL and a GET read posted to the bare
2189
+ // command, rebooting the router (#586).
2190
+ assertRouterOsMenuPath(normalized.path, { endpoint: request.endpoint });
2179
2191
  }
2180
2192
 
2181
2193
  /**
package/src/btest.ts CHANGED
@@ -113,6 +113,12 @@ export interface BtestClientRequest {
113
113
  natMode?: boolean;
114
114
  cdbFile?: string;
115
115
  cdbPassword?: string;
116
+ /**
117
+ * `--match` selector for a `<router>` several CDB records share
118
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
119
+ * `devices show --match` takes. Single-target only (#583).
120
+ */
121
+ match?: string;
116
122
  env?: Record<string, string | undefined>;
117
123
  signal?: AbortSignal;
118
124
  /** Live per-interval frame callback (text/csv streaming). */
@@ -264,6 +270,7 @@ export async function btestClient(
264
270
  targetInput: request.targetInput,
265
271
  cdbFile: request.cdbFile,
266
272
  cdbPassword: request.cdbPassword,
273
+ match: request.match,
267
274
  },
268
275
  env,
269
276
  );
package/src/cli/api.ts CHANGED
@@ -23,7 +23,10 @@ import {
23
23
  formatCentrsErrorText,
24
24
  } from "../errors.ts";
25
25
  import { describeCentrs } from "../index.ts";
26
- import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
26
+ import {
27
+ assertNoMatchInFanout,
28
+ assertNoQuickchrOverrideConflict,
29
+ } from "../resolver/index.ts";
27
30
  import {
28
31
  type CliCommandMetadata,
29
32
  expectValue,
@@ -165,6 +168,12 @@ export const apiCommand: CliCommandMetadata = {
165
168
  valueName: "<password>",
166
169
  description: "Decrypt an encrypted WinBox CDB file.",
167
170
  },
171
+ {
172
+ flag: "--match",
173
+ valueName: "<user=name|target=addr|record-type>",
174
+ description:
175
+ "Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
176
+ },
168
177
  {
169
178
  flag: "--resolve",
170
179
  valueName: "<none|arp>",
@@ -330,6 +339,9 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
330
339
  case "--cdb-password":
331
340
  parsed.cdbPassword = expectValue(args, ++index, arg);
332
341
  break;
342
+ case "--match":
343
+ parsed.match = expectValue(args, ++index, arg);
344
+ break;
333
345
  case "--resolve":
334
346
  parsed.resolve = expectValue(args, ++index, arg);
335
347
  break;
@@ -473,6 +485,11 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
473
485
  selectionFlags.quickchr[0] ?? "",
474
486
  );
475
487
  }
488
+ // `--match` + fan-out is refused here too, before `--input -` can block
489
+ // on stdin; `apiFanout` keeps the same check for library callers (#583).
490
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
491
+ assertNoMatchInFanout(parsed.match);
492
+ }
476
493
 
477
494
  // Resolve `--input` (file / stdin) before handing the body to the orchestrator.
478
495
  if (parsed.inputPath !== undefined) {
package/src/cli/btest.ts CHANGED
@@ -145,6 +145,12 @@ export const btestCommand: CliCommandMetadata = {
145
145
  valueName: "<password>",
146
146
  description: "(client) Decrypt an encrypted WinBox CDB file.",
147
147
  },
148
+ {
149
+ flag: "--match",
150
+ valueName: "<user=name|target=addr|record-type>",
151
+ description:
152
+ "(client) Pick one of several CDB records saved for <router> (as `devices show --match`); `btest server` rejects it.",
153
+ },
148
154
  {
149
155
  flag: "--format",
150
156
  valueName: `<${btestOutputFormats.join("|")}>`,
@@ -302,6 +308,9 @@ function parseClientArgs(args: readonly string[]): ClientCliArgs {
302
308
  case "--cdb-password":
303
309
  request.cdbPassword = expectValue(args, ++i, arg);
304
310
  break;
311
+ case "--match":
312
+ request.match = expectValue(args, ++i, arg);
313
+ break;
305
314
  case "--csv":
306
315
  parsed.format = "csv";
307
316
  break;
@@ -22,6 +22,7 @@ import {
22
22
  describeCentrs,
23
23
  devicesOutputFormats,
24
24
  editInteractiveOnlyError,
25
+ encryptCdb,
25
26
  initCdb,
26
27
  listDevices,
27
28
  listGroups,
@@ -48,10 +49,20 @@ import {
48
49
  export const devicesCommand: CliCommandMetadata = {
49
50
  name: "devices",
50
51
  usage:
51
- "centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]",
52
+ "centrs devices <init|encrypt|list|show|groups|add|edit|set|remove> [args] [flags]",
52
53
  summary:
53
54
  "Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
54
55
  options: [
56
+ {
57
+ flag: "--encrypt",
58
+ description:
59
+ "`init` only — require encrypted creation with a non-empty CDB password; refuse an existing open CDB.",
60
+ },
61
+ {
62
+ flag: "--no-backup",
63
+ description:
64
+ "`encrypt` only — skip the plaintext pre-conversion snapshot. Without that snapshot, a forgotten password cannot be recovered.",
65
+ },
55
66
  {
56
67
  flag: "--cdb-file",
57
68
  valueName: "<path>",
@@ -62,7 +73,7 @@ export const devicesCommand: CliCommandMetadata = {
62
73
  flag: "--cdb-password",
63
74
  valueName: "<secret>",
64
75
  description:
65
- "Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used to re-encrypt on writes.",
76
+ "Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used for `init --encrypt`, `encrypt`, and to re-encrypt on writes.",
66
77
  },
67
78
  {
68
79
  flag: "--via",
@@ -200,6 +211,7 @@ export const devicesCommand: CliCommandMetadata = {
200
211
 
201
212
  type DevicesSubcommand =
202
213
  | "init"
214
+ | "encrypt"
203
215
  | "list"
204
216
  | "show"
205
217
  | "groups"
@@ -226,6 +238,8 @@ interface DevicesCliArgs {
226
238
  subcommand?: DevicesSubcommand;
227
239
  cdbFile?: string;
228
240
  cdbPassword?: string;
241
+ encrypt?: boolean;
242
+ noBackup?: boolean;
229
243
  group?: string;
230
244
  where?: string[];
231
245
  via?: string;
@@ -254,20 +268,44 @@ interface DevicesCliArgs {
254
268
  bbox?: BboxPredicate;
255
269
  }
256
270
 
271
+ /**
272
+ * Flags `init`/`encrypt` honor. They touch only the CDB file, never a record,
273
+ * so record, selection, and provenance flags would be silently ignored there.
274
+ */
275
+ const CDB_FILE_FLAGS: Readonly<Record<"init" | "encrypt", readonly string[]>> =
276
+ {
277
+ init: ["--cdb-file", "--cdb-password", "--format", "--json", "--encrypt"],
278
+ encrypt: [
279
+ "--cdb-file",
280
+ "--cdb-password",
281
+ "--format",
282
+ "--json",
283
+ "--no-backup",
284
+ ],
285
+ };
286
+
257
287
  function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
258
288
  const parsed: DevicesCliArgs = {};
259
289
  const positional: string[] = [];
290
+ const suppliedFlags: string[] = [];
260
291
 
261
292
  for (let index = 0; index < args.length; index += 1) {
262
293
  const arg = args[index];
263
294
  if (arg === undefined) {
264
295
  continue;
265
296
  }
297
+ if (arg.startsWith("-")) suppliedFlags.push(arg);
266
298
  switch (arg) {
267
299
  case "--help":
268
300
  case "-h":
269
301
  parsed.help = true;
270
302
  break;
303
+ case "--encrypt":
304
+ parsed.encrypt = true;
305
+ break;
306
+ case "--no-backup":
307
+ parsed.noBackup = true;
308
+ break;
271
309
  case "--cdb-file":
272
310
  parsed.cdbFile = expectValue(args, ++index, arg);
273
311
  break;
@@ -382,6 +420,7 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
382
420
 
383
421
  const subcommands: readonly DevicesSubcommand[] = [
384
422
  "init",
423
+ "encrypt",
385
424
  "list",
386
425
  "show",
387
426
  "groups",
@@ -393,17 +432,23 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
393
432
  const [rawSub, ...rest] = positional;
394
433
  if (rawSub === undefined) {
395
434
  throw new Error(
396
- "`centrs devices` requires a subcommand: init, list, show, groups, add, edit, set, or remove.",
435
+ "`centrs devices` requires a subcommand: init, encrypt, list, show, groups, add, edit, set, or remove.",
397
436
  );
398
437
  }
399
438
  // Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
400
439
  const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
401
440
  if (!subcommands.includes(sub)) {
402
441
  throw new Error(
403
- `Unknown devices subcommand: ${rawSub}. Use init, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
442
+ `Unknown devices subcommand: ${rawSub}. Use init, encrypt, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
404
443
  );
405
444
  }
406
445
  parsed.subcommand = sub;
446
+ if (parsed.encrypt && sub !== "init")
447
+ throw new Error(
448
+ "--encrypt is supported only by devices init; use devices encrypt for conversion.",
449
+ );
450
+ if (parsed.noBackup && sub !== "encrypt")
451
+ throw new Error("--no-backup is supported only by devices encrypt.");
407
452
 
408
453
  // Reject flags that don't apply to the resolved subcommand instead of
409
454
  // silently ignoring the user's input: `--where`/`--near`/`--bbox` select
@@ -446,6 +491,18 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
446
491
  context: { subcommand: sub, flag: geoFlag },
447
492
  });
448
493
  }
494
+ if (sub === "init" || sub === "encrypt") {
495
+ const allowed = CDB_FILE_FLAGS[sub];
496
+ const ignored = suppliedFlags.find((flag) => !allowed.includes(flag));
497
+ if (ignored !== undefined) {
498
+ throw new CentrsError({
499
+ code: "input/invalid-command",
500
+ summary: `\`${ignored}\` is not supported by \`centrs devices ${sub}\`.`,
501
+ remediation: `\`centrs devices ${sub}\` accepts only ${allowed.map((flag) => `\`${flag}\``).join(", ")}. Edit records with \`centrs devices add\`/\`set\`.`,
502
+ context: { subcommand: sub, flag: ignored },
503
+ });
504
+ }
505
+ }
449
506
 
450
507
  if (sub === "show" || sub === "edit" || sub === "remove") {
451
508
  if (rest.length !== 1) {
@@ -511,7 +568,14 @@ function parseKvArg(token: string): CommentKvUpdate {
511
568
  // canonical key a `--lon`/`--lat`/`--altitude` flag would use. A no-op for
512
569
  // every non-geo key.
513
570
  const key = canonicalizeGeoKey(token.slice(0, eq));
514
- return { key, value: parseKvValue(token.slice(eq + 1)) };
571
+ const value = parseKvValue(token.slice(eq + 1));
572
+ // An empty value removes the token (`devices set <t> via=` deletes the
573
+ // `via` override) instead of storing a literally-empty value, per
574
+ // `commands/devices/README.md` (Comment kv-soup). The core upsert/remove
575
+ // model lives in `applyCommentKv` (`src/resolver/comment-kv.ts`), which
576
+ // treats `value: null` as removal; the CLI lowers `key=` to that form
577
+ // here. There is currently no need to store a literally-empty value.
578
+ return { key, value: value === "" ? null : value };
515
579
  }
516
580
 
517
581
  /**
@@ -610,12 +674,16 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
610
674
  }
611
675
 
612
676
  envSnapshot = Bun.env;
613
- if (parsed.subcommand === "init") {
614
- const envelope = await initCdb({
677
+ if (parsed.subcommand === "init" || parsed.subcommand === "encrypt") {
678
+ const options = {
615
679
  cdbFile: parsed.cdbFile,
616
680
  cdbPassword: parsed.cdbPassword,
617
681
  env: envSnapshot,
618
- });
682
+ };
683
+ const envelope =
684
+ parsed.subcommand === "init"
685
+ ? await initCdb({ ...options, encrypt: parsed.encrypt })
686
+ : await encryptCdb({ ...options, skipBackup: parsed.noBackup });
619
687
  console.log(renderDevicesEnvelope(envelope, parsed.format ?? "text"));
620
688
  return 0;
621
689
  }
@@ -121,6 +121,12 @@ export const executeCommand: CliCommandMetadata = {
121
121
  valueName: "<password>",
122
122
  description: "Decrypt an encrypted WinBox CDB file.",
123
123
  },
124
+ {
125
+ flag: "--match",
126
+ valueName: "<user=name|target=addr|record-type>",
127
+ description:
128
+ "Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
129
+ },
124
130
  {
125
131
  flag: "--resolve",
126
132
  valueName: "<none|arp>",
@@ -262,6 +268,9 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
262
268
  case "--cdb-password":
263
269
  parsed.cdbPassword = expectValue(args, ++index, arg);
264
270
  break;
271
+ case "--match":
272
+ parsed.match = expectValue(args, ++index, arg);
273
+ break;
265
274
  case "--resolve":
266
275
  parsed.resolve = expectValue(args, ++index, arg);
267
276
  break;
@@ -199,6 +199,12 @@ export const retrieveCommand: CliCommandMetadata = {
199
199
  valueName: "<password>",
200
200
  description: "Decrypt an encrypted WinBox CDB file.",
201
201
  },
202
+ {
203
+ flag: "--match",
204
+ valueName: "<user=name|target=addr|record-type>",
205
+ description:
206
+ "Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
207
+ },
202
208
  {
203
209
  flag: "--resolve",
204
210
  valueName: "<none|arp>",
@@ -537,6 +543,9 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
537
543
  case "--cdb-password":
538
544
  request.cdbPassword = expectValue(args, ++index, arg);
539
545
  break;
546
+ case "--match":
547
+ request.match = expectValue(args, ++index, arg);
548
+ break;
540
549
  case "--resolve":
541
550
  request.resolve = expectValue(args, ++index, arg);
542
551
  break;
@@ -97,6 +97,12 @@ export const terminalCommand: CliCommandMetadata = {
97
97
  valueName: "<password>",
98
98
  description: "Decrypt an encrypted WinBox CDB file.",
99
99
  },
100
+ {
101
+ flag: "--match",
102
+ valueName: "<user=name|target=addr|record-type>",
103
+ description:
104
+ "Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
105
+ },
100
106
  {
101
107
  flag: "--resolve",
102
108
  valueName: "<none|arp>",
@@ -259,6 +265,9 @@ export function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
259
265
  case "--cdb-password":
260
266
  flags.cdbPassword = expectValue(args, ++index, arg);
261
267
  break;
268
+ case "--match":
269
+ flags.match = expectValue(args, ++index, arg);
270
+ break;
262
271
  case "--resolve":
263
272
  flags.resolve = expectValue(args, ++index, arg);
264
273
  break;
@@ -73,7 +73,7 @@ export const transferCommand: CliCommandMetadata = {
73
73
  flag: "--via",
74
74
  valueName: "<method>",
75
75
  description:
76
- "Pin the method: rest, native, or sftp (large transfers); scp/fetch/ftp are not built yet. Auto picks the cheapest by size/direction.",
76
+ "Pin the method: rest, native, or sftp (large transfers; `ssh` is an alias); scp/fetch/ftp are not built yet. Auto picks the cheapest by size/direction, after a CDB record's `via=` (ssh→sftp).",
77
77
  },
78
78
  {
79
79
  flag: "--ssh-key",
@@ -164,6 +164,12 @@ export const transferCommand: CliCommandMetadata = {
164
164
  valueName: "<password>",
165
165
  description: "Decrypt an encrypted WinBox CDB file.",
166
166
  },
167
+ {
168
+ flag: "--match",
169
+ valueName: "<user=name|target=addr|record-type>",
170
+ description:
171
+ "Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
172
+ },
167
173
  {
168
174
  flag: "--validate / --no-validate",
169
175
  description: "Enable or disable the existence-probe preflight.",
@@ -414,6 +420,9 @@ export function parseTransferCliArgs(
414
420
  case "--cdb-password":
415
421
  flags.cdbPassword = expectValue(args, ++index, arg);
416
422
  break;
423
+ case "--match":
424
+ flags.match = expectValue(args, ++index, arg);
425
+ break;
417
426
  case "--validate":
418
427
  flags.validate = true;
419
428
  break;
@@ -53,6 +53,15 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
53
53
  summary:
54
54
  "The encrypted CDB could not be decrypted (wrong or missing password).",
55
55
  },
56
+ {
57
+ code: "cdb/encrypt-failed",
58
+ summary: "The CDB could not be rewritten with encryption.",
59
+ },
60
+ {
61
+ code: "cdb/plaintext-backups",
62
+ summary:
63
+ "CDB backups may still contain plaintext credentials after encryption.",
64
+ },
56
65
  {
57
66
  code: "cdb/empty-group",
58
67
  summary:
@@ -95,7 +104,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
95
104
  },
96
105
  {
97
106
  code: "cdb/password-required",
98
- summary: "The CDB is encrypted; a --cdb-password is required to read it.",
107
+ summary:
108
+ "A CDB password is required for decryption or explicit encryption.",
99
109
  },
100
110
  {
101
111
  code: "cdb/reserved-key",
@@ -187,7 +197,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
187
197
  },
188
198
  {
189
199
  code: "input/invalid-routeros-path",
190
- summary: "The RouterOS path is malformed (it must be slash-prefixed).",
200
+ summary:
201
+ "The RouterOS path is malformed: not slash-prefixed, a segment that is not a menu name, or characters a REST URL would reinterpret.",
191
202
  },
192
203
  {
193
204
  code: "input/invalid-target",
@@ -0,0 +1,89 @@
1
+ /**
2
+ * RouterOS menu-path grammar for the read surfaces (`retrieve`, `api`) and
3
+ * the REST URL guard every REST request passes through (#586).
4
+ *
5
+ * Every dir and command name in the RouterOS 7.9–7.26 command tree (rosetta
6
+ * v0.12.0, 579 dirs / 5370 commands) is letters, digits and hyphens,
7
+ * starting with a letter or digit (`/interface/6to4`); case folds. Anything else
8
+ * in a menu segment is not a menu, and some of it changes what the request
9
+ * addresses: in a REST URL, `#` and `?` turn the appended `/print` into a
10
+ * fragment or query, so `retrieve '/system/reboot#'` posted to
11
+ * `/rest/system/reboot` and rebooted the router (CHR 7.24.5).
12
+ */
13
+
14
+ import { CentrsError } from "../errors.ts";
15
+ import { pathTokens } from "./inspect.ts";
16
+
17
+ const MENU_SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
18
+
19
+ /** The menu segments of `path` that are not RouterOS menu or command names. */
20
+ export function invalidMenuSegments(path: string): string[] {
21
+ // Case folds, as RouterOS menus do (`src/explain/menus.ts` isMenuPath);
22
+ // letter case cannot change how a URL parses, so it is not a hazard.
23
+ return pathTokens(path).filter(
24
+ (segment) => !MENU_SEGMENT.test(segment.toLowerCase()),
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Reject a read-surface path whose segments are not RouterOS menu names.
30
+ * Runs before any network work and regardless of `--validate`: that flag
31
+ * skips command validation, not the guarantee that a read dispatches a read.
32
+ */
33
+ export function assertRouterOsMenuPath(
34
+ path: string,
35
+ context: { readonly flag?: string; readonly endpoint?: string } = {},
36
+ ): void {
37
+ const invalid = invalidMenuSegments(path);
38
+ if (invalid.length === 0) {
39
+ return;
40
+ }
41
+ throw new CentrsError({
42
+ code: "input/invalid-routeros-path",
43
+ summary: `RouterOS path ${JSON.stringify(path)} has a segment that is not a menu name: ${invalid.map((segment) => JSON.stringify(segment)).join(", ")}.`,
44
+ remediation:
45
+ "Write the menu path with letters, digits and hyphens between slashes, such as `/ip/address` or `/system/resource`. Filter rows with `--query`, not characters in the path.",
46
+ context: { path, invalidSegments: invalid, ...context },
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Characters and segments that make a WHATWG URL address something other
52
+ * than the path as written: `?` starts a query, `#` a fragment, `\` is read
53
+ * as `/`, tabs and newlines are stripped, and `.`/`..` segments collapse
54
+ * (also percent-encoded: `%2e%2e`, `.%2E`).
55
+ * RouterOS does not decode percent-escapes in REST paths (`reboot%23` is
56
+ * "no such command or directory" on CHR 7.24.5), so the rest is inert.
57
+ */
58
+ function hasUrlStructuralCharacter(path: string): boolean {
59
+ for (const char of path) {
60
+ const code = char.charCodeAt(0);
61
+ if (char === "?" || char === "#" || char === "\\") return true;
62
+ if (code <= 0x1f || code === 0x7f) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /** `.`/`..` and the percent-encoded forms WHATWG also collapses (`%2e%2e`, `.%2E`). */
68
+ const DOT_SEGMENT = /^(?:\.|%2e){1,2}$/i;
69
+
70
+ /**
71
+ * Refuse a REST request path that the URL parser would rewrite. This is the
72
+ * backstop under every REST caller (retrieve, api, execute, transfer, explain)
73
+ * so an appended `/print` can never be cut off before dispatch.
74
+ */
75
+ export function assertRestRequestPath(path: string): void {
76
+ const dotSegment = path
77
+ .split("/")
78
+ .some((segment) => DOT_SEGMENT.test(segment));
79
+ if (!hasUrlStructuralCharacter(path) && !dotSegment) {
80
+ return;
81
+ }
82
+ throw new CentrsError({
83
+ code: "input/invalid-routeros-path",
84
+ summary: `Refusing REST request path ${JSON.stringify(path)}: it contains characters the URL would reinterpret (?, #, \\, control characters, or a . / .. segment), so the request would not reach the path as written.`,
85
+ remediation:
86
+ "Remove the character from the path. Pass filters with `--query` and values as fields; names containing these characters can be selected with a query instead of the URL path.",
87
+ context: { path, via: "rest-api" },
88
+ });
89
+ }