@creeperhost/modlens-mcp 1.6.20 → 1.6.21
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/ATTRIBUTION.md +8 -1
- package/README.md +67 -0
- package/RUNTIME.md +262 -0
- package/TESTING.md +8 -0
- package/dist/hosted-mod-license.d.ts +36 -0
- package/dist/hosted-mod-license.d.ts.map +1 -0
- package/dist/hosted-mod-license.js +65 -0
- package/dist/hosted-mod-license.js.map +1 -0
- package/dist/hosted-policy.d.ts +32 -0
- package/dist/hosted-policy.d.ts.map +1 -0
- package/dist/hosted-policy.js +257 -0
- package/dist/hosted-policy.js.map +1 -0
- package/dist/launcher.js +104 -89
- package/dist/launcher.js.map +1 -1
- package/dist/license-notices.d.ts +8 -0
- package/dist/license-notices.d.ts.map +1 -0
- package/dist/license-notices.js +10 -0
- package/dist/license-notices.js.map +1 -0
- package/dist/license-source-references.d.ts +12 -0
- package/dist/license-source-references.d.ts.map +1 -0
- package/dist/license-source-references.js +18 -0
- package/dist/license-source-references.js.map +1 -0
- package/dist/license-templates.d.ts +6 -0
- package/dist/license-templates.d.ts.map +1 -0
- package/dist/license-templates.js +55 -0
- package/dist/license-templates.js.map +1 -0
- package/dist/local-mod.d.ts +31 -0
- package/dist/local-mod.d.ts.map +1 -0
- package/dist/local-mod.js +71 -0
- package/dist/local-mod.js.map +1 -0
- package/dist/mod-license.d.ts +53 -0
- package/dist/mod-license.d.ts.map +1 -0
- package/dist/mod-license.js +617 -0
- package/dist/mod-license.js.map +1 -0
- package/dist/runtime/cli.d.ts +2 -0
- package/dist/runtime/cli.d.ts.map +1 -0
- package/dist/runtime/cli.js +163 -0
- package/dist/runtime/cli.js.map +1 -0
- package/dist/runtime/companion.d.ts +32 -0
- package/dist/runtime/companion.d.ts.map +1 -0
- package/dist/runtime/companion.js +135 -0
- package/dist/runtime/companion.js.map +1 -0
- package/dist/runtime/guidance.d.ts +73 -0
- package/dist/runtime/guidance.d.ts.map +1 -0
- package/dist/runtime/guidance.js +40 -0
- package/dist/runtime/guidance.js.map +1 -0
- package/dist/runtime/hub.d.ts +150 -0
- package/dist/runtime/hub.d.ts.map +1 -0
- package/dist/runtime/hub.js +561 -0
- package/dist/runtime/hub.js.map +1 -0
- package/dist/runtime/modlens-agent.jar +0 -0
- package/dist/runtime/protocol.d.ts +202 -0
- package/dist/runtime/protocol.d.ts.map +1 -0
- package/dist/runtime/protocol.js +91 -0
- package/dist/runtime/protocol.js.map +1 -0
- package/dist/runtime/requests.d.ts +367 -0
- package/dist/runtime/requests.d.ts.map +1 -0
- package/dist/runtime/requests.js +57 -0
- package/dist/runtime/requests.js.map +1 -0
- package/dist/server.js +125 -36
- package/dist/server.js.map +1 -1
- package/dist/tools/project.d.ts +7 -3
- package/dist/tools/project.d.ts.map +1 -1
- package/dist/tools/project.js +45 -3
- package/dist/tools/project.js.map +1 -1
- package/dist/tools/report-issue.d.ts +77 -0
- package/dist/tools/report-issue.d.ts.map +1 -0
- package/dist/tools/report-issue.js +92 -0
- package/dist/tools/report-issue.js.map +1 -0
- package/dist/tools/runtime.d.ts +6 -0
- package/dist/tools/runtime.d.ts.map +1 -0
- package/dist/tools/runtime.js +18 -0
- package/dist/tools/runtime.js.map +1 -0
- package/dist/tools/source.d.ts +1 -1
- package/dist/tools/source.d.ts.map +1 -1
- package/dist/tools/source.js +5 -1
- package/dist/tools/source.js.map +1 -1
- package/package.json +4 -1
- package/scripts/build-runtime-agent.mjs +64 -0
- package/scripts/fixtures/mod-license-corpus.json +154 -0
- package/scripts/gradle/modlens-runtime.init.gradle +17 -0
- package/scripts/runtime-test-client.mjs +36 -0
- package/scripts/test-hosted-policy.mjs +123 -0
- package/scripts/test-mod-licenses-live.mjs +86 -0
- package/scripts/test-package.mjs +44 -0
- package/scripts/test-project-http.mjs +46 -3
- package/scripts/test-runtime-gradle.mjs +107 -0
- package/scripts/test-runtime-minecraft.mjs +190 -0
- package/scripts/test-runtime-native.mjs +120 -0
- package/scripts/test-runtime-remote.mjs +209 -0
- package/scripts/update-license-templates.mjs +14 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import AdmZip from "adm-zip";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import { licenseTemplates } from "./license-templates.js";
|
|
6
|
+
import { USER_AGENT } from "./modpacks-ch.js";
|
|
7
|
+
import { reviewedSourceReferences } from "./license-source-references.js";
|
|
8
|
+
// Prefer simpler obligations among explicit alternatives. Conditional licences
|
|
9
|
+
// must also satisfy their purpose, attribution and source-offer requirements.
|
|
10
|
+
const permissive = ["0BSD", "Unlicense", "MIT", "ISC", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0"];
|
|
11
|
+
const supported = [...permissive, "LGPL-3.0-only", "CC-BY-NC-SA-4.0"];
|
|
12
|
+
const normalize = (text) => text.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, "");
|
|
13
|
+
const expressionName = (value) => typeof value === "string"
|
|
14
|
+
? (/^(?:The )?MIT License \(MIT\)$/i.test(value.trim()) ? "MIT" :
|
|
15
|
+
supported.find(id => normalize(value) === normalize(licenseTemplates[id].name) || normalize(value) === normalize(id)) ?? value)
|
|
16
|
+
: undefined;
|
|
17
|
+
function body(text) {
|
|
18
|
+
// Copyright and title lines are variable SPDX fields; preserve them in output.
|
|
19
|
+
// Only exact standard titles are removable: "MIT License, private use only"
|
|
20
|
+
// must not accidentally become a match for the standard grant beneath it.
|
|
21
|
+
const titles = new Set([...Object.values(licenseTemplates).map(t => normalize(t.name)), normalize("The MIT License (MIT)"), normalize("MIT License (MIT)"), normalize("BSD 3-Clause License")]);
|
|
22
|
+
return normalize(text.replace(/\[([^\]]+)\]\((?:https?:\/\/[^\s)]+|#[^\s)]*)\)/g, "$1")
|
|
23
|
+
// These GNU document URL variants have identical legal text.
|
|
24
|
+
.replace(/https?:\/\/(?:www\.)?(?:gnu\.org|fsf\.org)\//g, "https://gnu.org/")
|
|
25
|
+
.replace(/gnu\.org\/licenses\/why-not-lgpl/g, "gnu.org/philosophy/why-not-lgpl")
|
|
26
|
+
.split(/\r\n|\n|\r/).filter(line => {
|
|
27
|
+
if (titles.has(normalize(line)))
|
|
28
|
+
return false;
|
|
29
|
+
if (/^\s*Copyright\s+(?:\([cC]\)\s*)?(?:©\s*)?(?:\d{4}|<year>|YEAR\b|\[year\])/i.test(line) && !/\b(?:only|unless|except|prohibit\w*|restrict\w*|noncommercial|private|internal|decompil\w*|permission|redistribut\w*)\b/i.test(line))
|
|
30
|
+
return false;
|
|
31
|
+
return true;
|
|
32
|
+
}).join("\n"));
|
|
33
|
+
}
|
|
34
|
+
export function identifyLicense(text) {
|
|
35
|
+
const normalized = body(text);
|
|
36
|
+
return Object.keys(licenseTemplates).find(id => {
|
|
37
|
+
const template = licenseTemplates[id].text;
|
|
38
|
+
// SPDX supplies LGPL plus the incorporated GPL in one document;
|
|
39
|
+
// upstreams commonly ship them separately as COPYING.LESSER/COPYING.
|
|
40
|
+
const lesser = id === "LGPL-3.0-only" ? template.split("GNU GENERAL PUBLIC LICENSE")[0] : undefined;
|
|
41
|
+
// CC's Markdown rendering can renumber the final definition labels;
|
|
42
|
+
// compare every word of each definition, ignoring only those labels.
|
|
43
|
+
const ccDefinitions = (value) => value.replace(/^\s*[a-n]\.\s*(?=(?:__)?(?:Licensor|NonCommercial|Share|Sui Generis Database Rights|You)(?:__)? means)/gm, "");
|
|
44
|
+
return body(template) === normalized || (lesser !== undefined && body(lesser) === normalized)
|
|
45
|
+
|| (id === "CC-BY-NC-SA-4.0" && body(ccDefinitions(template)) === body(ccDefinitions(text)));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Small strict SPDX-expression reader: AND requires every obligation; OR offers a choice. */
|
|
49
|
+
export function permittedOptions(expression) {
|
|
50
|
+
const tokens = expression.match(/[A-Za-z0-9.+-]+|[()]/g) ?? [];
|
|
51
|
+
if (tokens.join("") !== expression.replace(/\s/g, "") || tokens.length > 100)
|
|
52
|
+
return [];
|
|
53
|
+
let offset = 0;
|
|
54
|
+
function term() {
|
|
55
|
+
if (tokens[offset] === "(") {
|
|
56
|
+
offset++;
|
|
57
|
+
const result = or();
|
|
58
|
+
if (tokens[offset++] !== ")")
|
|
59
|
+
throw new Error();
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
const id = tokens[offset++];
|
|
63
|
+
if (!id || ["AND", "OR", "WITH", ")"].includes(id))
|
|
64
|
+
throw new Error();
|
|
65
|
+
return [[id]];
|
|
66
|
+
}
|
|
67
|
+
function and() {
|
|
68
|
+
let options = term();
|
|
69
|
+
while (tokens[offset] === "AND") {
|
|
70
|
+
offset++;
|
|
71
|
+
const rhs = term();
|
|
72
|
+
options = options.flatMap(a => rhs.map(b => [...a, ...b]));
|
|
73
|
+
if (options.length > 100)
|
|
74
|
+
throw new Error();
|
|
75
|
+
}
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
function or() {
|
|
79
|
+
let options = and();
|
|
80
|
+
while (tokens[offset] === "OR") {
|
|
81
|
+
offset++;
|
|
82
|
+
options = [...options, ...and()];
|
|
83
|
+
}
|
|
84
|
+
return options;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const options = or();
|
|
88
|
+
if (offset !== tokens.length)
|
|
89
|
+
return [];
|
|
90
|
+
// Multiple distinct AND obligations are left for review; never weaken AND to OR.
|
|
91
|
+
return [...new Set(options.filter(ids => ids.every(id => id === ids[0]) && supported.includes(ids[0])).map(ids => ids[0]))]
|
|
92
|
+
.sort((a, b) => supported.indexOf(a) - supported.indexOf(b));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// The hosted service is operated for noncommercial mod development. Carry NC
|
|
99
|
+
// conditions through to recipients without requiring another operator opt-in.
|
|
100
|
+
export function decideLicense(sha1, sha256, evidence, checkedAt = new Date().toISOString()) {
|
|
101
|
+
const base = { sha1, sha256, disposition: "local_only", reason: "No applicable license with clear hosted-source permission and complete notices was found.", evidence, notices: [], checkedAt };
|
|
102
|
+
const tier = ["github", "jar", "curseforge"].find(origin => evidence.some(e => e.origin === origin && e.applicable));
|
|
103
|
+
if (!tier)
|
|
104
|
+
return base;
|
|
105
|
+
const winners = evidence.filter(e => e.origin === tier && e.applicable);
|
|
106
|
+
const corroborating = tier === "jar" ? evidence.filter(e => e.origin === "github" && !e.applicable && e.expression
|
|
107
|
+
&& permissive.includes(e.expression) && winners.some(w => expressionName(w.expression) === e.expression)
|
|
108
|
+
&& !e.note?.startsWith("REVIEW:")) : [];
|
|
109
|
+
// The artifact's declaration supplies the grant. Matching upstream text can
|
|
110
|
+
// supply its notices without making a current branch a historical grant.
|
|
111
|
+
const documents = [...winners, ...corroborating].flatMap(e => e.documents);
|
|
112
|
+
const declared = winners.map(e => expressionName(e.expression)).filter((e) => !!e);
|
|
113
|
+
const licenseDocuments = documents.filter(d => licenseFile.test(d.name) && !/(?:^|\/)NOTICE(?:\.|$)/i.test(d.name)
|
|
114
|
+
&& (declared.length > 0 || !/(?:^|\/)LICENSE[-_][\w-]+$/i.test(d.name) || /(?:^|\/)LICENSE[-_](?:MIT|APACHE|BSD|ISC|0BSD)$/i.test(d.name)));
|
|
115
|
+
const recognized = licenseDocuments.map(d => identifyLicense(d.licenseText)).filter((id) => !!id);
|
|
116
|
+
const expressions = declared.length ? declared : recognized;
|
|
117
|
+
base.selectedOrigin = tier;
|
|
118
|
+
if (winners.some(e => e.note?.startsWith("REVIEW:"))) {
|
|
119
|
+
base.reason = `The ${tier} evidence requires review; lower-priority sources cannot override it.`;
|
|
120
|
+
return base;
|
|
121
|
+
}
|
|
122
|
+
// Conflicting declarations at the same tier are not an explicit dual-license offer.
|
|
123
|
+
if (new Set(expressions).size > 1) {
|
|
124
|
+
base.reason = `Multiple ${tier} licenses lack a single explicit alternative-license expression.`;
|
|
125
|
+
return base;
|
|
126
|
+
}
|
|
127
|
+
const options = permittedOptions(expressions[0] ?? "");
|
|
128
|
+
if (documents.some(d => /^(?:META-INF\/)?licenses\//i.test(d.name) && !permissive.includes(identifyLicense(d.licenseText) ?? ""))) {
|
|
129
|
+
base.reason = "A separately licensed component has additional or unrecognised conditions requiring review.";
|
|
130
|
+
return base;
|
|
131
|
+
}
|
|
132
|
+
for (const id of options) {
|
|
133
|
+
const matched = licenseDocuments.find(d => identifyLicense(d.licenseText) === id);
|
|
134
|
+
if (!matched)
|
|
135
|
+
continue;
|
|
136
|
+
const conditional = id === "LGPL-3.0-only" || id === "CC-BY-NC-SA-4.0";
|
|
137
|
+
if (!conditional && !["0BSD", "Unlicense", "Apache-2.0"].includes(id) && !/copyright[^\n]*(?:\d{4}|©|\(c\))/i.test(matched.licenseText))
|
|
138
|
+
continue;
|
|
139
|
+
// Do not replace absent author-specific notices with a generic template.
|
|
140
|
+
if (!conditional && id !== "0BSD" && /<(?:year|owner|copyright holders)>|\[(?:year|fullname|name of copyright owner)\]/i.test(matched.licenseText))
|
|
141
|
+
continue;
|
|
142
|
+
const allDocuments = [...documents, ...evidence.filter(e => e.origin === "jar").flatMap(e => e.documents)]
|
|
143
|
+
.filter((d, i, all) => all.findIndex(other => other.licenseText === d.licenseText) === i);
|
|
144
|
+
const attribution = allDocuments.filter(d => /(?:^|\/)(?:NOTICE|ATTRIBUTION)(?:\.|$)/i.test(d.name)).map(d => d.licenseText);
|
|
145
|
+
const sourceArchive = winners.find(e => e.sourceArchive)?.sourceArchive;
|
|
146
|
+
if (conditional && !attribution.length) {
|
|
147
|
+
base.reason = `${id} requires the author's attribution information.`;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (id === "LGPL-3.0-only" && (!sourceArchive || !allDocuments.some(d => identifyLicense(d.licenseText) === "GPL-3.0-only"))) {
|
|
151
|
+
return { ...base, selectedLicense: id, reason: "LGPL source access requires both GPL/LGPL notices and an artifact-matched complete corresponding-source offer." };
|
|
152
|
+
}
|
|
153
|
+
return { ...base, disposition: "hosted_allowed", selectedLicense: id,
|
|
154
|
+
reason: `Selected ${id} from ${tier}; retain the supplied license/attribution notices and identify decompiled output.`,
|
|
155
|
+
conditions: {
|
|
156
|
+
licenseUrl: id === "CC-BY-NC-SA-4.0" ? "https://creativecommons.org/licenses/by-nc-sa/4.0/" : `https://spdx.org/licenses/${id}.html`,
|
|
157
|
+
outputLicense: id, noncommercialOnly: id === "CC-BY-NC-SA-4.0", shareAlike: conditional, sourceArchive, attribution,
|
|
158
|
+
downstreamRights: "Recipients retain all rights granted by the applicable licences. Service access limits do not restrict copying, modification or redistribution allowed by those licences.",
|
|
159
|
+
}, notices: allDocuments };
|
|
160
|
+
}
|
|
161
|
+
base.reason = `The highest-priority applicable evidence (${tier}) has unsupported terms or incomplete license/attribution notices. Local consent is required.`;
|
|
162
|
+
return base;
|
|
163
|
+
}
|
|
164
|
+
const licenseFile = /^(?:(?:META-INF)\/)?(?:LICENSE(?:[-_][\w.-]+|\.2\.0)?|LICENCE|COPYING(?:\.LESSER)?|NOTICE)(?:\.(?:txt|md))?$/i;
|
|
165
|
+
const MAX_DOCUMENT = 64 * 1024;
|
|
166
|
+
function jarEvidence(bytes, artifactName) {
|
|
167
|
+
const zip = new AdmZip(bytes);
|
|
168
|
+
const documents = [];
|
|
169
|
+
let total = 0;
|
|
170
|
+
for (const entry of zip.getEntries().filter(e => !e.isDirectory && (licenseFile.test(e.entryName) || /^(?:META-INF\/)?licenses\/[^/]+\.(?:txt|md)$/i.test(e.entryName)))) {
|
|
171
|
+
total += entry.header.size;
|
|
172
|
+
if (entry.header.size > MAX_DOCUMENT || total > 128 * 1024 || documents.length >= 12)
|
|
173
|
+
throw new Error("License notices exceed review limit");
|
|
174
|
+
documents.push({ name: entry.entryName, licenseText: entry.getData().toString("utf8") });
|
|
175
|
+
}
|
|
176
|
+
const read = (name) => {
|
|
177
|
+
const e = zip.getEntry(name);
|
|
178
|
+
if (!e)
|
|
179
|
+
return undefined;
|
|
180
|
+
if (e.header.size > MAX_DOCUMENT)
|
|
181
|
+
throw new Error("Oversized mod manifest");
|
|
182
|
+
return e.getData().toString("utf8");
|
|
183
|
+
};
|
|
184
|
+
const sources = [], declarations = [];
|
|
185
|
+
const attribution = [];
|
|
186
|
+
const attribute = (value) => { if (value)
|
|
187
|
+
attribution.push(typeof value === "string" ? value : JSON.stringify(value)); };
|
|
188
|
+
let version;
|
|
189
|
+
const add = (license, source, v) => {
|
|
190
|
+
// An array does not itself specify AND/OR: preserve ambiguity for review.
|
|
191
|
+
if (typeof license === "string")
|
|
192
|
+
declarations.push(expressionName(license));
|
|
193
|
+
else if (Array.isArray(license))
|
|
194
|
+
declarations.push(...license.filter((l) => typeof l === "string"));
|
|
195
|
+
if (typeof source === "string")
|
|
196
|
+
sources.push(source);
|
|
197
|
+
if (typeof v === "string")
|
|
198
|
+
version ??= v;
|
|
199
|
+
};
|
|
200
|
+
const fabric = read("fabric.mod.json");
|
|
201
|
+
if (fabric) {
|
|
202
|
+
const m = JSON.parse(fabric);
|
|
203
|
+
add(m.license, m.contact?.sources, m.version);
|
|
204
|
+
attribute(m.name);
|
|
205
|
+
attribute(m.authors);
|
|
206
|
+
attribute(m.contributors);
|
|
207
|
+
}
|
|
208
|
+
const quilt = read("quilt.mod.json");
|
|
209
|
+
if (quilt) {
|
|
210
|
+
const m = JSON.parse(quilt).quilt_loader;
|
|
211
|
+
add(m?.metadata?.license, m?.metadata?.contact?.sources, m?.version);
|
|
212
|
+
attribute(m?.metadata?.name);
|
|
213
|
+
attribute(m?.metadata?.contributors);
|
|
214
|
+
}
|
|
215
|
+
for (const file of ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"]) {
|
|
216
|
+
const toml = read(file);
|
|
217
|
+
if (toml) {
|
|
218
|
+
const m = parseToml(toml);
|
|
219
|
+
add(m.license, m.issueTrackerURL?.replace(/\/issues(?:\/?(?:\?.*)?)?$/, ""), m.mods?.[0]?.version);
|
|
220
|
+
for (const mod of m.mods ?? []) {
|
|
221
|
+
attribute(mod.displayName);
|
|
222
|
+
attribute(mod.authors);
|
|
223
|
+
attribute(mod.credits);
|
|
224
|
+
attribute(mod.displayURL);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const manifest = read("META-INF/MANIFEST.MF")?.replace(/\r?\n /g, "");
|
|
229
|
+
if (manifest) {
|
|
230
|
+
version = version === "${file.jarVersion}" || !version ? manifest.match(/^Implementation-Version:\s*(.+)$/im)?.[1]?.trim() : version;
|
|
231
|
+
const scm = manifest.match(/^(?:Implementation-URL|Source-URL):\s*(.+)$/im)?.[1];
|
|
232
|
+
if (scm)
|
|
233
|
+
sources.push(scm.trim());
|
|
234
|
+
}
|
|
235
|
+
if (attribution.length)
|
|
236
|
+
documents.push({ name: "ATTRIBUTION", licenseText: [...new Set(attribution)].join("\n") });
|
|
237
|
+
// A library may carry LICENSE_LibraryName without a mod-loader manifest.
|
|
238
|
+
// Accept that as its own licence only when the nested artifact name agrees;
|
|
239
|
+
// unrelated suffixed dependency notices cannot license an unknown parent.
|
|
240
|
+
if (!declarations.length && artifactName)
|
|
241
|
+
for (const document of documents) {
|
|
242
|
+
const owner = document.name.match(/^LICENSE[-_]([\w-]+?)(?:\.(?:txt|md))?$/i)?.[1];
|
|
243
|
+
const filename = artifactName.split("/").at(-1);
|
|
244
|
+
if (owner && filename.toLowerCase().startsWith(`${owner.toLowerCase()}-`)) {
|
|
245
|
+
const id = identifyLicense(document.licenseText);
|
|
246
|
+
if (id)
|
|
247
|
+
declarations.push(id);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const unique = [...new Set(declarations)];
|
|
251
|
+
const nestedEntries = zip.getEntries().filter(e => /^META-INF\/(?:jars|jarjar)\/.*\.jar$/i.test(e.entryName));
|
|
252
|
+
if (nestedEntries.length > 128 || nestedEntries.reduce((n, e) => n + e.header.size, 0) > 32 * 1024 * 1024)
|
|
253
|
+
throw new Error("Bundled dependency review limit exceeded");
|
|
254
|
+
const uncertainScope = documents.some(d => /^(?:META-INF\/)?licenses\//i.test(d.name) && !permissive.includes(identifyLicense(d.licenseText) ?? ""));
|
|
255
|
+
return { evidence: { origin: "jar", location: "mod JAR", applicable: !!(documents.length || unique.length), documents,
|
|
256
|
+
expression: unique.length === 1 ? unique[0] : undefined,
|
|
257
|
+
...(unique.length > 1 ? { note: "REVIEW: Conflicting embedded license declarations." } : {}) }, sources, version,
|
|
258
|
+
specificationVersion: manifest?.match(/^Specification-Version:\s*(.+)$/im)?.[1]?.trim(),
|
|
259
|
+
nested: nestedEntries.map(e => ({ name: e.entryName, bytes: e.getData() })), uncertainScope };
|
|
260
|
+
}
|
|
261
|
+
function bundledNotices(children, depth = 0, budget = { count: 0, bytes: 0 }) {
|
|
262
|
+
const notices = [];
|
|
263
|
+
for (const { name, bytes } of children) {
|
|
264
|
+
budget.bytes += bytes.length;
|
|
265
|
+
if (depth >= 4 || ++budget.count > 256 || budget.bytes > 64 * 1024 * 1024)
|
|
266
|
+
throw new Error("Bundled dependency review limit exceeded");
|
|
267
|
+
const nested = jarEvidence(bytes, name);
|
|
268
|
+
const child = decideLicense(createHash("sha1").update(bytes).digest("hex"), createHash("sha256").update(bytes).digest("hex"), [nested.evidence]);
|
|
269
|
+
if (child.disposition !== "hosted_allowed" || nested.uncertainScope || child.conditions?.shareAlike)
|
|
270
|
+
throw new Error("Unresolved bundled licence");
|
|
271
|
+
notices.push(...child.notices.map(d => ({ ...d, name: `dependency/${child.sha256}/${d.name}` })), ...bundledNotices(nested.nested, depth + 1, budget));
|
|
272
|
+
}
|
|
273
|
+
return notices;
|
|
274
|
+
}
|
|
275
|
+
export async function licenseFetch(url) {
|
|
276
|
+
const parsed = new URL(url);
|
|
277
|
+
if (parsed.protocol !== "https:" || parsed.port || parsed.username || parsed.password || !["api.github.com", "api.modpacks.ch", "api.modrinth.com", "www.curseforge.com"].includes(parsed.hostname))
|
|
278
|
+
throw new Error("Unsupported license evidence host");
|
|
279
|
+
// GitHub redirects renamed repositories to their stable numeric repository
|
|
280
|
+
// endpoint. Follow only same-host HTTPS redirects, never arbitrary locations.
|
|
281
|
+
let response;
|
|
282
|
+
let current = parsed;
|
|
283
|
+
const signal = AbortSignal.timeout(8000);
|
|
284
|
+
for (let redirects = 0;; redirects++) {
|
|
285
|
+
response = await fetch(current, { redirect: "manual", signal, headers: { "User-Agent": USER_AGENT, Accept: "application/json" } });
|
|
286
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
287
|
+
break;
|
|
288
|
+
const location = response.headers.get("location");
|
|
289
|
+
await response.body?.cancel();
|
|
290
|
+
if (parsed.hostname !== "api.github.com" || !location || redirects >= 3)
|
|
291
|
+
throw new Error("Unsupported license evidence redirect");
|
|
292
|
+
const target = new URL(location, current);
|
|
293
|
+
if (target.protocol !== "https:" || target.hostname !== parsed.hostname || target.port || target.username || target.password)
|
|
294
|
+
throw new Error("Unsupported license evidence redirect");
|
|
295
|
+
current = target;
|
|
296
|
+
}
|
|
297
|
+
if (response.status === 404)
|
|
298
|
+
return null;
|
|
299
|
+
if (!response.ok)
|
|
300
|
+
throw new Error(`License evidence unavailable (${response.status})`);
|
|
301
|
+
if (Number(response.headers.get("content-length")) > 4 * 1024 * 1024)
|
|
302
|
+
throw new Error("Oversized license response");
|
|
303
|
+
const reader = response.body?.getReader();
|
|
304
|
+
if (!reader)
|
|
305
|
+
throw new Error("Empty license response");
|
|
306
|
+
const chunks = [];
|
|
307
|
+
let size = 0;
|
|
308
|
+
try {
|
|
309
|
+
while (true) {
|
|
310
|
+
const part = await reader.read();
|
|
311
|
+
if (part.done)
|
|
312
|
+
break;
|
|
313
|
+
size += part.value.length;
|
|
314
|
+
if (size > 4 * 1024 * 1024)
|
|
315
|
+
throw new Error("Oversized license response");
|
|
316
|
+
chunks.push(part.value);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await reader.cancel().catch(() => { });
|
|
321
|
+
}
|
|
322
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
323
|
+
if (parsed.hostname === "www.curseforge.com")
|
|
324
|
+
return text;
|
|
325
|
+
const data = JSON.parse(text);
|
|
326
|
+
if (parsed.hostname === "api.modpacks.ch" && data?.status === "error")
|
|
327
|
+
throw new Error("modpacks.ch did not resolve this artifact");
|
|
328
|
+
return data;
|
|
329
|
+
}
|
|
330
|
+
function githubRepo(url) {
|
|
331
|
+
try {
|
|
332
|
+
const u = new URL(url);
|
|
333
|
+
if (u.protocol !== "https:" || u.hostname !== "github.com")
|
|
334
|
+
return;
|
|
335
|
+
const parts = u.pathname.replace(/\.git\/?$/, "").split("/").filter(Boolean);
|
|
336
|
+
if (!parts.slice(0, 2).every(p => /^[\w.-]+$/.test(p)) || parts.length < 2)
|
|
337
|
+
return;
|
|
338
|
+
return { repository: parts.slice(0, 2).join("/"), pinned: ["tree", "commit"].includes(parts[2]) && /^[a-f0-9]{40}$/i.test(parts[3]) ? parts[3] : undefined };
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function releaseTags(version, specificationVersion) {
|
|
345
|
+
if (!version || !/^[\w.+-]{1,100}$/.test(version))
|
|
346
|
+
return [];
|
|
347
|
+
const loaderVersion = version.match(/^(.+)\+(fabric|forge|neoforge|quilt)$/);
|
|
348
|
+
const release = specificationVersion && version.startsWith(`${specificationVersion}.`) && /^[\w.+-]{1,100}$/.test(specificationVersion) ? specificationVersion : undefined;
|
|
349
|
+
return [...new Set([...(release ? [release, `v${release}`] : []), ...(loaderVersion ? [`${loaderVersion[2]}-${loaderVersion[1]}`] : []), version, `v${version}`, `build-${version}`])];
|
|
350
|
+
}
|
|
351
|
+
// Public source archives are independent of GitHub's anonymous REST quota.
|
|
352
|
+
// The archive's ZIP comment contains the resolved commit; retain that immutable
|
|
353
|
+
// identity and inspect only licence files, without extracting or executing code.
|
|
354
|
+
async function githubArchiveEvidence(source, version, specificationVersion) {
|
|
355
|
+
const repo = githubRepo(source);
|
|
356
|
+
if (!repo)
|
|
357
|
+
return;
|
|
358
|
+
for (const ref of repo.pinned ? [repo.pinned] : [...releaseTags(version, specificationVersion), "HEAD"]) {
|
|
359
|
+
const response = await fetch(`https://codeload.github.com/${repo.repository}/zip/${encodeURIComponent(ref)}`, {
|
|
360
|
+
redirect: "error", signal: AbortSignal.timeout(30_000), headers: { "User-Agent": USER_AGENT },
|
|
361
|
+
});
|
|
362
|
+
if (response.status === 404)
|
|
363
|
+
continue;
|
|
364
|
+
if (!response.ok)
|
|
365
|
+
throw new Error(`GitHub source archive unavailable (${response.status})`);
|
|
366
|
+
const reader = response.body?.getReader();
|
|
367
|
+
if (!reader)
|
|
368
|
+
throw new Error("Empty GitHub source archive");
|
|
369
|
+
const chunks = [];
|
|
370
|
+
let total = 0;
|
|
371
|
+
try {
|
|
372
|
+
while (true) {
|
|
373
|
+
const { done, value } = await reader.read();
|
|
374
|
+
if (done)
|
|
375
|
+
break;
|
|
376
|
+
total += value.length;
|
|
377
|
+
if (total > 32 * 1024 * 1024)
|
|
378
|
+
throw new Error("GitHub source archive exceeds review limit");
|
|
379
|
+
chunks.push(value);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
await reader.cancel().catch(() => { });
|
|
384
|
+
}
|
|
385
|
+
const zip = new AdmZip(Buffer.concat(chunks));
|
|
386
|
+
const commit = zip.getZipComment().trim();
|
|
387
|
+
if (!/^[a-f0-9]{40}$/.test(commit) || (repo.pinned && commit !== repo.pinned))
|
|
388
|
+
throw new Error("Archive commit identity unavailable");
|
|
389
|
+
const documents = [];
|
|
390
|
+
let noticeBytes = 0;
|
|
391
|
+
for (const entry of zip.getEntries()) {
|
|
392
|
+
const name = entry.entryName.replace(/^[^/]+\//, "");
|
|
393
|
+
if (entry.isDirectory || !(licenseFile.test(name) || /^licenses\/[\w.-]+\.(?:txt|md)$/i.test(name)))
|
|
394
|
+
continue;
|
|
395
|
+
noticeBytes += entry.header.size;
|
|
396
|
+
if (entry.header.size > MAX_DOCUMENT || noticeBytes > 128 * 1024 || documents.length >= 12)
|
|
397
|
+
throw new Error("Archive notices exceed review limit");
|
|
398
|
+
documents.push({ name, licenseText: entry.getData().toString("utf8") });
|
|
399
|
+
}
|
|
400
|
+
const ids = [...new Set(documents.filter(d => licenseFile.test(d.name) && !/^NOTICE/i.test(d.name)).map(d => identifyLicense(d.licenseText)).filter(Boolean))];
|
|
401
|
+
const expression = ids.includes("LGPL-3.0-only") && ids.every(id => ["LGPL-3.0-only", "GPL-3.0-only"].includes(id)) ? "LGPL-3.0-only" : ids.length === 1 ? ids[0] : undefined;
|
|
402
|
+
return { origin: "github", applicable: ref !== "HEAD", expression, documents,
|
|
403
|
+
location: `https://github.com/${repo.repository}/tree/${commit}`,
|
|
404
|
+
sourceArchive: `https://github.com/${repo.repository}/archive/${commit}.zip`,
|
|
405
|
+
note: ref === "HEAD" ? "Current upstream notices; not a version-specific grant." : `Resolved ${ref} through the public source archive; commit identity read from its ZIP comment.` };
|
|
406
|
+
}
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
async function githubEvidence(source, version, fetcher, specificationVersion) {
|
|
410
|
+
const repo = githubRepo(source);
|
|
411
|
+
if (!repo)
|
|
412
|
+
return;
|
|
413
|
+
const base = `https://api.github.com/repos/${repo.repository}`;
|
|
414
|
+
let commit = repo.pinned;
|
|
415
|
+
if (!commit && version && /^[\w.+-]{1,100}$/.test(version)) {
|
|
416
|
+
// Some loaders are encoded as SemVer build metadata in the JAR but
|
|
417
|
+
// as a prefix on release tags (e.g. Jade 15.10.5+neoforge).
|
|
418
|
+
// Only use a shorter release version when the artifact declares it;
|
|
419
|
+
// stripping an arbitrary version suffix could select a different release.
|
|
420
|
+
for (const tag of releaseTags(version, specificationVersion)) {
|
|
421
|
+
let ref = await fetcher(`${base}/git/ref/tags/${encodeURIComponent(tag)}`);
|
|
422
|
+
if (!ref)
|
|
423
|
+
continue;
|
|
424
|
+
if (ref.object?.type === "tag")
|
|
425
|
+
ref = await fetcher(`${base}/git/tags/${ref.object.sha}`);
|
|
426
|
+
if (ref?.object?.type === "commit" && /^[a-f0-9]{40}$/i.test(ref.object.sha)) {
|
|
427
|
+
commit = ref.object.sha;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const applicable = !!commit;
|
|
433
|
+
if (!commit) {
|
|
434
|
+
const latest = await fetcher(`${base}/commits?per_page=1`);
|
|
435
|
+
if (Array.isArray(latest) && /^[a-f0-9]{40}$/i.test(latest[0]?.sha))
|
|
436
|
+
commit = latest[0].sha;
|
|
437
|
+
}
|
|
438
|
+
if (!commit)
|
|
439
|
+
return { origin: "github", location: source, applicable: false, documents: [], note: "No pinned commit or exact version tag; current-branch licensing cannot establish this version's permission." };
|
|
440
|
+
const licensed = await fetcher(`${base}/license?ref=${commit}`);
|
|
441
|
+
// GitHub sometimes recognises COPYING (GPL) instead of COPYING.LESSER.
|
|
442
|
+
// Read the entire root inventory even when its licence classifier fails.
|
|
443
|
+
const documents = [];
|
|
444
|
+
if (licensed?.encoding === "base64" && typeof licensed.content === "string") {
|
|
445
|
+
const bytes = Buffer.from(licensed.content, "base64");
|
|
446
|
+
if (bytes.length > MAX_DOCUMENT)
|
|
447
|
+
throw new Error("Oversized GitHub license");
|
|
448
|
+
documents.push({ name: licensed.path ?? "LICENSE", licenseText: bytes.toString("utf8") });
|
|
449
|
+
}
|
|
450
|
+
// Inventory the version's root notices (including NOTICE.txt/NOTICE.md and
|
|
451
|
+
// explicit alternative license files), not just GitHub's one detected file.
|
|
452
|
+
const listing = await fetcher(`${base}/contents?ref=${commit}`);
|
|
453
|
+
if (!Array.isArray(listing))
|
|
454
|
+
throw new Error("GitHub notice inventory unavailable");
|
|
455
|
+
const extra = listing.filter((e) => e.type === "file" && typeof e.name === "string" && licenseFile.test(e.name) && !documents.some(d => d.name === e.name));
|
|
456
|
+
for (const directory of listing.filter((e) => e.type === "dir" && e.name === "licenses")) {
|
|
457
|
+
const children = await fetcher(`${base}/contents/${directory.name}?ref=${commit}`);
|
|
458
|
+
if (!Array.isArray(children))
|
|
459
|
+
throw new Error("GitHub third-party notices unavailable");
|
|
460
|
+
for (const entry of children)
|
|
461
|
+
if (entry.type === "file" && /^[\w.-]+\.(?:txt|md)$/i.test(entry.name))
|
|
462
|
+
extra.push({ name: `licenses/${entry.name}` });
|
|
463
|
+
}
|
|
464
|
+
if (extra.length > 12)
|
|
465
|
+
throw new Error("Too many GitHub license documents for automatic review");
|
|
466
|
+
for (const entry of extra) {
|
|
467
|
+
const notice = await fetcher(`${base}/contents/${encodeURIComponent(entry.name)}?ref=${commit}`);
|
|
468
|
+
if (notice?.encoding !== "base64" || typeof notice.content !== "string")
|
|
469
|
+
throw new Error("GitHub license document unavailable");
|
|
470
|
+
const bytes = Buffer.from(notice.content, "base64");
|
|
471
|
+
if (bytes.length > MAX_DOCUMENT)
|
|
472
|
+
throw new Error("Oversized GitHub notice");
|
|
473
|
+
documents.push({ name: entry.name, licenseText: bytes.toString("utf8") });
|
|
474
|
+
}
|
|
475
|
+
const id = licensed?.license?.spdx_id;
|
|
476
|
+
const rootIds = documents.filter(d => licenseFile.test(d.name)).map(d => identifyLicense(d.licenseText)).filter(Boolean);
|
|
477
|
+
const detected = rootIds.includes("LGPL-3.0-only") && rootIds.every(id => ["LGPL-3.0-only", "GPL-3.0-only"].includes(id)) ? "LGPL-3.0-only"
|
|
478
|
+
: [...new Set(rootIds)].length === 1 ? rootIds[0] : undefined;
|
|
479
|
+
return { origin: "github", location: `https://github.com/${repo.repository}/tree/${commit}`, applicable,
|
|
480
|
+
...(!applicable ? { note: "Current upstream notices; not a version-specific grant." } : {}),
|
|
481
|
+
sourceArchive: `https://github.com/${repo.repository}/archive/${commit}.zip`,
|
|
482
|
+
expression: detected ?? (typeof id === "string" && id !== "NOASSERTION" ? id : documents.map(d => d.licenseText.match(/^SPDX-License-Identifier:\s*(.+)$/m)?.[1]).find(Boolean)), documents };
|
|
483
|
+
}
|
|
484
|
+
export async function inspectModLicense(jarPath, hints = {}, fetcher = licenseFetch) {
|
|
485
|
+
const bytes = await readFile(jarPath);
|
|
486
|
+
const sha1 = createHash("sha1").update(bytes).digest("hex"), sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
487
|
+
const jar = jarEvidence(bytes);
|
|
488
|
+
const evidence = [jar.evidence];
|
|
489
|
+
const sources = [...jar.sources, ...(hints.sourceUrl ? [hints.sourceUrl] : [])];
|
|
490
|
+
const reviewed = reviewedSourceReferences[sha256];
|
|
491
|
+
if (reviewed && reviewed.version === jar.version)
|
|
492
|
+
sources.unshift(`https://github.com/${reviewed.repository}/tree/${reviewed.commit}`);
|
|
493
|
+
try {
|
|
494
|
+
const project = await fetcher(`https://api.modpacks.ch/public/mod/${sha1}`);
|
|
495
|
+
if (project) {
|
|
496
|
+
for (const link of project.links ?? [])
|
|
497
|
+
if (["github", "source"].includes(String(link.type).toLowerCase()))
|
|
498
|
+
sources.push(link.link);
|
|
499
|
+
const matched = (project.versions ?? []).find((v) => v.sha1?.toLowerCase() === sha1);
|
|
500
|
+
const license = matched?.license ?? project.license;
|
|
501
|
+
const expression = expressionName(typeof license === "string" ? license : license?.spdx_id ?? license?.id ?? license?.name);
|
|
502
|
+
const text = typeof license?.text === "string" ? license.text : undefined;
|
|
503
|
+
const page = (project.links ?? []).find((l) => l.type === "curseforge")?.link;
|
|
504
|
+
if (expression || text)
|
|
505
|
+
evidence.push({ origin: "curseforge", location: page ?? `modpacks.ch/mod/${project.id}`,
|
|
506
|
+
applicable: !!matched?.license, expression, documents: text ? [{ name: "platform license", licenseText: text }] : [],
|
|
507
|
+
note: matched?.license ? "License associated with the hash-matched file." : "Current project license; applicability to this file is unconfirmed." });
|
|
508
|
+
if (typeof page === "string" && /^https:\/\/www\.curseforge\.com\/minecraft\/mc-mods\/[\w-]+\/?$/.test(page)) {
|
|
509
|
+
try {
|
|
510
|
+
const html = await fetcher(page);
|
|
511
|
+
if (typeof html === "string") {
|
|
512
|
+
const decoded = html.replace(/\\"/g, '"').replace(/\\\//g, "/").replace(/&/g, "&");
|
|
513
|
+
// Only source-code fields/links, not arbitrary repository
|
|
514
|
+
// mentions in descriptions (which may refer to dependencies).
|
|
515
|
+
for (const m of decoded.matchAll(/"(?:sourceUrl|sourceCodeUrl)"\s*:\s*"(https:\/\/github\.com\/[\w.-]+\/[\w.-]+)"/g))
|
|
516
|
+
sources.push(m[1]);
|
|
517
|
+
for (const m of decoded.matchAll(/<a\b[^>]*href="(https:\/\/github\.com\/[\w.-]+\/[\w.-]+)"[^>]*>\s*(?:<[^>]+>\s*)*(?:Source|Source Code)\s*</gi))
|
|
518
|
+
sources.push(m[1]);
|
|
519
|
+
const name = decoded.match(/"(?:licenseName|license)"\s*:\s*"([^"<>]{1,150})"/)?.[1];
|
|
520
|
+
evidence.push({ origin: "curseforge", location: page, applicable: false, expression: name, documents: [], note: "Current project page; not a version-specific license grant." });
|
|
521
|
+
const licensePage = await fetcher(`${page.replace(/\/$/, "")}/license`);
|
|
522
|
+
if (typeof licensePage === "string") {
|
|
523
|
+
const pre = licensePage.match(/<pre\b[^>]*>([\s\S]*?)<\/pre>/i)?.[1];
|
|
524
|
+
const licenseText = pre?.replace(/<[^>]*>/g, "").replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
525
|
+
if (licenseText)
|
|
526
|
+
evidence.push({ origin: "curseforge", location: `${page}/license`,
|
|
527
|
+
applicable: !!matched?.license, expression: expressionName(name) ?? identifyLicense(licenseText),
|
|
528
|
+
documents: [{ name: "LICENSE", licenseText }],
|
|
529
|
+
note: matched?.license ? "License associated with the hash-matched platform file." : "Project license text found, but applicability to this exact version needs review." });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
evidence.push({ origin: "curseforge", location: page, applicable: false, documents: [], note: "Project page unavailable; no permission inferred." });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
evidence.push({ origin: "curseforge", location: "modpacks.ch SHA-1 lookup", applicable: false, documents: [], note: "Hash lookup unavailable; no permission inferred." });
|
|
541
|
+
}
|
|
542
|
+
// Identity fallback only. Modrinth's current project licence does not enter
|
|
543
|
+
// the user's GitHub -> JAR -> CurseForge priority order.
|
|
544
|
+
if (!sources.some(s => githubRepo(s)))
|
|
545
|
+
try {
|
|
546
|
+
const version = await fetcher(`https://api.modrinth.com/v2/version_file/${sha1}?algorithm=sha1`);
|
|
547
|
+
if (version?.files?.some((f) => f.hashes?.sha1 === sha1) && /^[\w-]+$/.test(version.project_id)) {
|
|
548
|
+
const project = await fetcher(`https://api.modrinth.com/v2/project/${version.project_id}`);
|
|
549
|
+
if (typeof project?.source_url === "string")
|
|
550
|
+
sources.push(project.source_url);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch { /* Unavailable identity fallback is not a licence denial. */ }
|
|
554
|
+
const seen = new Set();
|
|
555
|
+
for (const source of [...new Set(sources)].filter(s => typeof s === "string").slice(0, 4)) {
|
|
556
|
+
const repo = githubRepo(source);
|
|
557
|
+
if (!repo || seen.has(repo.repository.toLowerCase()))
|
|
558
|
+
continue;
|
|
559
|
+
seen.add(repo.repository.toLowerCase());
|
|
560
|
+
try {
|
|
561
|
+
const result = await githubEvidence(source, jar.version ?? hints.version, fetcher, jar.specificationVersion);
|
|
562
|
+
if (result)
|
|
563
|
+
evidence.push(result);
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
// Keep injected transports deterministic in unit tests. Production
|
|
567
|
+
// falls back only on REST quota failures, not missing permissions.
|
|
568
|
+
if (fetcher === licenseFetch && /\((?:403|429)\)/.test(String(error))) {
|
|
569
|
+
try {
|
|
570
|
+
const result = await githubArchiveEvidence(source, jar.version ?? hints.version, jar.specificationVersion);
|
|
571
|
+
if (result) {
|
|
572
|
+
evidence.push(result);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch { /* Preserve the unresolved higher-priority evidence. */ }
|
|
577
|
+
}
|
|
578
|
+
evidence.push({ origin: "github", location: source, applicable: true, documents: [], note: "REVIEW: Linked repository evidence is unavailable; do not bypass a potentially higher-priority license." });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const result = decideLicense(sha1, sha256, evidence);
|
|
582
|
+
result.sourceUrl = sources.find(s => githubRepo(s));
|
|
583
|
+
if (jar.uncertainScope) {
|
|
584
|
+
result.disposition = "local_only";
|
|
585
|
+
result.reason = "Unrecognised third-party licence text requires a scope review.";
|
|
586
|
+
}
|
|
587
|
+
// A dependency's MIT notice never changes the parent mod's licence. It is
|
|
588
|
+
// reviewed independently because whole-JAR decompilation includes JiJ code.
|
|
589
|
+
try {
|
|
590
|
+
result.notices.push(...bundledNotices(jar.nested));
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
result.disposition = "local_only";
|
|
594
|
+
result.reason = "A bundled dependency's licence conditions remain unresolved.";
|
|
595
|
+
}
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
const decisionCache = new Map();
|
|
599
|
+
export async function cachedModLicense(jarPath, hints = {}) {
|
|
600
|
+
const s = await stat(jarPath);
|
|
601
|
+
const key = JSON.stringify([jarPath, s.size, s.mtimeMs, s.ctimeMs, hints]);
|
|
602
|
+
const cached = decisionCache.get(key);
|
|
603
|
+
if (cached && cached.expires > Date.now())
|
|
604
|
+
return cached.value;
|
|
605
|
+
const value = inspectModLicense(jarPath, hints);
|
|
606
|
+
decisionCache.set(key, { expires: Date.now() + 10 * 60_000, value });
|
|
607
|
+
if (decisionCache.size > 256)
|
|
608
|
+
decisionCache.delete(decisionCache.keys().next().value);
|
|
609
|
+
try {
|
|
610
|
+
return await value;
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
decisionCache.delete(key);
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
//# sourceMappingURL=mod-license.js.map
|