From e8e241083b95c882b318c6f23345ee298724046a Mon Sep 17 00:00:00 2001 From: Mateusz Pietryga Date: Sat, 31 Oct 2026 21:48:47 +0000 Subject: [PATCH 1/3] Build the macOS natives on macos-15 The macos-14 runner image retires on November 2, 2026, and its brownouts already cancel the macOS jobs. Move them to macos-15, which is arm64 too and still sets XCODE_16_DEVELOPER_DIR. macos-15 defaults to Xcode 16.4, whose xcrun shims (/usr/bin/make, /usr/bin/c++) read SDKROOT and cannot resolve MacOSX10.9.sdk, so CMake fails to configure. The workflows now export only CMAKE_OSX_SYSROOT, which ant/build.xml passes to CMake as -DCMAKE_OSX_SYSROOT, and the shims keep the default SDK. This does not change the compatibility of the natives: they are still built against the same MacOSX10.9.sdk and MacOSX11.0.sdk with the same MACOSX_DEPLOYMENT_TARGET values. --- .github/workflows/build.yml | 6 ++---- .github/workflows/cross-compile.yml | 6 ++---- ant/build.xml | 9 ++++++++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f18c4d0ae..dd7b1a4db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,7 @@ jobs: - run: mvn -P "${{ matrix.profile }}" --batch-mode macos: - runs-on: [macos-14] + runs-on: [macos-15] strategy: fail-fast: false matrix: @@ -81,10 +81,8 @@ jobs: - name: Set SDK version run: | export MACOSX_DEPLOYMENT_TARGET=${{ matrix.macos-deployment-target }} - export SDKROOT=$XCODE_16_DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/${{ matrix.sdk-version }} - export CMAKE_OSX_SYSROOT=$SDKROOT + export CMAKE_OSX_SYSROOT=$XCODE_16_DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/${{ matrix.sdk-version }} echo "MACOSX_DEPLOYMENT_TARGET=$MACOSX_DEPLOYMENT_TARGET" >> $GITHUB_ENV - echo "SDKROOT=$SDKROOT" >> $GITHUB_ENV echo "CMAKE_OSX_SYSROOT=$CMAKE_OSX_SYSROOT" >> $GITHUB_ENV - run: mvn -P "${{ matrix.profile }}" --batch-mode diff --git a/.github/workflows/cross-compile.yml b/.github/workflows/cross-compile.yml index fc7c7d12d..d942cd041 100644 --- a/.github/workflows/cross-compile.yml +++ b/.github/workflows/cross-compile.yml @@ -135,7 +135,7 @@ jobs: while git pull --rebase && ! git push; do sleep 5; done macos: - runs-on: macos-14 + runs-on: macos-15 needs: create-branch strategy: matrix: @@ -170,10 +170,8 @@ jobs: - name: Set SDK version run: | export MACOSX_DEPLOYMENT_TARGET=${{ matrix.macos-deployment-target }} - export SDKROOT=$XCODE_16_DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/${{ matrix.sdk-version }} - export CMAKE_OSX_SYSROOT=$SDKROOT + export CMAKE_OSX_SYSROOT=$XCODE_16_DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/${{ matrix.sdk-version }} echo "MACOSX_DEPLOYMENT_TARGET=$MACOSX_DEPLOYMENT_TARGET" >> $GITHUB_ENV - echo "SDKROOT=$SDKROOT" >> $GITHUB_ENV echo "CMAKE_OSX_SYSROOT=$CMAKE_OSX_SYSROOT" >> $GITHUB_ENV - name: Build with Maven run: | diff --git a/ant/build.xml b/ant/build.xml index 9d3c69689..0afe4f6d4 100644 --- a/ant/build.xml +++ b/ant/build.xml @@ -159,8 +159,15 @@ + + + + + + - + From 3a26663ef281e1f4572f302d977ffcf5ceadbbef Mon Sep 17 00:00:00 2001 From: Mateusz Pietryga Date: Sat, 31 Oct 2026 18:58:41 +0000 Subject: [PATCH 2/3] Check the ident of the natives built in a run Every native carries a JSSC-IDENT line; check it where natives are built: - NativeIdentTest reads the ident of the library built in this run without loading it, with the parser in NativeIdent, and compares it with what ant/build.xml passed to CMake. Cross builds now run it instead of skipping all tests; it is the only test they run. SerialNativeInterfaceTest checks that a host build loads the library it built. - ant/build.xml computes the ident values once, in ident-props, and exports them together with the test selection to maven, which passes them to the tests. - The committed jssc_SerialNativeInterface.h stands for the JNI surface, so a JNI change is a change to src/main/cpp like any other native change. cmake-build rewrites it when the header generated from SerialNativeInterface.java differs, and build.yml fails when a build changed it, so an updated header can't be left out of a commit. The written header is the same on every OS and JDK: javac on Windows writes long constants with MSVC's i64 suffix, which GCC and Clang reject, so they get LL as elsewhere, and line endings are LF. The committed copy is refreshed (OS_UNKNOWN was missing). - CMakeLists.txt refuses ident values that are not printable ASCII without spaces or quotes. --- .github/workflows/build.yml | 9 +++ CMakeLists.txt | 6 ++ ant/build.xml | 77 +++++++++++++------ pom.xml | 15 ++++ src/main/cpp/jssc_SerialNativeInterface.h | 2 + src/test/java/jssc/NativeIdent.java | 69 +++++++++++++++++ src/test/java/jssc/NativeIdentTest.java | 76 ++++++++++++++++++ .../java/jssc/SerialNativeInterfaceTest.java | 9 +++ 8 files changed, 240 insertions(+), 23 deletions(-) create mode 100644 src/test/java/jssc/NativeIdent.java create mode 100644 src/test/java/jssc/NativeIdentTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd7b1a4db..9e53f8558 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,9 @@ jobs: - run: sudo apt-get update && sudo apt-get install socat ${{ matrix.packages }} - run: mvn -P "${{ matrix.profile }}" --batch-mode + # The build rewrites the committed JNI header when SerialNativeInterface.java changed it (ant/build.xml) + - name: Check the JNI header is committed + run: git diff --exit-code -- src/main/cpp/jssc_SerialNativeInterface.h macos: runs-on: [macos-15] @@ -86,6 +89,9 @@ jobs: echo "CMAKE_OSX_SYSROOT=$CMAKE_OSX_SYSROOT" >> $GITHUB_ENV - run: mvn -P "${{ matrix.profile }}" --batch-mode + # The build rewrites the committed JNI header when SerialNativeInterface.java changed it (ant/build.xml) + - name: Check the JNI header is committed + run: git diff --exit-code -- src/main/cpp/jssc_SerialNativeInterface.h windows: runs-on: [windows-latest] @@ -105,3 +111,6 @@ jobs: distribution: temurin - run: mvn -P "${{ matrix.profile }}" --batch-mode + # The build rewrites the committed JNI header when SerialNativeInterface.java changed it (ant/build.xml) + - name: Check the JNI header is committed + run: git diff --exit-code -- src/main/cpp/jssc_SerialNativeInterface.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b0dde9172..2bba9545f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -205,6 +205,12 @@ endif() if(NOT JSSC_BUILD_ORIGIN) set(JSSC_BUILD_ORIGIN "local") endif() +# The ident is space separated key=value pairs of printable ASCII +foreach(IDENT_FIELD JSSC_VERSION JSSC_BUILD_REV JSSC_BUILD_ORIGIN) + if(NOT "${${IDENT_FIELD}}" MATCHES "^[A-Za-z0-9._+/-]+$") + message(FATAL_ERROR "${IDENT_FIELD} \"${${IDENT_FIELD}}\" must be printable ASCII without spaces or quotes") + endif() +endforeach() set(JSSC_BUILD_INFO "version=${JSSC_VERSION} rev=${JSSC_BUILD_REV} target=${NATIVE_LIB_DIR} origin=${JSSC_BUILD_ORIGIN}") set(JSSC_IDENT "@(#)JSSC-IDENT ${JSSC_BUILD_INFO}") message(STATUS "Native library ident: ${JSSC_IDENT}") diff --git a/ant/build.xml b/ant/build.xml index 0afe4f6d4..77fe1c6fa 100644 --- a/ant/build.xml +++ b/ant/build.xml @@ -1,6 +1,6 @@ - + - + @@ -8,7 +8,11 @@ + + + + @@ -199,17 +203,23 @@ + + + + + + + + + + - - - - - - - - - - + + + + + @@ -218,15 +228,18 @@ - Tests will run only if the TARGET and HOST match:${line.separator}${line.separator} + All tests will run only if the TARGET and HOST match:${line.separator}${line.separator} TARGET: ${os.target.classifier} DETECTED: ${os.detected.classifier} - + + + + ===== ${maven.test.message} ===== @@ -265,15 +278,7 @@ - - - - - - - - + @@ -299,6 +304,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 129f68768..b4c210385 100644 --- a/pom.xml +++ b/pom.xml @@ -327,7 +327,22 @@ ${project.version} + + + ${maven.nativelibdir.path} + + ${jssc.native.version} + + ${jssc.build.rev} + + ${jssc.build.origin} + ${cmake.build.skip} + + + + ${maven.include.tests} + ${maven.exclude.tests} diff --git a/src/main/cpp/jssc_SerialNativeInterface.h b/src/main/cpp/jssc_SerialNativeInterface.h index c202f0311..29556a2e4 100644 --- a/src/main/cpp/jssc_SerialNativeInterface.h +++ b/src/main/cpp/jssc_SerialNativeInterface.h @@ -15,6 +15,8 @@ extern "C" { #define jssc_SerialNativeInterface_OS_SOLARIS 2L #undef jssc_SerialNativeInterface_OS_MAC_OS_X #define jssc_SerialNativeInterface_OS_MAC_OS_X 3L +#undef jssc_SerialNativeInterface_OS_UNKNOWN +#define jssc_SerialNativeInterface_OS_UNKNOWN -1L #undef jssc_SerialNativeInterface_ERR_PORT_BUSY #define jssc_SerialNativeInterface_ERR_PORT_BUSY -1LL #undef jssc_SerialNativeInterface_ERR_PORT_NOT_FOUND diff --git a/src/test/java/jssc/NativeIdent.java b/src/test/java/jssc/NativeIdent.java new file mode 100644 index 000000000..ad06c5480 --- /dev/null +++ b/src/test/java/jssc/NativeIdent.java @@ -0,0 +1,69 @@ +package jssc; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Reads the JSSC-IDENT line (see src/main/cpp/ident.cpp) of a native library without + * loading it + */ +final class NativeIdent { + + static final List LIBRARY_NAMES = Arrays.asList("libjssc.so", "libjssc.dylib", "jssc.dll"); + private static final String IDENT_MARKER = "JSSC-IDENT "; + + private NativeIdent() { + } + + /** + * Finds the one printable JSSC-IDENT key=value ... line in the binary + */ + static Map parse(File library) throws IOException { + final InputStream in = new FileInputStream(library); + final String content; + try { + content = new String(readFully(in), "ISO-8859-1"); + } finally { + in.close(); + } + final int start = content.indexOf(IDENT_MARKER); + assertTrue("No " + IDENT_MARKER + "in " + library, start >= 0); + assertEquals("More than one " + IDENT_MARKER + "in " + library, -1, content.indexOf(IDENT_MARKER, start + 1)); + int end = start; + while (end < content.length() && content.charAt(end) >= 0x20 && content.charAt(end) < 0x7f) { + end++; + } + final String line = content.substring(start + IDENT_MARKER.length(), end); + final Map fields = new HashMap(); + for (String field : line.split(" ")) { + final int eq = field.indexOf('='); + assertTrue("Malformed field '" + field + "' in " + line, eq > 0); + fields.put(field.substring(0, eq), field.substring(eq + 1)); + } + return fields; + } + + static byte[] readFully(InputStream in) throws IOException { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + } + return out.toByteArray(); + } + + static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} diff --git a/src/test/java/jssc/NativeIdentTest.java b/src/test/java/jssc/NativeIdentTest.java new file mode 100644 index 000000000..23f94de56 --- /dev/null +++ b/src/test/java/jssc/NativeIdentTest.java @@ -0,0 +1,76 @@ +package jssc; + +import jssc.junit.rules.DisplayMethodNameRule; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static jssc.NativeIdent.isBlank; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; +import static org.junit.Assume.assumeTrue; + +/** + * Checks the JSSC-IDENT line ({@link NativeIdent}) of the native library built in this run against the + * jssc.expected.* properties. The library is read, not loaded, so any target can be checked. + */ +public class NativeIdentTest extends DisplayMethodNameRule { + + private File nativeDir; + private Map ident; + + @Before + public void readIdent() throws IOException { + final String dir = System.getProperty("jssc.expected.native.dir"); + assumeTrue("jssc.expected.native.dir is not set; skipping", !isBlank(dir)); + assumeFalse("jssc.native.build.skip is set; skipping", Boolean.getBoolean("jssc.native.build.skip")); + nativeDir = new File(dir); + ident = NativeIdent.parse(findLibrary(nativeDir)); + } + + @Test + public void versionIsTheNativeVersion() { + assertEquals("version", System.getProperty("jssc.expected.native.version"), ident.get("version")); + } + + @Test + public void targetIsTheOutputDirectory() { + assertEquals("target", nativeDir.getName(), ident.get("target")); + } + + @Test + public void revIsThePassedRevision() { + final String expected = System.getProperty("jssc.expected.build.rev"); + if (isBlank(expected)) { + // Not given: taken from git + final String rev = ident.get("rev"); + assertTrue("rev is " + rev, rev != null && rev.matches("[0-9a-f]{4,40}(-dirty)?|unknown")); + } else { + assertEquals("rev", expected, ident.get("rev")); + } + } + + @Test + public void originIsThePassedOrigin() { + final String expected = System.getProperty("jssc.expected.build.origin"); + assertEquals("origin", isBlank(expected) ? "local" : expected, ident.get("origin")); + } + + private static File findLibrary(File dir) { + final List found = new ArrayList(); + for (String name : NativeIdent.LIBRARY_NAMES) { + final File file = new File(dir, name); + if (file.isFile()) { + found.add(file); + } + } + assertEquals("Native libraries in " + dir + ": " + found, 1, found.size()); + return found.get(0); + } +} diff --git a/src/test/java/jssc/SerialNativeInterfaceTest.java b/src/test/java/jssc/SerialNativeInterfaceTest.java index ffd789c99..4e3c5761e 100644 --- a/src/test/java/jssc/SerialNativeInterfaceTest.java +++ b/src/test/java/jssc/SerialNativeInterfaceTest.java @@ -57,6 +57,15 @@ public void libraryVersionIsFilteredFromPom() { assertThat(SerialNativeInterface.getLibraryVersion(), is(expectedVersion)); } + @Test + public void loadsTheNativeLibraryBuiltInThisRun() { + final String expectedVersion = System.getProperty("jssc.expected.native.version"); + Assume.assumeNotNull(expectedVersion); + assumeFalse("jssc.native.build.skip is set; skipping", Boolean.getBoolean("jssc.native.build.skip")); + assertThat("Loaded a native library other than the one built in this run (stale build output?)", + SerialNativeInterface.getNativeLibraryVersion(), is(expectedVersion)); + } + @Test(expected = java.io.IOException.class) public void reportsWriteErrorsAsIOException() throws Exception { Assume.assumeFalse(SerialNativeInterface.getOsType() == SerialNativeInterface.OS_WINDOWS); From a5fb3fdc1f9038e514cc4ff70ebfb38318c64a64 Mon Sep 17 00:00:00 2001 From: Mateusz Pietryga Date: Sat, 31 Oct 2026 18:58:41 +0000 Subject: [PATCH 3/3] Verify the version and provenance of the natives before a release PrecompiledNativesTest checks the precompiled natives on the classpath, which a release ships as they are: every target the caller names in jssc.native.targets (comma separated, required) has one library with one ident, there are no other directories, and each has the expected version, target = directory, one CI rev and origin, no -dirty or unknown rev and no origin=local, and no change to the native sources since rev. It reads the idents with NativeIdent. - The test runs on request only: the verify-natives surefire execution runs it, and only it, when -Djssc.native.targets is given (the verify-natives profile) or with -Djssc.verify.natives.skip=false, which fails without the targets. The package profile now skips the tests with skipTests instead of maven.test.skip, so the test classes are still compiled. - release:prepare runs it through the release plugin's arguments, refusing natives not built by this repository's CI for the release version from these sources. The expected origin defaults to GITHUB_REPOSITORY. The releaseVersion input now reaches its scripts through the environment instead of being substituted into them. - release:perform passes the same arguments to its build in a fresh clone of the tag, so the test runs there again; that clone is no longer shallow, so the rev the natives were built from is in its history. - cross-compile.yml runs it before the squashed branch is pushed, with the rev and origin of the run. - Both workflows name the targets in NATIVE_TARGETS; the one in cross-compile.yml lists what its matrices build. --- .github/workflows/cross-compile.yml | 16 +- .github/workflows/release-new-version.yml | 15 +- ant/build.xml | 7 +- pom.xml | 42 +++- .../java/jssc/PrecompiledNativesTest.java | 229 ++++++++++++++++++ 5 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 src/test/java/jssc/PrecompiledNativesTest.java diff --git a/.github/workflows/cross-compile.yml b/.github/workflows/cross-compile.yml index d942cd041..be2ff6b57 100644 --- a/.github/workflows/cross-compile.yml +++ b/.github/workflows/cross-compile.yml @@ -12,6 +12,8 @@ env: GITHUB_BOT_NAME: github-actions GITHUB_BOT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com DOCK_CROSS_TAG: 20231208-4e4d3ad + # The targets the matrices below build, checked by PrecompiledNativesTest + NATIVE_TARGETS: linux_32,linux_64,linux_arm,linux_arm64,linux_ppc,linux_riscv32,linux_riscv64,osx_64,osx_arm64,windows_32,windows_64,windows_arm64 jobs: create-branch: @@ -202,4 +204,16 @@ jobs: git reset --soft ${{ needs.create-branch.outputs.base_rev }} git add src/main/resources-precompiled/** git commit -m "Precompiled natives @${{ needs.create-branch.outputs.base_rev }}" - git push -f + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: 11 + distribution: temurin + # Same check as release:prepare (PrecompiledNativesTest): a missing or stale native fails here, before the squashed branch is pushed + - name: Verify the natives + run: | + mvn -B -P package test -Djssc.native.targets=${NATIVE_TARGETS} \ + -Djssc.build.rev=${{ needs.create-branch.outputs.base_rev }} \ + -Djssc.build.origin=${{ github.repository }} \ + -Djssc.native.release=${{ inputs.release }} + - run: git push -f diff --git a/.github/workflows/release-new-version.yml b/.github/workflows/release-new-version.yml index 641ba991a..ec8de5e05 100644 --- a/.github/workflows/release-new-version.yml +++ b/.github/workflows/release-new-version.yml @@ -11,12 +11,20 @@ env: JAVA_TOOL_OPTIONS: -Duser.name=io.github.java-native GITHUB_BOT_NAME: github-actions GITHUB_BOT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + # The targets a release ships, checked by PrecompiledNativesTest; keep in step with cross-compile.yml + NATIVE_TARGETS: linux_32,linux_64,linux_arm,linux_arm64,linux_ppc,linux_riscv32,linux_riscv64,osx_64,osx_arm64,windows_32,windows_64,windows_arm64 jobs: maven-central-release: runs-on: ubuntu-latest + env: + # Passed through the environment, not substituted into the scripts, so the input cannot inject commands + REQUESTED_RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }} steps: - uses: actions/checkout@v3 + with: + # PrecompiledNativesTest, run by release:prepare, diffs the sources against the revision the natives were built from + fetch-depth: 0 - run: git config --global user.email "${GITHUB_BOT_EMAIL}" - run: git config --global user.name "${GITHUB_BOT_NAME}" - name: Set up JDK 11 @@ -30,9 +38,12 @@ jobs: gpg-private-key: ${{ secrets.JAVA_NATIVE_PGP_KEY }} - name: Set release version if provided as input if: github.event.inputs.releaseVersion != '' - run: echo "VERSIONS=-DreleaseVersion=${{ github.event.inputs.releaseVersion }}" >> $GITHUB_ENV + run: echo "VERSIONS=-DreleaseVersion=${REQUESTED_RELEASE_VERSION}" >> $GITHUB_ENV - name: Publish artifacts to Maven Central - run: mvn -B release:prepare release:perform -P package,maven-central-release ${VERSIONS} + # Only natives built by this repository for NATIVE_TARGETS may ship (PrecompiledNativesTest checks them) + run: >- + mvn -B release:prepare release:perform -P package,maven-central-release ${VERSIONS} + "-Djssc.release.arguments=-Djssc.build.origin=${{ github.repository }} -Djssc.native.targets=${NATIVE_TARGETS}" env: MAVEN_CENTRAL_PUBLISHING_TOKEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_PUBLISHING_TOKEN_USERNAME }} MAVEN_CENTRAL_PUBLISHING_TOKEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PUBLISHING_TOKEN_PASSWORD }} diff --git a/ant/build.xml b/ant/build.xml index 77fe1c6fa..d8a706ca2 100644 --- a/ant/build.xml +++ b/ant/build.xml @@ -215,7 +215,7 @@ - + @@ -235,7 +235,10 @@ - + + + + diff --git a/pom.xml b/pom.xml index b4c210385..5af0e0e0c 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,11 @@ false true true + true + + + + 4.13.2 @@ -327,7 +332,7 @@ ${project.version} - + ${maven.nativelibdir.path} @@ -336,6 +341,7 @@ ${jssc.build.rev} ${jssc.build.origin} + ${jssc.native.targets} ${cmake.build.skip} @@ -346,8 +352,22 @@ ${maven.exclude.tests} + + **/PrecompiledNativesTest.java + + + + verify-natives + test + + ${jssc.verify.natives.skip} + + PrecompiledNativesTest + + + @@ -515,12 +535,26 @@ + + + verify-natives + + + jssc.native.targets + + + + false + + + package true - true + + true true true @@ -729,6 +763,10 @@ false true deploy + + -Djssc.verify.natives.skip=false ${jssc.release.arguments} + + false diff --git a/src/test/java/jssc/PrecompiledNativesTest.java b/src/test/java/jssc/PrecompiledNativesTest.java new file mode 100644 index 000000000..6be7906bb --- /dev/null +++ b/src/test/java/jssc/PrecompiledNativesTest.java @@ -0,0 +1,229 @@ +package jssc; + +import jssc.junit.rules.DisplayMethodNameRule; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import static jssc.NativeIdent.isBlank; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * Checks the JSSC-IDENT ({@link NativeIdent}) of each native library under natives/ on the + * classpath: + *
    + *
  1. Every target in jssc.expected.native.targets (required, comma separated) has exactly one + * library with exactly one ident; there are no other directories.
  2. + *
  3. version is jssc.expected.native.version.
  4. + *
  5. target is the directory the library sits in.
  6. + *
  7. All share one rev (jssc.expected.build.rev, if set) and one origin + * (jssc.expected.build.origin, if set); no "-dirty" or + * "unknown" rev and no "origin=local".
  8. + *
  9. rev is an ancestor of HEAD and the native sources are unchanged since. Needs the git history back to + * rev.
  10. + *
+ * Committed natives are usually stale between releases, so expect failures outside of a release. + */ +public class PrecompiledNativesTest extends DisplayMethodNameRule { + + private static final Logger LOG = LogManager.getLogger(PrecompiledNativesTest.class); + + // src/main/cpp holds the JNI header, so a JNI change counts too + private static final String[] NATIVE_SOURCES = {"src/main/cpp", "toolchain", "CMakeLists.txt"}; + + // The targets the caller expects, each a directory under natives/ + private static final Set targets = new TreeSet(); + private static File nativesDir; + // The idents of the targets whose library could be read; the others are in unreadable + private static final Map> idents = new TreeMap>(); + private static final List unreadable = new ArrayList(); + + @BeforeClass + public static void readIdents() throws Exception { + final String expectedTargets = System.getProperty("jssc.expected.native.targets"); + assertTrue("jssc.expected.native.targets is not set: name the targets to check, e.g." + + " -Djssc.native.targets=linux_64,osx_arm64", !isBlank(expectedTargets)); + targets.addAll(Arrays.asList(expectedTargets.trim().split("\\s*,\\s*"))); + LOG.info("Targets: {}", targets); + final URL natives = PrecompiledNativesTest.class.getClassLoader().getResource("natives"); + assertNotNull("No natives on the classpath", natives); + assertEquals("Natives on the classpath are not a directory: " + natives, "file", natives.getProtocol()); + nativesDir = new File(natives.toURI()); + for (String target : targets) { + final File[] files = new File(nativesDir, target).listFiles(); + if (files == null || files.length == 0) { + unreadable.add(target + ": no native library in " + new File(nativesDir, target)); + } else if (files.length != 1) { + unreadable.add(target + ": expected one library, found " + Arrays.toString(files)); + } else if (!NativeIdent.LIBRARY_NAMES.contains(files[0].getName())) { + unreadable.add(target + ": " + files[0] + " is not one of " + NativeIdent.LIBRARY_NAMES); + } else { + try { + idents.put(target, NativeIdent.parse(files[0])); + } catch (AssertionError e) { + unreadable.add(target + ": " + e.getMessage()); + } + } + } + for (Map.Entry> entry : idents.entrySet()) { + LOG.info("{}: {}", entry.getKey(), entry.getValue()); + } + } + + @Test + public void everyTargetHasOneLibraryWithOneIdent() { + assertNoProblems(unreadable); + } + + @Test + public void onlyTheTargetsAreShipped() { + final List problems = new ArrayList(); + for (File dir : nativesDir.listFiles()) { + if (!targets.contains(dir.getName())) { + problems.add(dir + " is not one of the targets " + targets); + } + } + assertNoProblems(problems); + } + + @Test + public void versionIsTheNativeVersion() { + final String expected = System.getProperty("jssc.expected.native.version"); + assumeTrue("jssc.expected.native.version is not set; skipping", !isBlank(expected)); + final List problems = new ArrayList(); + for (Map.Entry> entry : idents.entrySet()) { + final String version = entry.getValue().get("version"); + if (!expected.equals(version)) { + problems.add(entry.getKey() + ": version is " + version + ", expected " + expected); + } + } + assertNoProblems(problems); + } + + @Test + public void targetIsTheDirectory() { + final List problems = new ArrayList(); + for (Map.Entry> entry : idents.entrySet()) { + final String target = entry.getValue().get("target"); + if (!entry.getKey().equals(target)) { + problems.add(entry.getKey() + ": ident says target=" + target); + } + } + assertNoProblems(problems); + } + + @Test + public void allComeFromOneCiRun() { + final String expectedRev = System.getProperty("jssc.expected.build.rev"); + final String expectedOrigin = System.getProperty("jssc.expected.build.origin"); + final List problems = new ArrayList(); + for (Map.Entry> entry : idents.entrySet()) { + final String target = entry.getKey(); + final String rev = entry.getValue().get("rev"); + final String origin = entry.getValue().get("origin"); + if (!isTraceable(rev)) { + problems.add(target + ": rev=" + rev + " is not a commit"); + } else if (!isBlank(expectedRev) && !expectedRev.equals(rev)) { + problems.add(target + ": rev is " + rev + ", expected " + expectedRev); + } + if (origin == null || origin.equals("local")) { + problems.add(target + ": origin=" + origin); + } else if (!isBlank(expectedOrigin) && !expectedOrigin.equals(origin)) { + problems.add(target + ": origin is " + origin + ", expected " + expectedOrigin); + } + } + final Set revs = distinct("rev"); + if (revs.size() > 1) { + problems.add("natives come from different revisions: " + revs); + } + final Set origins = distinct("origin"); + if (origins.size() > 1) { + problems.add("natives come from different origins: " + origins); + } + assertNoProblems(problems); + } + + @Test + public void nativeSourcesAreUnchangedSinceRev() throws Exception { + assertNotNull("Cannot read the git history: not a git checkout, or git is missing", git("rev-parse", "HEAD")); + final List problems = new ArrayList(); + for (String rev : distinct("rev")) { + if (!isTraceable(rev)) { + continue; // see allComeFromOneCiRun + } + if (git("rev-parse", "-q", "--verify", rev + "^{commit}") == null) { + problems.add("rev " + rev + " is not in the git history (shallow clone?)"); + } else if (git("merge-base", "--is-ancestor", rev, "HEAD") == null) { + problems.add("natives were built from " + rev + ", which is not part of this branch"); + } else { + final List diff = new ArrayList(Arrays.asList("diff", "--name-only", rev, "HEAD", "--")); + diff.addAll(Arrays.asList(NATIVE_SOURCES)); + final String changed = git(diff.toArray(new String[0])); + assertNotNull("git diff failed", changed); + if (!changed.isEmpty()) { + problems.add("native sources changed since " + rev + ": " + + changed.replace('\n', ' ')); + } + } + } + assertNoProblems(problems); + } + + private static boolean isTraceable(String rev) { + return rev != null && rev.matches("[0-9a-f]{4,40}"); + } + + private static Set distinct(String key) { + final Set values = new TreeSet(); + for (Map ident : idents.values()) { + values.add(String.valueOf(ident.get(key))); + } + return values; + } + + /** + * Runs git in the working directory + * + * @return its trimmed output, or null if it failed + */ + private static String git(String... args) throws IOException, InterruptedException { + final List command = new ArrayList(); + command.add("git"); + command.addAll(Arrays.asList(args)); + final Process process = new ProcessBuilder(command).start(); + process.getOutputStream().close(); + final InputStream in = process.getInputStream(); + final String output; + try { + output = new String(NativeIdent.readFully(in), "UTF-8").trim(); + } finally { + in.close(); + } + return process.waitFor() == 0 ? output : null; + } + + private static void assertNoProblems(Collection problems) { + final StringBuilder message = new StringBuilder(problems.size() + " problem(s) with the precompiled natives:"); + for (String problem : problems) { + message.append("\n ").append(problem); + } + assertTrue(message.toString(), problems.isEmpty()); + } +}