@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
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/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +333 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +18 -0
- package/common/dependency-reviews.js +64 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +44 -13
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +19 -27
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +3 -3
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// A `run:` script read into the commands it runs, as a shell would split
|
|
2
|
+
// them, for commands.js. A quick read, not a shell's parse: a command built
|
|
3
|
+
// as the script runs goes unseen.
|
|
4
|
+
import { SHELLS } from '../../files/runs-code.js'
|
|
5
|
+
import { resolved } from '../../npm/files.js'
|
|
6
|
+
|
|
7
|
+
// A shell, by its program, wherever it is, Windows' `.exe` too (risk's
|
|
8
|
+
// runs-code.js SHELLS).
|
|
9
|
+
const SHELL = new RegExp(String.raw`^(?:.*[\/\\])?${SHELLS}(?:\.exe)?$`, 'iu')
|
|
10
|
+
|
|
11
|
+
// A line of a script, its continuations joined (an escaped line's end, or a
|
|
12
|
+
// pipe, `&&` or `||` the next line goes on from), and the line it starts
|
|
13
|
+
// on, counting from 0.
|
|
14
|
+
export function scriptLines(script) {
|
|
15
|
+
// An expression (`${{ … }}`), which GitHub writes in before the shell reads
|
|
16
|
+
// the script, read as a variable is: a value unknown here, each line it
|
|
17
|
+
// spans past its first continuing the one it is on; a `}}` in one of its
|
|
18
|
+
// strings (`format('{{0}}')`) not its end.
|
|
19
|
+
const lines = script.replaceAll(/\$\{\{(?:'[^']*'|[^'])*?\}\}/gu, expression => `$EXPRESSION${' \\\n'.repeat(expression.split('\n').length - 1)}`).split('\n')
|
|
20
|
+
const joined = []
|
|
21
|
+
for (let at = 0; at < lines.length; at++) {
|
|
22
|
+
const start = at
|
|
23
|
+
let text = lines[at].replace(/\r$/u, '')
|
|
24
|
+
for (;;) {
|
|
25
|
+
const escaped = /(?:^|[^\\])(?:\\\\)*[\\`^]$/u.test(text), piped = /(?:\||&&)\s*$/u.test(text) && !/(?:^|\s)#/u.test(text)
|
|
26
|
+
if (!(escaped || piped) || at + 1 === lines.length) break
|
|
27
|
+
text = `${escaped ? text.slice(0, -1) : text} ${lines[++at].replace(/\r$/u, '')}`
|
|
28
|
+
}
|
|
29
|
+
joined.push({ text, line: start })
|
|
30
|
+
}
|
|
31
|
+
return joined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const WRAPPERS = new Set(['sudo', 'env', 'time', 'nohup', 'exec', 'command', 'nice', 'stdbuf', 'xvfb-run', 'timeout', 'corepack'])
|
|
35
|
+
// A wrapper's options taking a value, as `sudo -u user`, `timeout 10m` do.
|
|
36
|
+
const WRAPPER_VALUES = new Map([['sudo', /^-[ugCDhpRrTU]$/u], ['env', /^-[uC]$/u], ['nice', /^-n$/u], ['timeout', /^-[sk]$/u]])
|
|
37
|
+
|
|
38
|
+
// What a command runs (`argv`): its words past settings, kept (`env`), and
|
|
39
|
+
// wrappers, a wrapper's options and their values, and `timeout`'s duration.
|
|
40
|
+
function argvOf(words) {
|
|
41
|
+
const env = {}
|
|
42
|
+
let at = 0
|
|
43
|
+
for (;;) {
|
|
44
|
+
for (; at < words.length && /^[A-Za-z_]\w*=/u.test(words[at]); at++) env[words[at].slice(0, words[at].indexOf('='))] = words[at].slice(words[at].indexOf('=') + 1)
|
|
45
|
+
const wrapper = words[at]?.replace(/^.*\//u, '')
|
|
46
|
+
if (!WRAPPERS.has(wrapper)) return { argv: words.slice(at), env }
|
|
47
|
+
at++
|
|
48
|
+
while (at < words.length && words[at].startsWith('-')) at += WRAPPER_VALUES.get(wrapper)?.test(words[at]) ? 2 : 1
|
|
49
|
+
if (wrapper === 'timeout' && /^\d/u.test(words[at] ?? '')) at++
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A command's words, quotes taken off, redirections dropped, and the file
|
|
54
|
+
// its output is written to (`> file`, `>> file`), or null.
|
|
55
|
+
function wordsOf(text) {
|
|
56
|
+
const words = []
|
|
57
|
+
let quote = null, word = null
|
|
58
|
+
for (let at = 0; at <= text.length; at++) {
|
|
59
|
+
const char = text[at]
|
|
60
|
+
if (quote) {
|
|
61
|
+
if (char === quote) quote = null
|
|
62
|
+
else if (char === undefined) break
|
|
63
|
+
else word += quote === '"' && char === '\\' && /["\\$`]/u.test(text[at + 1] ?? '') ? text[++at] : char
|
|
64
|
+
} else if (char === undefined || /\s/u.test(char)) {
|
|
65
|
+
if (word !== null) words.push(word)
|
|
66
|
+
word = null
|
|
67
|
+
} else if (char === '"' || char === "'") { quote = char; word ??= '' }
|
|
68
|
+
// A redirection joined to a word before it (`url>file`) starts its own.
|
|
69
|
+
else if ((char === '>' || char === '<') && word !== null && !/^\d*&?$|[<>]$/u.test(word)) { words.push(word); word = char }
|
|
70
|
+
else word = (word ?? '') + (char === '\\' && at + 1 < text.length ? text[++at] : char)
|
|
71
|
+
}
|
|
72
|
+
const kept = []
|
|
73
|
+
let out = null
|
|
74
|
+
for (let at = 0; at < words.length; at++) {
|
|
75
|
+
const redirect = /^(\d*)(>>?|<<?|&>>?)(.*)$/u.exec(words[at])
|
|
76
|
+
if (!redirect) { kept.push(words[at]); continue }
|
|
77
|
+
const target = redirect[3] === '' ? words[++at] : redirect[3]
|
|
78
|
+
if (redirect[2].includes('>') && (redirect[1] === '' || redirect[1] === '1') && target && !target.startsWith('&')) out = target
|
|
79
|
+
}
|
|
80
|
+
return { words: kept, out }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Where a `$(…)` starting at `from` (its `(`) closes, or the line's end.
|
|
84
|
+
function closing(text, from) {
|
|
85
|
+
let depth = 0, quote = null
|
|
86
|
+
for (let at = from; at < text.length; at++) {
|
|
87
|
+
const char = text[at]
|
|
88
|
+
if (quote) { if (char === quote) quote = null; else if (char === '\\') at++ }
|
|
89
|
+
else if (char === "'" || char === '"') quote = char
|
|
90
|
+
else if (char === '(') depth++
|
|
91
|
+
else if (char === ')' && --depth === 0) return at
|
|
92
|
+
}
|
|
93
|
+
return text.length
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A line's commands, each `{ argv, env, piped, out, nested }`, `env` the
|
|
97
|
+
// settings it runs with, `piped` where a pipe feeds it, `out` the file it writes its output to and `nested` its
|
|
98
|
+
// command substitutions' text, to read as lines of their own; and those of
|
|
99
|
+
// none of them (`nested`).
|
|
100
|
+
function commandsOf(text) {
|
|
101
|
+
const commands = []
|
|
102
|
+
let nested = [], piped = false, quote = null, start = 0
|
|
103
|
+
const end = (at, next) => {
|
|
104
|
+
const { words, out } = wordsOf(text.slice(start, at))
|
|
105
|
+
const { argv, env } = argvOf(words)
|
|
106
|
+
if (argv.length > 0) { commands.push({ argv, env, piped, out, nested }); nested = [] }
|
|
107
|
+
piped = next
|
|
108
|
+
}
|
|
109
|
+
for (let at = 0; at < text.length; at++) {
|
|
110
|
+
const char = text[at]
|
|
111
|
+
if (quote === "'") { if (char === "'") quote = null; continue }
|
|
112
|
+
if (char === '\\') { at++; continue }
|
|
113
|
+
if (char === '$' && text[at + 1] === '(') {
|
|
114
|
+
const close = closing(text, at + 1)
|
|
115
|
+
nested.push(text.slice(at + 2, close))
|
|
116
|
+
at = close
|
|
117
|
+
} else if (quote === '"') { if (char === '"') quote = null }
|
|
118
|
+
else if (char === '"' || char === "'") quote = char
|
|
119
|
+
else if (char === '#' && (at === 0 || /\s/u.test(text[at - 1]))) { end(at, false); start = text.length; break }
|
|
120
|
+
else if ((char === '&' || char === '|') && text[at + 1] === char) { end(at, false); start = ++at + 1 }
|
|
121
|
+
else if (char === ';' || (char === '&' && !/[<>]/u.test(text[at - 1] ?? '') && text[at + 1] !== '>')) { end(at, false); start = at + 1 }
|
|
122
|
+
else if (char === '|') { end(at, true); start = at + (text[at + 1] === '&' ? 2 : 1); if (text[at + 1] === '&') at++ }
|
|
123
|
+
}
|
|
124
|
+
if (start < text.length) end(text.length, false)
|
|
125
|
+
return { commands, nested }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The command line a shell is given to run (`bash -euc '…'`, `pwsh
|
|
129
|
+
// -Command '…'`), else null.
|
|
130
|
+
function shellLine([program, ...words]) {
|
|
131
|
+
if (!SHELL.test(program)) return null
|
|
132
|
+
const option = /(?:pwsh|powershell)(?:\.exe)?$/iu.test(program) ? /^-(?:c|command)$/iu : /^-[a-z]*c[a-z]*$/u
|
|
133
|
+
const at = words.findIndex(word => option.test(word))
|
|
134
|
+
return at === -1 ? null : words[at + 1] ?? null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A path `to` from `dir`, both from the step's directory ('' for it); as
|
|
138
|
+
// written where a variable, `~`, `-` or an absolute path names it.
|
|
139
|
+
export const pathFrom = (dir, to) => /^[$~/-]/u.test(to) ? to : resolved(dir ? dir.split('/') : [], to)?.join('/') ?? [dir, to].filter(Boolean).join('/')
|
|
140
|
+
|
|
141
|
+
// Each command of a script, in the order they run (`order`): its `line` from
|
|
142
|
+
// 0, the directory the `cd`s before it moved to (`dir`, by pathFrom), and
|
|
143
|
+
// `pipedTo`, the commands its output is piped through; a command
|
|
144
|
+
// substitution's before its command's, a shell's command line's (`sh -c`)
|
|
145
|
+
// after it.
|
|
146
|
+
export function scriptCommands(script) {
|
|
147
|
+
const found = []
|
|
148
|
+
let dir = '', read = 0
|
|
149
|
+
// A substitution's or a shell's command line (`apart`) moves no `cd` of its caller's.
|
|
150
|
+
const readLine = (text, line, apart = false) => {
|
|
151
|
+
if (read++ >= 64) return
|
|
152
|
+
const outer = dir, { commands, nested } = commandsOf(text)
|
|
153
|
+
commands.forEach(({ nested: substituted, ...command }, at) => {
|
|
154
|
+
for (const each of substituted) readLine(each, line, true)
|
|
155
|
+
const end = commands.findIndex((each, after) => after > at && !each.piped)
|
|
156
|
+
found.push({ ...command, line, dir, order: found.length, pipedTo: commands.slice(at + 1, end === -1 ? undefined : end) })
|
|
157
|
+
if (command.argv[0] === 'cd') dir = pathFrom(dir, command.argv.slice(1).find(word => !/^-[LPe@]+$/u.test(word)) ?? '~')
|
|
158
|
+
const shell = shellLine(command.argv)
|
|
159
|
+
if (shell) readLine(shell, line, true)
|
|
160
|
+
})
|
|
161
|
+
for (const each of nested) readLine(each, line, true)
|
|
162
|
+
if (apart) dir = outer
|
|
163
|
+
}
|
|
164
|
+
for (const { text, line } of scriptLines(script)) {
|
|
165
|
+
read = 0
|
|
166
|
+
readLine(text, line)
|
|
167
|
+
}
|
|
168
|
+
return found
|
|
169
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// What a `uses:` names, as GitHub reads it (`owner/repo[/path]@ref`, `./path`,
|
|
2
|
+
// `$/path`, `docker://image`), and an image a job runs in.
|
|
3
|
+
|
|
4
|
+
const GITHUB_OWNERS = new Set(['actions', 'github'])
|
|
5
|
+
|
|
6
|
+
const OWNER = /^[\da-z](?:[\da-z]|-(?=[\da-z])){0,38}$/iu
|
|
7
|
+
const NAME = /^(?!\.{1,2}$)[\w.-]{1,100}$/u
|
|
8
|
+
const SHA = /^[\da-f]{40}$/u
|
|
9
|
+
const SHORT_SHA = /^[\da-f]{7,39}$/u
|
|
10
|
+
// A version a comment beside a pin names, as Dependabot, Renovate, pinact
|
|
11
|
+
// and ratchet write one (`# v4.1.1`, `# tag=v4.1.1`, `# ratchet:x@v4`).
|
|
12
|
+
const VERSION_COMMENT = /^#\s*(?:tag\s*[=:]\s*|pin@|ratchet:[^\s@]+@)?(v?\d+(?:\.\d+){0,3}(?:[-+][\w.-]+)?)(?![\w.])/iu
|
|
13
|
+
|
|
14
|
+
// `sha`, `short-sha`, `ref` (a tag or a branch), or `expression`, which GitHub refuses.
|
|
15
|
+
function pinOf(ref) {
|
|
16
|
+
if (/\$\{\{/u.test(ref)) return 'expression'
|
|
17
|
+
if (SHA.test(ref)) return 'sha'
|
|
18
|
+
return SHORT_SHA.test(ref) ? 'short-sha' : 'ref'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// An image's name, tag and digest (`[host[:port]/]path[:tag][@digest]`), null
|
|
22
|
+
// for none; null where it isn't one.
|
|
23
|
+
export function readImage(text) {
|
|
24
|
+
if (typeof text !== 'string' || text === '' || /\$\{\{|\s/u.test(text)) return null
|
|
25
|
+
const at = text.indexOf('@')
|
|
26
|
+
const digest = at === -1 ? null : text.slice(at + 1)
|
|
27
|
+
const named = at === -1 ? text : text.slice(0, at)
|
|
28
|
+
const colon = named.lastIndexOf(':')
|
|
29
|
+
const tagged = colon > named.lastIndexOf('/')
|
|
30
|
+
if (digest !== null && !/^sha256:[\da-f]{64}$/u.test(digest)) return null
|
|
31
|
+
return { image: tagged ? named.slice(0, colon) : named, tag: tagged ? named.slice(colon + 1) : null, digest }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const commentVersion = comment => typeof comment === 'string' ? VERSION_COMMENT.exec(comment.trim())?.[1] ?? null : null
|
|
35
|
+
|
|
36
|
+
export const isWorkflowPath = path => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(path)
|
|
37
|
+
|
|
38
|
+
// `{ kind, uses, … }`: `action`, or `workflow` at a job's level (`job`), with
|
|
39
|
+
// its `repo`, `path`, `ref`, `pin` and `comment`'s version; `local`, `self`
|
|
40
|
+
// at the commit running, `job` a reusable workflow's, at the calling
|
|
41
|
+
// workflow's commit; `docker`; or `invalid`, which GitHub won't run.
|
|
42
|
+
export function readUses(text, { job = false, comment = null } = {}) {
|
|
43
|
+
const uses = typeof text === 'string' ? text.trim() : ''
|
|
44
|
+
if (uses.startsWith('./') || uses.startsWith('$/')) {
|
|
45
|
+
return { kind: 'local', uses, path: uses.slice(2).replace(/\/+$/u, ''), ...uses.startsWith('$/') ? { self: true } : {}, ...job ? { job: true } : {} }
|
|
46
|
+
}
|
|
47
|
+
if (uses.startsWith('docker://')) {
|
|
48
|
+
const image = job ? null : readImage(uses.slice('docker://'.length))
|
|
49
|
+
return image ? { kind: 'docker', uses, ...image } : { kind: 'invalid', uses }
|
|
50
|
+
}
|
|
51
|
+
const at = uses.indexOf('@')
|
|
52
|
+
const [owner = '', name = '', ...path] = (at === -1 ? uses : uses.slice(0, at)).split('/')
|
|
53
|
+
const ref = at === -1 ? '' : uses.slice(at + 1)
|
|
54
|
+
if (!OWNER.test(owner) || !NAME.test(name) || ref === '' || path.some(part => !NAME.test(part)) || (job && !isWorkflowPath(path.join('/')))) {
|
|
55
|
+
return { kind: 'invalid', uses }
|
|
56
|
+
}
|
|
57
|
+
return { kind: job ? 'workflow' : 'action', uses, repo: `${owner}/${name}`, path: path.join('/'), ref, pin: pinOf(ref), comment: commentVersion(comment) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The key a reference is resolved once by, a commit's with its comment's version.
|
|
61
|
+
export const usesKey = ({ repo, path, ref, pin, comment }) => `${repo.toLowerCase()}${path ? `/${path}` : ''}@${ref}${pin === 'sha' && comment ? ` # ${comment}` : ''}`
|
|
62
|
+
|
|
63
|
+
// A reference from its key, a reusable workflow's by its path's.
|
|
64
|
+
export function usesOfKey(key) {
|
|
65
|
+
const m = typeof key === 'string' ? /^([^@\s]+@\S+)(?: # (\S+))?$/u.exec(key) : null
|
|
66
|
+
const job = Boolean(m && /^[^/]+\/[^/]+\/\.github\/workflows\/[^/@]+\.ya?ml@/u.test(m[1]))
|
|
67
|
+
const read = m ? readUses(m[1], { job, comment: m[2] ? `# ${m[2]}` : null }) : null
|
|
68
|
+
return read && (read.kind === 'action' || read.kind === 'workflow') && usesKey(read) === key ? read : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// GitHub's own, the workflow's owner's, or another's.
|
|
72
|
+
export function ownerKind(repo, owner) {
|
|
73
|
+
const of = repo.split('/')[0].toLowerCase()
|
|
74
|
+
if (GITHUB_OWNERS.has(of)) return 'github'
|
|
75
|
+
return typeof owner === 'string' && of === owner.toLowerCase() ? 'own' : 'third-party'
|
|
76
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A workflow, or an action's metadata, read for what it runs: its events, its
|
|
2
|
+
// jobs' steps (`uses`, `run`) and images, each on its line from 1.
|
|
3
|
+
import { YamlError, parseYaml } from '@preventive/lockfile/yaml.js'
|
|
4
|
+
import { readImage, readUses } from './uses.js'
|
|
5
|
+
|
|
6
|
+
const isMap = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
7
|
+
const stringOf = value => typeof value === 'string' ? value : null
|
|
8
|
+
|
|
9
|
+
// The line, from 1, `key` of a mapping or an entry of a sequence is on.
|
|
10
|
+
function lineOf(file, container, key, fallback) {
|
|
11
|
+
const at = container !== null && typeof container === 'object' ? file.positions.get(container) : undefined
|
|
12
|
+
const line = at instanceof Map ? at.get(key) : at?.[key]
|
|
13
|
+
return line === undefined ? fallback : line + 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A step's or a job's `uses`, on its line, with the comment that line ends with.
|
|
17
|
+
function usesOf(file, owner, fallback, job = false) {
|
|
18
|
+
if (typeof owner.uses !== 'string') return null
|
|
19
|
+
const line = lineOf(file, owner, 'uses', fallback)
|
|
20
|
+
return { ...readUses(owner.uses, { job, comment: /(?:^|[ \t])(#.*)$/u.exec(file.lines[line - 1] ?? '')?.[1] ?? null }), line }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function triggersOf(on) {
|
|
24
|
+
if (typeof on === 'string') return [on]
|
|
25
|
+
if (Array.isArray(on)) return on.filter(event => typeof event === 'string')
|
|
26
|
+
return isMap(on) ? Object.keys(on) : []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The line a `|` block scalar's text starts on, after its key's; else null.
|
|
30
|
+
const LITERAL_HEADER = /:[ \t]+(?:&\S+[ \t]+)?\|[1-9+-]{0,2}[ \t]*(?:#.*)?$/u
|
|
31
|
+
const literalStart = (file, line) => line !== null && LITERAL_HEADER.test(file.lines[line - 1] ?? '') ? line + 1 : null
|
|
32
|
+
|
|
33
|
+
// A `run:` step's script, and its directory, else `directory` (defaults.run);
|
|
34
|
+
// `production` where its `env`, else the job's or the workflow's (`env`),
|
|
35
|
+
// sets NODE_ENV so, as an install then leaves devDependencies out.
|
|
36
|
+
const envOf = (owner, outer = {}) => ({ ...outer, ...isMap(owner?.env) ? owner.env : {} })
|
|
37
|
+
function runOf(file, step, directory, env) {
|
|
38
|
+
const script = stringOf(step.run)
|
|
39
|
+
if (script === null) return null
|
|
40
|
+
const line = lineOf(file, step, 'run', null)
|
|
41
|
+
return { script, line, start: literalStart(file, line), workingDirectory: stringOf(step['working-directory']) ?? directory,
|
|
42
|
+
production: envOf(step, env).NODE_ENV === 'production' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A step's or a job's `with`, and the line each input is on.
|
|
46
|
+
function inputsOf(file, owner, line) {
|
|
47
|
+
const inputs = isMap(owner.with) ? owner.with : null
|
|
48
|
+
return { with: inputs, withLines: inputs ? Object.fromEntries(Object.keys(inputs).map(key => [key, lineOf(file, inputs, key, line)])) : {} }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stepsOf(file, steps, directory, env = {}) {
|
|
52
|
+
if (!Array.isArray(steps)) return []
|
|
53
|
+
return steps.flatMap((step, index) => {
|
|
54
|
+
if (!isMap(step)) return []
|
|
55
|
+
const line = lineOf(file, steps, index, null)
|
|
56
|
+
return [{ line, uses: usesOf(file, step, line), run: runOf(file, step, directory, env), ...inputsOf(file, step, line) }]
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The directory a workflow's or a job's `defaults.run` names, else `outer`.
|
|
61
|
+
const directoryOf = (owner, outer) => stringOf(isMap(owner?.defaults) && isMap(owner.defaults.run) ? owner.defaults.run['working-directory'] : null) ?? outer
|
|
62
|
+
|
|
63
|
+
// The images a job runs in: its container's and its services'.
|
|
64
|
+
function imagesOf(file, job) {
|
|
65
|
+
const images = []
|
|
66
|
+
const container = isMap(job.container) ? job.container.image : job.container
|
|
67
|
+
if (typeof container === 'string') images.push({ text: container, line: lineOf(file, isMap(job.container) ? job.container : job, isMap(job.container) ? 'image' : 'container', null) })
|
|
68
|
+
if (isMap(job.services)) {
|
|
69
|
+
for (const service of Object.values(job.services)) {
|
|
70
|
+
if (isMap(service) && typeof service.image === 'string') images.push({ text: service.image, line: lineOf(file, service, 'image', null) })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return images.map(image => ({ ...image, read: readImage(image.text) }))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function jobsOf(file, doc) {
|
|
77
|
+
if (!isMap(doc.jobs)) throw new YamlError('a workflow has no jobs')
|
|
78
|
+
const directory = directoryOf(doc, null)
|
|
79
|
+
return Object.values(doc.jobs).filter(isMap).map(job => ({ uses: usesOf(file, job, null, true), secrets: job.secrets ?? null, ...inputsOf(file, job, null),
|
|
80
|
+
steps: stepsOf(file, job.steps, directoryOf(job, directory), envOf(job, envOf(doc))), images: imagesOf(file, job) }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// An action's `runs`: its runtime, a composite one's steps as a job, and a
|
|
84
|
+
// Docker one's image or Dockerfile.
|
|
85
|
+
function runsOf(file, doc) {
|
|
86
|
+
const runs = isMap(doc.runs) ? doc.runs : null
|
|
87
|
+
if (!runs || typeof runs.using !== 'string') throw new YamlError('an action has no runs.using')
|
|
88
|
+
const using = runs.using.toLowerCase()
|
|
89
|
+
const usingLine = lineOf(file, runs, 'using', null)
|
|
90
|
+
const image = using === 'docker' ? stringOf(runs.image) : null
|
|
91
|
+
const imageLine = lineOf(file, runs, 'image', usingLine)
|
|
92
|
+
const docker = image?.startsWith('docker://') ? { text: image.slice('docker://'.length), line: imageLine, read: readImage(image.slice('docker://'.length)) } : null
|
|
93
|
+
const steps = using === 'composite' ? stepsOf(file, runs.steps, null) : []
|
|
94
|
+
return { using, usingLine, dockerfile: image && !docker ? { path: image.replace(/^\.\//u, ''), line: imageLine } : null, jobs: [{ uses: null, steps, images: docker ? [docker] : [] }] }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A workflow (`kind` 'workflow') or an action's metadata ('action') read, or
|
|
98
|
+
// the YamlError of one GitHub can't run.
|
|
99
|
+
export function readWorkflow(kind, text) {
|
|
100
|
+
try {
|
|
101
|
+
const file = { lines: text.split('\n'), positions: new WeakMap() }
|
|
102
|
+
const doc = parseYaml(text, { workflow: true, lines: file.positions })
|
|
103
|
+
if (!isMap(doc)) throw new YamlError(`${kind === 'action' ? 'an action' : 'a workflow'} is not a mapping`)
|
|
104
|
+
if (kind === 'action') return { triggers: [], ...runsOf(file, doc) }
|
|
105
|
+
return { triggers: triggersOf(doc.on), using: null, usingLine: null, dockerfile: null, jobs: jobsOf(file, doc) }
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof YamlError) return error
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The characters a text holds that can make it read other than it runs, or
|
|
2
|
+
// hide text in it: control and bidirectional characters, and the invisible
|
|
3
|
+
// characters a payload or a name no one sees can be spelled in (variation
|
|
4
|
+
// selectors, Hangul fillers, tags). Shared with a code viewer that shows each
|
|
5
|
+
// as a labelled box (ui/view/control-chars.js), which leaves what spells an
|
|
6
|
+
// emoji, a recommended flag or a line's end as it is (passed).
|
|
7
|
+
|
|
8
|
+
// What a text holds beyond printing characters and its line breaks: the C0
|
|
9
|
+
// controls other than tab, line feed and carriage return, DEL, the C1
|
|
10
|
+
// controls, and the bidirectional controls that can make code read other than
|
|
11
|
+
// it runs ("Trojan Source"); and the invisible characters that can hide text
|
|
12
|
+
// in it (invisibleCharacters), as a payload hidden in
|
|
13
|
+
// variation selectors or a variable named by a Hangul filler.
|
|
14
|
+
export const CONTROLS = /(?![\t\n\r])\p{Cc}|[\u202A-\u202E\u2066-\u2069]/gu
|
|
15
|
+
const NON_ASCII = /\P{ASCII}/u
|
|
16
|
+
|
|
17
|
+
// A file's text: `kind` 'ascii' or 'utf8', by whether it holds anything past
|
|
18
|
+
// ASCII, else 'binary' (which the server tells by its bytes, sending no
|
|
19
|
+
// text); `controls` the control and invisible characters a text holds, how
|
|
20
|
+
// often by code point, or null for none.
|
|
21
|
+
export function npmTextEncoding(text) {
|
|
22
|
+
if (typeof text !== 'string') return { kind: 'binary', controls: null }
|
|
23
|
+
const controls = new Map()
|
|
24
|
+
for (const [char] of text.matchAll(CONTROLS)) controls.set(char.codePointAt(0), (controls.get(char.codePointAt(0)) ?? 0) + 1)
|
|
25
|
+
// The invisible characters are all past ASCII: looked for in a text that
|
|
26
|
+
// has any, from its first.
|
|
27
|
+
const past = text.search(NON_ASCII)
|
|
28
|
+
if (past !== -1) for (const [code, times] of invisibleCharacters(text, past) ?? []) controls.set(code, times)
|
|
29
|
+
return { kind: past === -1 ? 'ascii' : 'utf8', controls: controls.size > 0 ? controls : null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A pictograph is an assigned one (Emoji): Extended_Pictographic also takes
|
|
33
|
+
// in code points reserved for emoji to come, unassigned, each boxed itself,
|
|
34
|
+
// and a joiner or selector beside one would go unseen.
|
|
35
|
+
const EMOJI_BEFORE = /(?=\p{Emoji})\p{Extended_Pictographic}(?:\u{FE0F}|[\u{1F3FB}-\u{1F3FF}])?$/u
|
|
36
|
+
const EMOJI_AFTER = /^(?=\p{Emoji})\p{Extended_Pictographic}/u
|
|
37
|
+
const PICTOGRAPH_BEFORE = /(?=\p{Emoji})\p{Extended_Pictographic}$/u
|
|
38
|
+
const KEYCAP_BASE = /^[\d#*]$/u
|
|
39
|
+
const isTag = code => code >= 0xe0020 && code <= 0xe007f
|
|
40
|
+
// The subdivision flags Unicode recommends (RGI), their tags spelling the
|
|
41
|
+
// subdivision after a black flag: England's, Scotland's and Wales's. Others
|
|
42
|
+
// show as a black flag and their tags as nothing, as a payload in them would.
|
|
43
|
+
const FLAGS = new Set(['gbeng', 'gbsct', 'gbwls'])
|
|
44
|
+
const tagged = (text, from, to) => to - from === 10 ? String.fromCodePoint(...Array.from({ length: 5 }, (_, i) => text.codePointAt(from + 2 * i) - 0xe0000)) : ''
|
|
45
|
+
|
|
46
|
+
// Where to go on from where `code` at `at` in `text` stays text, else -1 for
|
|
47
|
+
// one to show. A CR before a newline is part of the line end, as is one that
|
|
48
|
+
// ends `text` where `lineEnd` says a newline follows it. A joiner between two
|
|
49
|
+
// emoji joins them into one, a recommended subdivision flag's tags, up to the
|
|
50
|
+
// cancel tag, name its region (FLAGS), and the text or emoji presentation
|
|
51
|
+
// selector after a pictograph, or the emoji one between a keycap's digit, #
|
|
52
|
+
// or * and its mark, draws it; after a letter or a digit alone it is another
|
|
53
|
+
// name.
|
|
54
|
+
export function passed(text, at, code, lineEnd) {
|
|
55
|
+
if (code === 0x0d && (text[at + 1] === '\n' || lineEnd && at + 1 === text.length)) return at + 1
|
|
56
|
+
if (code === 0x200d && EMOJI_BEFORE.test(text.slice(Math.max(0, at - 4), at)) && EMOJI_AFTER.test(text.slice(at + 1, at + 3))) return at + 1
|
|
57
|
+
if ((code === 0xfe0e || code === 0xfe0f) && PICTOGRAPH_BEFORE.test(text.slice(Math.max(0, at - 2), at))) return at + 1
|
|
58
|
+
if (code === 0xfe0f && KEYCAP_BASE.test(text[at - 1] ?? '') && text[at + 1] === '\u20E3') return at + 1
|
|
59
|
+
if (isTag(code) && !isTag(text.codePointAt(at - 2))) {
|
|
60
|
+
let end = at
|
|
61
|
+
while (isTag(text.codePointAt(end))) end += 2
|
|
62
|
+
if (text.codePointAt(at - 2) === 0x1f3f4 && text.codePointAt(end - 2) === 0xe007f && FLAGS.has(tagged(text, at, end - 2))) return end
|
|
63
|
+
}
|
|
64
|
+
return -1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Those text is hidden in, as a file's Invisible chars label counts them
|
|
68
|
+
// (readability.js), where they show: variation selectors (a payload, a
|
|
69
|
+
// byte a selector), Hangul fillers (a name no one sees) and tags (ASCII no one
|
|
70
|
+
// sees). Not the format characters, a BOM or a zero-width space, which text
|
|
71
|
+
// has its uses for, nor the other marks that show as nothing (the combining
|
|
72
|
+
// grapheme joiner, the Khmer inherent vowels and the Mongolian free variation
|
|
73
|
+
// selectors), which tables of the characters a name may hold, as JavaScript
|
|
74
|
+
// parsers have, hold too; nor what of these hides nothing (benign).
|
|
75
|
+
const INVISIBLE = /[\u115F\u1160\u3164\uFFA0\u{E0001}\u{E0020}-\u{E007F}]|[\uFE00-\uFE0F]|[\u{E0100}-\u{E01EF}]/u
|
|
76
|
+
// The code units they are in, to look for them by, faster than by code point:
|
|
77
|
+
// the high surrogate starting those past the Basic Multilingual Plane (tags,
|
|
78
|
+
// the selectors' supplement) too, told apart from its others once found. No
|
|
79
|
+
// `u`, which would read a pair as the one code point it spells.
|
|
80
|
+
// eslint-disable-next-line require-unicode-regexp
|
|
81
|
+
const INVISIBLE_UNITS = /[\uFE00-\uFE0F\u115F\u1160\u3164\uFFA0\uDB40]/g
|
|
82
|
+
// A table's code points, from its ranges in hex (`30 2018-2019`).
|
|
83
|
+
const codePoints = ranges => new Set(ranges.split(' ').flatMap(range => {
|
|
84
|
+
const [from, to = from] = range.split('-').map(digits => Number.parseInt(digits, 16))
|
|
85
|
+
return Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
|
86
|
+
}))
|
|
87
|
+
// The variation sequences Unicode registers, which hide nothing, though Code
|
|
88
|
+
// shows their selectors after a letter or a digit, another name there: VS1 to
|
|
89
|
+
// VS14's on other than an ideograph (StandardizedVariants.txt, Unicode
|
|
90
|
+
// 18.0.0), each selector's bases, as an HTML entity table's math symbols, and
|
|
91
|
+
// the emoji with a text and an emoji presentation, VS15 and VS16 after each
|
|
92
|
+
// (emoji-variation-sequences.txt), as a heading's anchor keeps one (`#`).
|
|
93
|
+
const STANDARDIZED = new Map([
|
|
94
|
+
[0xfe00, '30 1000 1002 1004 1010-1011 1015 1019-101a 101c-101d 1022 1031 1075'
|
|
95
|
+
+ ' 1078 107a 1080 2018-2019 201c-201d 210b 2110 2112 211b 212c 2130-2131'
|
|
96
|
+
+ ' 2133 2205 2229-222a 223d 2242-2243 2248 2268-2269 2272-2273 228a-228b'
|
|
97
|
+
+ ' 2293-2295 2297 229c 22cd 22da-22dd 29b7 2a3c-2a3d 2a6c 2a9d-2a9e'
|
|
98
|
+
+ ' 2aac-2aad 2acb-2acc 3001-3002 31c0 31c6 31c9 31cf-31d2 31d4-31d5 31d7'
|
|
99
|
+
+ ' 31db-31dd 31e5 a856 a85c a85e-a860 a868 aa60-aa66 aa6b-aa6c aa6f aa7a'
|
|
100
|
+
+ ' ff01 ff0c ff0e ff10 ff1a-ff1b ff1f 10ac5-10ac6 10ad6-10ad7 10ae1 13091'
|
|
101
|
+
+ ' 1310f 1311c 13121 13127 13139 131a0 131b1 131b8-131b9 131cb 131e0'
|
|
102
|
+
+ ' 131f9-131fa 1327b 1327f 13285 1328c 132aa 132cb 132dc 132e7 13307'
|
|
103
|
+
+ ' 1331b 13322 1333c 13377-13378 13399-1339a 133d3 133e4-133e5 133e7'
|
|
104
|
+
+ ' 133ee 133f2 133f5-133f6 13416 13419-1341a 13423 13443-13446'
|
|
105
|
+
+ ' 13be8-13bea 13f72 1d49c 1d49e-1d49f 1d4a2 1d4a5-1d4a6 1d4a9-1d4ac'
|
|
106
|
+
+ ' 1d4ae-1d4b5 1db10'],
|
|
107
|
+
[0xfe01, '2018-2019 201c-201d 210b 2110 2112 211b 212c 2130-2131 2133 3001-3002'
|
|
108
|
+
+ ' 31c6 31cf 31dd ff01 ff0c ff0e ff1a-ff1b ff1f 13093 13132 13139 13187'
|
|
109
|
+
+ ' 131b1 131ee 131f8-131fa 13257 1327f 1328b 132a4 13308 13312-13314'
|
|
110
|
+
+ ' 1331b 13321-13322 13331 133e8 13419 13f1f 14274 1d49c 1d49e-1d49f'
|
|
111
|
+
+ ' 1d4a2 1d4a5-1d4a6 1d4a9-1d4ac 1d4ae-1d4b5'],
|
|
112
|
+
[0xfe02, '2018-2019 201c-201d 13117 13139 13183 131a0 131ba 131ee 13216 1327b'
|
|
113
|
+
+ ' 132a4 132e7 132e9 132f8 132fd 13302-13303 13310-13314 1331c 13321'
|
|
114
|
+
+ ' 13331 1334a 13361 13373 1337d 13385 133af-133b0 133bf 133db 133dd'
|
|
115
|
+
+ ' 13419 1342c 1342e 14274 1d4cf'],
|
|
116
|
+
[0xfe03, '13012 130b8 130ba 1310f 1312f 13139 13184 1319c-1319d 1319f 131b1'
|
|
117
|
+
+ ' 131db 13205 13296 13338 13419'],
|
|
118
|
+
[0xfe04, '13139 1340d'],
|
|
119
|
+
[0xfe05, '13139 14274'],
|
|
120
|
+
[0xfe06, '1312f 13139 13184 131db 131ee 13205 132a4 132e9 1332b 13338'
|
|
121
|
+
+ ' 13370-13371 13419 14274'],
|
|
122
|
+
].map(([selector, bases]) => [selector, codePoints(bases)]))
|
|
123
|
+
const EMOJI = codePoints('23 2a 30-39 a9 ae 203c 2049 2122 2139 2194-2199 21a9-21aa 231a-231b 2328'
|
|
124
|
+
+ ' 23cf 23e9-23f3 23f8-23fa 24c2 25aa-25ab 25b6 25c0 25fb-25fe 2600-2604 260e'
|
|
125
|
+
+ ' 2611 2614-2615 2618 261d 2620 2622-2623 2626 262a 262e-262f 2638-263a 2640'
|
|
126
|
+
+ ' 2642 2648-2653 265f-2660 2663 2665-2666 2668 267b 267e-267f 2692-2697 2699'
|
|
127
|
+
+ ' 269b-269c 26a0-26a1 26a7 26aa-26ab 26b0-26b1 26bd-26be 26c4-26c5 26c8'
|
|
128
|
+
+ ' 26ce-26cf 26d1 26d3-26d4 26e9-26ea 26f0-26f5 26f7-26fa 26fd 2702 2705'
|
|
129
|
+
+ ' 2708-270d 270f 2712 2714 2716 271d 2721 2728 2733-2734 2744 2747 274c 274e'
|
|
130
|
+
+ ' 2753-2755 2757 2763-2764 2795-2797 27a1 27b0 27bf 2934-2935 2b05-2b07'
|
|
131
|
+
+ ' 2b1b-2b1c 2b50 2b55 3030 303d 3297 3299 1f004 1f170-1f171 1f17e-1f17f'
|
|
132
|
+
+ ' 1f202 1f21a 1f22f 1f237 1f30d-1f30f 1f315 1f31c 1f321 1f324-1f32c 1f336'
|
|
133
|
+
+ ' 1f378 1f37d 1f393 1f396-1f397 1f399-1f39b 1f39e-1f39f 1f3a7 1f3ac-1f3ae'
|
|
134
|
+
+ ' 1f3c2 1f3c4 1f3c6 1f3ca-1f3ce 1f3d4-1f3e0 1f3ed 1f3f3 1f3f5 1f3f7 1f408'
|
|
135
|
+
+ ' 1f415 1f41f 1f426 1f43f 1f441-1f442 1f446-1f449 1f44d-1f44e 1f453 1f46a'
|
|
136
|
+
+ ' 1f47d 1f4a3 1f4b0 1f4b3 1f4bb 1f4bf 1f4cb 1f4da 1f4df 1f4e4-1f4e6'
|
|
137
|
+
+ ' 1f4ea-1f4ed 1f4f7 1f4f9-1f4fb 1f4fd 1f508 1f50d 1f512-1f513 1f549-1f54a'
|
|
138
|
+
+ ' 1f550-1f567 1f56f-1f570 1f573-1f579 1f587 1f58a-1f58d 1f590 1f5a5 1f5a8'
|
|
139
|
+
+ ' 1f5b1-1f5b2 1f5bc 1f5c2-1f5c4 1f5d1-1f5d3 1f5dc-1f5de 1f5e1 1f5e3 1f5e8'
|
|
140
|
+
+ ' 1f5ef 1f5f3 1f5fa 1f610 1f687 1f68d 1f691 1f694 1f698 1f6ad 1f6b2'
|
|
141
|
+
+ ' 1f6b9-1f6ba 1f6bc 1f6cb 1f6cd-1f6cf 1f6e0-1f6e5 1f6e9 1f6f0 1f6f3')
|
|
142
|
+
// The blocks of variation selectors, as those tables write them out as ranges.
|
|
143
|
+
const BLOCKS = [[0xfe00, 0xfe0f], [0xe0100, 0xe01ef]]
|
|
144
|
+
// The compatibility jamo but their filler.
|
|
145
|
+
const JAMO = [[0x3131, 0x3163], [0x3165, 0x318e]]
|
|
146
|
+
|
|
147
|
+
// The code point before `at` in `text`, a pair of surrogates read as one.
|
|
148
|
+
function codePointBefore(text, at) {
|
|
149
|
+
if (at === 0) return undefined
|
|
150
|
+
const pair = at >= 2 ? text.codePointAt(at - 2) : 0
|
|
151
|
+
return pair > 0xffff ? pair : text.codePointAt(at - 1)
|
|
152
|
+
}
|
|
153
|
+
const within = (code, ...ranges) => code !== undefined && ranges.some(([from, to]) => code >= from && code <= to)
|
|
154
|
+
|
|
155
|
+
// Whether the one shown as `code` at `at` hides nothing: a Hangul filler
|
|
156
|
+
// composing Hangul (the initial consonant's before a vowel, a syllable with
|
|
157
|
+
// none, or the vowel's after an initial consonant, one with none), or a
|
|
158
|
+
// compatibility or halfwidth jamo's among those jamo, as a codepage's table
|
|
159
|
+
// lists them, though not one after a syllable, a name that reads as it; a
|
|
160
|
+
// variation sequence Unicode registers; a block of selectors written out as a
|
|
161
|
+
// range in a table (`VS1-VS16`); or an emoji's presentation selector doubled,
|
|
162
|
+
// as copying text leaves it.
|
|
163
|
+
function benign(text, at, code) {
|
|
164
|
+
const end = at + (code > 0xffff ? 2 : 1)
|
|
165
|
+
const after = text.codePointAt(end), before = codePointBefore(text, at)
|
|
166
|
+
switch (code) {
|
|
167
|
+
case 0x115f: return within(after, [0x1161, 0x11a7], [0xd7b0, 0xd7c6])
|
|
168
|
+
case 0x1160: return within(before, [0x1100, 0x115e], [0xa960, 0xa97c])
|
|
169
|
+
case 0x3164: return within(before, ...JAMO) || within(after, ...JAMO)
|
|
170
|
+
case 0xffa0: return within(before, [0xffa1, 0xffdc]) || within(after, [0xffa1, 0xffdc])
|
|
171
|
+
default: return (code === 0xfe0e || code === 0xfe0f ? EMOJI.has(before) : STANDARDIZED.get(code)?.has(before))
|
|
172
|
+
|| BLOCKS.some(([from, to]) => code === from && text[end] === '-' && text.codePointAt(end + 1) === to
|
|
173
|
+
|| code === to && text[at - 1] === '-' && codePointBefore(text, at - 1) === from)
|
|
174
|
+
|| (code === 0xfe0e || code === 0xfe0f) && before === code && PICTOGRAPH_BEFORE.test(text.slice(Math.max(0, at - 3), at - 1))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// How many of each a text holds, by code point, or null for none: each one
|
|
179
|
+
// a code viewer shows (passed), visited alone, not each character it shows. `from`:
|
|
180
|
+
// where to look from, as the first character past ASCII, since all of them are.
|
|
181
|
+
export function invisibleCharacters(text, from = 0) {
|
|
182
|
+
let found = null
|
|
183
|
+
INVISIBLE_UNITS.lastIndex = from
|
|
184
|
+
for (let match = INVISIBLE_UNITS.exec(text); match; match = INVISIBLE_UNITS.exec(text)) {
|
|
185
|
+
const at = match.index, code = text.codePointAt(at)
|
|
186
|
+
if (!INVISIBLE.test(String.fromCodePoint(code))) continue
|
|
187
|
+
const next = passed(text, at, code, false)
|
|
188
|
+
if (next !== -1) INVISIBLE_UNITS.lastIndex = next
|
|
189
|
+
else if (!benign(text, at, code)) {
|
|
190
|
+
found ??= new Map()
|
|
191
|
+
found.set(code, (found.get(code) ?? 0) + 1)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return found
|
|
195
|
+
}
|
|
196
|
+
|