@portll/cobolwork 0.2.117 → 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/lib/dataflow.mjs CHANGED
@@ -4,6 +4,9 @@ import { parseBms, symbolicNames } from './bms.mjs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { parseSource, buildFileIndex } from './parser.mjs';
6
6
  import { parseJcl } from './jcl.mjs';
7
+ import { holdsDecimal } from './layout.mjs';
8
+ import { hostVariablesIn } from './embedded-sql.mjs';
9
+ import { tsoCommands } from './utilities.mjs';
7
10
  import { parseCsd, ddOfQueue } from './csd.mjs';
8
11
  import { loadSite } from './site.mjs';
9
12
  import { ssrangeAbends } from './options.mjs';
@@ -68,6 +71,7 @@ STANDARD-DEVIATION RANDOM SQRT FACTORIAL LOG LOG10 EXP EXP10 PI E ACOS ASIN ATAN
68
71
  SECONDS-PAST-MIDNIGHT`.split(/\s+/));
69
72
  const computes = (st) => ARITHMETIC.has(st.verb) || st.counts === true || (st.fns || []).some((f) => NUMERIC_FUNCTIONS.has(f));
70
73
  const computedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.why && s.why.computes) return true; return false; };
74
+ const typedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.node.typed) return true; return false; };
71
75
 
72
76
  // Every literal a program holds that could be a program name: its VALUE clauses and the literals its
73
77
  // statements and commands use. A menu that XCTLs through a table of names starts every one of them.
@@ -89,15 +93,6 @@ function lastOption(name, list) {
89
93
  return null;
90
94
  }
91
95
 
92
- // Whether an item's bytes are read as decimal digits: zoned (DISPLAY with a numeric picture) or
93
- // packed. A group, an edited picture, binary and floating point are not.
94
- function holdsDecimal(it) {
95
- if ((it.children || []).some((c) => c.level !== 88)) return false;
96
- const usage = String(it.effectiveUsage || 'DISPLAY').replace('COMPUTATIONAL', 'COMP');
97
- if (usage === 'COMP-3' || usage === 'PACKED-DECIMAL' || usage === 'COMP-6') return true;
98
- return usage === 'DISPLAY' && !!it.picture && /9/.test(it.picture) && /^[S9VP()0-9]+$/i.test(it.picture);
99
- }
100
-
101
96
  const RELATION = new Set(['>', '<', '>=', '<=', '=', '<>']);
102
97
  const RELATION_WORDS = new Set(['GREATER', 'LESS', 'EQUAL']);
103
98
  const FILLER_WORDS = new Set(['IS', 'NOT', 'THAN', 'TO', 'OR', 'GREATER', 'LESS', 'EQUAL', 'OF', 'IN']);
@@ -178,27 +173,6 @@ function execOptions(exec) {
178
173
  return { opts, words };
179
174
  }
180
175
 
181
- function sqlHostVars(exec) {
182
- const out = [];
183
- for (let i = 0; i < exec.toks.length; i++) {
184
- const t = exec.toks[i];
185
- if (t.t === 'op' && t.v === ':' && exec.toks[i + 1] && exec.toks[i + 1].t === 'word') out.push({ tok: exec.toks[i + 1], at: i + 1 });
186
- }
187
- return out;
188
- }
189
-
190
- // The host variables a SELECT or FETCH fills are the ones in its INTO list, which ends at FROM.
191
- // Taking every variable at or after the INTO line made the input parameters in a WHERE clause read
192
- // as database values — and they are whatever the caller sent, under a rule that says data at rest.
193
- function sqlIntoRange(exec) {
194
- const words = exec.toks.map(t => (t.t === 'word' ? t.u : ''));
195
- const into = words.indexOf('INTO');
196
- if (into < 0) return null;
197
- let end = exec.toks.length;
198
- for (let i = into + 1; i < words.length; i++) if (words[i] === 'FROM') { end = i; break; }
199
- return [into, end];
200
- }
201
-
202
176
  export function analyze(root, opts = {}) {
203
177
  const repos = opts.repos || [''];
204
178
  const stats = { files: 0, programs: 0, edges: 0, nodes: 0, threw: 0, overBudget: 0, unreadable: 0, ebcdic: 0,
@@ -457,11 +431,14 @@ export function analyze(root, opts = {}) {
457
431
  usesOf.get(at.name).push(rec);
458
432
  }
459
433
  const offset = x.offset || 0;
434
+ // A constant length past the start, or a constant start before the length, takes that many bytes off the top.
435
+ const base = limitOf(host, table);
436
+ const limit = kind === 'reference-modification' && base != null && x.span ? base - x.span + 1 : base;
460
437
  const use = `${key}|${point ?? `s${st.at}`}|${offset}`;
461
438
  if (indexed.has(use)) continue;
462
439
  indexed.add(use);
463
440
  at.sinks.push({
464
- kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit: limitOf(host, table), ...(offset ? { offset } : {}), ...(offset > 0 && idx && unsignedWhole(idx) ? { nonNeg: true } : {}), ...(ssrange ? { ssrange } : {}),
441
+ kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit, ...(offset ? { offset } : {}), ...(offset > 0 && idx && unsignedWhole(idx) ? { nonNeg: true } : {}), ...(ssrange ? { ssrange } : {}),
465
442
  detail: kind === 'subscript'
466
443
  ? `${at.name} subscripts ${host.name}, a table of ${table.occurs}`
467
444
  : `${at.name} sets the ${x.kind === 'refmod-offset' ? 'start' : 'length'} of a reference to ${host.name}, which is ${host.size} bytes`,
@@ -724,14 +701,16 @@ export function analyze(root, opts = {}) {
724
701
  walk(record);
725
702
  for (const field of map.fields) {
726
703
  if (!field.name) continue;
727
- const marks = ['PROT', 'ASKIP', 'DRK', 'NUM'].filter((a) => field.effective.has(a));
728
- if (!marks.length) continue;
729
704
  const iname = symbolicNames(map, field).find((n) => n.suffix === 'I');
730
705
  const item = iname && within.get(iname.name);
731
706
  if (!item) continue;
707
+ const marks = ['PROT', 'ASKIP', 'DRK', 'NUM'].filter((a) => field.effective.has(a));
708
+ const typed = !marks.includes('PROT') && !marks.includes('ASKIP');
709
+ if (typed) nodeOf(item).typed = true;
710
+ if (!marks.length) continue;
732
711
  const n = nodeOf(item);
733
712
  n.screen = { item: item.name, field: field.name, map: map.name, mapset: mapset.name, marks, declared: !!field.attrb };
734
- if (!marks.includes('PROT') && !marks.includes('ASKIP')) continue;
713
+ if (typed) continue;
735
714
  stats.protectedFields++;
736
715
  const back = field.effective.has('FSET') ? ' with FSET' : '';
737
716
  n.sources.push({ kind: 'cics-protected-field', onlyTo: ['record-key', 'record-update'], ...at,
@@ -921,15 +900,14 @@ export function analyze(root, opts = {}) {
921
900
  }
922
901
  }
923
902
  if (e.kind === 'SQL') {
924
- // The parser's host variables name the item a qualified :G.F resolves to, where the first
925
- // word after the colon would name the group G and so every field in it.
926
- const hv = e.hostVariables || sqlHostVars(e);
903
+ // A host variable names the item a qualified :G.F resolves to; the statement fills the ones
904
+ // Db2 writes, and those hold what the database held.
905
+ const hv = e.hostVariables || hostVariablesIn(e.toks);
927
906
  const verb = words[0];
928
- const range = e.hostVariables ? null : ['SELECT', 'FETCH'].includes(verb) && sqlIntoRange(e);
929
907
  for (const h of hv) {
930
- if (e.hostVariables ? !h.written : !range || h.at < range[0] || h.at > range[1]) continue;
908
+ if (!h.written) continue;
931
909
  const n = nodeOfToken(h.tok);
932
- if (n) n.sources.push({ kind: 'database', ...at, detail: `EXEC SQL ${verb} INTO host variable` });
910
+ if (n) n.sources.push({ kind: 'database', ...at, detail: `EXEC SQL ${verb} ${verb === 'SELECT' || verb === 'FETCH' ? 'INTO' : 'writes'} host variable` });
933
911
  }
934
912
  // The location CONNECT TO or SET CONNECTION names, where a host variable holds it; what
935
913
  // follows USER and USING is the credential, not the target.
@@ -1123,59 +1101,61 @@ export function analyze(root, opts = {}) {
1123
1101
  for (const dd of step.dds) if (dd.name && dd.name.toUpperCase() === 'SYSIN' && dd.inStream) addCsd(dd.inStream.map((l) => l.text).join('\n'), f, dd.inStream[0].line - 1);
1124
1102
  }
1125
1103
  jobSteps.push({ file: f, job: job.jobs[0]?.name || null, step: step.name, line: step.line, pgm: step.pgm.toUpperCase() });
1126
- // A TSO batch step starts a program by name in its commands: DSN ... RUN PROGRAM(X).
1127
- if (/^IKJEFT(01|1A|1B)$/i.test(step.pgm)) {
1128
- for (const dd of step.dds) for (const l of dd.inStream || []) {
1129
- const run = /\bRUN\s+PROGRAM\s*\(\s*([A-Z0-9$#@]{1,8})\s*\)/i.exec(l.text);
1130
- if (run) jobSteps.push({ file: f, job: job.jobs[0]?.name || null, step: step.name, line: l.line, pgm: run[1].toUpperCase() });
1131
- }
1104
+ // A TSO batch step starts programs by name in its commands, TSO CALL and DSN RUN PROGRAM, and
1105
+ // each is handed the step's DDs and its own parameter as EXEC PGM= would be.
1106
+ const runs = [{ pgm: step.pgm.toUpperCase(), parm: step.parm, line: step.line, how: `PARM= on step ${step.name || '(unnamed)'} of ${rel(f)}` }];
1107
+ for (const r of tsoCommands(step).runs) {
1108
+ jobSteps.push({ file: f, job: job.jobs[0]?.name || null, step: step.name, line: r.line, pgm: r.program });
1109
+ runs.push({ pgm: r.program, parm: r.parm, line: r.line, how: `the parameter ${r.via} passes in step ${step.name || '(unnamed)'} of ${rel(f)}` });
1132
1110
  }
1133
- const holders = allById.get(step.pgm.toUpperCase()) || [];
1134
- if (!holders.length) continue; // a system utility, or a program not in this tree
1135
- stats.jclStepsResolved++;
1136
- // A program id held several times has no caller file to choose by, so each holder is a candidate.
1137
- const amb = holders.length > 1 ? `, any of ${holders.length} programs named ${step.pgm}` : '';
1138
- for (const callee of holders) {
1111
+ for (const run of runs) {
1112
+ const holders = allById.get(run.pgm) || [];
1113
+ if (!holders.length) continue; // a system utility, or a program not in this tree
1114
+ stats.jclStepsResolved++;
1115
+ // A program id held several times has no caller file to choose by, so each holder is a candidate.
1116
+ const amb = holders.length > 1 ? `, any of ${holders.length} programs named ${run.pgm}` : '';
1117
+ for (const callee of holders) {
1139
1118
 
1140
- // PARM arrives in the first PROCEDURE DIVISION USING item: on z/OS a halfword length
1141
- // followed by the text. A program with no USING cannot receive one, and saying it does
1142
- // would be a path nobody could follow.
1143
- if (step.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1144
- callee.params[0].node.sources.push({
1145
- kind: 'jcl-parm', file: f, line: step.line,
1146
- detail: `PARM= on step ${step.name || '(unnamed)'} of ${rel(f)}, which runs ${step.pgm}${amb}`,
1147
- });
1148
- stats.jclCrossings++;
1149
- }
1119
+ // PARM arrives in the first PROCEDURE DIVISION USING item: on z/OS a halfword length
1120
+ // followed by the text. A program with no USING cannot receive one, and saying it does
1121
+ // would be a path nobody could follow.
1122
+ if (run.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1123
+ callee.params[0].node.sources.push({
1124
+ kind: 'jcl-parm', file: f, line: run.line,
1125
+ detail: `${run.how}, which runs ${run.pgm}${amb}`,
1126
+ });
1127
+ stats.jclCrossings++;
1128
+ }
1150
1129
 
1151
- // In-stream data reaches whatever record the program reads from that DD. The COBOL says
1152
- // ASSIGN TO SYSIN and the job says //SYSIN DD *; neither half names the other, and the
1153
- // join is the whole point of reading both.
1154
- for (const dd of step.dds) {
1155
- // The same join in the other direction: what the program writes through SELECT ... ASSIGN
1156
- // TO a DD the job sends to the internal reader is submitted as a job.
1157
- if (dd.name && dd.sysout && /\bINTRDR\b/i.test(dd.sysout)) {
1130
+ // In-stream data reaches whatever record the program reads from that DD. The COBOL says
1131
+ // ASSIGN TO SYSIN and the job says //SYSIN DD *; neither half names the other, and the
1132
+ // join is the whole point of reading both.
1133
+ for (const dd of step.dds) {
1134
+ // The same join in the other direction: what the program writes through SELECT ... ASSIGN
1135
+ // TO a DD the job sends to the internal reader is submitted as a job.
1136
+ if (dd.name && dd.sysout && /\bINTRDR\b/i.test(dd.sysout)) {
1137
+ for (const m of callee.ddFiles || []) {
1138
+ if (m.dd !== dd.name.toUpperCase()) continue;
1139
+ for (const n of m.nodes) {
1140
+ n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1141
+ detail: `records written through SELECT ${m.select} to //${dd.name}, which step ${step.name || '(unnamed)'} of ${rel(f)} sends to the internal reader${amb}` });
1142
+ }
1143
+ }
1144
+ }
1145
+ if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1158
1146
  for (const m of callee.ddFiles || []) {
1159
1147
  if (m.dd !== dd.name.toUpperCase()) continue;
1160
1148
  for (const n of m.nodes) {
1161
- n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1162
- detail: `records written through SELECT ${m.select} to //${dd.name}, which step ${step.name || '(unnamed)'} of ${rel(f)} sends to the internal reader${amb}` });
1149
+ n.sources.push({
1150
+ kind: 'jcl-instream', file: f, line: dd.line,
1151
+ detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}${amb}`,
1152
+ });
1153
+ stats.jclCrossings++;
1163
1154
  }
1164
1155
  }
1165
- }
1166
- if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1167
- for (const m of callee.ddFiles || []) {
1168
- if (m.dd !== dd.name.toUpperCase()) continue;
1169
- for (const n of m.nodes) {
1170
- n.sources.push({
1171
- kind: 'jcl-instream', file: f, line: dd.line,
1172
- detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}${amb}`,
1173
- });
1174
- stats.jclCrossings++;
1175
- }
1176
- }
1177
1156
 
1178
- }
1157
+ }
1158
+ }
1179
1159
  }
1180
1160
  }
1181
1161
  }
@@ -1494,6 +1474,9 @@ export function analyze(root, opts = {}) {
1494
1474
  // arithmetic or a numeric function carries no bad bytes to the next one. Only this route is
1495
1475
  // dropped: a later one that copies the bytes still reports.
1496
1476
  if (sink.kind === 'arithmetic' && computedOnRoute(state)) continue;
1477
+ // A protected field's value carried through a field the terminal may type into reaches the
1478
+ // key as typed input, which the user could have entered anyway: the protection was no control.
1479
+ if (src.kind === 'cics-protected-field' && typedOnRoute(state)) continue;
1497
1480
  seen.add(key);
1498
1481
  // The ends of a long path are what a reader uses: where the value came from, and what it
1499
1482
  // reached. Keeping every hop of a thousand-hop chain, for a thousand findings, is the
@@ -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
+ }
@@ -4,7 +4,7 @@
4
4
  import { randomBytes } from 'node:crypto';
5
5
  import { closeSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { makeRecord, newChain } from './record.mjs';
7
+ import { makeRecord, newChain, recordHash } from './record.mjs';
8
8
  import { EvidenceRefusal, createExclusive, lastLine, openAppend, prepareDir, syncClose, takeLock, writeAll } from './store.mjs';
9
9
 
10
10
  export const LEDGER = 'ledger.jsonl';
@@ -13,12 +13,21 @@ export const LOCK = 'ledger.lock';
13
13
  const isoNow = () => new Date().toISOString();
14
14
  const runStamp = (iso) => iso.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
15
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
+
16
25
  // Appends to the ledger; the caller holds the lock. A torn ledger is refused rather than extended.
17
26
  function appendLedger(dir, entries, now) {
18
27
  const path = join(dir, LEDGER);
19
28
  const { line, torn } = lastLine(path);
20
29
  if (torn) throw new EvidenceRefusal(`${path} ends in a partial line; verify it before anything is appended`);
21
- let prev = line ? JSON.parse(line) : null;
30
+ let prev = line ? ledgerTip(line, path) : null;
22
31
  const fd = openAppend(path);
23
32
  try {
24
33
  const out = [];
package/lib/explain.mjs CHANGED
@@ -7,15 +7,16 @@ import { EVIDENCE } from './kernel/findings.mjs';
7
7
  import { REGISTRY } from './kernel/registry.mjs';
8
8
  import { WHO_ACTS } from './tui/model.mjs';
9
9
  import { verificationPlan } from './verify.mjs';
10
+ import { cobolCard, statementCard } from './card.mjs';
10
11
 
11
12
  const HIDDEN_RULES = new Set(Object.keys(REGISTRY.find((s) => s.name === 'hidden').rules));
12
13
 
13
14
  // The columns quoted: COBOL's indicator and program text, JCL's statement field, or the whole line.
14
15
  function codeArea(line, kind, format) {
15
- if (kind === 'cobol' && format === 'fixed') return { text: line.slice(6, 72), seq: line.slice(0, 6), rest: line.slice(72) };
16
- if (kind === 'cobol' && format === 'variable') return { text: line.slice(6, 250), seq: line.slice(0, 6), rest: line.slice(250) };
17
- if (kind === 'jcl') return { text: line.slice(0, 72), seq: '', rest: line.slice(72) };
18
- return { text: line, seq: '', rest: '' };
16
+ if (kind === 'jcl') { const c = statementCard(line); return { text: c.text, seq: '', rest: c.sequence }; }
17
+ if (kind !== 'cobol') return { text: line, seq: '', rest: '' };
18
+ const c = cobolCard(line, format);
19
+ return { text: c.indicator + c.text, seq: c.sequence, rest: c.ignored };
19
20
  }
20
21
 
21
22
  const NOTE = 'This packet contains source text from the files it names. A cobolwork report never does: '
package/lib/ftp.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { statementCard } from './card.mjs';
3
+
4
+ // IBM's batch FTP client takes the host and its options on PARM, or the host as the first line of
5
+ // its input, reads subcommands from INPUT, and logs on from NETRC or from its input. CardDemo's job
6
+ // gives the same lines on SYSIN, which is read here when there is no INPUT, though the page below
7
+ // does not say the client falls back to it.
8
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halu001/ftpreq.htm
9
+ // A session is in cleartext unless something asks for TLS: -a TLS or -r TLS on PARM, or
10
+ // SECURE_MECHANISM TLS in an in-stream SYSFTPD. A SYSFTPD naming a data set is configuration this
11
+ // reader cannot see, so that step is not called cleartext either way.
12
+ // https://www.ibm.com/docs/en/zos/2.1.0?topic=commands-ftp-command
13
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halz002/ftp_cust_client_for_tls.htm
14
+ // PUT, MPUT and APPEND send local files, and IBM's capitals - PUt, MPut, APpend, LCd - are the
15
+ // shortest spellings. A local name in quotes is a whole data set name, //DD: names a DD of the
16
+ // step, and anything else hangs off the local working directory, which only a quoted LCD fixes.
17
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/ftpsubcmd.htm
18
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/put.htm
19
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mput.htm
20
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/append.htm
21
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/lcd.htm
22
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/dd_name_support.htm
23
+ // Get and MGet copy remote files in, each to the local name given or, with none, to the foreign
24
+ // file's own name, placed as a sent name is. SIte FILETYPE=JES makes the remote host submit what
25
+ // is sent as a job and return job output to a Get; FILETYPE=SQL makes it run a query. Either holds
26
+ // for the transfers after it, until another FILETYPE.
27
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/get.htm
28
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mget.htm
29
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/site.htm
30
+ const FTP_SENDS = [[/^PUT?$/i, 'PUT'], [/^MP(?:UT?)?$/i, 'MPUT'], [/^AP(?:P(?:E(?:ND?)?)?)?$/i, 'APPEND']];
31
+
32
+ // Every subcommand on the page above, in IBM's spelling.
33
+ const FTP_SUBCOMMANDS = ['?', '!', 'ACCount', 'APpend', 'AUth', 'AScii', 'BIG5', 'BINary', 'BLock', 'CCc', 'CD',
34
+ 'CDUp', 'CLEar', 'CLose', 'COMpress', 'CProtect', 'CWd', 'DEBug', 'DELEte', 'DELImit', 'DIr', 'DUMP', 'EBcdic',
35
+ 'EUckanji', 'FEature', 'FIle', 'Get', 'GLob', 'HAngeul', 'HElp', 'Ibmkanji', 'JIS78kj', 'JIS83kj', 'Ksc5601',
36
+ 'LANGuage', 'LCd', 'LOCSIte', 'LOCSTat', 'LMkdir', 'LPwd', 'LS', 'MDelete', 'MGet', 'MKdir', 'MKFifo', 'MOde',
37
+ 'MPut', 'MVSGet', 'MVSPut', 'NOop', 'Open', 'PAss', 'PRIvate', 'PROMpt', 'PROTect', 'PROXy', 'PUt', 'PWd', 'QUIt',
38
+ 'QUOte', 'RECord', 'REName', 'REStart', 'RMdir', 'SAfe', 'SChinese', 'SENDPort', 'SENDSite', 'SIte', 'SJiskanji',
39
+ 'SRestart', 'STAtus', 'STREam', 'STRucture', 'SUnique', 'SYstem', 'TChinese', 'TSO', 'TYpe', 'UCs2', 'User', 'Verbose']
40
+ .map((name) => ({ full: name.toUpperCase(), min: name.match(/^[^a-z]*/)[0].length }));
41
+ const subcommandOf = (word) => {
42
+ const w = word.toUpperCase();
43
+ const hit = FTP_SUBCOMMANDS.find((c) => w.length >= c.min && c.full.startsWith(w));
44
+ return hit ? hit.full : null;
45
+ };
46
+ // A value that stands for a password rather than being one: a symbolic, a mask, or the word itself.
47
+ const PLACEHOLDER = /^(&.*|\*+|X+|\?+|<.*>|\{.*\}|PASSWORD|PASSWD|PWD|PASS|SECRET)$/i;
48
+
49
+ // The session one FTP step runs, from its DDs grouped by name (ddGroups in lib/utilities.mjs).
50
+ export function ftpSession(step, dds) {
51
+ let host = null, tls = null;
52
+ const words = (step.parm || '').split('(')[0].trim().split(/\s+/).filter(Boolean);
53
+ for (let i = 0; i < words.length; i++) {
54
+ if (/^-[ar]$/i.test(words[i])) {
55
+ const mechanism = (words[++i] || '').toUpperCase();
56
+ if (mechanism === 'TLS') tls = 'asked';
57
+ else if (mechanism === 'NEVER' && !tls) tls = 'never';
58
+ } else if (!words[i].startsWith('-') && !host) host = words[i];
59
+ }
60
+ let configUnread = null;
61
+ for (const dd of dds.get('SYSFTPD') || []) {
62
+ if (dd.inStream) { if (tls !== 'never' && dd.inStream.some((l) => /^\s*SECURE_MECHANISM\s+TLS\b/i.test(l.text))) tls = 'asked'; }
63
+ else if (dd.dsn && !configUnread) configUnread = dd.dsn;
64
+ }
65
+
66
+ const input = dds.get('INPUT') || dds.get('SYSIN') || [];
67
+ const hostLine = !host && input[0]?.inStream;
68
+ let inputUnread = null, lcd = null, unixDir = false;
69
+ const sends = [], gets = [];
70
+ let filetype = null;
71
+ // With no NETRC to answer for it, the client asks for a user ID and a password and in batch reads
72
+ // them from its input after the host: "user password" on one line, or each on its own. A first
73
+ // line that is a subcommand means something else answered, a NETRC the client found for itself.
74
+ // USER and PASS give the same answers later in a session.
75
+ let expect = dds.has('NETRC') ? null : 'logon', user = null;
76
+ const passwords = [];
77
+ const password = (dd, line, value) => { if (!PLACEHOLDER.test(value)) passwords.push({ dd, line, user }); };
78
+ const local = (name) => {
79
+ const dd = /^\/\/DD:([A-Z$#@][A-Z0-9$#@]{0,7})$/i.exec(name);
80
+ if (dd) return { dd: dd[1].toUpperCase(), dsns: (dds.get(dd[1].toUpperCase()) || []).map((d) => d.dsn).filter(Boolean) };
81
+ const quoted = /^'(.+)'$/.exec(name);
82
+ if (quoted) return { dsns: [quoted[1].toUpperCase()] };
83
+ if (name.startsWith('/') || unixDir) return { dsns: [] };
84
+ return lcd ? { dsns: [`${lcd}.${name.toUpperCase()}`] } : { dsns: [], relative: name };
85
+ };
86
+ for (const dd of input) {
87
+ if (!dd.inStream) { if (dd.dsn && !inputUnread) inputUnread = dd.dsn; continue; }
88
+ for (const { line, text } of dd.inStream) {
89
+ const [verb, ...args] = statementCard(text).text.trim().split(/\s+/);
90
+ if (!verb) continue;
91
+ if (hostLine && !host) { host = verb; continue; }
92
+ const sub = subcommandOf(verb);
93
+ if (expect && !sub) {
94
+ if (expect === 'logon') user = verb;
95
+ if (expect === 'password') password(dd, line, verb);
96
+ else if (args[0]) password(dd, line, args[0]);
97
+ expect = expect === 'logon' && !args[0] ? 'password' : null;
98
+ continue;
99
+ }
100
+ expect = null;
101
+ if (sub === 'USER' && args[0]) {
102
+ user = args[0];
103
+ if (args[1]) password(dd, line, args[1]); else expect = 'password';
104
+ continue;
105
+ }
106
+ if (sub === 'PASS' && args[0]) { password(dd, line, args[0]); continue; }
107
+ if (/^LCD?$/i.test(verb) && args[0]) {
108
+ const quoted = /^'(.+)'$/.exec(args[0]);
109
+ unixDir = args[0].startsWith('/');
110
+ lcd = quoted ? quoted[1].toUpperCase() : lcd && !unixDir ? `${lcd}.${args[0].toUpperCase()}` : null;
111
+ continue;
112
+ }
113
+ const site = sub === 'SITE' ? args : sub === 'QUOTE' && subcommandOf(args[0] || '') === 'SITE' ? args.slice(1) : null;
114
+ if (site) {
115
+ for (const a of site) { const t = /^FILE(?:T(?:Y(?:P(?:E)?)?)?)?=(\w+)$/i.exec(a); if (t) filetype = t[1].toUpperCase() === 'SEQ' ? null : t[1].toUpperCase(); }
116
+ continue;
117
+ }
118
+ const typed = filetype ? { filetype } : {};
119
+ if (sub === 'GET' && args[0]) {
120
+ const to = args[1] && !args[1].startsWith('(') ? args[1] : args[0];
121
+ gets.push({ line, verb: 'GET', name: args[0], ...typed, ...local(to) });
122
+ continue;
123
+ }
124
+ if (sub === 'MGET') {
125
+ for (const name of args.filter((a) => !a.startsWith('(') && !/[*?]/.test(a))) gets.push({ line, verb: 'MGET', name, ...typed, ...local(name) });
126
+ continue;
127
+ }
128
+ const sent = FTP_SENDS.find(([re]) => re.test(verb));
129
+ if (!sent) continue;
130
+ const foreign = sent[1] !== 'MPUT' && args[1] && !args[1].startsWith('(') ? args[1] : null;
131
+ for (const name of sent[1] === 'MPUT' ? args : args.slice(0, 1)) sends.push({ line, verb: sent[1], name, foreign: foreign || name, ...typed, ...local(name) });
132
+ }
133
+ }
134
+ const cleartext = tls === 'never' || (!tls && !configUnread);
135
+ return { host, cleartext, configUnread, inputUnread, logon: dds.has('NETRC') ? 'NETRC' : null, sends, gets, passwords, input };
136
+ }
package/lib/ironwork.mjs CHANGED
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
7
7
  import { resolve } from 'node:path';
8
8
  import { directoryTree } from './kernel/source-tree.mjs';
9
9
  import { printable } from './kernel/printable.mjs';
10
- import { buildFileIndex, VERBS } from './parser.mjs';
10
+ import { buildFileIndex } from './parser.mjs';
11
11
  import { isProgram, relPath } from './sources.mjs';
12
12
  import { EIB_FIELDS, DIB_FIELDS, SQLCA_FIELDS } from './words.mjs';
13
13
 
@@ -20,15 +20,13 @@ const NOT_AN_ERROR = /^(?:informational:|.*?: warning: )/;
20
20
  const PER_PROGRAM_MS = 60000;
21
21
  const MAX_LISTED = 10;
22
22
 
23
- // ironwork refuses by name what it does not model yet; that says nothing about the program. Where its
24
- // parser stops at a statement it does not read (ENTRY, ALTER), or at a name the CICS, DL/I or SQL
25
- // translator declares, it says so without the name, so both are read as its gap, not the program's.
23
+ // ironwork refuses by name what it does not model yet; that says nothing about the program. A name
24
+ // the CICS, DL/I or SQL translator declares it reports as undefined, without saying so, and that is
25
+ // read as its gap, not the program's.
26
26
  const NOT_SUPPORTED = /\bnot supported\b/i;
27
27
  const TRANSLATOR_NAMES = [EIB_FIELDS, DIB_FIELDS, SQLCA_FIELDS];
28
28
  function notModelled(message) {
29
29
  if (NOT_SUPPORTED.test(message)) return true;
30
- const verb = /^a statement, found ([A-Z][A-Z-]*)$/.exec(message);
31
- if (verb) return VERBS.has(verb[1]);
32
30
  const name = /^([A-Z][A-Z0-9-]*) is not defined$/.exec(message);
33
31
  return !!name && TRANSLATOR_NAMES.some((s) => s.has(name[1]));
34
32
  }
package/lib/jcl.mjs CHANGED
@@ -12,17 +12,14 @@
12
12
  // output as `unreadable`, never dropped, because a step nobody parsed is not a step that is not
13
13
  // there.
14
14
  import { readSource } from './sources.mjs';
15
- import { copiesOf } from './utilities.mjs';
15
+ import { copiesOf, tsoCommands } from './utilities.mjs';
16
+ import { statementCard } from './card.mjs';
16
17
 
17
18
  // The operations a statement may carry. Anything else in the operation field is a statement this
18
19
  // reader does not know, which is a diagnostic rather than a silent skip.
19
20
  export const OPERATIONS = new Set(['JOB', 'EXEC', 'DD', 'PROC', 'PEND', 'SET', 'IF', 'THEN',
20
21
  'ELSE', 'ENDIF', 'INCLUDE', 'JCLLIB', 'OUTPUT', 'CNTL', 'ENDCNTL', 'XMIT', 'COMMAND', 'NOTIFY', 'EXPORT']);
21
22
 
22
- // Columns 73 to 80 are the sequence area. They are not the statement, and a payload hidden there
23
- // is the hidden-content rules' business, not this reader's.
24
- const STATEMENT_COLUMNS = 72;
25
-
26
23
  // A continued operand resumes in these columns. Outside them it is not a continuation, whatever
27
24
  // the previous line ended with.
28
25
  const CONTINUE_FROM = 4;
@@ -146,8 +143,10 @@ export function foldStatements(src) {
146
143
  const line = i + 1;
147
144
  const raw = phys[i];
148
145
  if (raw.length > 80) diags.push({ sev: 'warn', line, text: 'line is longer than 80 columns' });
149
- const text = raw.slice(0, STATEMENT_COLUMNS).replace(/\s+$/, '');
150
- const sequence = raw.slice(STATEMENT_COLUMNS);
146
+ // The sequence area is not the statement; a payload hidden there is the hidden-content rules' business.
147
+ const card = statementCard(raw);
148
+ const text = card.text.replace(/\s+$/, '');
149
+ const sequence = card.sequence;
151
150
 
152
151
  if (stream) {
153
152
  // A custom DLM ends the stream and nothing else does. The default ends at /* or at the next
@@ -402,6 +401,27 @@ export function parseJcl(src, file, opts = {}) {
402
401
  else diags.push({ sev: 'warn', line: 0, text: `symbolic parameter &${s} has no value, so the operands using it were read unsubstituted` });
403
402
  }
404
403
 
404
+ // A TSO batch step's ALLOCATE commands define DDs its JCL does not show. Each is kept as a DD of the
405
+ // step, marked `allocated`, with the datasets of a list concatenated under it as JCL would.
406
+ for (const s of steps) {
407
+ const tso = tsoCommands(s);
408
+ for (const n of tso.notes) diags.push({ sev: 'info', ...n });
409
+ for (const a of tso.allocations) {
410
+ const ends = a.datasets.length ? a.datasets : [{ written: null, dsn: null }];
411
+ ends.forEach((d, i) => {
412
+ const dd = {
413
+ name: i === 0 ? a.dd : null, procStep: null, dsn: d.dsn, rawDsn: d.written, referback: null,
414
+ disp: a.status, access: accessOf(a.status), temporary: false, sysout: a.sysout,
415
+ inStream: null, dlm: null, concatenated: i > 0, keywords: new Map(), line: a.line, endLine: a.line,
416
+ step: s.name, allocated: true,
417
+ };
418
+ dds.push(dd);
419
+ s.dds.push(dd);
420
+ stepOf.set(dd, s);
421
+ });
422
+ }
423
+ }
424
+
405
425
  // What each step's program copies, from lib/utilities.mjs. A program that table does not know
406
426
  // copies nothing here, which is not the same as copying nothing.
407
427
  const copies = [];