@portll/cobolwork 0.2.76 → 0.2.140
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 +66 -14
- package/bin/cobolwork.mjs +126 -9
- package/lib/arith.mjs +235 -0
- package/lib/baseline.mjs +5 -0
- package/lib/build.mjs +112 -30
- package/lib/capabilities.mjs +12 -7
- package/lib/card.mjs +18 -0
- package/lib/cics-commands.mjs +136 -125
- package/lib/consequence.mjs +29 -0
- package/lib/control.mjs +1169 -91
- package/lib/csd.mjs +17 -2
- package/lib/dataflow.mjs +276 -126
- package/lib/embedded-sql.mjs +110 -0
- package/lib/enterprise-options.mjs +255 -0
- package/lib/equivalence.mjs +124 -0
- package/lib/evidence/cli.mjs +65 -0
- package/lib/evidence/journal.mjs +94 -0
- package/lib/evidence/record.mjs +110 -0
- package/lib/evidence/run.mjs +111 -0
- package/lib/evidence/seal.mjs +177 -0
- package/lib/evidence/slsa.mjs +64 -0
- package/lib/evidence/sshsig.mjs +172 -0
- package/lib/evidence/store.mjs +170 -0
- package/lib/evidence/verify.mjs +167 -0
- package/lib/explain.mjs +9 -5
- package/lib/exploitability.mjs +263 -0
- package/lib/ftp.mjs +136 -0
- package/lib/ironwork.mjs +109 -0
- package/lib/jcl.mjs +27 -7
- package/lib/kernel/findings.mjs +11 -0
- package/lib/kernel/registry.mjs +4 -0
- package/lib/layout.mjs +202 -0
- package/lib/option-diff.mjs +49 -0
- package/lib/options.mjs +109 -46
- package/lib/parser.mjs +80 -209
- package/lib/policy.mjs +8 -3
- package/lib/precompile.mjs +5 -84
- package/lib/reach.mjs +29 -12
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +1 -0
- package/lib/sbom.mjs +235 -0
- package/lib/scan.mjs +93 -27
- package/lib/sets/cics.mjs +119 -2
- package/lib/sets/copybook.mjs +2 -1
- package/lib/sets/flow.mjs +82 -2
- package/lib/sets/hidden.mjs +4 -8
- package/lib/sets/jcl.mjs +70 -106
- package/lib/sets/recon.mjs +2 -1
- package/lib/sets/semantics.mjs +377 -0
- package/lib/sets/web.mjs +34 -10
- package/lib/sets/zowe.mjs +246 -0
- package/lib/sources.mjs +10 -0
- package/lib/tui/app.mjs +31 -13
- package/lib/tui/model.mjs +17 -2
- package/lib/utilities.mjs +229 -15
- package/lib/verify.mjs +128 -0
- package/package.json +1 -1
- package/rules/compliance-dora.json +507 -0
- package/rules/compliance-ffiec.json +507 -0
- package/rules/compliance-nist80053.json +507 -0
- package/schema/cobolwork.policy.schema.json +94 -16
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// EXEC SQL as Db2 reads it, whichever language embeds it: a statement's words, host variables and
|
|
3
|
+
// literals, and which host variables it reads and which it writes.
|
|
4
|
+
// The words, host variables and literals of a statement. A host variable is :NAME, qualified as
|
|
5
|
+
// :GROUP.NAME, and may carry an indicator variable, :NAME:IND or :NAME INDICATOR :IND.
|
|
6
|
+
export function sqlTokens(sql) {
|
|
7
|
+
const toks = [];
|
|
8
|
+
const re = /'(?:[^']|'')*'|"(?:[^"]|"")*"|:\s*[A-Z0-9_$#@-]+(?:\.[A-Z0-9_$#@-]+)*|[A-Z0-9_$#@-]+|[(),=<>+*/;.]/gi;
|
|
9
|
+
for (const m of sql.matchAll(re)) {
|
|
10
|
+
const t = m[0];
|
|
11
|
+
if (t.startsWith(':')) toks.push({ host: t.slice(1).trim().toUpperCase() });
|
|
12
|
+
else if (/^['"]/.test(t)) toks.push({ lit: t });
|
|
13
|
+
else toks.push({ word: t.toUpperCase() });
|
|
14
|
+
}
|
|
15
|
+
return toks;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Clauses that end an INTO list.
|
|
19
|
+
const AFTER_INTO = new Set(['FROM', 'WHERE', 'GROUP', 'HAVING', 'ORDER', 'FETCH', 'FOR', 'WITH', 'OPTIMIZE', 'QUERYNO', 'SKIP', 'UNION', 'USING', 'VALUES']);
|
|
20
|
+
|
|
21
|
+
// Which host variables a statement reads and which it writes, as Db2's SQL reference gives them. A
|
|
22
|
+
// variable both read and written is in both lists. INTO a host variable writes it (INSERT INTO and
|
|
23
|
+
// MERGE INTO name a table); so do SET's targets, GET DIAGNOSTICS' and ASSOCIATE LOCATORS' list. A
|
|
24
|
+
// procedure's argument that is a lone variable may be IN, OUT or INOUT, which only the server knows.
|
|
25
|
+
export function sqlRoles(toks) {
|
|
26
|
+
const sending = [];
|
|
27
|
+
const receiving = [];
|
|
28
|
+
const read = (h) => { if (!sending.includes(h)) sending.push(h); };
|
|
29
|
+
const write = (h) => { if (!receiving.includes(h)) receiving.push(h); };
|
|
30
|
+
const verb = toks.find((t) => t.word)?.word;
|
|
31
|
+
if (verb === 'CALL') return callRoles(toks, read, write), { sending, receiving };
|
|
32
|
+
// DESCRIBE and PREPARE read the SQLDA's SQLN and write the rest of it.
|
|
33
|
+
const intoBoth = verb === 'DESCRIBE' || verb === 'PREPARE';
|
|
34
|
+
let into = false;
|
|
35
|
+
let assigning = verb === 'SET';
|
|
36
|
+
let depth = 0;
|
|
37
|
+
for (let i = 0; i < toks.length; i++) {
|
|
38
|
+
const t = toks[i];
|
|
39
|
+
const next = toks[i + 1];
|
|
40
|
+
if (t.word === '(') depth++;
|
|
41
|
+
else if (t.word === ')') depth--;
|
|
42
|
+
else if (verb === 'SET' && t.word === '=' && depth === 0) assigning = false;
|
|
43
|
+
else if (verb === 'SET' && t.word === ',' && depth === 0) assigning = true;
|
|
44
|
+
if (t.word === 'INTO' && next && (next.host || next.word === 'DESCRIPTOR')) { into = true; continue; }
|
|
45
|
+
if (into && t.word && AFTER_INTO.has(t.word)) into = false;
|
|
46
|
+
if (t.word === 'DESCRIPTOR' && next?.host) {
|
|
47
|
+
// FETCH ... INTO DESCRIPTOR and USING DESCRIPTOR are synonyms: the program fills the SQLDA and Db2 writes where it points.
|
|
48
|
+
if (verb === 'FETCH' || intoBoth) { read(next.host); write(next.host); } else read(next.host);
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!t.host) continue;
|
|
53
|
+
const written = into || assigning
|
|
54
|
+
|| (verb === 'GET' && next?.word === '=')
|
|
55
|
+
|| (verb === 'ASSOCIATE' && !toks.slice(0, i).some((x) => x.word === 'WITH'));
|
|
56
|
+
if (!written || (into && intoBoth)) read(t.host);
|
|
57
|
+
if (written) write(t.host);
|
|
58
|
+
}
|
|
59
|
+
return { sending, receiving };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// CALL :name reads the name. Each argument that is a lone variable, with or without its indicator,
|
|
63
|
+
// is read and may be written; any other argument is an expression, IN only.
|
|
64
|
+
function callRoles(toks, read, write) {
|
|
65
|
+
let depth = 0;
|
|
66
|
+
let arg = [];
|
|
67
|
+
const close = () => {
|
|
68
|
+
const hosts = arg.filter((t) => t.host);
|
|
69
|
+
const lone = hosts.length && arg.every((t) => t.host || t.word === 'INDICATOR');
|
|
70
|
+
for (const h of hosts) { read(h.host); if (lone) write(h.host); }
|
|
71
|
+
arg = [];
|
|
72
|
+
};
|
|
73
|
+
for (let i = 1; i < toks.length; i++) {
|
|
74
|
+
const t = toks[i];
|
|
75
|
+
if (t.word === 'DESCRIPTOR' && toks[i + 1]?.host) { read(toks[i + 1].host); write(toks[i + 1].host); i++; continue; }
|
|
76
|
+
if (t.word === '(') { if (depth++ > 0) arg.push(t); continue; }
|
|
77
|
+
if (t.word === ')') { if (--depth > 0) arg.push(t); else close(); continue; }
|
|
78
|
+
if (depth === 0) { if (t.host) read(t.host); continue; }
|
|
79
|
+
if (depth === 1 && t.word === ',') { close(); continue; }
|
|
80
|
+
arg.push(t);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function hostVariableRoles(sql) {
|
|
85
|
+
return sqlRoles(sqlTokens(sql));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The host variables in a parsed EXEC SQL block: a colon, then a name qualified by joined periods as
|
|
89
|
+
// :GROUP.FIELD. Each carries its path, the token naming the item, where it stands, and whether the
|
|
90
|
+
// statement writes it by sqlRoles.
|
|
91
|
+
export function hostVariablesIn(toks) {
|
|
92
|
+
const found = [];
|
|
93
|
+
const sql = [];
|
|
94
|
+
for (let k = 0; k < toks.length; k++) {
|
|
95
|
+
const t = toks[k];
|
|
96
|
+
if (t.t === 'op' && t.v === ':' && toks[k + 1] && toks[k + 1].t === 'word') {
|
|
97
|
+
const path = [toks[k + 1]];
|
|
98
|
+
let j = k + 2;
|
|
99
|
+
for (; j + 1 < toks.length && toks[j].t === 'period' && toks[j].joined && toks[j + 1].t === 'word'; j += 2) path.push(toks[j + 1]);
|
|
100
|
+
const host = path.map((p) => p.u).join('.');
|
|
101
|
+
found.push({ path, tok: path[path.length - 1], at: k + 1, host });
|
|
102
|
+
sql.push({ host });
|
|
103
|
+
k = j - 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
sql.push(t.t === 'lit' ? { lit: t.v } : { word: String(t.u ?? t.v).toUpperCase() });
|
|
107
|
+
}
|
|
108
|
+
const { receiving } = sqlRoles(sql);
|
|
109
|
+
return found.map((h) => ({ ...h, written: receiving.includes(h.host) }));
|
|
110
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Enterprise COBOL's compiler options: every spelling each one answers to, and where it may be given.
|
|
3
|
+
//
|
|
4
|
+
// GENERATED - do not edit by hand. Run `node diag/generate-options.mjs` to rebuild it from
|
|
5
|
+
// provenance/enterprise-options.json, which names the IBM table and page behind each row.
|
|
6
|
+
|
|
7
|
+
// Each spelling's option, and whether the spelling turns the option off.
|
|
8
|
+
export const OPTION_SPELLINGS = {
|
|
9
|
+
"APOST": ["APOST/QUOTE", false],
|
|
10
|
+
"QUOTE": ["APOST/QUOTE", false],
|
|
11
|
+
"Q": ["APOST/QUOTE", false],
|
|
12
|
+
"ARITH": ["ARITH", false],
|
|
13
|
+
"AR": ["ARITH", false],
|
|
14
|
+
"CICS": ["CICS", false],
|
|
15
|
+
"NOCICS": ["CICS", true],
|
|
16
|
+
"CODEPAGE": ["CODEPAGE", false],
|
|
17
|
+
"CP": ["CODEPAGE", false],
|
|
18
|
+
"CURRENCY": ["CURRENCY", false],
|
|
19
|
+
"NOCURRENCY": ["CURRENCY", true],
|
|
20
|
+
"CURR": ["CURRENCY", false],
|
|
21
|
+
"NOCURR": ["CURRENCY", true],
|
|
22
|
+
"DBCS": ["DBCS", false],
|
|
23
|
+
"NODBCS": ["DBCS", true],
|
|
24
|
+
"NSYMBOL": ["NSYMBOL", false],
|
|
25
|
+
"NS": ["NSYMBOL", false],
|
|
26
|
+
"NUMBER": ["NUMBER", false],
|
|
27
|
+
"NONUMBER": ["NUMBER", true],
|
|
28
|
+
"NUM": ["NUMBER", false],
|
|
29
|
+
"NONUM": ["NUMBER", true],
|
|
30
|
+
"QUALIFY": ["QUALIFY", false],
|
|
31
|
+
"QUA": ["QUALIFY", false],
|
|
32
|
+
"SEQUENCE": ["SEQUENCE", false],
|
|
33
|
+
"NOSEQUENCE": ["SEQUENCE", true],
|
|
34
|
+
"SEQ": ["SEQUENCE", false],
|
|
35
|
+
"NOSEQ": ["SEQUENCE", true],
|
|
36
|
+
"SQL": ["SQL", false],
|
|
37
|
+
"NOSQL": ["SQL", true],
|
|
38
|
+
"SQLCCSID": ["SQLCCSID", false],
|
|
39
|
+
"NOSQLCCSID": ["SQLCCSID", true],
|
|
40
|
+
"SQLC": ["SQLCCSID", false],
|
|
41
|
+
"NOSQLC": ["SQLCCSID", true],
|
|
42
|
+
"SQLIMS": ["SQLIMS", false],
|
|
43
|
+
"NOSQLIMS": ["SQLIMS", true],
|
|
44
|
+
"SUPPRESS": ["SUPPRESS", false],
|
|
45
|
+
"NOSUPPRESS": ["SUPPRESS", true],
|
|
46
|
+
"SUPP": ["SUPPRESS", false],
|
|
47
|
+
"NOSUPP": ["SUPPRESS", true],
|
|
48
|
+
"WORD": ["WORD", false],
|
|
49
|
+
"NOWORD": ["WORD", true],
|
|
50
|
+
"WD": ["WORD", false],
|
|
51
|
+
"NOWD": ["WORD", true],
|
|
52
|
+
"XMLPARSE": ["XMLPARSE", false],
|
|
53
|
+
"XP": ["XMLPARSE", false],
|
|
54
|
+
"INTDATE": ["INTDATE", false],
|
|
55
|
+
"CONDCOMP": ["CONDCOMP", false],
|
|
56
|
+
"CC": ["CONDCOMP", false],
|
|
57
|
+
"LANGUAGE": ["LANGUAGE", false],
|
|
58
|
+
"LANG": ["LANGUAGE", false],
|
|
59
|
+
"LINECOUNT": ["LINECOUNT", false],
|
|
60
|
+
"LC": ["LINECOUNT", false],
|
|
61
|
+
"LIST": ["LIST", false],
|
|
62
|
+
"NOLIST": ["LIST", true],
|
|
63
|
+
"MAP": ["MAP", false],
|
|
64
|
+
"NOMAP": ["MAP", true],
|
|
65
|
+
"OFFSET": ["OFFSET", false],
|
|
66
|
+
"NOOFFSET": ["OFFSET", true],
|
|
67
|
+
"OFF": ["OFFSET", false],
|
|
68
|
+
"NOOFF": ["OFFSET", true],
|
|
69
|
+
"SOURCE": ["SOURCE", false],
|
|
70
|
+
"NOSOURCE": ["SOURCE", true],
|
|
71
|
+
"S": ["SOURCE", false],
|
|
72
|
+
"NOS": ["SOURCE", true],
|
|
73
|
+
"SPACE": ["SPACE", false],
|
|
74
|
+
"TERMINAL": ["TERMINAL", false],
|
|
75
|
+
"NOTERMINAL": ["TERMINAL", true],
|
|
76
|
+
"TERM": ["TERMINAL", false],
|
|
77
|
+
"NOTERM": ["TERMINAL", true],
|
|
78
|
+
"VBREF": ["VBREF", false],
|
|
79
|
+
"NOVBREF": ["VBREF", true],
|
|
80
|
+
"XREF": ["XREF", false],
|
|
81
|
+
"NOXREF": ["XREF", true],
|
|
82
|
+
"X": ["XREF", false],
|
|
83
|
+
"NOX": ["XREF", true],
|
|
84
|
+
"COMPILE": ["COMPILE", false],
|
|
85
|
+
"NOCOMPILE": ["COMPILE", true],
|
|
86
|
+
"C": ["COMPILE", false],
|
|
87
|
+
"NOC": ["COMPILE", true],
|
|
88
|
+
"COPYRIGHT": ["COPYRIGHT", false],
|
|
89
|
+
"NOCOPYRIGHT": ["COPYRIGHT", true],
|
|
90
|
+
"CPYR": ["COPYRIGHT", false],
|
|
91
|
+
"NOCPYR": ["COPYRIGHT", true],
|
|
92
|
+
"DECK": ["DECK", false],
|
|
93
|
+
"NODECK": ["DECK", true],
|
|
94
|
+
"D": ["DECK", false],
|
|
95
|
+
"NOD": ["DECK", true],
|
|
96
|
+
"NAME": ["NAME", false],
|
|
97
|
+
"NONAME": ["NAME", true],
|
|
98
|
+
"OBJECT": ["OBJECT", false],
|
|
99
|
+
"NOOBJECT": ["OBJECT", true],
|
|
100
|
+
"OBJ": ["OBJECT", false],
|
|
101
|
+
"NOOBJ": ["OBJECT", true],
|
|
102
|
+
"PGMNAME": ["PGMNAME", false],
|
|
103
|
+
"PGMN": ["PGMNAME", false],
|
|
104
|
+
"SERVICE": ["SERVICE", false],
|
|
105
|
+
"NOSERVICE": ["SERVICE", true],
|
|
106
|
+
"SERV": ["SERVICE", false],
|
|
107
|
+
"NOSERV": ["SERVICE", true],
|
|
108
|
+
"ADV": ["ADV", false],
|
|
109
|
+
"NOADV": ["ADV", true],
|
|
110
|
+
"AFP": ["AFP", false],
|
|
111
|
+
"ARCH": ["ARCH", false],
|
|
112
|
+
"AWO": ["AWO", false],
|
|
113
|
+
"NOAWO": ["AWO", true],
|
|
114
|
+
"BLOCK0": ["BLOCK0", false],
|
|
115
|
+
"NOBLOCK0": ["BLOCK0", true],
|
|
116
|
+
"DEFINE": ["DEFINE", false],
|
|
117
|
+
"NODEFINE": ["DEFINE", true],
|
|
118
|
+
"DEF": ["DEFINE", false],
|
|
119
|
+
"NODEF": ["DEFINE", true],
|
|
120
|
+
"DISPSIGN": ["DISPSIGN", false],
|
|
121
|
+
"DS": ["DISPSIGN", false],
|
|
122
|
+
"DLL": ["DLL", false],
|
|
123
|
+
"NODLL": ["DLL", true],
|
|
124
|
+
"EXPORTALL": ["EXPORTALL", false],
|
|
125
|
+
"NOEXPORTALL": ["EXPORTALL", true],
|
|
126
|
+
"EXP": ["EXPORTALL", false],
|
|
127
|
+
"NOEXP": ["EXPORTALL", true],
|
|
128
|
+
"FASTSRT": ["FASTSRT", false],
|
|
129
|
+
"NOFASTSRT": ["FASTSRT", true],
|
|
130
|
+
"FSRT": ["FASTSRT", false],
|
|
131
|
+
"NOFSRT": ["FASTSRT", true],
|
|
132
|
+
"HGPR": ["HGPR", false],
|
|
133
|
+
"INITIAL": ["INITIAL", false],
|
|
134
|
+
"NOINITIAL": ["INITIAL", true],
|
|
135
|
+
"INLINE": ["INLINE", false],
|
|
136
|
+
"NOINLINE": ["INLINE", true],
|
|
137
|
+
"INL": ["INLINE", false],
|
|
138
|
+
"NOINL": ["INLINE", true],
|
|
139
|
+
"INVDATA": ["INVDATA", false],
|
|
140
|
+
"NOINVDATA": ["INVDATA", true],
|
|
141
|
+
"NOINV": ["INVDATA", true],
|
|
142
|
+
"INVD": ["INVDATA", false],
|
|
143
|
+
"NOINVD": ["INVDATA", true],
|
|
144
|
+
"JAVAIOP": ["JAVAIOP", false],
|
|
145
|
+
"NOJAVAIOP": ["JAVAIOP", true],
|
|
146
|
+
"JIOP": ["JAVAIOP", false],
|
|
147
|
+
"NOJIOP": ["JAVAIOP", true],
|
|
148
|
+
"LP": ["LP", false],
|
|
149
|
+
"MAXPCF": ["MAXPCF", false],
|
|
150
|
+
"NUMCHECK": ["NUMCHECK", false],
|
|
151
|
+
"NONUMCHECK": ["NUMCHECK", true],
|
|
152
|
+
"NC": ["NUMCHECK", false],
|
|
153
|
+
"NONC": ["NUMCHECK", true],
|
|
154
|
+
"NUMPROC": ["NUMPROC", false],
|
|
155
|
+
"OPTIMIZE": ["OPTIMIZE", false],
|
|
156
|
+
"NOOPTIMIZE": ["OPTIMIZE", true],
|
|
157
|
+
"OPT": ["OPTIMIZE", false],
|
|
158
|
+
"OUTDD": ["OUTDD", false],
|
|
159
|
+
"OUT": ["OUTDD", false],
|
|
160
|
+
"PARMCHECK": ["PARMCHECK", false],
|
|
161
|
+
"NOPARMCHECK": ["PARMCHECK", true],
|
|
162
|
+
"PC": ["PARMCHECK", false],
|
|
163
|
+
"NOPC": ["PARMCHECK", true],
|
|
164
|
+
"TRUNC": ["TRUNC", false],
|
|
165
|
+
"TUNE": ["TUNE", false],
|
|
166
|
+
"VLR": ["VLR", false],
|
|
167
|
+
"ZONECHECK": ["ZONECHECK", false],
|
|
168
|
+
"NOZONECHECK": ["ZONECHECK", true],
|
|
169
|
+
"NOZC": ["ZONECHECK", true],
|
|
170
|
+
"ZC": ["ZONECHECK", false],
|
|
171
|
+
"ZONEDATA": ["ZONEDATA", false],
|
|
172
|
+
"ZD": ["ZONEDATA", false],
|
|
173
|
+
"ZWB": ["ZWB", false],
|
|
174
|
+
"NOZWB": ["ZWB", true],
|
|
175
|
+
"BUFSIZE": ["BUFSIZE", false],
|
|
176
|
+
"BUF": ["BUFSIZE", false],
|
|
177
|
+
"DATA": ["DATA", false],
|
|
178
|
+
"DYNAM": ["DYNAM", false],
|
|
179
|
+
"NODYNAM": ["DYNAM", true],
|
|
180
|
+
"DYN": ["DYNAM", false],
|
|
181
|
+
"NODYN": ["DYNAM", true],
|
|
182
|
+
"RENT": ["RENT", false],
|
|
183
|
+
"NORENT": ["RENT", true],
|
|
184
|
+
"RMODE": ["RMODE", false],
|
|
185
|
+
"STGOPT": ["STGOPT", false],
|
|
186
|
+
"NOSTGOPT": ["STGOPT", true],
|
|
187
|
+
"SO": ["STGOPT", false],
|
|
188
|
+
"NOSO": ["STGOPT", true],
|
|
189
|
+
"DIAGTRUNC": ["DIAGTRUNC", false],
|
|
190
|
+
"NODIAGTRUNC": ["DIAGTRUNC", true],
|
|
191
|
+
"DTR": ["DIAGTRUNC", false],
|
|
192
|
+
"NODTR": ["DIAGTRUNC", true],
|
|
193
|
+
"DUMP": ["DUMP", false],
|
|
194
|
+
"NODUMP": ["DUMP", true],
|
|
195
|
+
"DU": ["DUMP", false],
|
|
196
|
+
"NODU": ["DUMP", true],
|
|
197
|
+
"FLAG": ["FLAG", false],
|
|
198
|
+
"NOFLAG": ["FLAG", true],
|
|
199
|
+
"F": ["FLAG", false],
|
|
200
|
+
"NOF": ["FLAG", true],
|
|
201
|
+
"FLAGSTD": ["FLAGSTD", false],
|
|
202
|
+
"NOFLAGSTD": ["FLAGSTD", true],
|
|
203
|
+
"INITCHECK": ["INITCHECK", false],
|
|
204
|
+
"NOINITCHECK": ["INITCHECK", true],
|
|
205
|
+
"IC": ["INITCHECK", false],
|
|
206
|
+
"NOIC": ["INITCHECK", true],
|
|
207
|
+
"RULES": ["RULES", false],
|
|
208
|
+
"NORULES": ["RULES", true],
|
|
209
|
+
"SSRANGE": ["SSRANGE", false],
|
|
210
|
+
"NOSSRANGE": ["SSRANGE", true],
|
|
211
|
+
"SSR": ["SSRANGE", false],
|
|
212
|
+
"NOSSR": ["SSRANGE", true],
|
|
213
|
+
"TEST": ["TEST", false],
|
|
214
|
+
"NOTEST": ["TEST", true],
|
|
215
|
+
"ADATA": ["ADATA", false],
|
|
216
|
+
"NOADATA": ["ADATA", true],
|
|
217
|
+
"COPYLOC": ["COPYLOC", false],
|
|
218
|
+
"NOCOPYLOC": ["COPYLOC", true],
|
|
219
|
+
"CPLC": ["COPYLOC", false],
|
|
220
|
+
"NOCPLC": ["COPYLOC", true],
|
|
221
|
+
"EXIT": ["EXIT", false],
|
|
222
|
+
"NOEXIT": ["EXIT", true],
|
|
223
|
+
"NOEX": ["EXIT", true],
|
|
224
|
+
"EX": ["EXIT", false],
|
|
225
|
+
"MDECK": ["MDECK", false],
|
|
226
|
+
"NOMDECK": ["MDECK", true],
|
|
227
|
+
"NOMD": ["MDECK", true],
|
|
228
|
+
"MD": ["MDECK", false],
|
|
229
|
+
"OPTFILE": ["OPTFILE", false],
|
|
230
|
+
"SMARTBIN": ["SMARTBIN", false],
|
|
231
|
+
"NOSMARTBIN": ["SMARTBIN", true],
|
|
232
|
+
"THREAD": ["THREAD", false],
|
|
233
|
+
"NOTHREAD": ["THREAD", true],
|
|
234
|
+
"VSAMOPENFS": ["VSAMOPENFS", false],
|
|
235
|
+
"VS": ["VSAMOPENFS", false],
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// The options with a rule on where they may be given: not in a CBL or PROCESS statement (process),
|
|
239
|
+
// in one only before a batch compilation's first program (firstProgramOnly), or not as an
|
|
240
|
+
// installation default (installationDefault). Every other option may be given anywhere.
|
|
241
|
+
export const OPTION_PLACEMENT = {
|
|
242
|
+
"CICS": { firstProgramOnly: true },
|
|
243
|
+
"SQL": { firstProgramOnly: true },
|
|
244
|
+
"SQLIMS": { firstProgramOnly: true },
|
|
245
|
+
"DEFINE": { firstProgramOnly: true },
|
|
246
|
+
"JAVAIOP": { firstProgramOnly: true },
|
|
247
|
+
"LP": { firstProgramOnly: true },
|
|
248
|
+
"OUTDD": { firstProgramOnly: true },
|
|
249
|
+
"BUFSIZE": { firstProgramOnly: true },
|
|
250
|
+
"ADATA": { process: false },
|
|
251
|
+
"COPYLOC": { firstProgramOnly: true },
|
|
252
|
+
"EXIT": { process: false },
|
|
253
|
+
"MDECK": { process: false },
|
|
254
|
+
"OPTFILE": { installationDefault: false },
|
|
255
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// The build's change-assurance check (docs/spec/evidence.md §12): an ironwork equivalence
|
|
3
|
+
// statement for each program the change edits, matched to the base and head bytes by digest,
|
|
4
|
+
// verdict equivalent, coverage measured, and signed by an allowed signer where signers are named.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, join } from 'node:path';
|
|
9
|
+
import { directoryTree } from './kernel/source-tree.mjs';
|
|
10
|
+
import { parseSource } from './parser.mjs';
|
|
11
|
+
import { isProgram, readSource, relPath } from './sources.mjs';
|
|
12
|
+
import { NAMESPACE, PAYLOAD_TYPE, cleanGitEnv, pae } from './evidence/seal.mjs';
|
|
13
|
+
import { verifySshsig } from './evidence/sshsig.mjs';
|
|
14
|
+
|
|
15
|
+
export const EQUIVALENCE_PREDICATE = 'https://github.com/Portll/ironwork/blob/main/docs/evidence.md#equivalence-v1';
|
|
16
|
+
const PASSING = new Set(['equivalent', 'equivalent-as-declared']);
|
|
17
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
18
|
+
|
|
19
|
+
// { statement, signed, signer, reason } for one file holding a statement or a DSSE envelope of one.
|
|
20
|
+
export function readStatement(path, allowed = null) {
|
|
21
|
+
let doc;
|
|
22
|
+
try { doc = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { statement: null, reason: `${basename(path)} is not JSON (${e.message})` }; }
|
|
23
|
+
if (doc && doc.payloadType === PAYLOAD_TYPE && typeof doc.payload === 'string') {
|
|
24
|
+
const payload = Buffer.from(doc.payload, 'base64');
|
|
25
|
+
let statement;
|
|
26
|
+
try { statement = JSON.parse(payload.toString('utf8')); } catch { return { statement: null, reason: `${basename(path)} carries no statement` }; }
|
|
27
|
+
const checks = (doc.signatures || []).map((s) => (allowed ? verifySshsig({ blob: Buffer.from(String(s.sig), 'base64'), message: pae(PAYLOAD_TYPE, payload), namespace: NAMESPACE, allowed }) : { valid: null }));
|
|
28
|
+
const good = checks.find((c) => c.valid === true);
|
|
29
|
+
return { statement, signed: Boolean(good), signer: good ? good.principals : null, reason: good ? null : allowed ? 'no signature from an allowed signer' : 'no allowed signers were given' };
|
|
30
|
+
}
|
|
31
|
+
return { statement: doc, signed: false, signer: null, reason: 'the statement is not signed' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const subjectDigest = (st, side) => {
|
|
35
|
+
const s = (st.subject || []).find((x) => String(x.name || '').startsWith(`${side}:`));
|
|
36
|
+
return s && s.digest ? s.digest.sha256 : null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// The copybooks a program resolves, by path relative to its tree and digest.
|
|
40
|
+
function closure(root, file, idx, systemDirs) {
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = parseSource(readSource(file).text, file, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(file), copyFormat: 'auto', systemDirs });
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
const out = new Map();
|
|
46
|
+
for (const c of res.copies) {
|
|
47
|
+
if (c.status !== 'resolved' || !c.path) continue;
|
|
48
|
+
try { out.set(relPath(root, c.path), sha256(readFileSync(c.path))); } catch { out.set(relPath(root, c.path), null); }
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Programs the change edits or adds, or whose copybooks it edits: [{ path, base, head, via }] with
|
|
54
|
+
// each side's source digest and the copybooks that changed under an unchanged program.
|
|
55
|
+
export function changedPrograms(baseDir, headDir, allow = null, systemDirs = []) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const headTree = directoryTree(headDir);
|
|
58
|
+
const baseIdx = directoryTree(baseDir).index;
|
|
59
|
+
for (const head of headTree.list().filter((p) => isProgram(p) && (!allow || allow.has(p))).sort()) {
|
|
60
|
+
const path = relPath(headDir, head);
|
|
61
|
+
const h = sha256(readFileSync(head));
|
|
62
|
+
const baseFile = join(baseDir, path);
|
|
63
|
+
const b = existsSync(baseFile) ? sha256(readFileSync(baseFile)) : null;
|
|
64
|
+
if (b !== h) { out.push({ path, base: b, head: h }); continue; }
|
|
65
|
+
const after = closure(headDir, head, headTree.index, systemDirs);
|
|
66
|
+
const before = closure(baseDir, baseFile, baseIdx, systemDirs);
|
|
67
|
+
if (!after || !before) continue;
|
|
68
|
+
const via = [...new Set([...after.keys(), ...before.keys()])].filter((k) => after.get(k) !== before.get(k)).sort();
|
|
69
|
+
if (via.length) out.push({ path, base: b, head: h, via, viaDigests: via.map((k) => after.get(k)).filter(Boolean) });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Whether a commit in base..head reads as authored by a tool, from its identities and trailers.
|
|
75
|
+
const TOOL = /\[bot\]|(^|[^a-z])bot@|copilot|claude|anthropic|openai|codex|devin|cursor|gemini|aider|sweep-ai|dependabot|renovate/i;
|
|
76
|
+
export function machineAuthored(repo, base, head, env = process.env) {
|
|
77
|
+
const r = spawnSync('git', ['-C', repo, 'rev-list', '--format=%x1e%an%x00%ae%x00%cn%x00%ce%x00%B', `${base}..${head}`], { encoding: 'utf8', timeout: 30000, maxBuffer: 16 << 20, env: cleanGitEnv(env) });
|
|
78
|
+
if (r.status !== 0) return { known: false, commits: [] };
|
|
79
|
+
const commits = [];
|
|
80
|
+
for (const chunk of r.stdout.split('\x1e').slice(1)) {
|
|
81
|
+
const [an, ae, cn, ce, body = ''] = chunk.split('\x00');
|
|
82
|
+
const trailers = body.split('\n').filter((l) => /^(co-authored-by|generated-by|assisted-by):/i.test(l.trim()));
|
|
83
|
+
const signals = [an, ae, cn, ce, ...trailers, ...(/generated with/i.test(body) ? ['generated with'] : [])].filter((x) => x && TOOL.test(x));
|
|
84
|
+
if (signals.length) commits.push({ signals: [...new Set(signals.map((s) => s.trim().slice(0, 80)))] });
|
|
85
|
+
}
|
|
86
|
+
return { known: true, commits };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The equivalence check for one build: which changed programs a statement covers, and whether the
|
|
90
|
+
// policy's requirement is met. `required` is true, false, or null when machine authorship is unknown.
|
|
91
|
+
export function checkEquivalence({ baseDir, headDir, allow, files = [], allowed = null, mode = 'never', repo = null, base = null, head = null, systemDirs = [], env = process.env }) {
|
|
92
|
+
const changed = changedPrograms(baseDir, headDir, allow, systemDirs);
|
|
93
|
+
const statements = files.map((f) => ({ file: basename(f), ...readStatement(f, allowed) }));
|
|
94
|
+
const problems = [];
|
|
95
|
+
for (const s of statements) {
|
|
96
|
+
if (!s.statement) { problems.push(s.reason); continue; }
|
|
97
|
+
if (s.statement.predicateType !== EQUIVALENCE_PREDICATE) { problems.push(`${s.file} is not an ironwork equivalence statement`); continue; }
|
|
98
|
+
const h = subjectDigest(s.statement, 'head');
|
|
99
|
+
const b = subjectDigest(s.statement, 'base');
|
|
100
|
+
if (!changed.some((c) => c.head === h && (c.base === b || (c.base === null && b === null)))) {
|
|
101
|
+
problems.push(`${s.file} names head ${h ? h.slice(0, 12) : 'nothing'} and base ${b ? b.slice(0, 12) : 'nothing'}, which match no program this change edits`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const programs = changed.map((c) => {
|
|
105
|
+
const s = statements.find((x) => x.statement && subjectDigest(x.statement, 'head') === c.head && subjectDigest(x.statement, 'base') === c.base);
|
|
106
|
+
const via = c.via ? { via: c.via } : {};
|
|
107
|
+
if (!s) return { path: c.path, ...via, statement: null, ok: false, because: c.via ? `no equivalence statement names this program, whose copybook${c.via.length > 1 ? 's' : ''} ${c.via.join(', ')} the change edits` : 'no equivalence statement names this change' };
|
|
108
|
+
const p = s.statement.predicate || {};
|
|
109
|
+
const verdict = p.verdict || null;
|
|
110
|
+
const covered = p.coverage !== null && p.coverage !== undefined;
|
|
111
|
+
const ranOn = new Set(((p.closure && p.closure.head) || []).map((x) => x.sha256));
|
|
112
|
+
const missing = (c.viaDigests || []).filter((d) => !ranOn.has(d));
|
|
113
|
+
const because = !PASSING.has(verdict) ? `the statement's verdict is ${verdict}${Array.isArray(p.inconclusive) && p.inconclusive.length ? ` (${p.inconclusive.join('; ')})` : ''}`
|
|
114
|
+
: missing.length ? `the statement does not show the head run read the edited copybook${c.via.length > 1 ? 's' : ''} ${c.via.join(', ')}`
|
|
115
|
+
: !covered ? 'the statement measured no coverage of the changed paragraphs, so it is inconclusive'
|
|
116
|
+
: allowed && !s.signed ? `the statement is ${s.reason}`
|
|
117
|
+
: null;
|
|
118
|
+
const unreached = covered && Array.isArray(p.coverage.unreached) ? p.coverage.unreached : [];
|
|
119
|
+
return { path: c.path, ...via, statement: s.file, verdict, signed: s.signed, signer: s.signer, coverage: covered, ...(unreached.length ? { unreached } : {}), ok: because === null && !unreached.length, ...(because || unreached.length ? { because: because || `the inputs never reached ${unreached.join(', ')}` } : {}) };
|
|
120
|
+
});
|
|
121
|
+
const authorship = mode === 'machineAuthored' && repo && base ? machineAuthored(repo, base, head || 'HEAD', env) : null;
|
|
122
|
+
const required = mode === 'always' ? true : mode === 'machineAuthored' ? (authorship && authorship.known ? authorship.commits.length > 0 : null) : false;
|
|
123
|
+
return { mode, required, ...(authorship ? { machineAuthored: authorship } : {}), programs, problems };
|
|
124
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// `cobolwork evidence verify|seal|anchor`: the command half of docs/spec/evidence.md §7-9. Returns
|
|
3
|
+
// the exit status; writes one JSON document through `write`.
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { anchorGit, envelopeFor, seal, sealFiles, timeStampRequest } from './seal.mjs';
|
|
7
|
+
import { parseAllowedSigners } from './sshsig.mjs';
|
|
8
|
+
import { EvidenceRefusal, evidenceDirFrom } from './store.mjs';
|
|
9
|
+
import { verifyEvidence } from './verify.mjs';
|
|
10
|
+
|
|
11
|
+
const VERBS = ['verify', 'seal', 'anchor', 'sign'];
|
|
12
|
+
|
|
13
|
+
function anchorNewest(dir, opts) {
|
|
14
|
+
const seals = sealFiles(dir);
|
|
15
|
+
if (!seals.length) throw new EvidenceRefusal('there is no seal to anchor; run evidence seal first');
|
|
16
|
+
const newest = seals[seals.length - 1];
|
|
17
|
+
const text = readFileSync(newest.path, 'utf8');
|
|
18
|
+
const payload = JSON.parse(Buffer.from(JSON.parse(text).payload, 'base64').toString('utf8'));
|
|
19
|
+
const chain = /^ledger:([0-9a-f]{32})$/.exec(payload.subject[0].name)[1];
|
|
20
|
+
const out = { seal: newest.name };
|
|
21
|
+
if (opts.anchorGit) out.git = anchorGit(newest.path, { repo: resolve(opts.anchorGit), chain, seq: newest.seq, push: opts.push === true });
|
|
22
|
+
if (opts.tsq) {
|
|
23
|
+
const req = timeStampRequest(Buffer.from(text, 'utf8'));
|
|
24
|
+
writeFileSync(resolve(opts.tsq), req.der);
|
|
25
|
+
out.tsq = { path: resolve(opts.tsq), sha256: req.digest, nonce: req.nonce };
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function evidenceCommand(verb, opts, { toolVersion, write, env = process.env }) {
|
|
31
|
+
if (!VERBS.includes(verb)) throw new EvidenceRefusal(`evidence takes ${VERBS.join(', ')}; got ${verb || 'nothing'}`);
|
|
32
|
+
if (verb === 'sign') {
|
|
33
|
+
const file = opts._[2];
|
|
34
|
+
if (!file) throw new EvidenceRefusal('evidence sign needs the statement file to sign');
|
|
35
|
+
const payload = readFileSync(resolve(file));
|
|
36
|
+
JSON.parse(payload.toString('utf8'));
|
|
37
|
+
const env2 = envelopeFor(payload, { sshKey: opts.sshKey ? resolve(opts.sshKey) : null, signer: opts.signer ? resolve(opts.signer) : null, expectKeyid: opts.expectKey || null, env });
|
|
38
|
+
const text = `${JSON.stringify(env2)}\n`;
|
|
39
|
+
if (opts.out) writeFileSync(resolve(opts.out), text); else write(text);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
const dir = evidenceDirFrom(opts, env);
|
|
43
|
+
if (!dir) throw new EvidenceRefusal('evidence needs --evidence <dir> or COBOLWORK_EVIDENCE');
|
|
44
|
+
const emit = (doc) => write(`${JSON.stringify({ tool: 'cobolwork-evidence', verb, ...doc }, null, opts.quiet ? 0 : 1)}\n`);
|
|
45
|
+
|
|
46
|
+
if (verb === 'verify') {
|
|
47
|
+
const allowed = opts.allowedSigners ? parseAllowedSigners(readFileSync(resolve(opts.allowedSigners), 'utf8')) : null;
|
|
48
|
+
const max = opts.maxUnsealed === undefined ? 0 : Number(opts.maxUnsealed);
|
|
49
|
+
if (!Number.isSafeInteger(max) || max < 0) throw new EvidenceRefusal(`--max-unsealed takes a whole number; got ${opts.maxUnsealed}`);
|
|
50
|
+
const verdict = verifyEvidence(dir, { allowed, anchorGit: opts.anchorGit ? resolve(opts.anchorGit) : null, anchorRef: opts.ref || null, maxUnsealed: max, env });
|
|
51
|
+
emit(verdict);
|
|
52
|
+
return verdict.exit;
|
|
53
|
+
}
|
|
54
|
+
if (verb === 'seal') {
|
|
55
|
+
if (opts.sshKey && opts.signer) throw new EvidenceRefusal('one signer per seal: --ssh-key or --signer');
|
|
56
|
+
const s = seal(dir, { sshKey: opts.sshKey ? resolve(opts.sshKey) : null, signer: opts.signer ? resolve(opts.signer) : null, expectKeyid: opts.expectKey || null, toolVersion, env });
|
|
57
|
+
const out = { seq: s.seq, path: s.path, sha256: s.digest, signed: s.signed, ledgerLength: s.statement.predicate.ledgerLength, tip: s.statement.subject[0].digest.sha256 };
|
|
58
|
+
if (opts.anchorGit || opts.tsq) out.anchor = anchorNewest(dir, opts);
|
|
59
|
+
emit(out);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
if (!opts.anchorGit && !opts.tsq) throw new EvidenceRefusal('anchor needs --anchor-git <repo> or --tsq <file>');
|
|
63
|
+
emit(anchorNewest(dir, opts));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// A run journal (runs/<runId>.jsonl) and the ledger (ledger.jsonl) that chains the runs' tips.
|
|
3
|
+
// docs/spec/evidence.md §5-6.
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { closeSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { makeRecord, newChain, recordHash } from './record.mjs';
|
|
8
|
+
import { EvidenceRefusal, createExclusive, lastLine, openAppend, prepareDir, syncClose, takeLock, writeAll } from './store.mjs';
|
|
9
|
+
|
|
10
|
+
export const LEDGER = 'ledger.jsonl';
|
|
11
|
+
export const LOCK = 'ledger.lock';
|
|
12
|
+
|
|
13
|
+
const isoNow = () => new Date().toISOString();
|
|
14
|
+
const runStamp = (iso) => iso.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
15
|
+
|
|
16
|
+
// The record the next ledger line extends: one whose chain, seq and hash hold.
|
|
17
|
+
function ledgerTip(line, path) {
|
|
18
|
+
let r = null;
|
|
19
|
+
try { r = JSON.parse(line); } catch { /* refused below */ }
|
|
20
|
+
const ok = r && typeof r === 'object' && /^[0-9a-f]{32}$/.test(r.chain) && Number.isInteger(r.seq) && r.seq >= 0 && typeof r.hash === 'string' && recordHash(r) === r.hash;
|
|
21
|
+
if (!ok) throw new EvidenceRefusal(`${path} ends in a line that is no ledger record; verify it before anything is appended`);
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Appends to the ledger; the caller holds the lock. A torn ledger is refused rather than extended.
|
|
26
|
+
function appendLedger(dir, entries, now) {
|
|
27
|
+
const path = join(dir, LEDGER);
|
|
28
|
+
const { line, torn } = lastLine(path);
|
|
29
|
+
if (torn) throw new EvidenceRefusal(`${path} ends in a partial line; verify it before anything is appended`);
|
|
30
|
+
let prev = line ? ledgerTip(line, path) : null;
|
|
31
|
+
const fd = openAppend(path);
|
|
32
|
+
try {
|
|
33
|
+
const out = [];
|
|
34
|
+
if (!prev) entries = [{ kind: 'genesis', fields: { createdAt: now() } }, ...entries];
|
|
35
|
+
const chain = prev ? prev.chain : newChain();
|
|
36
|
+
for (const { kind, fields } of entries) {
|
|
37
|
+
const { record, line: text } = makeRecord({ chain, prev, kind, fields, at: now() });
|
|
38
|
+
writeAll(fd, text);
|
|
39
|
+
out.push(record);
|
|
40
|
+
prev = record;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
} finally { syncClose(fd); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `roots` is what the journal records; `readRoots` are the trees the run reads, which the evidence
|
|
47
|
+
// directory must not be inside.
|
|
48
|
+
export function openJournal(dir, { command, argv = [], roots = [], readRoots = [], toolVersion, toolRevision = null, now = isoNow, lock = {} }) {
|
|
49
|
+
const real = prepareDir(dir, readRoots);
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
const openedAt = now();
|
|
52
|
+
const id = `${runStamp(openedAt)}-${randomBytes(8).toString('hex')}`;
|
|
53
|
+
const path = join(real, 'runs', `${id}.jsonl`);
|
|
54
|
+
const fd = createExclusive(path);
|
|
55
|
+
const chain = newChain();
|
|
56
|
+
const counts = {};
|
|
57
|
+
let prev = null;
|
|
58
|
+
let closed = false;
|
|
59
|
+
|
|
60
|
+
const append = (kind, fields) => {
|
|
61
|
+
if (closed) throw new EvidenceRefusal(`run ${id} is closed`);
|
|
62
|
+
const { record, line } = makeRecord({ chain, prev, kind, fields, at: prev ? now() : openedAt });
|
|
63
|
+
writeAll(fd, line);
|
|
64
|
+
prev = record;
|
|
65
|
+
counts[kind] = (counts[kind] || 0) + 1;
|
|
66
|
+
return record;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
append('open', { tool: 'cobolwork', toolVersion, toolRevision, command, argv, roots, node: process.version, platform: process.platform });
|
|
70
|
+
|
|
71
|
+
// Writes close, then the ledger record carrying this journal's tip. Returns the ledger outcome.
|
|
72
|
+
const close = ({ exit }) => {
|
|
73
|
+
const held = takeLock(join(real, LOCK), lock);
|
|
74
|
+
const ledger = held ? 'recorded' : 'unrecorded';
|
|
75
|
+
try {
|
|
76
|
+
append('close', { exit, counts: { ...counts }, durationMs: Math.max(0, Date.now() - started), ledger });
|
|
77
|
+
closed = true;
|
|
78
|
+
syncClose(fd);
|
|
79
|
+
if (!held) return { ledger, reason: 'the ledger lock could not be had' };
|
|
80
|
+
const entries = [];
|
|
81
|
+
if (held.broken) entries.push({ kind: 'lock-broken', fields: held.broken });
|
|
82
|
+
entries.push({ kind: 'run', fields: { run: id, runChain: chain, runLength: prev.seq + 1, runTip: prev.hash } });
|
|
83
|
+
appendLedger(real, entries, now);
|
|
84
|
+
return { ledger };
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (!closed) { closed = true; try { closeSync(fd); } catch { /* already closed */ } }
|
|
87
|
+
return { ledger: 'unrecorded', reason: e.message };
|
|
88
|
+
} finally {
|
|
89
|
+
if (held) held.release();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return { id, path, dir: real, chain, append, close, get tip() { return prev; } };
|
|
94
|
+
}
|