@portll/cobolwork 0.2.140 → 0.2.150

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.
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  import { detectFormat, expandTabs } from '../parser.mjs';
3
- import { cobolCard } from '../card.mjs';
3
+ import { cobolCard } from '../cards.mjs';
4
4
  import { decodeEbcdic, inScope, isJcl, isSource, looksEbcdic, relPath } from '../sources.mjs';
5
5
  import { report } from '../kernel/ruleset.mjs';
6
6
  import { treeFor, noteUnread } from '../kernel/source-tree.mjs';
package/lib/sets/jcl.mjs CHANGED
@@ -237,7 +237,7 @@ export function scanJcl(root, opts = {}) {
237
237
  // A vendor product supplies its own utilities. An estate that has loaded the pack for a product
238
238
  // is telling us those programs exist, so reporting them as undefined would be a false positive
239
239
  // created by the customer having told us the truth.
240
- const site = loadSite(root, opts.site || null);
240
+ const site = loadSite(root, opts.site || null, opts.tree);
241
241
  const { loaded } = loadPacks(opts.packs || site.vendorPacks || [], { allowUnvalidated: true });
242
242
  const vendorPrograms = programsOf(loaded);
243
243
 
package/lib/sets/priv.mjs CHANGED
@@ -102,7 +102,7 @@ function under(dsn, prefixes) {
102
102
  }
103
103
 
104
104
  export function scanPriv(root, opts = {}) {
105
- const site = loadSite(root, opts.site || null);
105
+ const site = loadSite(root, opts.site || null, opts.tree);
106
106
  const tree = treeFor(root, opts);
107
107
  const files = tree.list().filter(inScope(opts));
108
108
  const findings = [];
@@ -17,7 +17,7 @@ import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
17
17
  import { eachWithinMemory } from '../kernel/memory.mjs';
18
18
  import { loadSite, classifyPath, productionQualifierOf, SITE_FILE } from '../site.mjs';
19
19
  import { parseJcl, dispositionOf } from '../jcl.mjs';
20
- import { statementCard } from '../card.mjs';
20
+ import { statementCard } from '../cards.mjs';
21
21
 
22
22
 
23
23
  // The production-dataset rules claim more than the name rule: that the job acts on production, not
@@ -161,7 +161,7 @@ function productionTouches(job, qualifiers) {
161
161
  }
162
162
 
163
163
  export function scanRecon(root, opts = {}) {
164
- const site = loadSite(root, opts.site || null);
164
+ const site = loadSite(root, opts.site || null, opts.tree);
165
165
  const tree = treeFor(root, opts);
166
166
  const files = tree.list().filter(inScope(opts))
167
167
  .filter((f) => isJcl(f) || isProgram(f) || isCopybook(f));
@@ -216,7 +216,7 @@ export function scanSemantics(root, opts = {}) {
216
216
  truncOptPrograms: 0, truncUnknownPrograms: 0, arithExtendPrograms: 0, beyondEnterprisePrograms: 0, computesRead: 0, computesUnread: 0,
217
217
  rangesRead: 0, collatingDeclaredPrograms: 0,
218
218
  };
219
- const site = loadSite(root, opts.site || null).compilerOptions || [];
219
+ const site = loadSite(root, opts.site || null, opts.tree).compilerOptions || [];
220
220
  const parsedJcl = [];
221
221
  for (const f of all.filter(isJcl)) {
222
222
  try { parsedJcl.push({ ...parseJcl(tree.text(f).text, f), file: relPath(root, f) }); } catch { /* the jcl set reports it */ }
@@ -26,7 +26,7 @@ export const VENDOR_RULES = {
26
26
  const evidenceOf = (r) => (r.severity === 'info' ? { evidence: 'context' } : {});
27
27
 
28
28
  export function scanVendor(root, opts = {}) {
29
- const site = loadSite(root, opts.site || null);
29
+ const site = loadSite(root, opts.site || null, opts.tree);
30
30
  const wanted = opts.packs || site.vendorPacks || [];
31
31
  const { loaded, refused, problems, caveats } = loadPacks(wanted, {
32
32
  allowUnvalidated: !!(opts.allowUnvalidatedPacks ?? site.allowUnvalidatedPacks),
package/lib/site.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // It is also the only part of cobolwork that is inherently per-customer and inherently recurring:
10
10
  // conventions drift, environments are renamed, estates are acquired.
11
11
  import { readFileSync, existsSync } from 'node:fs';
12
- import { join } from 'node:path';
12
+ import { join, resolve } from 'node:path';
13
13
  import { resolvesInside } from './kernel/source-tree.mjs';
14
14
  import { printable } from './kernel/printable.mjs';
15
15
 
@@ -43,14 +43,24 @@ const EMPTY = {
43
43
  // A configuration that is absent is not a configuration that says "nothing is production". The
44
44
  // difference is the whole point: without it the recon rules have not looked, and a scan must say
45
45
  // it has not looked rather than report a clean result.
46
- export function loadSite(root, explicit = null) {
47
- const path = explicit || join(root, SITE_FILE);
48
- if (!existsSync(path)) return { ...EMPTY, path };
49
- // A site file in the tree is not read through a link out of it; one the caller names is.
50
- if (!explicit && !resolvesInside(root, path)) return { ...EMPTY, path, problems: [`${SITE_FILE} is a link that leads outside the tree, so it was not read`] };
46
+ // `tree` is the scan's source tree: one held in memory (a git revision) supplies the site file
47
+ // itself, and on disk the file is read from `root`.
48
+ export function loadSite(root, explicit = null, tree = null) {
49
+ const held = !explicit && tree && tree.kind !== 'directory';
50
+ const path = explicit || (held ? resolve(tree.root, SITE_FILE) : join(root, SITE_FILE));
51
+ let read;
52
+ if (held) {
53
+ if (!tree.contains(path)) return { ...EMPTY, path };
54
+ read = () => tree.bytes(path).toString('utf8');
55
+ } else {
56
+ if (!existsSync(path)) return { ...EMPTY, path };
57
+ // A site file in the tree is not read through a link out of it; one the caller names is.
58
+ if (!explicit && !resolvesInside(root, path)) return { ...EMPTY, path, problems: [`${SITE_FILE} is a link that leads outside the tree, so it was not read`] };
59
+ read = () => readFileSync(path, 'utf8');
60
+ }
51
61
 
52
62
  let raw;
53
- try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch (e) {
63
+ try { raw = JSON.parse(read()); } catch (e) {
54
64
  return { ...EMPTY, path, problems: [`${SITE_FILE} is not readable as JSON: ${printable(e.message, 120)}`] };
55
65
  }
56
66
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...EMPTY, path, problems: [`${SITE_FILE} is not a JSON object`] };
package/lib/sources.mjs CHANGED
@@ -23,41 +23,90 @@ const has = (list) => (p) => list.includes(extname(p).toLowerCase());
23
23
  // of it and reporting a clean zero, which is the failure this project exists to refuse.
24
24
  //
25
25
  // So a file with no extension is classified by what is in it. Only the first few kilobytes are
26
- // read, only files without an extension are sniffed, and the answer is cached: this runs over
27
- // every file in the tree and must not cost a parse.
26
+ // read and only files without an extension are sniffed. The tree a path belongs to classifies it
27
+ // and keeps the answer for as long as the tree lives: this runs over every file in the tree and
28
+ // must not cost a parse, and a cache that outlives the scan grows by one entry per file for the
29
+ // life of the process.
28
30
  const SNIFF_BYTES = 4096;
29
- const sniffed = new Map();
30
31
 
31
32
  // Names that are certainly not source, so the commonest extensionless files cost nothing.
32
33
  const NOT_SOURCE = /^(LICEN[CS]E|README|MAKEFILE|DOCKERFILE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS|VERSION|COPYING|Jenkinsfile|Procfile|\..*)$/i;
34
+ const notSource = (path) => NOT_SOURCE.test(path.split(/[\\/]/).pop());
35
+
36
+ function kindOfHead(head) {
37
+ // An EBCDIC member is decoded before it is read, or every test below sees mojibake.
38
+ const text = looksEbcdic(head) ? decodeEbcdic(head) : head.toString('latin1');
39
+ // A NUL byte means binary. Tested on the bytes rather than with a literal, because a literal
40
+ // NUL in source is exactly the character an editor or a patch is most likely to eat.
41
+ if (head.includes(0)) return null;
42
+ if (/^\s*\/\/\S*\s+(?:JOB|EXEC|PROC|DD)\b/m.test(text)) return 'jcl';
43
+ if (/\bIDENTIFICATION\s+DIVISION|\bPROGRAM-ID\s*\./i.test(text)) return 'program';
44
+ // A copybook is a data description with no divisions: level numbers and picture clauses.
45
+ if (/^[^*\n]{0,10}\s*\d\d\s+[A-Z][A-Z0-9-]*\s+(?:PIC|PICTURE|OCCURS|REDEFINES|USAGE)\b/im.test(text)) return 'copybook';
46
+ // A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
47
+ // classified before classifies differently.
48
+ if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) return 'bms';
49
+ return null;
50
+ }
33
51
 
34
- export function sniffKind(path) {
35
- if (sniffed.has(path)) return sniffed.get(path);
36
- let kind = null;
37
- const name = path.split(/[\\/]/).pop();
38
- if (!NOT_SOURCE.test(name)) {
39
- try {
40
- const fd = openSync(path, 'r');
41
- const buf = Buffer.alloc(SNIFF_BYTES);
42
- let n;
43
- try { n = readSync(fd, buf, 0, SNIFF_BYTES, 0); } finally { closeSync(fd); }
44
- const head = buf.subarray(0, n);
45
- // An EBCDIC member is decoded before it is read, or every test below sees mojibake.
46
- const text = looksEbcdic(head) ? decodeEbcdic(head) : head.toString('latin1');
47
- // A NUL byte means binary. Tested on the bytes rather than with a literal, because a literal
48
- // NUL in source is exactly the character an editor or a patch is most likely to eat.
49
- if (head.includes(0)) kind = null;
50
- else if (/^\s*\/\/\S*\s+(?:JOB|EXEC|PROC|DD)\b/m.test(text)) kind = 'jcl';
51
- else if (/\bIDENTIFICATION\s+DIVISION|\bPROGRAM-ID\s*\./i.test(text)) kind = 'program';
52
- // A copybook is a data description with no divisions: level numbers and picture clauses.
53
- else if (/^[^*\n]{0,10}\s*\d\d\s+[A-Z][A-Z0-9-]*\s+(?:PIC|PICTURE|OCCURS|REDEFINES|USAGE)\b/im.test(text)) kind = 'copybook';
54
- // A BMS map opens its mapset with DFHMSD in the operation field. Tested last, so nothing that
55
- // classified before classifies differently.
56
- else if (/^[^*\s]*[ \t]+DFHMSD(?=\s|$)/im.test(text)) kind = 'bms';
57
- } catch { kind = null; }
52
+ // What a member's bytes say it is, for a tree that holds them.
53
+ export const kindOfBytes = (buf) => kindOfHead(buf.subarray(0, SNIFF_BYTES));
54
+
55
+ function sniffDisk(path) {
56
+ if (notSource(path)) return null;
57
+ try {
58
+ const fd = openSync(path, 'r');
59
+ const buf = Buffer.alloc(SNIFF_BYTES);
60
+ let n;
61
+ try { n = readSync(fd, buf, 0, SNIFF_BYTES, 0); } finally { closeSync(fd); }
62
+ return kindOfHead(buf.subarray(0, n));
63
+ } catch { return null; }
64
+ }
65
+
66
+ // A tree's classifier for the paths under its root, remembered for as long as the tree lives.
67
+ const classifiers = new Map();
68
+
69
+ export const classifyUnder = (root, kindOf) => classifiers.set(root.replace(/[\\/]+$/, ''), new WeakRef(kindOf));
70
+
71
+ // The classifier of the deepest live tree whose root the path sits under.
72
+ function classifierFor(path) {
73
+ let best = null;
74
+ let depth = -1;
75
+ for (const [root, ref] of classifiers) {
76
+ if (root.length <= depth || !path.startsWith(root + sep)) continue;
77
+ const kindOf = ref.deref();
78
+ if (!kindOf) { classifiers.delete(root); continue; }
79
+ best = kindOf;
80
+ depth = root.length;
58
81
  }
59
- sniffed.set(path, kind);
60
- return kind;
82
+ return best;
83
+ }
84
+
85
+ // A directory tree's classifier: sniffed from disk once per path.
86
+ export function diskClassifier() {
87
+ const seen = new Map();
88
+ return (path) => {
89
+ if (!seen.has(path)) seen.set(path, sniffDisk(path));
90
+ return seen.get(path);
91
+ };
92
+ }
93
+
94
+ // A held tree's classifier: sniffed from what the tree holds, never from disk.
95
+ export function heldClassifier(bytesOf) {
96
+ const seen = new Map();
97
+ return (path) => {
98
+ if (!seen.has(path)) {
99
+ let kind = null;
100
+ if (!notSource(path)) try { kind = kindOfBytes(bytesOf(path)); } catch { kind = null; }
101
+ seen.set(path, kind);
102
+ }
103
+ return seen.get(path);
104
+ };
105
+ }
106
+
107
+ export function sniffKind(path) {
108
+ const kindOf = classifierFor(path);
109
+ return kindOf ? kindOf(path) : sniffDisk(path);
61
110
  }
62
111
 
63
112
  // Extension first, because it is free and it is right whenever it is present.
@@ -72,13 +121,6 @@ export const isSource = (p) => (extname(p)
72
121
  ? [...PROGRAM_EXT, ...COPY_EXT, ...JCL_EXT].includes(extname(p).toLowerCase())
73
122
  : ![null, 'bms'].includes(sniffKind(p)));
74
123
 
75
- // For a report that wants to say how much of what it read had no extension to go on.
76
- export const sniffedCount = () => {
77
- let n = 0;
78
- for (const v of sniffed.values()) if (v) n++;
79
- return n;
80
- };
81
-
82
124
  // `opts.only` is about rule sets; this is about files. A rule set given an allow list reads the
83
125
  // files on it and no others.
84
126
  //
@@ -159,8 +201,10 @@ export function decodeEbcdic(buf) {
159
201
 
160
202
  // Every rule set reads source through here, so an EBCDIC member is decoded once, the same way,
161
203
  // and reported as EBCDIC instead of silently parsing as noise.
162
- export function readSource(path) {
163
- const buf = readFileSync(path);
204
+ export const readSource = (path) => decodeSource(readFileSync(path));
205
+
206
+ // The same decoding for bytes that did not come from a file: a blob read out of a git revision.
207
+ export function decodeSource(buf) {
164
208
  if (looksEbcdic(buf)) return { text: decodeEbcdic(buf), encoding: 'ebcdic' };
165
209
  return { text: buf.toString('latin1'), encoding: 'latin1' };
166
210
  }
package/lib/utilities.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // then only the ones its DD names decide on their own.
10
10
 
11
11
  import { ftpSession } from './ftp.mjs';
12
- import { statementCard } from './card.mjs';
12
+ import { statementCard } from './cards.mjs';
13
13
 
14
14
  const upper = (s) => String(s).toUpperCase();
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portll/cobolwork",
3
- "version": "0.2.140",
3
+ "version": "0.2.150",
4
4
  "description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Portll <john@portll.net>",