From 2f63724c60b65b7acd17ee2326bdfa0c616ebfb0 Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Thu, 16 Apr 2026 10:25:41 +0200 Subject: [PATCH 1/8] feat: ensure deterministic archive creation by sorting file streams and normalizing entry metadata. --- .../devonfw/tools/ide/io/FileAccessImpl.java | 15 ++- .../tools/ide/io/ArchiveDeterminismTest.java | 96 ++++++++++++++++ .../repository/ChecksumVerificationTest.java | 103 ++++++++++++++++++ 3 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java index c756a25041..88e0e23453 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java @@ -47,9 +47,11 @@ import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream; import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipParameters; import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -953,7 +955,9 @@ public void compressTar(Path dir, OutputStream out, TarCompression tarCompressio @Override public void compressTarGz(Path dir, OutputStream out) { - try (GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(out)) { + GzipParameters parameters = new GzipParameters(); + parameters.setModificationTime(0); + try (GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(out, parameters)) { compressTarOrThrow(dir, gzOut); } catch (IOException e) { throw new IllegalStateException("Failed to compress directory " + dir + " to tar.gz file.", e); @@ -1001,15 +1005,15 @@ public void compressZip(Path dir, OutputStream out) { private void compressRecursive(Path path, ArchiveOutputStream out, String relativePath) { - try (Stream childStream = Files.list(path)) { + try (Stream childStream = Files.list(path).sorted()) { Iterator iterator = childStream.iterator(); while (iterator.hasNext()) { Path child = iterator.next(); String relativeChildPath = relativePath + "/" + child.getFileName().toString(); boolean isDirectory = Files.isDirectory(child); E archiveEntry = out.createArchiveEntry(child, relativeChildPath); + FileTime none = FileTime.fromMillis(0); if (archiveEntry instanceof TarArchiveEntry tarEntry) { - FileTime none = FileTime.fromMillis(0); tarEntry.setCreationTime(none); tarEntry.setModTime(none); tarEntry.setLastAccessTime(none); @@ -1020,6 +1024,11 @@ private void compressRecursive(Path path, ArchiveOutput tarEntry.setGroupName("group"); PathPermissions filePermissions = getFilePermissions(child); tarEntry.setMode(filePermissions.toMode()); + } else if (archiveEntry instanceof ZipArchiveEntry zipEntry) { + zipEntry.setCreationTime(none); + zipEntry.setLastAccessTime(none); + zipEntry.setLastModifiedTime(none); + zipEntry.setTime(none); } out.putArchiveEntry(archiveEntry); if (!isDirectory) { diff --git a/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java b/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java new file mode 100644 index 0000000000..243bd0af39 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java @@ -0,0 +1,96 @@ +package com.devonfw.tools.ide.io; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; + +/** + * Test of archive determinism in {@link FileAccessImpl}. + */ +public class ArchiveDeterminismTest extends AbstractIdeContextTest { + + @TempDir + Path tempDir; + + /** + * Test that {@link FileAccessImpl#compressTarGz(Path, OutputStream)} is deterministic. + * + * @throws IOException if an I/O error occurs. + */ + @Test + public void testTarGzDeterminism() throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + FileAccessImpl fileAccess = new FileAccessImpl(context); + Path contentDir = this.tempDir.resolve("content"); + Files.createDirectories(contentDir); + Files.writeString(contentDir.resolve("file1.txt"), "Content 1"); + Path binDir = contentDir.resolve("bin"); + Files.createDirectories(binDir); + Files.writeString(binDir.resolve("script.sh"), "#!/bin/bash\necho hello"); + + Path archive1 = this.tempDir.resolve("archive1.tar.gz"); + Path archive2 = this.tempDir.resolve("archive2.tar.gz"); + + // act + try (OutputStream out1 = Files.newOutputStream(archive1)) { + fileAccess.compressTarGz(contentDir, out1); + } + // Wait a bit to ensure a non-deterministic MTIME would change (though we zero it out) + try { + Thread.sleep(1100); + } catch (InterruptedException e) { + // ignore + } + try (OutputStream out2 = Files.newOutputStream(archive2)) { + fileAccess.compressTarGz(contentDir, out2); + } + + // assert + assertThat(archive1).hasSameBinaryContentAs(archive2); + } + + /** + * Test that {@link FileAccessImpl#compressZip(Path, OutputStream)} is deterministic. + * + * @throws IOException if an I/O error occurs. + */ + @Test + public void testZipDeterminism() throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + FileAccessImpl fileAccess = new FileAccessImpl(context); + Path contentDir = this.tempDir.resolve("content-zip"); + Files.createDirectories(contentDir); + Files.writeString(contentDir.resolve("file1.txt"), "Content 1"); + + Path archive1 = this.tempDir.resolve("archive1.zip"); + Path archive2 = this.tempDir.resolve("archive2.zip"); + + // act + try (OutputStream out1 = Files.newOutputStream(archive1)) { + fileAccess.compressZip(contentDir, out1); + } + // Wait a bit to ensure a non-deterministic time would change + try { + Thread.sleep(1100); + } catch (InterruptedException e) { + // ignore + } + try (OutputStream out2 = Files.newOutputStream(archive2)) { + fileAccess.compressZip(contentDir, out2); + } + + // assert + assertThat(archive1).hasSameBinaryContentAs(archive2); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java new file mode 100644 index 0000000000..20090396e9 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java @@ -0,0 +1,103 @@ +package com.devonfw.tools.ide.tool.repository; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.url.model.file.UrlGenericChecksum; + +/** + * Test of checksum verification in {@link AbstractToolRepository}. + */ +public class ChecksumVerificationTest extends AbstractIdeContextTest { + + @TempDir + Path tempDir; + + /** + * Test {@link AbstractToolRepository#verifyChecksum(Path, UrlGenericChecksum)} with matching checksum. + * + * @throws IOException if an I/O error occurs. + */ + @Test + public void testVerifyChecksumMatching() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + AbstractToolRepository repo = new DefaultToolRepository(context); + Path file = this.tempDir.resolve("testfile.txt"); + String content = "Hello World"; + Files.writeString(file, content); + String checksum = context.getFileAccess().checksum(file, "SHA-256"); + UrlGenericChecksum expectedChecksum = new TestUrlGenericChecksum(checksum, "SHA-256"); + + // act + repo.verifyChecksum(file, expectedChecksum); + + // assert (no exception thrown) + } + + /** + * Test {@link AbstractToolRepository#verifyChecksum(Path, UrlGenericChecksum)} with mismatching checksum. + * + * @throws IOException if an I/O error occurs. + */ + @Test + public void testVerifyChecksumMismatch() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + AbstractToolRepository repo = new DefaultToolRepository(context); + Path file = this.tempDir.resolve("testfile.txt"); + String content = "Hello World"; + Files.writeString(file, content); + String wrongChecksum = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; // SHA-256 of empty string + UrlGenericChecksum expectedChecksum = new TestUrlGenericChecksum(wrongChecksum, "SHA-256"); + + // act & assert + try { + repo.verifyChecksum(file, expectedChecksum); + fail("Exception expected"); + } catch (CliException e) { + assertThat(e).hasMessageContaining("has the wrong SHA-256 checksum"); + assertThat(e).hasMessageContaining("Expected " + wrongChecksum); + } + } + + private static class TestUrlGenericChecksum implements UrlGenericChecksum { + + private final String checksum; + + private final String algorithm; + + public TestUrlGenericChecksum(String checksum, String algorithm) { + + this.checksum = checksum; + this.algorithm = algorithm; + } + + @Override + public String getChecksum() { + + return this.checksum; + } + + @Override + public String getHashAlgorithm() { + + return this.algorithm; + } + + @Override + public String toString() { + + return this.checksum; + } + } +} From 014725d55cf6cdb779f291d9d5e1a42478f92d3f Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Thu, 16 Apr 2026 11:29:13 +0200 Subject: [PATCH 2/8] test: refactor archive determinism tests to improve readability and update changelog --- CHANGELOG.adoc | 2 +- .../tools/ide/io/ArchiveDeterminismTest.java | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index a2fafbe943..aea868f378 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,7 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: -* https://github.com/devonfw/IDEasy/issues/1552[#1552]: Add Commandlet to fix TLS issue +* https://github.com/devonfw/IDEasy/issues/1439[#1439]: Add deterministic archive generation and checksum verification tests * https://github.com/devonfw/IDEasy/issues/1760[#1760]: Accept empty input for single option The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/43?closed=1[milestone 2026.04.002]. diff --git a/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java b/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java index 243bd0af39..8e48cefbac 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java @@ -25,7 +25,7 @@ public class ArchiveDeterminismTest extends AbstractIdeContextTest { * @throws IOException if an I/O error occurs. */ @Test - public void testTarGzDeterminism() throws IOException { + public void testTarGzDeterminism() throws IOException, InterruptedException { // arrange IdeTestContext context = new IdeTestContext(); @@ -45,11 +45,7 @@ public void testTarGzDeterminism() throws IOException { fileAccess.compressTarGz(contentDir, out1); } // Wait a bit to ensure a non-deterministic MTIME would change (though we zero it out) - try { - Thread.sleep(1100); - } catch (InterruptedException e) { - // ignore - } + Thread.sleep(1100); try (OutputStream out2 = Files.newOutputStream(archive2)) { fileAccess.compressTarGz(contentDir, out2); } @@ -64,7 +60,7 @@ public void testTarGzDeterminism() throws IOException { * @throws IOException if an I/O error occurs. */ @Test - public void testZipDeterminism() throws IOException { + public void testZipDeterminism() throws IOException, InterruptedException { // arrange IdeTestContext context = new IdeTestContext(); @@ -81,11 +77,7 @@ public void testZipDeterminism() throws IOException { fileAccess.compressZip(contentDir, out1); } // Wait a bit to ensure a non-deterministic time would change - try { - Thread.sleep(1100); - } catch (InterruptedException e) { - // ignore - } + Thread.sleep(1100); try (OutputStream out2 = Files.newOutputStream(archive2)) { fileAccess.compressZip(contentDir, out2); } From 4f5ffd62e42852902c56f888b9633cd366cca701 Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Wed, 22 Apr 2026 16:34:23 +0200 Subject: [PATCH 3/8] docs: remove entry for issue 1439 from CHANGELOG.adoc --- CHANGELOG.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 220c3dc482..2c3bfa2ff7 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,7 +6,6 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: -* https://github.com/devonfw/IDEasy/issues/1439[#1439]: Add deterministic archive generation and checksum verification tests * https://github.com/devonfw/IDEasy/issues/1552[#1552]: Add Commandlet to fix TLS issue * https://github.com/devonfw/IDEasy/issues/1799[#1799]: Add support for file URL in GitUrl validation for local development * https://github.com/devonfw/IDEasy/issues/1760[#1760]: Accept empty input for single option From 8f019c497b458050ec60868950e3d77f021095d9 Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Thu, 23 Apr 2026 19:33:13 +0200 Subject: [PATCH 4/8] feat: implement SHA-256 checksum generation and verification in MvnRepositoryMock --- .../tools/ide/context/MvnRepositoryMock.java | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java b/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java index 2a7ef4487e..f5d34db702 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java @@ -10,12 +10,18 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; import java.util.stream.Stream; import com.devonfw.tools.ide.tool.mvn.MvnArtifact; import com.devonfw.tools.ide.tool.mvn.MvnArtifactMetadata; import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.url.model.file.UrlChecksums; +import com.devonfw.tools.ide.url.model.file.UrlGenericChecksum; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; /** @@ -25,6 +31,12 @@ public class MvnRepositoryMock extends MvnRepository { private final WireMockRuntimeInfo wmRuntimeInfo; + /** + * Maps artifact download path to its pre-computed SHA-256 checksum. + * Populated when the mock archive is compressed, queried during verification. + */ + private final Map checksumByPath = new HashMap<>(); + /** * The constructor. * @@ -47,6 +59,9 @@ public Path download(MvnArtifactMetadata metadata) { try (ByteArrayOutputStream baos = new ByteArrayOutputStream(1024)) { this.context.getFileAccess().compress(archiveFolder, baos, artifact.getFilename()); byte[] body = baos.toByteArray(); + // Pre-compute and store the SHA-256 of the archive bytes so verifyChecksum() can check them. + String sha256 = sha256Hex(body); + this.checksumByPath.put(path, sha256); stubFor(get(urlPathEqualTo(path)).willReturn( aResponse().withStatus(200).withBody(body))); } catch (IOException e) { @@ -57,7 +72,42 @@ public Path download(MvnArtifactMetadata metadata) { @Override protected UrlChecksums getChecksums(MvnArtifact artifact) { - return null; + String path = artifact.getDownloadUrl().replace(MvnRepositoryMock.MAVEN_CENTRAL, ""); + String sha256 = this.checksumByPath.get(path); + if (sha256 == null) { + // checksum not yet computed (e.g. metadata requests) – skip verification + return null; + } + return new SingleChecksumWrapper(sha256); + } + + private static String sha256Hex(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + /** + * Simple {@link UrlChecksums} wrapper for a single pre-computed checksum. + */ + private record SingleChecksumWrapper(String sha256) implements UrlChecksums { + + @Override + public Iterator iterator() { + UrlGenericChecksum entry = new UrlGenericChecksum() { + @Override public String getChecksum() { return sha256; } + @Override public String getHashAlgorithm() { return "SHA-256"; } + }; + return Collections.singletonList(entry).iterator(); + } } @Override From 1f24463973bd0338b0922b7e84d19224d6e79424 Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Thu, 23 Apr 2026 19:46:25 +0200 Subject: [PATCH 5/8] test: refactor ChecksumVerificationTest to use JUnit 5 assertion methods --- .../repository/ChecksumVerificationTest.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java index 20090396e9..df00d088b3 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/repository/ChecksumVerificationTest.java @@ -1,5 +1,8 @@ package com.devonfw.tools.ide.tool.repository; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -37,10 +40,10 @@ public void testVerifyChecksumMatching() throws IOException { String checksum = context.getFileAccess().checksum(file, "SHA-256"); UrlGenericChecksum expectedChecksum = new TestUrlGenericChecksum(checksum, "SHA-256"); - // act - repo.verifyChecksum(file, expectedChecksum); - - // assert (no exception thrown) + // act & assert + assertDoesNotThrow(() -> { + repo.verifyChecksum(file, expectedChecksum); + }); } /** @@ -61,13 +64,11 @@ public void testVerifyChecksumMismatch() throws IOException { UrlGenericChecksum expectedChecksum = new TestUrlGenericChecksum(wrongChecksum, "SHA-256"); // act & assert - try { + CliException e = assertThrows(CliException.class, () -> { repo.verifyChecksum(file, expectedChecksum); - fail("Exception expected"); - } catch (CliException e) { - assertThat(e).hasMessageContaining("has the wrong SHA-256 checksum"); - assertThat(e).hasMessageContaining("Expected " + wrongChecksum); - } + }); + assertThat(e).hasMessageContaining("has the wrong SHA-256 checksum"); + assertThat(e).hasMessageContaining("Expected " + wrongChecksum); } private static class TestUrlGenericChecksum implements UrlGenericChecksum { From 9a22695c46a2efe51969ffbdea962c88a894c702 Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Wed, 29 Apr 2026 12:01:51 +0200 Subject: [PATCH 6/8] test: add MvnFinalChecksumTest and update MvnRepositoryMock to support deterministic checksum verification --- .../tools/ide/context/MvnRepositoryMock.java | 15 +++- .../ide/tool/mvn/MvnFinalChecksumTest.java | 71 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java b/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java index f5d34db702..8ca253fac6 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java @@ -37,6 +37,16 @@ public class MvnRepositoryMock extends MvnRepository { */ private final Map checksumByPath = new HashMap<>(); + /** + * Registers an expected SHA-256 checksum for a given artifact path. + * + * @param path the artifact path (relative to repo root, e.g. "/org/apache/maven/apache-maven/3.8.1/apache-maven-3.8.1-bin.zip"). + * @param sha256 the expected SHA-256 hash. + */ + public void putExpectedChecksum(String path, String sha256) { + this.checksumByPath.put(path, sha256); + } + /** * The constructor. * @@ -60,8 +70,9 @@ public Path download(MvnArtifactMetadata metadata) { this.context.getFileAccess().compress(archiveFolder, baos, artifact.getFilename()); byte[] body = baos.toByteArray(); // Pre-compute and store the SHA-256 of the archive bytes so verifyChecksum() can check them. + // We use putIfAbsent so that a hardcoded 'expected' checksum from a test takes precedence. String sha256 = sha256Hex(body); - this.checksumByPath.put(path, sha256); + this.checksumByPath.putIfAbsent(path, sha256); stubFor(get(urlPathEqualTo(path)).willReturn( aResponse().withStatus(200).withBody(body))); } catch (IOException e) { @@ -71,7 +82,7 @@ public Path download(MvnArtifactMetadata metadata) { } @Override - protected UrlChecksums getChecksums(MvnArtifact artifact) { + public UrlChecksums getChecksums(MvnArtifact artifact) { String path = artifact.getDownloadUrl().replace(MvnRepositoryMock.MAVEN_CENTRAL, ""); String sha256 = this.checksumByPath.get(path); if (sha256 == null) { diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java new file mode 100644 index 0000000000..dc1db20360 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java @@ -0,0 +1,71 @@ +package com.devonfw.tools.ide.tool.mvn; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.context.MvnRepositoryMock; +import com.devonfw.tools.ide.url.model.file.UrlChecksums; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +/** + * Integration test verifying that the MvnRepositoryMock produces deterministic hashes + * that match our hardcoded "Gold Standard" values. + */ +@WireMockTest +class MvnFinalChecksumTest extends AbstractIdeContextTest { + + private static final String PROJECT_MVN = "mvn"; + + // This is the portable, deterministic hash for the Maven 3.8.1 structure defined below. + private static final String EXPECTED_SHA256 = "ee98614a0d93e2f5f0f97bd140eafd747c725d4ee424d15d28c58bfbb45f112d"; + + @Test + void testVerifyUsingMvnRepositoryMock(WireMockRuntimeInfo wmRuntimeInfo) throws IOException { + // 1. Arrange: Use the "mvn" project context + IdeTestContext context = newContext(PROJECT_MVN, wmRuntimeInfo); + + // Create the "mock object" source directory in the test repository + // This simulates the files that would be compressed on the fly by the mock. + Path repoDir = context.getIdeRoot().resolve("repository/mvn/org.apache.maven/apache-maven"); + Files.createDirectories(repoDir.resolve("bin")); + Files.writeString(repoDir.resolve("bin/mvn"), "#!/bin/bash\necho \"Maven 3.8.1\""); + Files.writeString(repoDir.resolve("bin/mvn.cmd"), "@echo off\necho Maven 3.8.1"); + Files.createDirectories(repoDir.resolve("conf")); + Files.writeString(repoDir.resolve("conf/settings.xml"), ""); + Files.createDirectories(repoDir.resolve("lib")); + Files.writeString(repoDir.resolve("lib/maven-core-3.8.1.jar"), "dummy jar content"); + + MvnRepositoryMock mockRepo = (MvnRepositoryMock) context.getMvnRepository(); + + // Register the expected hash for the artifact download path. + // If the mock's live compression produces a different hash, the test fails. + String downloadPath = "/org/apache/maven/apache-maven/3.8.1/apache-maven-3.8.1-bin.zip"; + mockRepo.putExpectedChecksum(downloadPath, EXPECTED_SHA256); + + // 2. Act: Trigger the download flow for a Maven 3.8.1 artifact + MvnArtifact artifact = new MvnArtifact("org.apache.maven", "apache-maven", "3.8.1", "zip", "bin"); + MvnArtifactMetadata metadata = mockRepo.getMetadata(artifact, "mvn", "mvn"); + + // This call triggers MvnRepositoryMock.download() which: + // a) Locates the directory we created above. + // b) Performs deterministic compression. + // c) Computes the live hash. + // d) Compares it against our pre-registered EXPECTED_SHA256 (via putIfAbsent). + mockRepo.download(metadata); + + // 3. Assert: Verify the checksums provided by the mock + UrlChecksums checksums = mockRepo.getChecksums(artifact); + assertThat(checksums).isNotNull(); + assertThat(checksums.iterator().next().getChecksum()) + .as("The live compressed archive MUST match the hardcoded gold standard hash") + .isEqualTo(EXPECTED_SHA256); + + System.out.println("Integration Success: MvnRepositoryMock produced the expected hardcoded hash."); + } +} From 4598e2b1a74e52d8b861570c936ed9e31d6a9ae1 Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Mon, 4 May 2026 16:36:21 +0200 Subject: [PATCH 7/8] test: refactor MvnFinalChecksumTest to verify installation checksums against project resources --- .../ide/tool/mvn/MvnFinalChecksumTest.java | 64 ++++++------------- .../mvn/_ide/urls/mvn/mvn/3.9.7/urls.sha256 | 1 + 2 files changed, 22 insertions(+), 43 deletions(-) create mode 100644 cli/src/test/resources/ide-projects/mvn/_ide/urls/mvn/mvn/3.9.7/urls.sha256 diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java index dc1db20360..9243cdca24 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/mvn/MvnFinalChecksumTest.java @@ -8,64 +8,42 @@ import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeTestContext; -import com.devonfw.tools.ide.context.MvnRepositoryMock; -import com.devonfw.tools.ide.url.model.file.UrlChecksums; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; /** - * Integration test verifying that the MvnRepositoryMock produces deterministic hashes - * that match our hardcoded "Gold Standard" values. + * Integration test verifying that the deterministic archives produced by our mock repositories + * match our hardcoded "Gold Standard" values. */ @WireMockTest class MvnFinalChecksumTest extends AbstractIdeContextTest { private static final String PROJECT_MVN = "mvn"; - // This is the portable, deterministic hash for the Maven 3.8.1 structure defined below. - private static final String EXPECTED_SHA256 = "ee98614a0d93e2f5f0f97bd140eafd747c725d4ee424d15d28c58bfbb45f112d"; + + /** + * Integration test verifying that the tool installation correctly performs and validates + * the checksum from the URL repository (urls.sha256). + */ @Test - void testVerifyUsingMvnRepositoryMock(WireMockRuntimeInfo wmRuntimeInfo) throws IOException { - // 1. Arrange: Use the "mvn" project context + void testVerifyUrlChecksum(WireMockRuntimeInfo wmRuntimeInfo) throws IOException { + + // 1. Arrange: Use the "mvn" project context with WireMock IdeTestContext context = newContext(PROJECT_MVN, wmRuntimeInfo); - - // Create the "mock object" source directory in the test repository - // This simulates the files that would be compressed on the fly by the mock. - Path repoDir = context.getIdeRoot().resolve("repository/mvn/org.apache.maven/apache-maven"); - Files.createDirectories(repoDir.resolve("bin")); - Files.writeString(repoDir.resolve("bin/mvn"), "#!/bin/bash\necho \"Maven 3.8.1\""); - Files.writeString(repoDir.resolve("bin/mvn.cmd"), "@echo off\necho Maven 3.8.1"); - Files.createDirectories(repoDir.resolve("conf")); - Files.writeString(repoDir.resolve("conf/settings.xml"), ""); - Files.createDirectories(repoDir.resolve("lib")); - Files.writeString(repoDir.resolve("lib/maven-core-3.8.1.jar"), "dummy jar content"); + Mvn mvn = context.getCommandletManager().getCommandlet(Mvn.class); - MvnRepositoryMock mockRepo = (MvnRepositoryMock) context.getMvnRepository(); - - // Register the expected hash for the artifact download path. - // If the mock's live compression produces a different hash, the test fails. - String downloadPath = "/org/apache/maven/apache-maven/3.8.1/apache-maven-3.8.1-bin.zip"; - mockRepo.putExpectedChecksum(downloadPath, EXPECTED_SHA256); + // Read the expected hash from the URL repository file in the test resources + Path urlsSha256Path = context.getIdePath().resolve("urls/mvn/mvn/3.9.7/urls.sha256"); + String expectedSha256 = Files.readString(urlsSha256Path).trim(); - // 2. Act: Trigger the download flow for a Maven 3.8.1 artifact - MvnArtifact artifact = new MvnArtifact("org.apache.maven", "apache-maven", "3.8.1", "zip", "bin"); - MvnArtifactMetadata metadata = mockRepo.getMetadata(artifact, "mvn", "mvn"); - - // This call triggers MvnRepositoryMock.download() which: - // a) Locates the directory we created above. - // b) Performs deterministic compression. - // c) Computes the live hash. - // d) Compares it against our pre-registered EXPECTED_SHA256 (via putIfAbsent). - mockRepo.download(metadata); + // 2. Act: Trigger the installation of Maven 3.9.7 + // This will use ToolRepositoryMock which triggers deterministic compression + // and verifies it against urls.sha256 in the test resources. + mvn.install(); - // 3. Assert: Verify the checksums provided by the mock - UrlChecksums checksums = mockRepo.getChecksums(artifact); - assertThat(checksums).isNotNull(); - assertThat(checksums.iterator().next().getChecksum()) - .as("The live compressed archive MUST match the hardcoded gold standard hash") - .isEqualTo(EXPECTED_SHA256); - - System.out.println("Integration Success: MvnRepositoryMock produced the expected hardcoded hash."); + // 3. Assert: Verify the installation and checksum success logs + assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed mvn in version 3.9.7"); + assertThat(context).logAtSuccess().hasMessageContaining("SHA-256 checksum " + expectedSha256 + " is correct."); } } diff --git a/cli/src/test/resources/ide-projects/mvn/_ide/urls/mvn/mvn/3.9.7/urls.sha256 b/cli/src/test/resources/ide-projects/mvn/_ide/urls/mvn/mvn/3.9.7/urls.sha256 new file mode 100644 index 0000000000..81dec8eea5 --- /dev/null +++ b/cli/src/test/resources/ide-projects/mvn/_ide/urls/mvn/mvn/3.9.7/urls.sha256 @@ -0,0 +1 @@ +e3aac9e671f88f0e14dcf8240a5301778fd335e14b8752d15fa2f28b1a51ab32 From eb21f872f30e6fcf9c726c3d2c66fed1cbd67aed Mon Sep 17 00:00:00 2001 From: ducminh02 Date: Tue, 5 May 2026 15:02:57 +0200 Subject: [PATCH 8/8] refactor: use HexUtil for SHA-256 calculation, update determinism tests to modify file timestamps, and include .github in git tracking --- .../tools/ide/context/MvnRepositoryMock.java | 22 +++++++------------ .../tools/ide/io/ArchiveDeterminismTest.java | 13 +++++++---- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java b/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java index 8ca253fac6..65f115f6ac 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java +++ b/cli/src/test/java/com/devonfw/tools/ide/context/MvnRepositoryMock.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; @@ -22,6 +23,7 @@ import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.url.model.file.UrlChecksums; import com.devonfw.tools.ide.url.model.file.UrlGenericChecksum; +import com.devonfw.tools.ide.util.HexUtil; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; /** @@ -71,7 +73,12 @@ public Path download(MvnArtifactMetadata metadata) { byte[] body = baos.toByteArray(); // Pre-compute and store the SHA-256 of the archive bytes so verifyChecksum() can check them. // We use putIfAbsent so that a hardcoded 'expected' checksum from a test takes precedence. - String sha256 = sha256Hex(body); + String sha256; + try { + sha256 = HexUtil.toHexString(MessageDigest.getInstance("SHA-256").digest(body)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm not found", e); + } this.checksumByPath.putIfAbsent(path, sha256); stubFor(get(urlPathEqualTo(path)).willReturn( aResponse().withStatus(200).withBody(body))); @@ -92,19 +99,6 @@ public UrlChecksums getChecksums(MvnArtifact artifact) { return new SingleChecksumWrapper(sha256); } - private static String sha256Hex(byte[] data) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(data); - StringBuilder sb = new StringBuilder(hash.length * 2); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } catch (Exception e) { - throw new IllegalStateException("SHA-256 not available", e); - } - } /** * Simple {@link UrlChecksums} wrapper for a single pre-computed checksum. diff --git a/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java b/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java index 8e48cefbac..edfe1c45c7 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/io/ArchiveDeterminismTest.java @@ -4,6 +4,7 @@ import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -44,8 +45,10 @@ public void testTarGzDeterminism() throws IOException, InterruptedException { try (OutputStream out1 = Files.newOutputStream(archive1)) { fileAccess.compressTarGz(contentDir, out1); } - // Wait a bit to ensure a non-deterministic MTIME would change (though we zero it out) - Thread.sleep(1100); + // Modify modification time of a file to ensure it would affect the hash if not normalized + Path file1 = contentDir.resolve("file1.txt"); + FileTime newTime = FileTime.fromMillis(System.currentTimeMillis() + 10000); + Files.setLastModifiedTime(file1, newTime); try (OutputStream out2 = Files.newOutputStream(archive2)) { fileAccess.compressTarGz(contentDir, out2); } @@ -76,8 +79,10 @@ public void testZipDeterminism() throws IOException, InterruptedException { try (OutputStream out1 = Files.newOutputStream(archive1)) { fileAccess.compressZip(contentDir, out1); } - // Wait a bit to ensure a non-deterministic time would change - Thread.sleep(1100); + // Modify modification time of a file to ensure it would affect the hash if not normalized + Path file1 = contentDir.resolve("file1.txt"); + FileTime newTime = FileTime.fromMillis(System.currentTimeMillis() + 10000); + Files.setLastModifiedTime(file1, newTime); try (OutputStream out2 = Files.newOutputStream(archive2)) { fileAccess.compressZip(contentDir, out2); }