diff --git a/NOTICE.txt b/NOTICE.txt index 453b604804..eb2741c106 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,17 +1,14 @@ Percussion CMS Copyright 1999-2026 Percussion Software, Inc. - -This product includes software developed by the Apache Software Foundation (http://www.apache.org/). -Copyright (c) 2004 The Apache Software Foundation. All rights reserved. - -GNU Runtime Libraries are included in this product and are covered under the GNU LGPL (http://www.gnu.org/licenses/lgpl.html). - -This product includes the jTDS driver, which is released under the terms of the GNU LGPL. - -XStream Copyright (c) 2003-2005, Joe Walnes. All rights reserved. -ASM Copyright (c) 2000-2005 INRIA, France Telecom All rights reserved. -Lato font Copyright (c) 2012, Lukasz Dziedzic -with Reserved Font Name Lato. -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL +Additional contributions and ongoing maintenance by Intersoft Data Labs Pvt. Ltd. +(https://www.intsof.com), 2023-present. + +This product is licensed under the Apache License, Version 2.0 +(https://www.apache.org/licenses/LICENSE-2.0). A copy of the license is provided +in the LICENSE.txt file in the product distribution. + +This product includes third-party open source software. A complete, versioned +inventory of third-party dependencies and their licenses is generated from the +Maven reactor dependency set at build time and is shipped as THIRD-PARTY.txt +in the product distribution. Do not hand-maintain version pins or component +lists here — the build-generated inventory is authoritative. diff --git a/docs/ai-generated/code-reviews/1689-license-maven-plugin-erlang.md b/docs/ai-generated/code-reviews/1689-license-maven-plugin-erlang.md new file mode 100644 index 0000000000..85b01bce9e --- /dev/null +++ b/docs/ai-generated/code-reviews/1689-license-maven-plugin-erlang.md @@ -0,0 +1,57 @@ +# Erlang review: #1689 license-maven-plugin THIRD-PARTY inventory + +| Field | Value | +|--------------------|----------------------------------| +| **Date** | 2026-08-02 | +| **Branch** | `feat/1689-license-maven-plugin` | +| **Scope** | Uncommitted work for issue #1689 | +| **Recommendation** | approve | +| **Gate** | May commit/push: **yes** | +| **Blocking bugs** | 0 | + +## Summary + +Adopts `org.codehaus.mojo:license-maven-plugin` on the reactor root to generate a versioned +`THIRD-PARTY.txt` inventory from the dependency set. Hand-curated component lists and version pins +are removed from `NOTICE.txt` and `thirdPartyCopyright`; both become stable pointers only. The +installer module copies `LICENSE.txt`, `NOTICE.txt`, and the generated inventory into the assembly +root. Behavioral tests cover the blurb policy and packaging when the inventory is present. + +## Scope + +- `pom.xml` — plugin version property + root-only aggregate execution +- `NOTICE.txt` — stable product notice + pointer +- `system/.../PSStringResources.properties` — thin `thirdPartyCopyright` / copyright year +- `system/.../PSThirdPartyCopyrightTest.java` — new +- `modules/perc-distribution-tree/pom.xml` — copy license artifacts into assembly +- `modules/perc-distribution-tree/.../ThirdPartyInventoryPackagingTest.java` — new +- `src/license/*` — missing-license map + README +- Out of scope discarded: `modules/perc-i18n/scripts/cache/i18n_translate.json` (unrelated drift) + +**Memory patterns hit:** non-portable path joins (checked clean — uses `Path`/`Files`); missing +behavioral tests (present for blurb + packaging); incomplete change-class (packaging companion +included). + +**Cross-platform path review:** clean. Tests resolve repo root via `Path` walk/`resolve`; no +hardcoded `/` or `\` filesystem joins; no Unix-only absolute roots; no line-ending fragile multi-line +file equality assertions. + +## Issues + +### suggestion — packaging soft-skips when inventory missing + +- **File:** `modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java` +- **Note:** `assumeTrue` means standalone Surefire without a prior root aggregate pass does not fail. + Acceptable for this monorepo (AC targets full reactor). Documented in pom comment + `src/license/README.md`. + Full-reactor / process-resources path was verified locally (LICENSE + NOTICE + THIRD-PARTY in assembly). + +### nit — copyright year pin in test + +- **File:** `system/.../PSThirdPartyCopyrightTest.java` (`1999-2026`) +- **Note:** Will need a yearly bump; matches product prose. Acceptable. + +## Gate + +No bugs. No missing behavioral tests for the changed policy. Path I/O portable. + +**May commit/push: yes** diff --git a/modules/intsof-common-utilities/README.md b/modules/intsof-common-utilities/README.md index 1d33014dd5..6529c85ee9 100644 --- a/modules/intsof-common-utilities/README.md +++ b/modules/intsof-common-utilities/README.md @@ -11,6 +11,45 @@ Product-agnostic Java utilities for Intersoft Data Labs projects | License | Apache License 2.0 | | Copyright | Intersoft Data Labs | +## Third-party license inventory (`license.ThirdPartyLicenseInventory`) + +Product-agnostic merge of a Maven-oriented inventory text file with **production** +npm packages from `package-lock.json` (lockfileVersion 2/3 `packages` map). No +Jackson or other runtime dependencies — includes a small JSON subset parser. + +```java +import com.intsof.common.utilities.license.ThirdPartyLicenseInventory; +import java.nio.file.Path; + +// Library API +var npm = + ThirdPartyLicenseInventory.readProductionPackagesFromLockFile( + Path.of("frontend/package-lock.json"), Path.of(".")); +String section = ThirdPartyLicenseInventory.formatNpmSection(npm); +String merged = + ThirdPartyLicenseInventory.mergeMavenAndNpm(mavenText, section, "My product inventory"); + +// Or write files (Maven half + lock list → merged THIRD-PARTY.txt) +ThirdPartyLicenseInventory.generateMergedInventory( + projectRoot, + outDir, + ThirdPartyLicenseInventory.DEFAULT_MAVEN_FILE_NAME, + ThirdPartyLicenseInventory.DEFAULT_NPM_FILE_NAME, + ThirdPartyLicenseInventory.DEFAULT_MERGED_FILE_NAME, + lockListFile, + "My product inventory", + true); +``` + +CLI (`main`) for Maven `exec-maven-plugin:java`: + +```text +java -cp utilities-0.0.1.jar com.intsof.common.utilities.license.ThirdPartyLicenseInventory \ + --root --require-maven [--title "..."] [--lock-list path] [--out-dir path] +``` + +Tests: `ThirdPartyLicenseInventoryTest`. + ## User configuration (`UserConfiguration`) Provides a portable per-user config root: diff --git a/modules/intsof-common-utilities/src/main/java/com/intsof/common/utilities/license/ThirdPartyLicenseInventory.java b/modules/intsof-common-utilities/src/main/java/com/intsof/common/utilities/license/ThirdPartyLicenseInventory.java new file mode 100644 index 0000000000..cdd545d385 --- /dev/null +++ b/modules/intsof-common-utilities/src/main/java/com/intsof/common/utilities/license/ThirdPartyLicenseInventory.java @@ -0,0 +1,912 @@ +/* + * Copyright 2026 Intersoft Data Labs (https://intsof.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.intsof.common.utilities.license; + +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Product-agnostic helpers for building a merged third-party license inventory + * that combines: + * + * + * + *

This type is intentionally free of product names and build-system coupling so any Intersoft + * (or other) multi-module project can reuse it. Callers supply paths and optional heading text. + * + *

Typical Maven layout

+ * + *
+ * target/generated-sources/license/
+ *   THIRD-PARTY-MAVEN.txt   ← produced by license-maven-plugin
+ *   THIRD-PARTY-NPM.txt     ← intermediate npm section (optional)
+ *   THIRD-PARTY.txt         ← merged inventory (ship / publish this file)
+ * 
+ * + *

CLI

+ * + *

A {@link #main(String[])} entry point supports {@code exec-maven-plugin:java} (or direct + * {@code java -cp …}). See {@link #main(String[])} for flags. + * + *

Paths use {@link java.nio.file} only (Windows / Linux / macOS). Written files use UTF-8 and LF + * line endings for stable cross-platform diffs. + * + * @since 0.0.1 + */ +public final class ThirdPartyLicenseInventory { + + /** Default Maven-only inventory file name. */ + public static final String DEFAULT_MAVEN_FILE_NAME = "THIRD-PARTY-MAVEN.txt"; + + /** Default intermediate npm-only inventory file name. */ + public static final String DEFAULT_NPM_FILE_NAME = "THIRD-PARTY-NPM.txt"; + + /** Default merged inventory file name (the file most products should ship). */ + public static final String DEFAULT_MERGED_FILE_NAME = "THIRD-PARTY.txt"; + + private ThirdPartyLicenseInventory() {} + + /** + * One production npm package taken from a package-lock {@code packages} entry. + * + * @param name package name (for example {@code react} or {@code @scope/pkg}) + * @param version resolved version string + * @param license SPDX-ish license expression or {@code "Unknown license"} + * @param sourceLabel human-readable origin (typically a repo-relative lockfile path) + */ + public record NpmPackage(String name, String version, String license, String sourceLabel) + implements Comparable { + + /** + * Creates a validated package coordinate. + * + * @throws NullPointerException if any argument is null + * @throws IllegalArgumentException if {@code name} or {@code version} is blank + */ + public NpmPackage { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(version, "version"); + Objects.requireNonNull(license, "license"); + Objects.requireNonNull(sourceLabel, "sourceLabel"); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (version.isBlank()) { + throw new IllegalArgumentException("version must not be blank"); + } + if (license.isBlank()) { + license = "Unknown license"; + } + } + + /** + * Formats a single inventory line in a style compatible with common Maven THIRD-PARTY listings. + * + * @return one inventory line (no trailing newline) + */ + public String toInventoryLine() { + return " (" + + license + + ") " + + name + + " (npm:" + + name + + ":" + + version + + " - " + + sourceLabel + + ")"; + } + + @Override + public int compareTo(NpmPackage other) { + int byName = name.compareToIgnoreCase(other.name); + if (byName != 0) { + return byName; + } + return version.compareTo(other.version); + } + } + + /** + * Reads production (non-dev) packages from an npm {@code package-lock.json} file. + * + *

Only lockfileVersion 2/3 style documents with a top-level {@code packages} object are + * supported. Entries marked {@code "dev": true} or {@code "devOptional": true} are skipped. + * Nested installs under {@code node_modules/…/node_modules/…} are included under the nested + * package name (last {@code node_modules/} segment). + * + * @param packageLockJson path to {@code package-lock.json} + * @param sourceRoot optional project root used to label {@link NpmPackage#sourceLabel()} as a + * relative path; may be {@code null} to use the absolute lock path + * @return sorted, de-duplicated production packages (name+version) + * @throws IOException if the file cannot be read + * @throws IllegalArgumentException if the document is not a supported package-lock + * @throws NullPointerException if {@code packageLockJson} is null + */ + public static List readProductionPackagesFromLockFile( + Path packageLockJson, Path sourceRoot) throws IOException { + Objects.requireNonNull(packageLockJson, "packageLockJson"); + if (!Files.isRegularFile(packageLockJson)) { + throw new IllegalArgumentException("package-lock.json not found: " + packageLockJson); + } + String json = Files.readString(packageLockJson, StandardCharsets.UTF_8); + Object root = MinimalJson.parse(json); + if (!(root instanceof Map rootMap)) { + throw new IllegalArgumentException( + "package-lock root must be a JSON object: " + packageLockJson); + } + Object packagesNode = rootMap.get("packages"); + if (!(packagesNode instanceof Map packages)) { + throw new IllegalArgumentException( + "Unsupported package-lock (missing packages map): " + packageLockJson); + } + + String sourceLabel = labelFor(packageLockJson, sourceRoot); + Map byKey = new TreeMap<>(); + for (Map.Entry entry : packages.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (!(entry.getValue() instanceof Map meta)) { + continue; + } + if (isTruthy(meta.get("dev")) || isTruthy(meta.get("devOptional"))) { + continue; + } + String name = packageNameFromLockKey(key); + if (name == null) { + continue; + } + String version = stringOrEmpty(meta.get("version")); + if (version.isBlank()) { + continue; + } + String license = licenseFromMeta(meta.get("license")); + NpmPackage pkg = new NpmPackage(name, version, license, sourceLabel); + byKey.put(name.toLowerCase(Locale.ROOT) + "@" + version, pkg); + } + List list = new ArrayList<>(byKey.values()); + Collections.sort(list); + return List.copyOf(list); + } + + /** + * Result of collecting production npm packages from a lock-list file. + * + * @param packages sorted union of production packages + * @param missingLockFiles absolute paths listed in the lock-list that are not regular files + * @param lockListFileMissing {@code true} when the lock-list path itself is not a regular file + */ + public record NpmCollectionResult( + List packages, List missingLockFiles, boolean lockListFileMissing) { + + /** Creates an immutable result. */ + public NpmCollectionResult { + packages = List.copyOf(Objects.requireNonNull(packages, "packages")); + missingLockFiles = List.copyOf(Objects.requireNonNull(missingLockFiles, "missingLockFiles")); + } + } + + /** + * Result of writing a merged inventory. + * + * @param mergedPath path to the merged {@code THIRD-PARTY.txt} (or equivalent) + * @param npmPackageCount number of production npm packages included + * @param mavenPresent whether the Maven inventory file was present + * @param missingLockFiles listed package-lock paths that were missing (empty when none) + */ + public record GenerateResult( + Path mergedPath, int npmPackageCount, boolean mavenPresent, List missingLockFiles) { + + /** Creates an immutable result. */ + public GenerateResult { + Objects.requireNonNull(mergedPath, "mergedPath"); + missingLockFiles = List.copyOf(Objects.requireNonNull(missingLockFiles, "missingLockFiles")); + } + } + + /** + * Reads every lockfile listed in {@code lockListFile} and unions production packages. + * + *

List file format (UTF-8): one path per line, relative to {@code projectRoot}. Blank lines + * and lines whose first non-whitespace character is {@code #} are ignored. Paths may use {@code + * /} or {@code \} separators; they are resolved with {@link Path}. + * + *

Missing listed lockfiles are recorded in {@link NpmCollectionResult#missingLockFiles()} — + * they are not silently ignored without a trace. Use {@link + * #requireCompleteNpmSources(NpmCollectionResult, Path)} to fail the build when sources are + * incomplete. + * + * @param projectRoot root directory for resolving relative lock paths and source labels + * @param lockListFile list of package-lock.json paths + * @return packages plus any missing listed lock paths + * @throws IOException if the list file exists but cannot be read, or a present lockfile cannot be + * parsed + */ + public static NpmCollectionResult collectProductionPackagesFromLockList( + Path projectRoot, Path lockListFile) throws IOException { + Objects.requireNonNull(projectRoot, "projectRoot"); + Objects.requireNonNull(lockListFile, "lockListFile"); + if (!Files.isRegularFile(lockListFile)) { + return new NpmCollectionResult(List.of(), List.of(), true); + } + List locks = readLockList(projectRoot, lockListFile); + Map byKey = new TreeMap<>(); + List missing = new ArrayList<>(); + for (Path lock : locks) { + if (!Files.isRegularFile(lock)) { + missing.add(lock); + continue; + } + for (NpmPackage pkg : readProductionPackagesFromLockFile(lock, projectRoot)) { + byKey.put(pkg.name().toLowerCase(Locale.ROOT) + "@" + pkg.version(), pkg); + } + } + List list = new ArrayList<>(byKey.values()); + Collections.sort(list); + return new NpmCollectionResult(list, missing, false); + } + + /** + * Convenience wrapper: collects packages and fails if the lock-list file or any listed lockfile + * is missing. + * + * @param projectRoot project root + * @param lockListFile lock list + * @return sorted production packages + * @throws IOException on I/O failure + * @throws IllegalStateException if the lock list or any listed lockfile is missing + */ + public static List readProductionPackagesFromLockList( + Path projectRoot, Path lockListFile) throws IOException { + NpmCollectionResult result = collectProductionPackagesFromLockList(projectRoot, lockListFile); + requireCompleteNpmSources(result, lockListFile); + return result.packages(); + } + + /** + * Fails when npm sources are incomplete (missing lock-list file or missing listed lockfiles). + * + * @param result collection result + * @param lockListFile path used for the error message + * @throws IllegalStateException if sources are incomplete + */ + public static void requireCompleteNpmSources(NpmCollectionResult result, Path lockListFile) { + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(lockListFile, "lockListFile"); + if (result.lockListFileMissing()) { + throw new IllegalStateException( + "npm package-lock list file is missing: " + + lockListFile + + ". Create the list (one package-lock.json path per line) or pass --lock-list."); + } + if (!result.missingLockFiles().isEmpty()) { + StringBuilder sb = new StringBuilder("Missing package-lock.json file(s) listed in "); + sb.append(lockListFile).append(':'); + for (Path p : result.missingLockFiles()) { + sb.append("\n - ").append(p); + } + throw new IllegalStateException(sb.toString()); + } + } + + /** + * Parses a lock-list file into absolute lockfile paths. + * + * @param projectRoot root for relative entries + * @param lockListFile list file (must exist as a regular file) + * @return absolute paths in list order (may include paths that do not yet exist on disk) + * @throws IOException if the list file cannot be read + * @throws IllegalStateException if the list file is not a regular file + */ + public static List readLockList(Path projectRoot, Path lockListFile) throws IOException { + Objects.requireNonNull(projectRoot, "projectRoot"); + Objects.requireNonNull(lockListFile, "lockListFile"); + if (!Files.isRegularFile(lockListFile)) { + throw new IllegalStateException("npm package-lock list file is missing: " + lockListFile); + } + List out = new ArrayList<>(); + for (String raw : Files.readAllLines(lockListFile, StandardCharsets.UTF_8)) { + String line = raw.strip(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String normalized = line.replace('\\', '/'); + Path rel = Path.of(""); + for (String segment : normalized.split("/")) { + if (!segment.isEmpty()) { + rel = rel.resolve(segment); + } + } + out.add(projectRoot.resolve(rel).normalize().toAbsolutePath()); + } + return List.copyOf(out); + } + + /** + * Formats the npm section body (title line + package lines). + * + * @param packages production packages + * @return section text ending with a trailing newline + */ + public static String formatNpmSection(List packages) { + Objects.requireNonNull(packages, "packages"); + StringBuilder sb = new StringBuilder(); + sb.append("Lists of ") + .append(packages.size()) + .append(" third-party npm dependencies (production).\n\n"); + for (NpmPackage pkg : packages) { + sb.append(pkg.toInventoryLine()).append('\n'); + } + return sb.toString(); + } + + /** + * Merges Maven inventory text and npm section text into a single document with clear section + * headers. + * + * @param mavenInventoryText contents of the Maven inventory (may be blank) + * @param npmInventoryText contents of the npm section (may be blank) + * @param documentTitle first heading line (for example product name + “third-party dependency + * license inventory”); if null or blank a generic title is used + * @return merged UTF-8 text ending with a trailing newline + */ + public static String mergeMavenAndNpm( + String mavenInventoryText, String npmInventoryText, String documentTitle) { + String title = + (documentTitle == null || documentTitle.isBlank()) + ? "Third-party dependency license inventory" + : documentTitle.strip(); + String maven = + mavenInventoryText == null || mavenInventoryText.isBlank() + ? "(no Maven inventory provided)" + : mavenInventoryText.strip(); + String npm = + npmInventoryText == null || npmInventoryText.isBlank() + ? "(no npm production dependencies found)" + : npmInventoryText.strip(); + + StringBuilder sb = new StringBuilder(); + sb.append(title).append('\n'); + sb.append("Generated at build time — do not hand-edit.\n\n"); + sb.append("================================================================================\n"); + sb.append("Maven third-party dependencies\n"); + sb.append( + "================================================================================\n\n"); + sb.append(maven).append("\n\n"); + sb.append("================================================================================\n"); + sb.append("npm third-party dependencies (production)\n"); + sb.append( + "================================================================================\n\n"); + sb.append(npm).append('\n'); + return sb.toString(); + } + + /** + * Reads the Maven inventory and package-lock list, writes npm intermediate and merged outputs. + * + *

When {@code requireCompleteSources} is {@code true} (typical for product builds), both the + * Maven inventory and the full npm lock-list (file present and every listed package-lock present) + * are required. When {@code false}, missing Maven inventory yields an empty Maven section; + * incomplete npm sources still fail only if the lock-list file is required by {@link + * #requireCompleteNpmSources} — callers should pass {@code true} for CI. + * + * @param projectRoot project / repository root + * @param outDir output directory (created if missing) + * @param mavenFileName Maven inventory file name under {@code outDir} + * @param npmFileName intermediate npm file name under {@code outDir} + * @param mergedFileName merged file name under {@code outDir} + * @param lockListFile package-lock list file + * @param documentTitle title line for the merged document; may be null + * @param requireCompleteSources if true, fail when Maven inventory or any npm source is missing + * @return generate result including package count (no second lockfile pass required) + * @throws IOException on I/O failure + * @throws IllegalStateException if required sources are incomplete + */ + public static GenerateResult generateMergedInventory( + Path projectRoot, + Path outDir, + String mavenFileName, + String npmFileName, + String mergedFileName, + Path lockListFile, + String documentTitle, + boolean requireCompleteSources) + throws IOException { + Objects.requireNonNull(projectRoot, "projectRoot"); + Objects.requireNonNull(outDir, "outDir"); + Objects.requireNonNull(mavenFileName, "mavenFileName"); + Objects.requireNonNull(npmFileName, "npmFileName"); + Objects.requireNonNull(mergedFileName, "mergedFileName"); + Objects.requireNonNull(lockListFile, "lockListFile"); + + Files.createDirectories(outDir); + Path mavenPath = outDir.resolve(mavenFileName); + Path npmPath = outDir.resolve(npmFileName); + Path mergedPath = outDir.resolve(mergedFileName); + + boolean mavenPresent = Files.isRegularFile(mavenPath); + String mavenText; + if (mavenPresent) { + mavenText = Files.readString(mavenPath, StandardCharsets.UTF_8); + } else if (requireCompleteSources) { + throw new IllegalStateException( + "Maven inventory missing: " + + mavenPath + + ". Run the Maven license aggregate goal first."); + } else { + mavenText = ""; + } + + NpmCollectionResult npmResult = + collectProductionPackagesFromLockList(projectRoot, lockListFile); + if (requireCompleteSources) { + requireCompleteNpmSources(npmResult, lockListFile); + } + + String npmText = formatNpmSection(npmResult.packages()); + writeUtf8Lf(npmPath, npmText); + + String merged = mergeMavenAndNpm(mavenText, npmText, documentTitle); + writeUtf8Lf(mergedPath, merged); + return new GenerateResult( + mergedPath, npmResult.packages().size(), mavenPresent, npmResult.missingLockFiles()); + } + + /** + * Command-line entry point for build integration ({@code exec-maven-plugin:java} or direct + * invocation). + * + *

Flags: + * + *

+ * + * @param args command-line arguments + */ + public static void main(String[] args) { + int code = runMain(args, System.out, System.err); + if (code != 0) { + System.exit(code); + } + } + + /** + * Testable {@link #main(String[])} implementation. + * + * @param args CLI args + * @param out stdout + * @param err stderr + * @return process exit code (0 success) + */ + static int runMain(String[] args, PrintStream out, PrintStream err) { + Path root = null; + Path outDir = null; + Path lockList = null; + String title = null; + boolean requireCompleteSources = false; + String mavenName = DEFAULT_MAVEN_FILE_NAME; + String npmName = DEFAULT_NPM_FILE_NAME; + String mergedName = DEFAULT_MERGED_FILE_NAME; + + for (int i = 0; i < args.length; i++) { + String a = args[i]; + try { + switch (a) { + case "--root" -> root = Path.of(requireValue(args, ++i, a)); + case "--out-dir" -> outDir = Path.of(requireValue(args, ++i, a)); + case "--lock-list" -> lockList = Path.of(requireValue(args, ++i, a)); + case "--title" -> title = requireValue(args, ++i, a); + case "--require-maven" -> requireCompleteSources = true; + case "--maven-name" -> mavenName = requireValue(args, ++i, a); + case "--npm-name" -> npmName = requireValue(args, ++i, a); + case "--merged-name" -> mergedName = requireValue(args, ++i, a); + case "--help", "-h" -> { + printUsage(out); + return 0; + } + default -> { + err.println("Unknown argument: " + a); + printUsage(err); + return 2; + } + } + } catch (IllegalArgumentException ex) { + err.println(ex.getMessage()); + printUsage(err); + return 2; + } + } + + if (root == null) { + err.println("--root is required"); + printUsage(err); + return 2; + } + + root = root.toAbsolutePath().normalize(); + if (outDir == null) { + outDir = root.resolve("target").resolve("generated-sources").resolve("license"); + } else { + outDir = outDir.toAbsolutePath().normalize(); + } + if (lockList == null) { + lockList = root.resolve("src").resolve("license").resolve("npm-package-locks.txt"); + } else { + lockList = lockList.toAbsolutePath().normalize(); + } + + try { + GenerateResult result = + generateMergedInventory( + root, + outDir, + mavenName, + npmName, + mergedName, + lockList, + title, + requireCompleteSources); + if (!requireCompleteSources && !result.missingLockFiles().isEmpty()) { + err.println("WARNING: missing package-lock.json file(s) (npm inventory incomplete):"); + for (Path p : result.missingLockFiles()) { + err.println(" - " + p); + } + } + out.println( + "Wrote " + + result.mergedPath() + + " (Maven present=" + + result.mavenPresent() + + ", npm packages=" + + result.npmPackageCount() + + ")"); + return 0; + } catch (IllegalStateException | IllegalArgumentException ex) { + err.println("ERROR: " + ex.getMessage()); + return 1; + } catch (IOException ex) { + err.println("ERROR: " + ex.getMessage()); + return 1; + } + } + + private static void printUsage(PrintStream out) { + out.println( + "Usage: ThirdPartyLicenseInventory --root [--out-dir ] [--lock-list ]"); + out.println(" [--title ] [--require-maven] [--maven-name name] [--npm-name name]"); + out.println(" [--merged-name name]"); + out.println( + " --require-maven Fail if Maven inventory or npm lock-list / listed locks are missing"); + } + + private static String requireValue(String[] args, int index, String flag) { + if (index >= args.length) { + throw new IllegalArgumentException("Missing value for " + flag); + } + return args[index]; + } + + private static void writeUtf8Lf(Path path, String content) throws IOException { + String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); + if (!normalized.endsWith("\n")) { + normalized = normalized + "\n"; + } + Files.writeString(path, normalized, StandardCharsets.UTF_8); + } + + private static String labelFor(Path packageLockJson, Path sourceRoot) { + Path abs = packageLockJson.toAbsolutePath().normalize(); + if (sourceRoot != null) { + try { + return sourceRoot + .toAbsolutePath() + .normalize() + .relativize(abs) + .toString() + .replace('\\', '/'); + } catch (IllegalArgumentException ignored) { + // different roots + } + } + return abs.toString().replace('\\', '/'); + } + + /** + * Maps a package-lock {@code packages} key to an npm package name, or {@code null} if the key is + * not a {@code node_modules} install path. + */ + static String packageNameFromLockKey(String key) { + if (key == null || key.isBlank() || ".".equals(key)) { + return null; + } + String norm = key.replace('\\', '/'); + String marker = "node_modules/"; + int idx = norm.lastIndexOf(marker); + if (idx < 0) { + return null; + } + String name = norm.substring(idx + marker.length()); + return name.isBlank() ? null : name; + } + + private static boolean isTruthy(Object value) { + if (value instanceof Boolean b) { + return b; + } + if (value instanceof String s) { + return "true".equalsIgnoreCase(s); + } + return false; + } + + private static String stringOrEmpty(Object value) { + return value == null ? "" : String.valueOf(value).strip(); + } + + private static String licenseFromMeta(Object licenseNode) { + if (licenseNode == null) { + return "Unknown license"; + } + if (licenseNode instanceof String s) { + return s.isBlank() ? "Unknown license" : s; + } + if (licenseNode instanceof Map map) { + Object type = map.get("type"); + if (type == null) { + type = map.get("name"); + } + String s = stringOrEmpty(type); + return s.isBlank() ? "Unknown license" : s; + } + if (licenseNode instanceof List list) { + StringBuilder sb = new StringBuilder(); + for (Object o : list) { + if (sb.length() > 0) { + sb.append(" OR "); + } + sb.append(o); + } + return sb.length() == 0 ? "Unknown license" : sb.toString(); + } + return String.valueOf(licenseNode); + } + + /** + * Minimal JSON parser sufficient for npm package-lock documents. Not a general-purpose JSON + * library; kept dependency-free for this module. + */ + static final class MinimalJson { + private MinimalJson() {} + + static Object parse(String json) { + return new Parser(json).parseValue(); + } + + private static final class Parser { + private final String s; + private int i; + + Parser(String s) { + this.s = s; + } + + Object parseValue() { + skipWs(); + if (i >= s.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + char c = s.charAt(i); + if (c == '{') { + return parseObject(); + } + if (c == '[') { + return parseArray(); + } + if (c == '"') { + return parseString(); + } + if (c == 't' || c == 'f') { + return parseBoolean(); + } + if (c == 'n') { + return parseNull(); + } + if (c == '-' || (c >= '0' && c <= '9')) { + return parseNumber(); + } + throw new IllegalArgumentException("Unexpected character at " + i + ": " + c); + } + + private Map parseObject() { + expect('{'); + skipWs(); + Map map = new LinkedHashMap<>(); + if (peek('}')) { + i++; + return map; + } + while (true) { + skipWs(); + String key = parseString(); + skipWs(); + expect(':'); + Object value = parseValue(); + map.put(key, value); + skipWs(); + if (peek('}')) { + i++; + return map; + } + expect(','); + } + } + + private List parseArray() { + expect('['); + skipWs(); + List list = new ArrayList<>(); + if (peek(']')) { + i++; + return list; + } + while (true) { + list.add(parseValue()); + skipWs(); + if (peek(']')) { + i++; + return list; + } + expect(','); + } + } + + private String parseString() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (i < s.length()) { + char c = s.charAt(i++); + if (c == '"') { + return sb.toString(); + } + if (c == '\\') { + if (i >= s.length()) { + throw new IllegalArgumentException("Unterminated escape"); + } + char e = s.charAt(i++); + sb.append( + switch (e) { + case '"', '\\', '/' -> e; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> { + if (i + 4 > s.length()) { + throw new IllegalArgumentException("Bad unicode escape"); + } + int code = Integer.parseInt(s.substring(i, i + 4), 16); + i += 4; + yield (char) code; + } + default -> throw new IllegalArgumentException("Bad escape: \\" + e); + }); + } else { + sb.append(c); + } + } + throw new IllegalArgumentException("Unterminated string"); + } + + private Boolean parseBoolean() { + if (s.startsWith("true", i)) { + i += 4; + return Boolean.TRUE; + } + if (s.startsWith("false", i)) { + i += 5; + return Boolean.FALSE; + } + throw new IllegalArgumentException("Invalid boolean at " + i); + } + + private Object parseNull() { + if (s.startsWith("null", i)) { + i += 4; + return null; + } + throw new IllegalArgumentException("Invalid null at " + i); + } + + private Number parseNumber() { + int start = i; + if (peek('-')) { + i++; + } + while (i < s.length() && Character.isDigit(s.charAt(i))) { + i++; + } + if (peek('.')) { + i++; + while (i < s.length() && Character.isDigit(s.charAt(i))) { + i++; + } + } + if (i < s.length() && (s.charAt(i) == 'e' || s.charAt(i) == 'E')) { + i++; + if (peek('+') || peek('-')) { + i++; + } + while (i < s.length() && Character.isDigit(s.charAt(i))) { + i++; + } + } + String num = s.substring(start, i); + if (num.contains(".") || num.contains("e") || num.contains("E")) { + return Double.valueOf(num); + } + try { + return Long.valueOf(num); + } catch (NumberFormatException ex) { + return Double.valueOf(num); + } + } + + private void skipWs() { + while (i < s.length() && Character.isWhitespace(s.charAt(i))) { + i++; + } + } + + private boolean peek(char c) { + return i < s.length() && s.charAt(i) == c; + } + + private void expect(char c) { + skipWs(); + if (i >= s.length() || s.charAt(i) != c) { + throw new IllegalArgumentException("Expected '" + c + "' at " + i); + } + i++; + } + } + } +} diff --git a/modules/intsof-common-utilities/src/test/java/com/intsof/common/utilities/license/ThirdPartyLicenseInventoryTest.java b/modules/intsof-common-utilities/src/test/java/com/intsof/common/utilities/license/ThirdPartyLicenseInventoryTest.java new file mode 100644 index 0000000000..1dcfc5e125 --- /dev/null +++ b/modules/intsof-common-utilities/src/test/java/com/intsof/common/utilities/license/ThirdPartyLicenseInventoryTest.java @@ -0,0 +1,289 @@ +/* + * Copyright 2026 Intersoft Data Labs (https://intsof.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.intsof.common.utilities.license; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.intsof.common.utilities.license.ThirdPartyLicenseInventory.GenerateResult; +import com.intsof.common.utilities.license.ThirdPartyLicenseInventory.NpmCollectionResult; +import com.intsof.common.utilities.license.ThirdPartyLicenseInventory.NpmPackage; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ThirdPartyLicenseInventoryTest { + + @TempDir Path tempDir; + + @Test + void packageNameFromLockKeyHandlesScopedAndNested() { + assertEquals("react", ThirdPartyLicenseInventory.packageNameFromLockKey("node_modules/react")); + assertEquals( + "@scope/pkg", ThirdPartyLicenseInventory.packageNameFromLockKey("node_modules/@scope/pkg")); + assertEquals( + "bar", + ThirdPartyLicenseInventory.packageNameFromLockKey("node_modules/foo/node_modules/bar")); + assertEquals(null, ThirdPartyLicenseInventory.packageNameFromLockKey("")); + assertEquals(null, ThirdPartyLicenseInventory.packageNameFromLockKey("../../../vendor/x")); + } + + @Test + void readProductionPackagesSkipsDevAndRequiresVersion() throws Exception { + Path lock = tempDir.resolve("package-lock.json"); + Files.writeString( + lock, + """ + { + "lockfileVersion": 3, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/react": { "version": "19.2.8", "license": "MIT" }, + "node_modules/vitest": { "version": "4.1.0", "license": "MIT", "dev": true }, + "node_modules/@scope/pkg": { "version": "1.2.3", "license": "Apache-2.0" }, + "node_modules/noversion": { "license": "MIT" } + } + } + """, + StandardCharsets.UTF_8); + + List pkgs = + ThirdPartyLicenseInventory.readProductionPackagesFromLockFile(lock, tempDir); + Set names = pkgs.stream().map(NpmPackage::name).collect(Collectors.toSet()); + assertTrue(names.contains("react")); + assertTrue(names.contains("@scope/pkg")); + assertFalse(names.contains("vitest")); + assertFalse(names.contains("noversion")); + + NpmPackage react = + pkgs.stream().filter(p -> p.name().equals("react")).findFirst().orElseThrow(); + assertEquals("19.2.8", react.version()); + assertEquals("MIT", react.license()); + assertTrue(react.toInventoryLine().contains("npm:react:19.2.8")); + } + + @Test + void readProductionPackagesFromLockFileThrowsWhenMissing() { + Path missing = tempDir.resolve("no-such-package-lock.json"); + assertThrows( + IllegalArgumentException.class, + () -> ThirdPartyLicenseInventory.readProductionPackagesFromLockFile(missing, tempDir)); + } + + @Test + void collectReportsMissingLockListAndMissingListedLocks() throws Exception { + Path missingList = tempDir.resolve("missing-list.txt"); + NpmCollectionResult absent = + ThirdPartyLicenseInventory.collectProductionPackagesFromLockList(tempDir, missingList); + assertTrue(absent.lockListFileMissing()); + assertTrue(absent.packages().isEmpty()); + + Path list = tempDir.resolve("locks.txt"); + Files.writeString(list, "ui/package-lock.json\n", StandardCharsets.UTF_8); + NpmCollectionResult missingLock = + ThirdPartyLicenseInventory.collectProductionPackagesFromLockList(tempDir, list); + assertFalse(missingLock.lockListFileMissing()); + assertEquals(1, missingLock.missingLockFiles().size()); + assertTrue(missingLock.packages().isEmpty()); + + assertThrows( + IllegalStateException.class, + () -> ThirdPartyLicenseInventory.requireCompleteNpmSources(missingLock, list)); + assertThrows( + IllegalStateException.class, + () -> ThirdPartyLicenseInventory.readProductionPackagesFromLockList(tempDir, list)); + } + + @Test + void readLockListThrowsWhenListFileMissing() { + assertThrows( + IllegalStateException.class, + () -> + ThirdPartyLicenseInventory.readLockList( + tempDir, tempDir.resolve("does-not-exist.txt"))); + } + + @Test + void npmPackageNormalizesBlankLicense() { + NpmPackage pkg = new NpmPackage("x", "1.0.0", " ", "src"); + assertEquals("Unknown license", pkg.license()); + } + + @Test + void licenseFromMetaViaLockSupportsMapAndListForms() throws Exception { + Path lock = tempDir.resolve("package-lock.json"); + Files.writeString( + lock, + """ + { + "lockfileVersion": 3, + "packages": { + "node_modules/a": { + "version": "1.0.0", + "license": { "type": "BSD-3-Clause" } + }, + "node_modules/b": { + "version": "2.0.0", + "license": ["MIT", "Apache-2.0"] + } + } + } + """, + StandardCharsets.UTF_8); + List pkgs = + ThirdPartyLicenseInventory.readProductionPackagesFromLockFile(lock, tempDir); + assertEquals( + "BSD-3-Clause", + pkgs.stream().filter(p -> p.name().equals("a")).findFirst().orElseThrow().license()); + assertEquals( + "MIT OR Apache-2.0", + pkgs.stream().filter(p -> p.name().equals("b")).findFirst().orElseThrow().license()); + } + + @Test + void mergeContainsBothSections() { + String merged = + ThirdPartyLicenseInventory.mergeMavenAndNpm( + "Lists of 1 third-party dependencies.\n (MIT) foo", + "Lists of 1 third-party npm dependencies (production).\n (MIT) react", + "Demo inventory"); + assertTrue(merged.startsWith("Demo inventory")); + assertTrue(merged.contains("Maven third-party dependencies")); + assertTrue(merged.contains("npm third-party dependencies (production)")); + assertTrue(merged.contains("foo")); + assertTrue(merged.contains("react")); + } + + @Test + void generateMergedInventoryWritesFilesAndReportsCount() throws Exception { + Path root = tempDir; + Path out = root.resolve("out"); + Files.createDirectories(out); + Files.writeString( + out.resolve(ThirdPartyLicenseInventory.DEFAULT_MAVEN_FILE_NAME), + "Lists of 1 third-party dependencies.\n (Apache License, Version 2.0) guava\n", + StandardCharsets.UTF_8); + + Path ui = root.resolve("ui"); + Files.createDirectories(ui); + Files.writeString( + ui.resolve("package-lock.json"), + """ + { + "lockfileVersion": 3, + "packages": { + "": {}, + "node_modules/jquery": { "version": "3.7.1", "license": "MIT" } + } + } + """, + StandardCharsets.UTF_8); + Path list = root.resolve("locks.txt"); + Files.writeString(list, "ui/package-lock.json\n", StandardCharsets.UTF_8); + + GenerateResult result = + ThirdPartyLicenseInventory.generateMergedInventory( + root, + out, + ThirdPartyLicenseInventory.DEFAULT_MAVEN_FILE_NAME, + ThirdPartyLicenseInventory.DEFAULT_NPM_FILE_NAME, + ThirdPartyLicenseInventory.DEFAULT_MERGED_FILE_NAME, + list, + "Test product inventory", + true); + + assertEquals(1, result.npmPackageCount()); + assertTrue(result.mavenPresent()); + assertTrue(result.missingLockFiles().isEmpty()); + String text = Files.readString(result.mergedPath(), StandardCharsets.UTF_8); + assertTrue(text.contains("guava")); + assertTrue(text.contains("jquery")); + assertTrue(text.contains("npm:jquery:3.7.1")); + } + + @Test + void generateMergedInventoryFailsWhenMavenMissingAndRequired() { + Path out = tempDir.resolve("out"); + Path list = tempDir.resolve("locks.txt"); + assertThrows( + IllegalStateException.class, + () -> + ThirdPartyLicenseInventory.generateMergedInventory( + tempDir, + out, + ThirdPartyLicenseInventory.DEFAULT_MAVEN_FILE_NAME, + ThirdPartyLicenseInventory.DEFAULT_NPM_FILE_NAME, + ThirdPartyLicenseInventory.DEFAULT_MERGED_FILE_NAME, + list, + null, + true)); + } + + @Test + void runMainStrictFailsOnMissingLockList() throws Exception { + Path out = tempDir.resolve("out"); + Files.createDirectories(out); + Files.writeString( + out.resolve(ThirdPartyLicenseInventory.DEFAULT_MAVEN_FILE_NAME), + "Lists of 0 third-party dependencies.\n", + StandardCharsets.UTF_8); + ByteArrayOutputStream errBuf = new ByteArrayOutputStream(); + ByteArrayOutputStream outBuf = new ByteArrayOutputStream(); + int code = + ThirdPartyLicenseInventory.runMain( + new String[] { + "--root", + tempDir.toString(), + "--out-dir", + out.toString(), + "--lock-list", + tempDir.resolve("no-list.txt").toString(), + "--require-maven" + }, + new PrintStream(outBuf, true, StandardCharsets.UTF_8), + new PrintStream(errBuf, true, StandardCharsets.UTF_8)); + assertEquals(1, code); + String err = errBuf.toString(StandardCharsets.UTF_8); + assertTrue(err.contains("ERROR:")); + assertTrue(err.contains("package-lock list") || err.contains("missing")); + } + + @Test + void runMainHelpExitsZero() { + ByteArrayOutputStream outBuf = new ByteArrayOutputStream(); + int code = + ThirdPartyLicenseInventory.runMain( + new String[] {"--help"}, + new PrintStream(outBuf, true, StandardCharsets.UTF_8), + new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)); + assertEquals(0, code); + assertTrue(outBuf.toString(StandardCharsets.UTF_8).contains("Usage:")); + } + + @Test + void npmPackageRejectsBlankName() { + assertThrows(IllegalArgumentException.class, () -> new NpmPackage(" ", "1.0.0", "MIT", "src")); + } +} diff --git a/modules/perc-distribution-tree/pom.xml b/modules/perc-distribution-tree/pom.xml index 1b3f52a0c6..52f5806b2f 100644 --- a/modules/perc-distribution-tree/pom.xml +++ b/modules/perc-distribution-tree/pom.xml @@ -393,6 +393,60 @@ + + + org.apache.maven.plugins + maven-resources-plugin + ${maven.resources.plugin.version} + + + + copy-license-inventory + + copy-resources + + generate-resources + + ${assembly-directory} + + + ${maven.multiModuleProjectDirectory} + + LICENSE.txt + NOTICE.txt + + false + + + ${maven.multiModuleProjectDirectory}/target/generated-sources/license + + THIRD-PARTY.txt + + false + + + + + + maven-dependency-plugin @@ -735,6 +789,30 @@ org.codehaus.mojo exec-maven-plugin + + + merge-third-party-inventory + + java + + generate-resources + + com.intsof.common.utilities.license.ThirdPartyLicenseInventory + runtime + + --root + ${maven.multiModuleProjectDirectory} + --out-dir + ${maven.multiModuleProjectDirectory}/target/generated-sources/license + --lock-list + ${maven.multiModuleProjectDirectory}/src/license/npm-package-locks.txt + --title + Percussion CMS third-party dependency license inventory + --require-maven + + + diff --git a/modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java b/modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java new file mode 100644 index 0000000000..90d2200323 --- /dev/null +++ b/modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 1999-2026 Percussion Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.percussion.distribution.install; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +/** + * Packaging guard for the build-generated third-party license inventory (issue #1689). + * + *

When the reactor root has already produced {@code + * target/generated-sources/license/THIRD-PARTY.txt} (full reactor build or {@code mvn + * license:aggregate-add-third-party} without {@code -N}), the distribution assembly root must also + * contain that file after {@code generate-resources} (copy-license-inventory). Standalone module + * test runs without a prior generation step skip the assembly assertion rather than inventing a + * hand-curated inventory. + */ +public class ThirdPartyInventoryPackagingTest { + + @Test + void assemblyShipsGeneratedThirdPartyInventoryWhenAvailable() throws IOException { + Path repoRoot = resolveRepoRoot(); + Path generated = + repoRoot + .resolve("target") + .resolve("generated-sources") + .resolve("license") + .resolve("THIRD-PARTY.txt"); + assumeTrue( + Files.isRegularFile(generated) && Files.size(generated) > 0, + "THIRD-PARTY.txt not generated yet — run from repo root (no -N):" + + " mvnw license:aggregate-add-third-party"); + + Path assemblyCopy = + Paths.get("target") + .resolve("classes") + .resolve("distribution") + .resolve("THIRD-PARTY.txt") + .toAbsolutePath() + .normalize(); + assumeTrue( + Files.isRegularFile(assemblyCopy), + "Assembly copy not present yet (generate-resources / copy-license-inventory not run)."); + + String text = Files.readString(assemblyCopy, StandardCharsets.UTF_8); + assertFalse(text.isBlank(), "Packaged THIRD-PARTY.txt must not be blank"); + // Merged inventory shape (Maven + npm) — issue #1689 + assertTrue( + text.contains("Maven third-party dependencies"), + "Packaged THIRD-PARTY.txt should include the Maven inventory section header"); + assertTrue( + text.contains("npm third-party dependencies (production)"), + "Packaged THIRD-PARTY.txt should include the npm production section header"); + assertTrue( + text.contains("npm:"), + "Packaged THIRD-PARTY.txt should list at least one npm:coordinate entry"); + } + + @Test + void assemblyShipsStableLicenseAndNoticeWhenPresent() throws IOException { + Path repoRoot = resolveRepoRoot(); + Path licenseSrc = repoRoot.resolve("LICENSE.txt"); + Path noticeSrc = repoRoot.resolve("NOTICE.txt"); + assumeTrue(Files.isRegularFile(licenseSrc), "LICENSE.txt missing at repo root"); + assumeTrue(Files.isRegularFile(noticeSrc), "NOTICE.txt missing at repo root"); + + Path assemblyDir = + Paths.get("target").resolve("classes").resolve("distribution").toAbsolutePath().normalize(); + assumeTrue( + Files.isDirectory(assemblyDir), + "Assembly directory not built yet — generate-resources not run"); + + Path licenseOut = assemblyDir.resolve("LICENSE.txt"); + Path noticeOut = assemblyDir.resolve("NOTICE.txt"); + assumeTrue( + Files.isRegularFile(licenseOut) && Files.isRegularFile(noticeOut), + "LICENSE.txt/NOTICE.txt not yet copied into assembly (copy-license-inventory not run)"); + + assertFalse(Files.readString(licenseOut, StandardCharsets.UTF_8).isBlank()); + String notice = Files.readString(noticeOut, StandardCharsets.UTF_8); + assertTrue( + notice.contains("THIRD-PARTY.txt"), + "Packaged NOTICE.txt must point at the generated inventory"); + } + + /** + * Resolves the monorepo root whether Surefire runs from {@code modules/perc-distribution-tree} or + * another cwd. Portable Path API only. + */ + private static Path resolveRepoRoot() { + Path cwd = Paths.get("").toAbsolutePath().normalize(); + Path probe = cwd; + for (int i = 0; i < 8 && probe != null; i++) { + if (Files.isRegularFile(probe.resolve("LICENSE.txt")) + && Files.isRegularFile(probe.resolve("pom.xml"))) { + return probe; + } + probe = probe.getParent(); + } + // Fallback: module is two levels under root. + return cwd.resolve("..").resolve("..").normalize(); + } +} diff --git a/pom.xml b/pom.xml index c7f211ae14..f9f80eaee0 100644 --- a/pom.xml +++ b/pom.xml @@ -183,6 +183,8 @@ 1.3.1 4.13.2 6.0.2 + + 2.7.1 1.0.0 5.0.1 4.17.0 @@ -3044,6 +3046,53 @@ false + + + org.codehaus.mojo + license-maven-plugin + ${license.maven.plugin.version} + false + + ${maven.multiModuleProjectDirectory}/target/generated-sources/license + + THIRD-PARTY-MAVEN.txt + ${project.build.sourceEncoding} + true + true + false + + com\.percussion|com\.intsof + test,system + + false + true + ${maven.multiModuleProjectDirectory}/src/license/THIRD-PARTY.properties + + false + + Apache License, Version 2.0|The Apache Software License, Version 2.0|Apache 2|Apache-2.0|ASL, version 2|The Apache License, Version 2.0 + MIT License|The MIT License|MIT + BSD-3-Clause|BSD Licence 3|The BSD 3-Clause License|BSD 3-clause|3-Clause BSD License|New BSD License + Eclipse Public License - v 2.0|EPL 2.0|Eclipse Public License 2.0|Eclipse Public License v2.0 + + + + + aggregate-third-party-inventory + + aggregate-add-third-party + + generate-resources + + + org.apache.maven.plugins diff --git a/scripts/README.md b/scripts/README.md index 002af10f02..24c8f64e3b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,6 +12,18 @@ Out of scope for spec 994 (must NOT be touched): ## Scripts +### Third-party license inventory (Maven + npm merge) + +**Not a Python script.** Merged inventory generation for issue #1689 lives in +`com.intsof.common:utilities` as +`com.intsof.common.utilities.license.ThirdPartyLicenseInventory` (generic Java API + +`main` for `exec-maven-plugin:java`). Product wiring: + +- Root: `license-maven-plugin` → `THIRD-PARTY-MAVEN.txt` +- `perc-distribution-tree`: Java merge → `THIRD-PARTY.txt` + copy into assembly + +See `src/license/README.md` and `modules/intsof-common-utilities/README.md`. + ### `prune-stale-worktrees.py` / `prune-stale-worktrees.bat` List or remove **stale git worktrees** left by agent sessions (Kilo / Grok / etc.). diff --git a/src/license/README.md b/src/license/README.md new file mode 100644 index 0000000000..a5dd9cc9eb --- /dev/null +++ b/src/license/README.md @@ -0,0 +1,74 @@ +# Third-party license inventory (build-generated) + +## What is authoritative + +The **versioned** third-party dependency / license inventory is **not** hand-edited. + +It is produced at build time and written to a **single** merged file: + +| Piece | How | +|-----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Maven / Java dependencies | [`org.codehaus.mojo:license-maven-plugin`](https://www.mojohaus.org/license-maven-plugin/) `aggregate-add-third-party` on the reactor root → `THIRD-PARTY-MAVEN.txt` | +| npm production dependencies + **merge** | [`ThirdPartyLicenseInventory`](../../modules/intsof-common-utilities/src/main/java/com/intsof/common/utilities/license/ThirdPartyLicenseInventory.java) in `com.intsof.common:utilities` (generic, product-agnostic) → `THIRD-PARTY-NPM.txt` + **`THIRD-PARTY.txt`** | +| Ship | `perc-distribution-tree` copies merged `THIRD-PARTY.txt` into the installer assembly root | + +Issue [#1689](https://github.com/intersoftdatalabs-in/percussioncms/issues/1689). + +| Artifact | Location | +|----------------------------------|---------------------------------------------------------------------------------| +| Merged inventory (authoritative) | `${repo-root}/target/generated-sources/license/THIRD-PARTY.txt` | +| Maven intermediate | `…/THIRD-PARTY-MAVEN.txt` | +| npm intermediate | `…/THIRD-PARTY-NPM.txt` | +| Shipped with installer | `THIRD-PARTY.txt` at the root of the `perc-distribution-tree` assembly | +| Product notice (stable prose) | root `NOTICE.txt` — product copyright + pointer only | +| Startup / About blurb | `system` resource key `thirdPartyCopyright` — same pointer, **no version pins** | + +Do **not** reintroduce hand-curated component lists or dependency version pins into +`NOTICE.txt` or `thirdPartyCopyright`. + +## Why Java (not a Python merge) + +The merge lives in **`com.intsof.common:utilities`** so the monorepo build stays on **JDK + Maven** only. The API is product-agnostic (paths and titles are caller-supplied) and covered by JUnit in that module. + +## Manual generation + +From the repository root (JDK 21 + Maven wrapper). + +**Do not pass `-N` to `license:aggregate-add-third-party`** — that only loads the empty root POM. + +```bash +# 1) Maven half (full reactor dependency graph) +./mvnw license:aggregate-add-third-party + +# 2) npm half + merge (uses the installed utilities jar) +./mvnw -pl modules/intsof-common-utilities install -DskipTests +./mvnw -pl modules/perc-distribution-tree process-resources \ + -Dmaven.antrun.skip=true -Dexec.skip=false + +# Or invoke the main class directly after utilities is installed: +java -cp modules/intsof-common-utilities/target/utilities-0.0.1.jar \ + com.intsof.common.utilities.license.ThirdPartyLicenseInventory \ + --root . --require-maven \ + --title "Percussion CMS third-party dependency license inventory" +``` + +Windows: use `mvnw.cmd` and `;` / path separators as appropriate. + +A full reactor build runs the Maven aggregate on the root `generate-resources` phase and +the Java merge on `perc-distribution-tree` `generate-resources` (after `utilities` is built). + +## npm package locks + +Edit `npm-package-locks.txt` to add product-shipped frontend lockfiles. **Do not** list +QA-only trees (`modules/perc-qa-automation`) or pure build tooling. + +The WebUI SPA is built from `WebUI/src/main/frontend` — that lockfile is listed once. + +Production packages are those in the lockfile `packages` map that are **not** marked +`dev` / `devOptional`. + +## `THIRD-PARTY.properties` (this directory) + +Optional **missing-license map** for Maven dependencies whose POM does not declare a +license (format: `groupId--artifactId--version=License Name`). Not a hand-maintained +full inventory. diff --git a/src/license/THIRD-PARTY.properties b/src/license/THIRD-PARTY.properties new file mode 100644 index 0000000000..48ea421c17 --- /dev/null +++ b/src/license/THIRD-PARTY.properties @@ -0,0 +1,17 @@ +# Missing-license overrides for org.codehaus.mojo:license-maven-plugin. +# Format: groupId--artifactId--version=License Name +# +# Only list dependencies whose POM does not declare a license. Do not hand-maintain +# a full inventory here — the plugin generates THIRD-PARTY.txt from the reactor. +# +# Entries below are the small set currently reported as "Unknown license" by the plugin. + +avalon-framework--avalon-framework--4.1.5=Apache Software License, Version 1.1 +classworlds--classworlds--1.1-alpha-2=Apache Software License, Version 1.1 +org.codehaus.plexus--plexus-container-default--1.0-alpha-9-stable-1=Apache License, Version 2.0 +jakarta-regexp--jakarta-regexp--1.4=Apache License, Version 2.0 +net.htmlparser--jericho-html--2.1=Eclipse Public License 1.0 +oro--oro--2.0.8=Apache License, Version 2.0 +saxon--saxon--6.5.3=Mozilla Public License 1.0 +stax--stax--1.2.0=Apache License, Version 2.0 +xpp3--xpp3--1.1.3.3=Indiana University Extreme! Lab Software License diff --git a/src/license/npm-package-locks.txt b/src/license/npm-package-locks.txt new file mode 100644 index 0000000000..32bb03b2be --- /dev/null +++ b/src/license/npm-package-locks.txt @@ -0,0 +1,11 @@ +# Product-shipped npm package-lock.json paths (relative to repo root). +# One path per line. Comments (#) and blank lines are ignored. +# +# Only product UI / delivery frontends belong here — not QA automation +# (modules/perc-qa-automation) or pure build tooling lockfiles. +# +# The WebUI SPA is built from src/main/frontend (see WebUI/pom.xml +# frontend-maven-plugin workingDirectory). Do not also list WebUI/package-lock.json +# or production packages will be double-counted. + +WebUI/src/main/frontend/package-lock.json diff --git a/system/src/main/resources/com/percussion/server/PSStringResources.properties b/system/src/main/resources/com/percussion/server/PSStringResources.properties index 884962b69f..ac7fc9a801 100644 --- a/system/src/main/resources/com/percussion/server/PSStringResources.properties +++ b/system/src/main/resources/com/percussion/server/PSStringResources.properties @@ -7,18 +7,14 @@ ########################################################################### copyright=Percussion CMS \ - Copyright (C) Percussion Software, Inc. 1999-2023 -thirdPartyCopyright=This product includes software developed by the Apache Software Foundation (http://www.apache.org/). \ - Copyright (c) 2000 The Apache Software Foundation. All rights reserved. \ - GNU Runtime Libraries are included in this product and are covered under the GNU LGPL (http://www.gnu.org/licenses/lgpl.html). \ - This product includes the jTDS driver v1.2.2, which is released under the terms of the GNU LGPL. \ - XStream Copyright (c) 2003-2005, Joe Walnes. All rights reserved. \ - ASM Copyright (c) 2000-2005 INRIA, France Telecom All rights reserved. \ - Lato font Copyright (c) 2012, Lukasz Dziedzic \ - with Reserved Font Name Lato. \ - This Font Software is licensed under the SIL Open Font License, Version 1.1. \ - This license is copied below, and is also available with a FAQ at: \ - http://scripts.sil.org/OFL + Copyright (C) Percussion Software, Inc. 1999-2026 \ + Additional contributions and ongoing maintenance by Intersoft Data Labs Pvt. Ltd. (https://www.intsof.com), 2023-present. +# Stable, version-agnostic pointer only. The authoritative versioned inventory is +# build-generated by org.codehaus.mojo:license-maven-plugin as THIRD-PARTY.txt +# (see root pom.xml, issue #1689). Do not list components or pin versions here. +thirdPartyCopyright=This product is licensed under the Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0).\n\ + Additional contributions and ongoing maintenance by Intersoft Data Labs Pvt. Ltd. (https://www.intsof.com), 2023-present.\n\ + This product includes third-party open source software. A complete, versioned inventory of third-party dependencies and their licenses is generated from the Maven reactor dependency set at build time and is shipped as THIRD-PARTY.txt in the product distribution (alongside LICENSE.txt and NOTICE.txt). # Trace option names and descriptions traceBasicRequestInfo_dispname=Basic Request Info traceBasicRequestInfo_desc=The Basic Request Information trace logs the type of request (POST or GET) and the complete URL of the request. diff --git a/system/src/test/java/com/percussion/server/PSThirdPartyCopyrightTest.java b/system/src/test/java/com/percussion/server/PSThirdPartyCopyrightTest.java new file mode 100644 index 0000000000..14dd516baf --- /dev/null +++ b/system/src/test/java/com/percussion/server/PSThirdPartyCopyrightTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 1999-2026 Percussion Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.percussion.server; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.ResourceBundle; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * Verifies the third-party copyright blurb emitted at server startup. + * + *

Policy (issue #1689): the resource-bundle text and root {@code NOTICE.txt} are a stable, + * version-agnostic pointer only. The authoritative versioned inventory is build-generated by {@code + * org.codehaus.mojo:license-maven-plugin} as {@code THIRD-PARTY.txt}. Do not reintroduce + * hand-curated component lists or dependency version pins here. + */ +public class PSThirdPartyCopyrightTest { + + /** Matches {@code vMAJOR.MINOR.PATCH}-style pins (e.g. {@code v1.3.1}, {@code v2.3.232}). */ + private static final Pattern VERSION_PIN = Pattern.compile("\\bv\\d+\\.\\d+\\.\\d+\\b"); + + /** Matches bare {@code MAJOR.MINOR.PATCH} pins (no leading {@code v}). */ + private static final Pattern BARE_VERSION_PIN = + Pattern.compile("(?