Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1001,15 +1005,15 @@ public void compressZip(Path dir, OutputStream out) {

private <E extends ArchiveEntry> void compressRecursive(Path path, ArchiveOutputStream<E> out, String relativePath) {

try (Stream<Path> childStream = Files.list(path)) {
try (Stream<Path> childStream = Files.list(path).sorted()) {
Iterator<Path> 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);
Expand All @@ -1020,6 +1024,11 @@ private <E extends ArchiveEntry> 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);
Comment thread
hohwille marked this conversation as resolved.
}
out.putArchiveEntry(archiveEntry);
if (!isDirectory) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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<String, String> checksumByPath = new HashMap<>();

/**
* The constructor.
*
Expand All @@ -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) {
Expand All @@ -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<UrlGenericChecksum> iterator() {
UrlGenericChecksum entry = new UrlGenericChecksum() {
@Override public String getChecksum() { return sha256; }
@Override public String getHashAlgorithm() { return "SHA-256"; }
};
return Collections.singletonList(entry).iterator();
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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, InterruptedException {

// 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)
Thread.sleep(1100);
Comment thread
hohwille marked this conversation as resolved.
Outdated
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, InterruptedException {

// 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
Thread.sleep(1100);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as in the tarGz test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as in the tarGz test

try (OutputStream out2 = Files.newOutputStream(archive2)) {
fileAccess.compressZip(contentDir, out2);
}

// assert
assertThat(archive1).hasSameBinaryContentAs(archive2);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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;

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 & assert
assertDoesNotThrow(() -> {
repo.verifyChecksum(file, expectedChecksum);
});
}

/**
* 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
CliException e = assertThrows(CliException.class, () -> {
repo.verifyChecksum(file, expectedChecksum);
});
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;
}
}
}
Loading