@wenathlan/argan 2.0.81 → 2.0.89

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/argan",
3
- "version": "2.0.81",
3
+ "version": "2.0.89",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/wenathlan/devthink"
@@ -0,0 +1,124 @@
1
+ // # dnssec.test — honest unit tests for the DNSSEC validation math, runnable
2
+ // with the node built-in runner (no install, no dependencies):
3
+ // node --test tests/dnssec.test.ts
4
+ import assert from "node:assert/strict";
5
+ import { describe, it } from "node:test";
6
+ import {
7
+ DNSSECALGORITHMS,
8
+ DNSSECDIGESTTYPES,
9
+ keyTag,
10
+ parseRrSig,
11
+ validateChainLink,
12
+ validateDsMatch,
13
+ validateSignatureLabels,
14
+ validateSignatureTtl,
15
+ validateSignatureWindow,
16
+ DnssecValidationError,
17
+ type DnsKey,
18
+ } from "../dnssec.ts";
19
+
20
+ /** A small DNSKEY whose key tag was computed by hand from the RFC 4034 Appendix B loop. */
21
+ const ZONEKEY: DnsKey = { flags: 256, protocol: 3, algorithm: 8, publicKey: "qrs=" };
22
+
23
+ /** A fresh RRSIG presentation line over www.example.com. (2026 window). */
24
+ const SIGNATURELINE = "A 8 3 300 20261231235959 20260101000000 18928 www.example.com. dGVzdHNpZ25hdHVyZQ==";
25
+
26
+ describe("dnssec presentation parsing", () => {
27
+ it("parses an RRSIG line with its zone-file timestamps", () => {
28
+ const sig = parseRrSig(SIGNATURELINE);
29
+ assert.equal(sig.typeCovered, "A");
30
+ assert.equal(sig.algorithm, 8);
31
+ assert.equal(sig.labels, 3);
32
+ assert.equal(sig.originalTtl, 300);
33
+ assert.equal(sig.keyTag, 18928);
34
+ assert.equal(sig.signerName, "www.example.com.");
35
+ assert.equal(sig.expiration, Date.UTC(2026, 11, 31, 23, 59, 59));
36
+ assert.equal(sig.inception, Date.UTC(2026, 0, 1, 0, 0, 0));
37
+ });
38
+
39
+ it("accepts the optional leading RRSIG token and rejects broken rows", () => {
40
+ assert.equal(parseRrSig(`RRSIG ${SIGNATURELINE}`).typeCovered, "A");
41
+ assert.throws(() => parseRrSig("A 8 3"), DnssecValidationError);
42
+ assert.throws(() => parseRrSig("A 8 3 300 2026 20260101000000 1 example.com. aa"), DnssecValidationError);
43
+ // expiration before inception is structurally impossible
44
+ assert.throws(
45
+ () => parseRrSig("A 8 3 300 20260101000000 20261231235959 1 example.com. aa"),
46
+ DnssecValidationError,
47
+ );
48
+ });
49
+ });
50
+
51
+ describe("dnssec key tag and chain math", () => {
52
+ it("computes the RFC 4034 Appendix B checksum by hand", () => {
53
+ // rdata = 01 00 03 08 aa bb → 256 + 0 + 768 + 8 + 43520 + 187 = 44739
54
+ assert.equal(keyTag(ZONEKEY), 44739);
55
+ });
56
+
57
+ it("answers the IANA registries the validator consults", () => {
58
+ assert.equal(DNSSECALGORITHMS.get(15), "ED25519");
59
+ assert.equal(DNSSECDIGESTTYPES.get(2), "SHA-256");
60
+ assert.ok(!DNSSECALGORITHMS.has(0));
61
+ });
62
+
63
+ it("matches a DS to its DNSKEY and names the failed check", () => {
64
+ const ds = { keyTag: keyTag(ZONEKEY), algorithm: 8, digestType: 2, digest: "9f86d081884c7d65" };
65
+ assert.deepEqual(validateDsMatch(ds, ZONEKEY), { ok: true });
66
+ assert.deepEqual(validateChainLink(ds, ZONEKEY), { ok: true });
67
+ const wrongTag = validateDsMatch({ ...ds, keyTag: 1 }, ZONEKEY);
68
+ assert.equal(wrongTag.ok, false);
69
+ assert.equal(wrongTag.ok ? "" : wrongTag.code, "key-tag-mismatch");
70
+ const wrongAlgorithm = validateDsMatch({ ...ds, algorithm: 13 }, ZONEKEY);
71
+ assert.equal(wrongAlgorithm.ok ? "" : wrongAlgorithm.code, "algorithm-mismatch");
72
+ const unknownDigest = validateDsMatch({ ...ds, digestType: 9 }, ZONEKEY);
73
+ assert.equal(unknownDigest.ok ? "" : unknownDigest.code, "unknown-digest-type");
74
+ const unknownAlgorithm = validateDsMatch({ ...ds, algorithm: 0 }, ZONEKEY);
75
+ assert.equal(unknownAlgorithm.ok ? "" : unknownAlgorithm.code, "unknown-algorithm");
76
+ // flags and protocol are part of the rdata, so each mutation recomputes its own DS tag
77
+ const strippedKey = { ...ZONEKEY, flags: 1 };
78
+ const noZoneKey = validateDsMatch({ keyTag: keyTag(strippedKey), algorithm: 8, digestType: 2, digest: "aa" }, strippedKey);
79
+ assert.equal(noZoneKey.ok ? "" : noZoneKey.code, "not-a-zone-key");
80
+ const oldProtocolKey = { ...ZONEKEY, protocol: 2 };
81
+ const badProtocol = validateDsMatch({ keyTag: keyTag(oldProtocolKey), algorithm: 8, digestType: 2, digest: "aa" }, oldProtocolKey);
82
+ assert.equal(badProtocol.ok ? "" : badProtocol.code, "protocol-mismatch");
83
+ });
84
+ });
85
+
86
+ describe("dnssec clock and ttl windows", () => {
87
+ const sig = parseRrSig(SIGNATURELINE);
88
+
89
+ it("answers ok inside the validity window and names the reason outside", () => {
90
+ const inside = Date.UTC(2026, 5, 15, 12, 0, 0);
91
+ assert.deepEqual(validateSignatureWindow(sig, inside), { ok: true });
92
+ const after = Date.UTC(2027, 0, 2, 0, 0, 0);
93
+ const expired = validateSignatureWindow(sig, after);
94
+ assert.equal(expired.ok ? "" : expired.code, "expired-signature");
95
+ const before = Date.UTC(2025, 0, 1, 0, 0, 0);
96
+ const early = validateSignatureWindow(sig, before);
97
+ assert.equal(early.ok ? "" : early.code, "not-yet-valid-signature");
98
+ });
99
+
100
+ it("honors the clock skew on both edges of the window", () => {
101
+ const justExpired = sig.expiration + 60_000;
102
+ assert.equal(validateSignatureWindow(sig, justExpired, 120_000).ok, true);
103
+ assert.equal(validateSignatureWindow(sig, justExpired, 0).ok, false);
104
+ const justStarted = sig.inception - 60_000;
105
+ assert.equal(validateSignatureWindow(sig, justStarted, 120_000).ok, true);
106
+ });
107
+
108
+ it("enforces the rrset ttl equality and the original ttl ceiling", () => {
109
+ assert.deepEqual(validateSignatureTtl(sig, 300, 300), { ok: true });
110
+ const mismatch = validateSignatureTtl(sig, 600, 300);
111
+ assert.equal(mismatch.ok ? "" : mismatch.code, "ttl-mismatch");
112
+ const ceiling = validateSignatureTtl(parseRrSig("A 8 3 60 20261231235959 20260101000000 1 example.com. aa"), 300, 300);
113
+ assert.equal(ceiling.ok ? "" : ceiling.code, "ttl-exceeds-original");
114
+ });
115
+
116
+ it("checks the label count with the wildcard rule", () => {
117
+ assert.deepEqual(validateSignatureLabels(sig, "www.example.com."), { ok: true });
118
+ // the wildcard drops its leftmost label: *.example.com. signs as 2 labels
119
+ const wildcard = parseRrSig("A 8 2 300 20261231235959 20260101000000 1 example.com. aa");
120
+ assert.deepEqual(validateSignatureLabels(wildcard, "*.example.com."), { ok: true });
121
+ const broken = validateSignatureLabels(sig, "a.b.example.com.");
122
+ assert.equal(broken.ok ? "" : broken.code, "label-count-mismatch");
123
+ });
124
+ });
@@ -0,0 +1,162 @@
1
+ // # zonediff.test — honest unit tests for the zone diff, runnable with the node
2
+ // built-in runner (no install, no dependencies):
3
+ // node --test tests/zonediff.test.ts
4
+ import assert from "node:assert/strict";
5
+ import { describe, it } from "node:test";
6
+ import { nextSerial } from "../argan.ts";
7
+ import {
8
+ diffZoneFiles,
9
+ diffZones,
10
+ parseZoneRecords,
11
+ recordsFromRows,
12
+ serialForPublication,
13
+ ZoneDiffError,
14
+ type ZoneRecord,
15
+ } from "../zonediff.ts";
16
+
17
+ const ORIGIN = "example.com.";
18
+
19
+ /** A shorthand record builder. */
20
+ function record(name: string, type: string, ttl: number | undefined, data: string): ZoneRecord {
21
+ return { name, type, ttl, data };
22
+ }
23
+
24
+ describe("zonediff parser", () => {
25
+ it("parses absolute and relative owners, @, ttl and class in both orders", () => {
26
+ const text = [
27
+ "example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5",
28
+ "www 3600 IN A 192.0.2.10",
29
+ "@ IN A 192.0.2.1",
30
+ "mail IN 600 A 192.0.2.20",
31
+ ].join("\n");
32
+ const records = parseZoneRecords(text, ORIGIN);
33
+ assert.equal(records.length, 4);
34
+ assert.equal(records[0].name, "example.com.");
35
+ assert.equal(records[0].type, "SOA");
36
+ assert.equal(records[0].ttl, 300);
37
+ assert.equal(records[1].name, "www.example.com.");
38
+ assert.equal(records[1].ttl, 3600);
39
+ assert.equal(records[2].name, "example.com.");
40
+ assert.equal(records[2].ttl, undefined);
41
+ assert.equal(records[3].name, "mail.example.com.");
42
+ assert.equal(records[3].ttl, 600);
43
+ });
44
+
45
+ it("honors $TTL, $ORIGIN, unit ttls and name normalization in rdata", () => {
46
+ const text = [
47
+ "$TTL 1h30m",
48
+ "$ORIGIN example.com.",
49
+ "www 10m A 192.0.2.10",
50
+ "api CNAME www",
51
+ "@ 300 MX 10 Mail",
52
+ "_sip._tcp SRV 10 60 5060 sipserver",
53
+ ].join("\n");
54
+ const records = parseZoneRecords(text, "example.org.");
55
+ assert.equal(records[0].ttl, 600);
56
+ assert.equal(records[1].data, "www.example.com.");
57
+ assert.equal(records[2].data, "10 mail.example.com.");
58
+ assert.equal(records[3].data, "10 60 5060 sipserver.example.com.");
59
+ // a record without its own ttl inherits the $TTL default (RFC 2308)
60
+ assert.equal(records[1].ttl, 5400);
61
+ });
62
+
63
+ it("inherits the owner on indented lines and joins parenthesized continuations", () => {
64
+ const text = [
65
+ "$ORIGIN example.com.",
66
+ "@ 300 IN SOA ns1.example.com. hostmaster.example.com. (",
67
+ " 2026010101 ; serial",
68
+ " 7200 ; refresh",
69
+ " )",
70
+ "www 300 IN A 192.0.2.10",
71
+ ].join("\n");
72
+ const records = parseZoneRecords(text, ORIGIN);
73
+ assert.equal(records.length, 2);
74
+ assert.equal(records[0].type, "SOA");
75
+ assert.equal(records[0].name, "example.com.");
76
+ assert.ok(records[0].data.includes("2026010101"));
77
+ assert.ok(records[0].data.includes("7200"));
78
+ });
79
+
80
+ it("drops comments and quoted-content keeps spaces", () => {
81
+ const text = ['chat 300 IN TXT "hello world" ; the greeting'].join("\n");
82
+ const records = parseZoneRecords(text, ORIGIN);
83
+ assert.equal(records[0].data, "hello world");
84
+ });
85
+
86
+ it("throws a traceable ZoneDiffError with the line number", () => {
87
+ const text = ["ok 300 IN A 192.0.2.1", "broken 300 IN"].join("\n");
88
+ assert.throws(
89
+ () => parseZoneRecords(text, ORIGIN),
90
+ (error: unknown) => error instanceof ZoneDiffError && error.line === 2,
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("zonediff planner", () => {
96
+ it("answers an empty diff for identical records", () => {
97
+ const zone = [
98
+ record("example.com.", "A", 300, "192.0.2.1"),
99
+ record("www.example.com.", "A", 300, "192.0.2.10"),
100
+ record("www.example.com.", "AAAA", 300, "2001:db8::10"),
101
+ ];
102
+ const diff = diffZones(zone, [...zone].reverse());
103
+ assert.deepEqual(diff, { creates: [], deletes: [], updates: [] });
104
+ });
105
+
106
+ it("detects creates, deletes and target updates", () => {
107
+ const current = [
108
+ record("example.com.", "A", 300, "192.0.2.1"),
109
+ record("old.example.com.", "A", 300, "192.0.2.2"),
110
+ record("www.example.com.", "CNAME", 300, "web.example.com."),
111
+ ];
112
+ const desired = [
113
+ record("example.com.", "A", 300, "192.0.2.1"),
114
+ record("new.example.com.", "A", 300, "192.0.2.3"),
115
+ record("www.example.com.", "CNAME", 300, "edge.example.com."),
116
+ ];
117
+ const diff = diffZones(current, desired);
118
+ assert.equal(diff.creates.length, 1);
119
+ assert.equal(diff.creates[0].after?.name, "new.example.com.");
120
+ assert.equal(diff.deletes.length, 1);
121
+ assert.equal(diff.deletes[0].before?.name, "old.example.com.");
122
+ assert.equal(diff.updates.length, 1);
123
+ assert.equal(diff.updates[0].reason, "targets");
124
+ assert.equal(diff.updates[0].before?.data, "web.example.com.");
125
+ assert.equal(diff.updates[0].after?.data, "edge.example.com.");
126
+ });
127
+
128
+ it("never changes on round robin order and reports ttl-only updates", () => {
129
+ const current = [record("api.example.com.", "A", 300, "192.0.2.1"), record("api.example.com.", "A", 300, "192.0.2.2")];
130
+ const reordered = [record("api.example.com.", "A", 300, "192.0.2.2"), record("api.example.com.", "A", 300, "192.0.2.1")];
131
+ assert.deepEqual(diffZones(current, reordered), { creates: [], deletes: [], updates: [] });
132
+ const rettl = current.map((entry) => record(entry.name, entry.type, 600, entry.data));
133
+ const diff = diffZones(current, rettl);
134
+ assert.equal(diff.updates.length, 1);
135
+ assert.equal(diff.updates[0].reason, "ttl");
136
+ assert.equal(diff.updates[0].before?.ttl, 300);
137
+ assert.equal(diff.updates[0].after?.ttl, 600);
138
+ });
139
+
140
+ it("keeps the serial when the diff is empty and bumps it through argan.nextSerial", () => {
141
+ const zone = [record("example.com.", "A", 300, "192.0.2.1")];
142
+ const serial = "2026093001";
143
+ assert.equal(serialForPublication(diffZones(zone, zone), serial), serial);
144
+ assert.equal(serialForPublication(diffZones(zone, []), serial), nextSerial(serial));
145
+ assert.equal(nextSerial("2026093001"), "2026093002");
146
+ });
147
+
148
+ it("diffs two verbatim zone files and bridges the RecordSetRow tables", () => {
149
+ const currentText = "www 300 IN A 192.0.2.10";
150
+ const desiredText = "www 300 IN A 192.0.2.11";
151
+ const diff = diffZoneFiles(currentText, desiredText, ORIGIN);
152
+ assert.equal(diff.updates.length, 1);
153
+ const rows = [
154
+ { kind: "record" as const, name: "www", type: "A", data: "192.0.2.10", line: "www 300 IN A 192.0.2.10" },
155
+ { kind: "blank" as const, line: "" },
156
+ ];
157
+ const records = recordsFromRows(rows, ORIGIN);
158
+ assert.equal(records.length, 1);
159
+ assert.equal(records[0].name, "www.example.com.");
160
+ assert.deepEqual(diffZones(records, parseZoneRecords(desiredText, ORIGIN)).updates.length, 1);
161
+ });
162
+ });