fhirsmith 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +40 -19
  3. package/config-template.json +25 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/library/html-server.js +16 -16
  7. package/library/html.js +9 -0
  8. package/library/narrative-check.js +203 -0
  9. package/library/npm-audit.js +42 -3
  10. package/package.json +2 -1
  11. package/packages/openapi.yaml +6 -3
  12. package/packages/package-crawler.js +189 -82
  13. package/packages/packages.js +33 -69
  14. package/packages/readme.md +1 -1
  15. package/packages/utilities/dedupe-versions.js +22 -8
  16. package/registry/crawler.js +8 -1
  17. package/registry/registry.js +18 -11
  18. package/release.sh +80 -0
  19. package/shl/shl.js +5 -3
  20. package/testing/readme.md +1 -1
  21. package/tx/cm/cm-database.js +62 -0
  22. package/tx/cm/cm-package.js +20 -3
  23. package/tx/cs/cs-cs.js +86 -18
  24. package/tx/cs/cs-loinc.js +32 -14
  25. package/tx/library/resource-ids.js +16 -1
  26. package/tx/library.js +9 -2
  27. package/tx/ocl/README.md +62 -6
  28. package/tx/ocl/cm-ocl.cjs +166 -44
  29. package/tx/ocl/cs-ocl.cjs +163 -13
  30. package/tx/ocl/resolve/public-access.js +143 -0
  31. package/tx/ocl/resolve/reference-resolver.js +458 -0
  32. package/tx/ocl/vs-ocl.cjs +167 -4
  33. package/tx/problems.js +7 -5
  34. package/tx/provider.js +5 -1
  35. package/tx/tx-html.js +46 -7
  36. package/tx/tx.js +22 -6
  37. package/tx/vs/vs-database.js +26 -0
  38. package/tx/vs/vs-package.js +42 -10
  39. package/tx/workers/expand.js +1 -1
  40. package/tx/workers/metadata.js +6 -1
  41. package/tx/workers/read.js +87 -5
  42. package/utilities/submit-test-report.js +1 -1
  43. package/xig/xig.js +88 -12
@@ -16,6 +16,33 @@ const http = require('http');
16
16
  const https = require('https');
17
17
  const ipaddr = require('ipaddr.js');
18
18
  const {debugLog} = require("../tx/operation-context");
19
+ const {Parser: TarParser} = require('tar');
20
+
21
+ const MB = 1024 * 1024;
22
+
23
+ // Limits on what a package can make the crawler download, unpack and hold in memory, so
24
+ // that a hostile or broken package (a decompression bomb, say) costs a bounded amount.
25
+ // All can be set in the packages "crawler" config section:
26
+ // maxDownloadMB the package file as downloaded (or read from a local feed)
27
+ // maxUnpackedMB the total of the package's files once unzipped. The files are streamed,
28
+ // not held, so this limits the work, not the memory
29
+ // maxFileMB a file the crawler reads: package.json, .index.json, ig.ini, and each
30
+ // .json file (for its url). Memory is bounded by this, not by the package
31
+ // maxFiles the number of files in the package
32
+ const LIMIT_DEFAULTS = { maxDownloadMB: 250, maxUnpackedMB: 4096, maxFileMB: 50, maxFiles: 250000 };
33
+
34
+ function crawlerLimits(config) {
35
+ const c = { ...LIMIT_DEFAULTS, ...(config?.crawler || {}) };
36
+ return {
37
+ maxDownloadBytes: c.maxDownloadMB * MB,
38
+ maxUnpackedBytes: c.maxUnpackedMB * MB,
39
+ maxFileBytes: c.maxFileMB * MB,
40
+ maxFiles: c.maxFiles
41
+ };
42
+ }
43
+
44
+ // The files in a package that the crawler reads for its metadata
45
+ const META_FILES = new Set(['package.json', '.index.json', 'ig.ini']);
19
46
 
20
47
  // True if an IP literal is anything other than a normal public (unicast) address:
21
48
  // loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
@@ -350,8 +377,14 @@ class PackageCrawler {
350
377
 
351
378
  async fetchUrl(url) {
352
379
  try {
380
+ const limits = crawlerLimits(this.config);
353
381
  if (url.startsWith("/")) {
354
- const buffer = await fs.promises.readFile(this.resolveLocalReadPath(url));
382
+ const localPath = this.resolveLocalReadPath(url);
383
+ const size = (await fs.promises.stat(localPath)).size;
384
+ if (size > limits.maxDownloadBytes) {
385
+ throw new Error(`the package is ${Math.round(size / MB)} MB, more than the limit of ${limits.maxDownloadBytes / MB} MB (maxDownloadMB)`);
386
+ }
387
+ const buffer = await fs.promises.readFile(localPath);
355
388
  this.totalBytes += buffer.byteLength;
356
389
  return buffer;
357
390
  } else {
@@ -360,6 +393,7 @@ class PackageCrawler {
360
393
  responseType: 'arraybuffer',
361
394
  // We want the package file's own bytes: decodePackageBody undoes the Content-Encoding
362
395
  decompress: false,
396
+ maxContentLength: limits.maxDownloadBytes,
363
397
  signal: this.abortController?.signal,
364
398
  headers: {
365
399
  'User-Agent': 'FHIR Package Crawler/1.0'
@@ -373,6 +407,9 @@ class PackageCrawler {
373
407
  }
374
408
  } catch (error) {
375
409
  debugLog(`Failed to fetch ${url}: ${error.message}`);
410
+ if (/maxContentLength/.test(error.message)) {
411
+ throw new Error(`Failed to fetch ${url}: the package is more than the limit of ${crawlerLimits(this.config).maxDownloadBytes / MB} MB (maxDownloadMB)`);
412
+ }
376
413
  if (error.response && error.response.status === 429) {
377
414
  throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
378
415
  }
@@ -387,21 +424,41 @@ class PackageCrawler {
387
424
  // package unless unzipping it gives another gzip file - a server that really did compress
388
425
  // a .tgz for transfer.
389
426
  decodePackageBody(body, contentEncoding) {
427
+ // undoing the transfer encoding gives the package file, so the download limit applies
428
+ const max = { maxOutputLength: crawlerLimits(this.config).maxDownloadBytes };
429
+ const tooBig = (e) => e.code === 'ERR_BUFFER_TOO_LARGE'
430
+ ? new Error(`the package is more than the limit of ${max.maxOutputLength / MB} MB (maxDownloadMB) once its Content-Encoding is undone`) : e;
390
431
  const encodings = String(contentEncoding || '').toLowerCase().split(',').map(e => e.trim()).filter(e => e && e !== 'identity');
391
432
  for (const enc of encodings.reverse()) {
392
433
  if (enc === 'gzip' || enc === 'x-gzip') {
434
+ let start;
393
435
  try {
394
- const inner = zlib.gunzipSync(body);
395
- if (inner[0] === 0x1f && inner[1] === 0x8b) {
396
- body = inner;
397
- }
436
+ // only the start is needed to see whether it is gzip inside: a tar unzipped
437
+ // from a .tgz can be far larger than the download limit
438
+ start = zlib.gunzipSync(body.subarray(0, 4096), { finishFlush: zlib.constants.Z_SYNC_FLUSH });
398
439
  } catch (e) {
399
440
  // not gzip after all: leave it for extractNpmPackage to report
441
+ continue;
442
+ }
443
+ if (start[0] === 0x1f && start[1] === 0x8b) {
444
+ try {
445
+ body = zlib.gunzipSync(body, max);
446
+ } catch (e) {
447
+ throw tooBig(e);
448
+ }
400
449
  }
401
450
  } else if (enc === 'br') {
402
- body = zlib.brotliDecompressSync(body);
451
+ try {
452
+ body = zlib.brotliDecompressSync(body, max);
453
+ } catch (e) {
454
+ throw tooBig(e);
455
+ }
403
456
  } else if (enc === 'deflate') {
404
- body = zlib.inflateSync(body);
457
+ try {
458
+ body = zlib.inflateSync(body, max);
459
+ } catch (e) {
460
+ throw tooBig(e);
461
+ }
405
462
  } else {
406
463
  throw new Error(`Unsupported Content-Encoding ${enc}`);
407
464
  }
@@ -854,6 +911,10 @@ class PackageCrawler {
854
911
  let fid = this.fixPrefix(id);
855
912
  const filename = `${fid}-${version}.tgz`;
856
913
  const filepath = path.join(this.config.mirrorPath, filename);
914
+ // id and version are validated, but the file must land in the mirror whatever they say
915
+ if (path.dirname(path.resolve(filepath)) !== path.resolve(this.config.mirrorPath)) {
916
+ throw new Error(`Package ${id}#${version} rejected: its mirror file name is not a plain file name`);
917
+ }
857
918
  fs.writeFileSync(filepath, packageBuffer);
858
919
  }
859
920
 
@@ -866,60 +927,123 @@ class PackageCrawler {
866
927
  }
867
928
  }
868
929
 
869
- async extractNpmPackage(packageBuffer, source) {
870
- try {
871
- const files = {};
872
- if (packageBuffer.length < 2 || packageBuffer[0] !== 0x1f || packageBuffer[1] !== 0x8b) {
873
- const start = packageBuffer.subarray(0, 8).toString('hex');
874
- throw new Error(`not a gzip file (it starts with ${start || 'nothing'})`);
875
- }
876
-
877
- // First decompress the gzip
878
- const decompressed = zlib.gunzipSync(packageBuffer);
879
-
880
- // Parse tar manually without any file system operations
881
- let offset = 0;
882
-
883
- while (offset < decompressed.length) {
884
- // Read tar header (512 bytes)
885
- if (offset + 512 > decompressed.length) break;
886
-
887
- const header = decompressed.slice(offset, offset + 512);
888
-
889
- // Check if this is the end (null header)
890
- if (header[0] === 0) break;
891
-
892
- // Extract filename (first 100 bytes, null-terminated)
893
- let filename = '';
894
- for (let i = 0; i < 100; i++) {
895
- if (header[i] === 0) break;
896
- filename += String.fromCharCode(header[i]);
930
+ // Reads what the crawler needs from a package as the package is unzipped and untarred:
931
+ // package.json, .index.json and ig.ini, the file names, and the url of each resource. Only
932
+ // those files are held, one at a time, so memory is bounded by maxFileMB however large
933
+ // the package unpacks to (see crawlerLimits)
934
+ async unpackPackage(packageBuffer) {
935
+ const limits = crawlerLimits(this.config);
936
+ const meta = {};
937
+ const fileNames = [];
938
+ const resourceUrls = [];
939
+ const skipped = [];
940
+
941
+ await new Promise((resolve, reject) => {
942
+ let unpacked = 0;
943
+ let failed = false;
944
+ const gunzip = zlib.createGunzip();
945
+ const fail = (e) => {
946
+ if (!failed) {
947
+ failed = true;
948
+ gunzip.destroy();
949
+ reject(e);
897
950
  }
951
+ };
898
952
 
899
- // Extract file size (12 bytes starting at offset 124, octal)
900
- let sizeStr = '';
901
- for (let i = 124; i < 136; i++) {
902
- if (header[i] === 0 || header[i] === 32) break; // null or space
903
- sizeStr += String.fromCharCode(header[i]);
953
+ const parser = new TarParser({
954
+ onReadEntry: (entry) => {
955
+ if (failed || !['File', 'OldFile', 'ContiguousFile'].includes(entry.type)) {
956
+ entry.resume();
957
+ return;
958
+ }
959
+ const name = entry.path.replace(/^package\//, '');
960
+ fileNames.push(name);
961
+ if (fileNames.length > limits.maxFiles) {
962
+ fail(new Error(`the package has more than ${limits.maxFiles} files (maxFiles)`));
963
+ entry.resume();
964
+ return;
965
+ }
966
+ const isMeta = META_FILES.has(name);
967
+ if (!isMeta && !name.endsWith('.json')) {
968
+ entry.resume();
969
+ return;
970
+ }
971
+ if (entry.size > limits.maxFileBytes) {
972
+ if (name === 'package.json') {
973
+ fail(new Error(`package.json is ${Math.round(entry.size / MB)} MB, more than the limit of ${limits.maxFileBytes / MB} MB (maxFileMB)`));
974
+ } else {
975
+ skipped.push(name);
976
+ }
977
+ entry.resume();
978
+ return;
979
+ }
980
+ const chunks = [];
981
+ entry.on('data', (chunk) => chunks.push(chunk));
982
+ entry.on('end', () => {
983
+ if (failed) {
984
+ return;
985
+ }
986
+ const text = Buffer.concat(chunks).toString('utf8');
987
+ if (isMeta) {
988
+ meta[name] = text;
989
+ }
990
+ if (name.endsWith('.json') && name !== 'package.json' && name !== '.index.json') {
991
+ try {
992
+ const json = JSON.parse(text);
993
+ if (json.url && json.resourceType) {
994
+ resourceUrls.push(json.url);
995
+ }
996
+ } catch (e) {
997
+ // not a resource
998
+ }
999
+ }
1000
+ });
904
1001
  }
905
- const fileSize = parseInt(sizeStr, 8) || 0;
906
-
907
- // Move past header
908
- offset += 512;
909
-
910
- // Extract file content if we need this file
911
- if (fileSize > 0) {
912
- const cleanFilename = filename.replace(/^package\//, ''); // Remove package/ prefix
1002
+ });
1003
+ parser.on('warn', (code, message) => {
1004
+ if (code === 'TAR_BAD_ARCHIVE') {
1005
+ fail(new Error(`not a tar file once unzipped (${message})`));
1006
+ }
1007
+ });
1008
+ parser.on('error', fail);
1009
+ parser.on('end', () => {
1010
+ if (!failed) {
1011
+ resolve();
1012
+ }
1013
+ });
913
1014
 
914
- const fileContent = decompressed.slice(offset, offset + fileSize);
915
- files[cleanFilename] = fileContent.toString('utf8');
1015
+ gunzip.on('data', (chunk) => {
1016
+ if (failed) {
1017
+ return;
1018
+ }
1019
+ unpacked += chunk.length;
1020
+ if (unpacked > limits.maxUnpackedBytes) {
1021
+ fail(new Error(`the package unzips to more than the limit of ${limits.maxUnpackedBytes / MB} MB (maxUnpackedMB)`));
1022
+ return;
1023
+ }
1024
+ parser.write(chunk);
1025
+ });
1026
+ gunzip.on('end', () => {
1027
+ if (!failed) {
1028
+ parser.end();
916
1029
  }
1030
+ });
1031
+ gunzip.on('error', (e) => fail(new Error(`could not unzip the package: ${e.message}`)));
1032
+ gunzip.end(packageBuffer);
1033
+ });
1034
+
1035
+ return { meta, fileNames, resourceUrls, skipped };
1036
+ }
917
1037
 
918
- // Move to next file (files are padded to 512-byte boundaries)
919
- const paddedSize = Math.ceil(fileSize / 512) * 512;
920
- offset += paddedSize;
1038
+ async extractNpmPackage(packageBuffer, source) {
1039
+ try {
1040
+ if (packageBuffer.length < 2 || packageBuffer[0] !== 0x1f || packageBuffer[1] !== 0x8b) {
1041
+ const start = packageBuffer.subarray(0, 8).toString('hex');
1042
+ throw new Error(`not a gzip file (it starts with ${start || 'nothing'})`);
921
1043
  }
922
1044
 
1045
+ const { meta: files, fileNames, resourceUrls, skipped } = await this.unpackPackage(packageBuffer);
1046
+
923
1047
  // Parse package.json (required)
924
1048
  if (!files['package.json']) {
925
1049
  throw new Error('package.json not found in extracted package');
@@ -933,7 +1057,7 @@ class PackageCrawler {
933
1057
  packageJson.scripts.postinstall
934
1058
  )
935
1059
  );
936
- const hasJavaScript = Object.keys(files).some(f => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.cjs'));
1060
+ const hasJavaScript = fileNames.some(f => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.cjs'));
937
1061
 
938
1062
  // Extract basic NPM fields
939
1063
  const id = packageJson.name || '';
@@ -1025,6 +1149,10 @@ class PackageCrawler {
1025
1149
  }
1026
1150
  }
1027
1151
 
1152
+ if (skipped.length > 0) {
1153
+ this.log.warn(`Package ${id}#${version}: ${skipped.length} file(s) are more than the limit of ${crawlerLimits(this.config).maxFileBytes / MB} MB (maxFileMB), so were not read for their url: ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', ...' : ''}`);
1154
+ }
1155
+
1028
1156
  // Default fhirVersion if not found
1029
1157
  if (!fhirVersion) {
1030
1158
  fhirVersion = '4.0.1'; // Default to R4
@@ -1046,7 +1174,8 @@ class PackageCrawler {
1046
1174
  hasInstallScripts,
1047
1175
  hasJavaScript,
1048
1176
  notForPublication,
1049
- files
1177
+ fileNames,
1178
+ resourceUrls
1050
1179
  };
1051
1180
 
1052
1181
  } catch (error) {
@@ -1103,8 +1232,9 @@ class PackageCrawler {
1103
1232
  }
1104
1233
 
1105
1234
  isValidSemVersion(version) {
1106
- // Simple semantic version validation
1107
- return /^\d+\.\d+\.\d+/.test(version);
1235
+ // The whole version must be semver: it goes into the database, URLs and the mirror
1236
+ // file name, so a version like 1.0.0/../../x must not get through
1237
+ return /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(version);
1108
1238
  }
1109
1239
 
1110
1240
  isAbsoluteUrl(url) {
@@ -1117,31 +1247,8 @@ class PackageCrawler {
1117
1247
  }
1118
1248
 
1119
1249
  processPackageUrls(npmPackage) {
1120
- const urls = [];
1121
-
1122
- try {
1123
-
1124
- for (const filename of Object.keys(npmPackage.files)) {
1125
- try {
1126
- const bytes = npmPackage.files[filename];
1127
- if (filename.endsWith('.json')) {
1128
- try {
1129
- const jsonContent = JSON.parse(bytes);
1130
-
1131
- if (jsonContent.url && jsonContent.resourceType) {
1132
- urls.push(jsonContent.url);
1133
- }
1134
- } catch (fileError) {
1135
- // this.log.warn(`Error processing package file ${npmPackage.name}#${npmPackage.version}/package/${filename}: ${fileError.message}`);
1136
- }
1137
- }
1138
- } catch (fileError) {
1139
- this.log.warn(`Error processing package file ${npmPackage.name}#${npmPackage.version}/package/${filename}: ${fileError.message}`);
1140
- }
1141
- }
1142
- } catch (error) {
1143
- this.log.warn(`Error processing package URLs for ${npmPackage.name}#${npmPackage.version}:`, error.message);
1144
- }
1250
+ // the resources' urls are read as the package is unpacked (unpackPackage)
1251
+ const urls = [...(npmPackage.resourceUrls || [])];
1145
1252
 
1146
1253
  // Include main package URL
1147
1254
  if (npmPackage.url) {
@@ -944,11 +944,13 @@ class PackagesModule {
944
944
  }
945
945
  }
946
946
 
947
- stopCrawlerJob() {
947
+ // node-cron 4: stop() may return a promise
948
+ async stopCrawlerJob() {
948
949
  if (this.crawlerJob) {
949
950
  this.crawler.shutdown();
950
- this.crawlerJob.stop();
951
+ const job = this.crawlerJob;
951
952
  this.crawlerJob = null;
953
+ await job.stop();
952
954
  pckLog.info('Package crawler job stopped');
953
955
  }
954
956
  }
@@ -1024,15 +1026,16 @@ class PackagesModule {
1024
1026
  if (this.config.crawler.enabled) {
1025
1027
  pckLog.info('Starting initial package crawler...');
1026
1028
 
1027
- // Run crawler in background (non-blocking)
1028
- setImmediate(async () => {
1029
+ // Run crawler in background (non-blocking); errors are logged inside
1030
+ const crawl = async () => {
1029
1031
  try {
1030
1032
  await this.runCrawler();
1031
1033
  pckLog.info('Initial package crawler completed successfully');
1032
1034
  } catch (error) {
1033
1035
  pckLog.error('Initial package crawler failed:', error.message);
1034
1036
  }
1035
- });
1037
+ };
1038
+ setImmediate(() => void crawl());
1036
1039
  }
1037
1040
  }
1038
1041
 
@@ -1831,14 +1834,34 @@ class PackagesModule {
1831
1834
  res.send(htmlServer.renderPage('packages', 'npm Registry Check', content, stats));
1832
1835
  }
1833
1836
 
1834
- // GET /{id}/{version} is npm's version document when the request asks for JSON, and the
1835
- // tarball otherwise - which is what FHIR clients have always got there, without asking
1836
- // for JSON. A request carrying the TARBALL_HEADER always gets the tarball.
1837
+ // GET /{id}/{version} is npm's version document when the request prefers JSON, and the
1838
+ // tarball otherwise - which is what FHIR clients have always got there. Preferring JSON
1839
+ // means ranking it above anything else the request accepts: a JSON type with a higher q
1840
+ // than any */*, application/* or tarball type. Many clients send JSON together with */*
1841
+ // at the same q - axios's default, application/json, text/plain, */*, which is what
1842
+ // SUSHI (fhir-package-loader) sends when it downloads a tarball from here - and they get
1843
+ // the tarball. A request carrying the TARBALL_HEADER always gets the tarball.
1837
1844
  wantsVersionDocument(req) {
1838
1845
  if (req.get(TARBALL_HEADER) !== undefined) {
1839
1846
  return false;
1840
1847
  }
1841
- return /application\/(json|vnd\.npm\.install-v1\+json)/i.test(req.get('accept') || '');
1848
+ let json = 0;
1849
+ let other = 0;
1850
+ for (const part of String(req.get('accept') || '').split(',')) {
1851
+ const [type, ...params] = part.split(';').map(s => s.trim().toLowerCase());
1852
+ const qParam = params.find(p => p.startsWith('q='));
1853
+ const q = qParam ? parseFloat(qParam.substring(2)) : 1;
1854
+ if (!type || Number.isNaN(q)) {
1855
+ continue;
1856
+ }
1857
+ if (type === 'application/json' || type === 'application/vnd.npm.install-v1+json') {
1858
+ json = Math.max(json, q);
1859
+ } else if (['*/*', 'application/*', 'application/tar+gzip', 'application/gzip', 'application/x-gzip',
1860
+ 'application/x-tar', 'application/octet-stream'].includes(type)) {
1861
+ other = Math.max(other, q);
1862
+ }
1863
+ }
1864
+ return json > 0 && json > other;
1842
1865
  }
1843
1866
 
1844
1867
  async serveVersionDocument(secure, id, version, req, res) {
@@ -2782,69 +2805,10 @@ class PackagesModule {
2782
2805
  return va.pre.localeCompare(vb.pre, undefined, { numeric: true });
2783
2806
  }
2784
2807
 
2785
- generateSearchHtml(req, results, params) {
2786
- // Simplified HTML generation - you'd want to use a proper template engine
2787
- const {name, dependson, canonicalPkg, canonicalUrl, fhirVersion} = params;
2788
- return `
2789
- <!DOCTYPE html>
2790
- <html>
2791
- <head>
2792
- <title>FHIR Package Search</title>
2793
- <style>
2794
- body { font-family: Arial, sans-serif; margin: 20px; }
2795
- .search-form { background: #f5f5f5; padding: 20px; margin-bottom: 20px; }
2796
- .search-form input, .search-form select { margin: 5px; padding: 5px; }
2797
- .results { margin-top: 20px; }
2798
- .package { border: 1px solid #ddd; margin: 10px 0; padding: 15px; }
2799
- .package-name { font-weight: bold; font-size: 1.2em; }
2800
- .package-details { color: #666; margin-top: 5px; }
2801
- </style>
2802
- </head>
2803
- <body>
2804
- <h1>FHIR Package Search</h1>
2805
-
2806
- <form class="search-form" method="GET">
2807
- <input type="text" name="name" placeholder="Package name" value="${escape(name)}">
2808
- <input type="text" name="dependson" placeholder="Depends on" value="${escape(dependson)}">
2809
- <input type="text" name="canonicalPkg" placeholder="Canonical package" value="${escape(canonicalPkg)}">
2810
- <input type="text" name="canonicalUrl" placeholder="Canonical URL" value="${escape(canonicalUrl)}">
2811
- <select name="fhirVersion">
2812
- <option value="">Any FHIR version</option>
2813
- <option value="R2" ${fhirVersion === 'R2' ? 'selected' : ''}>R2</option>
2814
- <option value="R3" ${fhirVersion === 'R3' ? 'selected' : ''}>R3</option>
2815
- <option value="R4" ${fhirVersion === 'R4' ? 'selected' : ''}>R4</option>
2816
- <option value="R5" ${fhirVersion === 'R5' ? 'selected' : ''}>R5</option>
2817
- </select>
2818
- <button type="submit">Search</button>
2819
- </form>
2820
-
2821
- <div class="results">
2822
- <h2>Results (${results.length} packages found)</h2>
2823
- ${results.map(pkg => `
2824
- <div class="package">
2825
- <div class="package-name">
2826
- <a href="${pkg.url}">${escape(pkg.name)}</a> v${escape(pkg.version)}
2827
- </div>
2828
- <div class="package-details">
2829
- <strong>FHIR Version:</strong> ${escape(pkg.fhirVersion)}<br>
2830
- <strong>Type:</strong> ${escape(pkg.kind)}<br>
2831
- <strong>Canonical:</strong> ${escape(pkg.canonical)}<br>
2832
- ${pkg.description ? `<strong>Description:</strong> ${escape(pkg.description)}<br>` : ''}
2833
- ${pkg.date ? `<strong>Published:</strong> ${new Date(pkg.date).toLocaleDateString()}<br>` : ''}
2834
- ${pkg.count ? `<strong>Downloads:</strong> ${pkg.count}<br>` : ''}
2835
- </div>
2836
- </div>
2837
- `).join('')}
2838
- </div>
2839
- </body>
2840
- </html>
2841
- `;
2842
- }
2843
-
2844
2808
  async shutdown() {
2845
2809
  pckLog.info('Shutting down Packages module...');
2846
2810
 
2847
- this.stopCrawlerJob();
2811
+ await this.stopCrawlerJob();
2848
2812
 
2849
2813
  // Close database connection
2850
2814
  if (this.db) {
@@ -31,7 +31,7 @@ In summary:
31
31
  | `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
32
32
  | `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
33
33
  | `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
34
- | `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept` asks for JSON, npm's document for that version. A `FHIR-Package-Download` header always gets the tarball (the HL7 Java tools send it) |
34
+ | `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept` prefers JSON (ranks it above `*/*`), npm's document for that version. A `FHIR-Package-Download` header always gets the tarball (the HL7 Java tools send it) |
35
35
  | `GET /packages/updates` | Versions published since a date |
36
36
  | `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
37
37
  | `GET /packages/npm-check` | Check any registry against the npm registry protocol (a form, in a browser) |
@@ -9,7 +9,7 @@
9
9
  * One-off repair of a package server's database and mirror, for a database built before
10
10
  * the crawler stored each id#version only once.
11
11
  *
12
- * node packages/utilities/dedupe-versions.js -db <packages.db> -mirror <mirror folder> [-apply] [-verbose]
12
+ * node packages/utilities/dedupe-versions.js -db <packages.db> -mirror <mirror folder> [-apply] [-verbose] [-keep <GUID>]...
13
13
  *
14
14
  * Without -apply it reports what it would do and changes nothing. Back up the database
15
15
  * first. It is safe to run while the server is up, but best between crawler runs. It can be
@@ -19,7 +19,8 @@
19
19
  * crawler's rule: a direct copy beats a Simplifier copy (a secondary echo); otherwise the
20
20
  * first stored wins. Two direct copies with different content are a conflict - only their
21
21
  * Simplifier rows are removed, and they are listed for manual cleanup. Deleted rows'
22
- * download counts are added to the kept row.
22
+ * download counts are added to the kept row. To settle a conflict, run it again with
23
+ * -keep and the GUID (the URL it lists) of the copy to keep, once per conflict.
23
24
  * 2. The unique index on (Id, Version), once there are no duplicates left.
24
25
  * 3. The mirror. Every row's mirror file must be the bytes that row's Hash describes; a file
25
26
  * that is missing or different is rewritten from the row's Content. Conflicts are skipped.
@@ -59,8 +60,13 @@ function keepOwnership(file, like) {
59
60
  }
60
61
  const sha1 = buf => crypto.createHash('sha1').update(buf).digest('hex');
61
62
 
62
- // Which rows of one id#version to keep and delete. rows are in PackageVersionKey order.
63
- function plan(rows) {
63
+ // Which rows of one id#version to keep and delete. rows are in PackageVersionKey order;
64
+ // keepGuids are the copies chosen (-keep) to settle conflicts.
65
+ function plan(rows, keepGuids = new Set()) {
66
+ const chosen = rows.filter(r => keepGuids.has(r.GUID));
67
+ if (chosen.length === 1) {
68
+ return { keep: chosen, conflict: false };
69
+ }
64
70
  const direct = rows.filter(r => !isSimplifier(r.GUID));
65
71
  if (direct.length === 0) {
66
72
  return { keep: [rows[0]], conflict: false };
@@ -71,7 +77,8 @@ function plan(rows) {
71
77
  return { keep: direct, conflict: true };
72
78
  }
73
79
 
74
- function dedupe({ dbPath, mirrorPath, apply = false, verbose = false, log = console.log }) {
80
+ function dedupe({ dbPath, mirrorPath, apply = false, verbose = false, keep = [], log = console.log }) {
81
+ const keepGuids = new Set(keep);
75
82
  if (!fs.existsSync(dbPath)) {
76
83
  throw new Error(`No database at ${dbPath}`); // DatabaseSync would create an empty one
77
84
  }
@@ -87,7 +94,7 @@ function dedupe({ dbPath, mirrorPath, apply = false, verbose = false, log = cons
87
94
  const changes = [];
88
95
  for (const g of groups) {
89
96
  const rows = rowsOf.all(g.Id, g.Version);
90
- const { keep, conflict } = plan(rows);
97
+ const { keep, conflict } = plan(rows, keepGuids);
91
98
  const del = rows.filter(r => !keep.includes(r));
92
99
  if (conflict) {
93
100
  report.conflicts.push({ id: g.Id, version: g.Version, kept: keep.map(r => r.GUID) });
@@ -100,6 +107,12 @@ function dedupe({ dbPath, mirrorPath, apply = false, verbose = false, log = cons
100
107
  }
101
108
  }
102
109
  report.groups = groups.length;
110
+ const used = new Set(changes.flatMap(c => [c.keep.GUID]));
111
+ for (const g of keepGuids) {
112
+ if (!used.has(g)) {
113
+ log(`-keep ${g}: not a copy of any duplicated id#version, so ignored`);
114
+ }
115
+ }
103
116
  const toDelete = new Set(changes.flatMap(c => c.del.map(r => r.PackageVersionKey)));
104
117
  const conflicted = new Set(report.conflicts.map(c => `${c.id}#${c.version}`));
105
118
 
@@ -219,15 +232,16 @@ function main(argv) {
219
232
  const dbPath = arg('-db');
220
233
  const mirrorPath = arg('-mirror');
221
234
  if (!dbPath) {
222
- console.error('usage: node packages/utilities/dedupe-versions.js -db <packages.db> [-mirror <mirror folder>] [-apply] [-verbose]');
235
+ console.error('usage: node packages/utilities/dedupe-versions.js -db <packages.db> [-mirror <mirror folder>] [-apply] [-verbose] [-keep <GUID>]...');
223
236
  return 1;
224
237
  }
225
238
  const apply = argv.includes('-apply');
239
+ const keep = argv.flatMap((a, i) => a === '-keep' && argv[i + 1] ? [argv[i + 1]] : []);
226
240
  if (!apply) {
227
241
  console.log('Dry run: nothing will be changed (use -apply)');
228
242
  }
229
243
  try {
230
- dedupe({ dbPath, mirrorPath, apply, verbose: argv.includes('-verbose') });
244
+ dedupe({ dbPath, mirrorPath, apply, keep, verbose: argv.includes('-verbose') });
231
245
  } catch (e) {
232
246
  console.error(e.message);
233
247
  return 1;
@@ -42,7 +42,9 @@ class RegistryCrawler {
42
42
  userAgent: config.userAgent || 'HealthIntersections/FhirServer',
43
43
  crawlInterval: config.crawlInterval || 5 * 60 * 1000, // 5 minutes default
44
44
  apiKeys: config.apiKeys || {}, // Map of server URL or code to API key
45
- allowPrivateAddresses: config.allowPrivateAddresses || false // SSRF opt-out for local test servers
45
+ allowPrivateAddresses: config.allowPrivateAddresses || false, // SSRF opt-out for local test servers
46
+ // the most a server's response can be, once unzipped: the servers are other people's
47
+ maxResponseBytes: (config.maxResponseMB || 100) * 1024 * 1024
46
48
  };
47
49
  this.stats = stats;
48
50
 
@@ -588,6 +590,8 @@ class RegistryCrawler {
588
590
  headers: headers,
589
591
  signal: this.abortController?.signal,
590
592
  validateStatus: (status) => status < 500, // Don't throw on 4xx
593
+ // counted as it is unzipped, so a small response that unzips to a lot is stopped too
594
+ maxContentLength: this.config.maxResponseBytes,
591
595
  httpAgent: this.guardedAgents().httpAgent,
592
596
  httpsAgent: this.guardedAgents().httpsAgent
593
597
  });
@@ -608,6 +612,9 @@ class RegistryCrawler {
608
612
 
609
613
  } catch (error) {
610
614
  debugLog(error);
615
+ if (/maxContentLength/.test(error.message)) {
616
+ throw new Error(`The response from ${url} is more than ${this.config.maxResponseBytes / (1024 * 1024)} MB`);
617
+ }
611
618
  if (error.response) {
612
619
  throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`);
613
620
  } else if (error.request) {