This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch cassandra-5.0 in repository https://gitbox.apache.org/repos/asf/cassandra.git
commit d020df9ce3c48c709fedf8f1400996d3f194beac Merge: 06468fb06b 2bebf8f0b8 Author: Marcus Eriksson <[email protected]> AuthorDate: Wed Oct 21 15:28:47 2026 +0200 Merge branch 'cassandra-4.1' into cassandra-5.0 CHANGES.txt | 1 + conf/cassandra.yaml | 8 ++ src/java/org/apache/cassandra/config/Config.java | 5 + .../cassandra/config/DatabaseDescriptor.java | 10 ++ .../CassandraEntireSSTableStreamReader.java | 39 ++++++ .../cassandra/io/util/DataIntegrityMetadata.java | 10 +- .../apache/cassandra/metrics/StreamingMetrics.java | 8 ++ .../apache/cassandra/streaming/StreamSession.java | 5 + .../test/hostreplacement/HostReplacementTest.java | 83 +++++++++++++ .../CassandraEntireSSTableStreamWriterTest.java | 135 +++++++++++++++++++++ .../io/util/DataIntegrityMetadataTest.java | 72 +++++++++++ 11 files changed, 375 insertions(+), 1 deletion(-) diff --cc CHANGES.txt index ea45e19dfd,c6dbd85e4f..8d2d52ecc9 --- a/CHANGES.txt +++ b/CHANGES.txt @@@ -1,23 -1,6 +1,24 @@@ -4.1.13 +5.0.10 + * Replica filtering protection resurrects rows deleted by a range tombstone that spans pages (CASSANDRA-21781) + * Write an sstable with the compression its components were chosen for when ALTER TABLE changes it (CASSANDRA-21780) + * Clarify the unset, true and false behaviours of cassandra.reset_bootstrap_progress in the abort message and docs (CASSANDRA-21595) + * Isolate SAI index rebuilds from ZCS (CASSANDRA-21520) + * Bound and chunk the BTI partition index preload (CASSANDRA-21632) + * Restore volatile on CommitLogSegment.cdcState dropped in merge (CASSANDRA-21665) + * Fix compressed scan read-ahead buffer Block leak and chunk-cache pollution by scans (CASSANDRA-21671) + * A read on a replica that does not know the index yet fails with a NullPointerException (CASSANDRA-21658) + * Release hints write buffer when the write executor shuts down (CASSANDRA-21657) + * Fix concurrent SAI vector inserts failing once jvector's per-graph pool limit is exceeded (CASSANDRA-21644) + * Let the commit log allocator observe the shutdown state (CASSANDRA-21616) + * Unwrap LongType properly when calculating min/max terms in V1SSTableIndex (CASSANDRA-21635) + * Force repair should ignore min_repair_interval (CASSANDRA-21552) + * Render SubnetGroups as JSON in system_views.settings (CASSANDRA-21579) + * Avoid rebuilding per-SSTable SAI components unless missing or corrupted (CASSANDRA-21515) + * Propagate trickle_fsync settings to compressed SSTable writers (CASSANDRA-21487) + * Allow DatabaseDescriptor.setCompressedReadAheadBufferSizeInKb(0) to disable read-ahead buffer (CASSANDRA-21522) + * Return CorruptSSTableException if chunk metadata and file size are out of sync (CASSANDRA-21519) Merged from 4.0: + * Validate Digest.crc32 when zero-copy streaming SSTables (CASSANDRA-21694) * Avoid DNS lookup for hostname column in ClientsTable and GossipInfoTable (CASSANDRA-21539) * Separate memtable memory usage tracking from limit checking, and fixes a a flush writeBarrier deadlock (CASSANDRA-21019) * Fixes for fully-expired SSTable index notification in compaction (CASSANDRA-21577) diff --cc src/java/org/apache/cassandra/config/Config.java index 87cc553176,a59b0ad8e1..dca5f03a4e --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@@ -698,7 -661,11 +698,12 @@@ public class Confi public volatile int max_concurrent_automatic_sstable_upgrades = 1; public boolean stream_entire_sstables = true; + /** + * When receiving a zero-copy stream, validate Digest.crc32 before the sstable is made visible. + */ + public volatile boolean entire_sstable_stream_digest_validation_enabled = false; + + public volatile boolean skip_stream_disk_space_check = false; public volatile AuditLogOptions audit_logging_options = new AuditLogOptions(); public volatile FullQueryLoggerOptions full_query_logging_options = new FullQueryLoggerOptions(); diff --cc src/java/org/apache/cassandra/config/DatabaseDescriptor.java index c4fcc4ebef,6c20225b8d..d8e428b643 --- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java +++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java @@@ -4019,26 -3537,16 +4019,36 @@@ public class DatabaseDescripto return conf.stream_entire_sstables; } + @VisibleForTesting + public static boolean setStreamEntireSSTables(boolean value) + { + return conf.stream_entire_sstables = value; + } + + public static boolean getEntireSSTableStreamDigestValidationEnabled() + { + return conf.entire_sstable_stream_digest_validation_enabled; + } + + public static void setEntireSSTableStreamDigestValidationEnabled(boolean value) + { + conf.entire_sstable_stream_digest_validation_enabled = value; + } + + public static DurationSpec.LongMillisecondsBound getStreamTransferTaskTimeout() + { + return conf.stream_transfer_task_timeout; + } + + public static boolean getSkipStreamDiskSpaceCheck() + { + return conf.skip_stream_disk_space_check; + } + + public static void setSkipStreamDiskSpaceCheck(boolean value) + { + conf.skip_stream_disk_space_check = value; + } public static String getLocalDataCenter() { diff --cc src/java/org/apache/cassandra/db/streaming/CassandraEntireSSTableStreamReader.java index 97c3b2d4f9,c876926158..81a82c86cf --- a/src/java/org/apache/cassandra/db/streaming/CassandraEntireSSTableStreamReader.java +++ b/src/java/org/apache/cassandra/db/streaming/CassandraEntireSSTableStreamReader.java @@@ -29,16 -31,15 +31,18 @@@ import org.apache.cassandra.config.Data import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.Directories; import org.apache.cassandra.db.lifecycle.LifecycleNewTracker; +import org.apache.cassandra.io.compress.BufferType; import org.apache.cassandra.io.sstable.Component; import org.apache.cassandra.io.sstable.Descriptor; +import org.apache.cassandra.io.sstable.IOOptions; import org.apache.cassandra.io.sstable.SSTableMultiWriter; -import org.apache.cassandra.io.sstable.format.SSTableFormat; -import org.apache.cassandra.io.sstable.format.big.BigTableZeroCopyWriter; +import org.apache.cassandra.io.sstable.SSTableZeroCopyWriter; ++import org.apache.cassandra.io.sstable.format.SSTableFormat.Components; import org.apache.cassandra.io.sstable.metadata.StatsMetadata; import org.apache.cassandra.io.util.DataInputPlus; + import org.apache.cassandra.io.util.DataIntegrityMetadata; import org.apache.cassandra.io.util.File; +import org.apache.cassandra.io.util.SequentialWriterOption; import org.apache.cassandra.schema.TableId; import org.apache.cassandra.streaming.ProgressInfo; import org.apache.cassandra.streaming.StreamReceiver; @@@ -153,6 -157,37 +160,38 @@@ public class CassandraEntireSSTableStre } } + /** + * Validates the received {@code Data.db} against {@code Digest.crc32}. + * @throws IOException if a digest was streamed and the received data file does not match it + */ + private void validateDataDigest(Descriptor descriptor, ComponentManifest manifest) throws IOException + { + if (!DatabaseDescriptor.getEntireSSTableStreamDigestValidationEnabled()) + return; + + List<Component> components = manifest.components(); - if (!components.contains(Component.DATA) || !components.contains(Component.DIGEST)) ++ if (!components.contains(Components.DATA) || !components.contains(Components.DIGEST)) + { + NoSpamLogger.log(logger, NoSpamLogger.Level.WARN, 1, TimeUnit.MINUTES, + "Accepting zero-copy sstable stream from {} without validating CRC due to " + + "missing {} or {} from streamed components: {}", - session.peer, Component.DATA, Component.DIGEST, components); ++ session.peer, Components.DATA, Components.DIGEST, components); + return; + } + - try (DataIntegrityMetadata.FileDigestValidator validator = new DataIntegrityMetadata.FileDigestValidator(descriptor)) ++ try + { - validator.validate(); ++ new DataIntegrityMetadata.FileDigestValidator(descriptor.fileFor(Components.DATA), ++ descriptor.fileFor(Components.DIGEST)).validate(); + } + catch (IOException e) + { + session.countEntireSSTableDigestMismatch(); + throw new IOException(format("[Stream #%s] Digest mismatch for %s received from %s, aborting stream", + session.planId(), descriptor, session.peer), e); + } + } + private File getDataDir(ColumnFamilyStore cfs, long totalSize) throws IOException { Directories.DataDirectory localDir = cfs.getDirectories().getWriteableLocation(totalSize); diff --cc src/java/org/apache/cassandra/io/util/DataIntegrityMetadata.java index aef3614da6,65d4e58f0f..7805a87f34 --- a/src/java/org/apache/cassandra/io/util/DataIntegrityMetadata.java +++ b/src/java/org/apache/cassandra/io/util/DataIntegrityMetadata.java @@@ -103,17 -142,21 +103,25 @@@ public class DataIntegrityMetadat // Validate the entire file public void validate() throws IOException { - CheckedInputStream checkedInputStream = new CheckedInputStream(dataReader, checksum); - byte[] chunk = new byte[64 * 1024]; - - while( checkedInputStream.read(chunk) > 0 ) { } - long calculatedDigestValue = checkedInputStream.getChecksum().getValue(); - if (storedDigestValue != calculatedDigestValue) + try (RandomAccessReader digestReader = RandomAccessReader.open(digestFile); + RandomAccessReader dataReader = RandomAccessReader.open(dataFile); + CheckedInputStream checkedInputStream = new CheckedInputStream(dataReader, checksum);) { - long storedDigestValue = Long.parseLong(digestReader.readLine()); - throw new IOException("Corrupted SSTable : " + descriptor.filenameFor(Component.DATA)); ++ long storedDigestValue; ++ try ++ { ++ storedDigestValue = Long.parseLong(digestReader.readLine()); ++ } ++ catch (NumberFormatException e) ++ { ++ throw new IOException(String.format("Corrupted file: invalid digest value in %s", digestFile), e); ++ } + byte[] chunk = new byte[64 * 1024]; + while (checkedInputStream.read(chunk) > 0) ; + long calculatedDigestValue = checkedInputStream.getChecksum().getValue(); + if (storedDigestValue != calculatedDigestValue) + throw new IOException(String.format("Corrupted file: integrity check (digest) failed for %s: %d != %d", dataFile, storedDigestValue, calculatedDigestValue)); } } - - public void close() - { - Throwables.perform(digestReader::close, - dataReader::close); - } } } diff --cc test/distributed/org/apache/cassandra/distributed/test/hostreplacement/HostReplacementTest.java index 8219d43ad1,fa95a8aebf..c36e83ce16 --- a/test/distributed/org/apache/cassandra/distributed/test/hostreplacement/HostReplacementTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/hostreplacement/HostReplacementTest.java @@@ -35,8 -39,9 +39,10 @@@ import org.apache.cassandra.distributed import org.apache.cassandra.distributed.api.SimpleQueryResult; import org.apache.cassandra.distributed.api.TokenSupplier; import org.apache.cassandra.distributed.shared.AssertUtils; +import org.apache.cassandra.distributed.shared.ClusterUtils; import org.apache.cassandra.distributed.test.TestBaseImpl; -import org.apache.cassandra.io.sstable.Component; ++import org.apache.cassandra.io.sstable.format.SSTableFormat.Components; + import org.apache.cassandra.io.sstable.format.SSTableReader; import org.assertj.core.api.Assertions; import static org.apache.cassandra.config.CassandraRelevantProperties.BOOTSTRAP_SKIP_SCHEMA_CHECK; @@@ -206,6 -211,83 +212,83 @@@ public class HostReplacementTest extend } } + private static final int HEALTHY_PK = 10; + private static final int CORRUPT_PK = 11; + + /** + * With digest validation enabled, a single corrupted sstable must fail the entire stream session, so neither + * the corrupted sstable nor its healthy sibling should land on the replacing node. + */ + @Test + public void replaceHostAbortsStreamingWhenDigestValidationCatchesCorruption() throws IOException, TimeoutException + { + TokenSupplier even = TokenSupplier.evenlyDistributedTokens(2); + try (Cluster cluster = Cluster.build(2) + .withConfig(c -> c.with(Feature.GOSSIP, Feature.NETWORK) + .set("entire_sstable_stream_digest_validation_enabled", true)) + .withTokenSupplier(node -> even.token(node == 3 ? 2 : node)) + .start()) + { + IInvokableInstance node1 = cluster.get(1); + IInvokableInstance nodeToRemove = cluster.get(2); + + setupCluster(cluster); + writeHealthyAndCorruptSSTables(cluster, node1); + + stopUnchecked(nodeToRemove); + + IInvokableInstance replacingNode = replaceHostAndStart(cluster, nodeToRemove); + + replacingNode.logs().watchFor("Digest mismatch"); + + // the whole stream session is aborted on a digest mismatch, so neither sstable should have landed + AssertUtils.assertRows(replacingNode.executeInternal("SELECT * FROM " + KEYSPACE + ".tbl WHERE pk = " + HEALTHY_PK), EMPTY_ROWS); + AssertUtils.assertRows(replacingNode.executeInternal("SELECT * FROM " + KEYSPACE + ".tbl WHERE pk = " + CORRUPT_PK), EMPTY_ROWS); + } + } + + /** + * Flushes two separate sstables on {@code node1} for {@code KEYSPACE.tbl}: one containing {@link #HEALTHY_PK} + * left untouched, and one containing {@link #CORRUPT_PK} that gets its Data.db corrupted. + */ + static void writeHealthyAndCorruptSSTables(Cluster cluster, IInvokableInstance node1) throws IOException + { + node1.nodetoolResult("disableautocompaction").asserts().success(); + + cluster.coordinator(1).execute("INSERT INTO " + KEYSPACE + ".tbl (pk) VALUES (?)", + ConsistencyLevel.ALL, + HEALTHY_PK); + node1.flush(KEYSPACE); + + cluster.coordinator(1).execute("INSERT INTO " + KEYSPACE + ".tbl (pk) VALUES (?)", + ConsistencyLevel.ALL, + CORRUPT_PK); + node1.flush(KEYSPACE); + + corruptSSTable(node1, "tbl"); + } + + /** Flips a byte in the middle of the Data.db file of the most recently flushed sstable for the given table. */ + static void corruptSSTable(IInvokableInstance instance, String table) throws IOException + { + String dataFilePath = instance.callOnInstance(() -> { + ColumnFamilyStore cfs = Keyspace.open(KEYSPACE).getColumnFamilyStore(table); + SSTableReader sstable = cfs.getLiveSSTables().stream() + .max(SSTableReader.maxTimestampAscending) - .get(); - return sstable.descriptor.fileFor(Component.DATA).absolutePath(); ++ .orElseThrow(); ++ return sstable.descriptor.fileFor(Components.DATA).absolutePath(); + }); + + try (RandomAccessFile raf = new RandomAccessFile(dataFilePath, "rw")) + { + long corruptAt = raf.length() / 2; + raf.seek(corruptAt); + int b = raf.read(); + raf.seek(corruptAt); + raf.write(b ^ 0xFF); + } + } + static void setupCluster(Cluster cluster) { fixDistributedSchemas(cluster); diff --cc test/unit/org/apache/cassandra/db/streaming/CassandraEntireSSTableStreamWriterTest.java index 89a3e9ed36,5184b5ae3c..dcafae0058 --- a/test/unit/org/apache/cassandra/db/streaming/CassandraEntireSSTableStreamWriterTest.java +++ b/test/unit/org/apache/cassandra/db/streaming/CassandraEntireSSTableStreamWriterTest.java @@@ -22,8 -23,15 +23,10 @@@ import java.nio.ByteBuffer import java.nio.channels.WritableByteChannel; import java.util.Collection; import java.util.Collections; + import java.util.LinkedHashMap; import java.util.Queue; -import com.google.common.io.ByteStreams; - -import org.apache.cassandra.Util; -import org.apache.cassandra.io.sstable.Component; -import org.apache.cassandra.io.sstable.Descriptor; + import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@@ -35,13 -45,11 +38,16 @@@ import io.netty.channel.ChannelPromise import io.netty.channel.DefaultFileRegion; import io.netty.channel.embedded.EmbeddedChannel; import org.apache.cassandra.SchemaLoader; +import org.apache.cassandra.Util; ++import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.db.RowUpdateBuilder; import org.apache.cassandra.db.compaction.CompactionManager; ++import org.apache.cassandra.io.sstable.Component; +import org.apache.cassandra.io.sstable.Descriptor; import org.apache.cassandra.io.sstable.SSTableMultiWriter; ++import org.apache.cassandra.io.sstable.format.SSTableFormat.Components; import org.apache.cassandra.io.sstable.format.SSTableReader; import org.apache.cassandra.io.util.DataInputBuffer; import org.apache.cassandra.locator.InetAddressAndPort; @@@ -169,6 -189,125 +186,124 @@@ public class CassandraEntireSSTableStre } } + @Test + public void testCorruptDataComponentFailsDigestValidation() throws Throwable + { + ByteBuf serializedFile = Unpooled.buffer(8192); + ComponentManifest manifest = writeEntireSSTable(serializedFile); + corruptDataComponent(serializedFile, manifest); + + DatabaseDescriptor.setEntireSSTableStreamDigestValidationEnabled(true); + assertThatThrownBy(() -> readEntireSSTable(serializedFile, manifest)) + .isInstanceOf(IOException.class) + .hasMessageContaining("Digest mismatch") + .hasCauseInstanceOf(IOException.class); + } + + @Test + public void testCorruptDataComponentAcceptedWhenValidationDisabled() throws Throwable + { + ByteBuf serializedFile = Unpooled.buffer(8192); + ComponentManifest manifest = writeEntireSSTable(serializedFile); + corruptDataComponent(serializedFile, manifest); + + DatabaseDescriptor.setEntireSSTableStreamDigestValidationEnabled(false); + + // shouldn't throw an error + SSTableMultiWriter writer = readEntireSSTable(serializedFile, manifest); + assertNotNull(writer); + writer.abort(null); + } + + @Test + public void testMissingDigestIsAcceptedWithoutValidation() throws Throwable + { + DatabaseDescriptor.setEntireSSTableStreamDigestValidationEnabled(true); + + ComponentManifest full; - try (ComponentContext context = ComponentContext.create(descriptor)) ++ try (ComponentContext context = ComponentContext.create(sstable)) + { + full = context.manifest(); + } + + // remove digest component from sstable + LinkedHashMap<Component, Long> withoutDigest = new LinkedHashMap<>(); + for (Component component : full.components()) + { - if (!component.equals(Component.DIGEST)) ++ if (!component.equals(Components.DIGEST)) + withoutDigest.put(component, full.sizeOf(component)); + } + ComponentManifest manifest = new ComponentManifest(withoutDigest); + + // stream from live files + ByteBuf serializedFile = Unpooled.buffer(8192); + for (Component component : manifest.components()) + { + try (InputStream in = descriptor.fileFor(component).newInputStream()) + { - serializedFile.writeBytes(ByteStreams.toByteArray(in)); ++ serializedFile.writeBytes(in.readAllBytes()); + } + } + + // shouldn't throw an error + SSTableMultiWriter writer = readEntireSSTable(serializedFile, manifest); + assertNotNull(writer); + writer.abort(null); + } + + private ComponentManifest writeEntireSSTable(ByteBuf serializedFile) throws Exception + { + StreamSession session = setupStreamingSessionForTest(); + EmbeddedChannel channel = createMockNettyChannel(serializedFile); + try (AsyncStreamingOutputPlus out = new AsyncStreamingOutputPlus(channel); - ComponentContext context = ComponentContext.create(descriptor)) ++ ComponentContext context = ComponentContext.create(sstable)) + { + new CassandraEntireSSTableStreamWriter(sstable, session, context).write(out); + return context.manifest(); + } + } + + private SSTableMultiWriter readEntireSSTable(ByteBuf serializedFile, ComponentManifest manifest) throws Throwable + { + StreamSession session = setupStreamingSessionForTest(); + InetAddressAndPort peer = FBUtilities.getBroadcastAddressAndPort(); + session.prepareReceiving(new StreamSummary(sstable.metadata().id, 1, 5104)); + + CassandraStreamHeader header = + CassandraStreamHeader.builder() - .withSSTableFormat(sstable.descriptor.formatType) + .withSSTableVersion(sstable.descriptor.version) + .withSSTableLevel(0) + .withEstimatedKeys(sstable.estimatedKeys()) + .withSections(Collections.emptyList()) + .withSerializationHeader(sstable.header.toComponent()) + .withComponentManifest(manifest) + .isEntireSSTable(true) - .withFirstKey(sstable.first) ++ .withFirstKey(sstable.getFirst()) + .withTableId(sstable.metadata().id) + .build(); + + CassandraEntireSSTableStreamReader reader = + new CassandraEntireSSTableStreamReader(new StreamMessageHeader(sstable.metadata().id, peer, session.planId(), false, 0, 0, 0, null), + header, session); + + return reader.read(new DataInputBuffer(serializedFile.nioBuffer(), false)); + } + + /** Flips every bit of one byte in the middle of the Data.db region of an already-serialized stream. */ + private static void corruptDataComponent(ByteBuf serializedFile, ComponentManifest manifest) + { + long dataOffset = 0; + for (Component component : manifest.components()) + { - if (component.equals(Component.DATA)) ++ if (component.equals(Components.DATA)) + break; + dataOffset += manifest.sizeOf(component); + } + - int corruptAt = (int) (dataOffset + manifest.sizeOf(Component.DATA) / 2); ++ int corruptAt = (int) (dataOffset + manifest.sizeOf(Components.DATA) / 2); + serializedFile.setByte(corruptAt, serializedFile.getByte(corruptAt) ^ 0xff); + } + private EmbeddedChannel createMockNettyChannel(ByteBuf serializedFile) throws Exception { WritableByteChannel wbc = new WritableByteChannel() diff --cc test/unit/org/apache/cassandra/io/util/DataIntegrityMetadataTest.java index 0000000000,0000000000..9a5e2f3e23 new file mode 100644 --- /dev/null +++ b/test/unit/org/apache/cassandra/io/util/DataIntegrityMetadataTest.java @@@ -1,0 -1,0 +1,72 @@@ ++/* ++ * Licensed to the Apache Software Foundation (ASF) under one ++ * or more contributor license agreements. See the NOTICE file ++ * distributed with this work for additional information ++ * regarding copyright ownership. The ASF licenses this file ++ * to you 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 org.apache.cassandra.io.util; ++ ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++ ++import org.junit.BeforeClass; ++import org.junit.Rule; ++import org.junit.Test; ++import org.junit.rules.TemporaryFolder; ++ ++import org.apache.cassandra.config.DatabaseDescriptor; ++ ++import static org.assertj.core.api.Assertions.assertThatThrownBy; ++import static org.junit.Assert.assertEquals; ++ ++public class DataIntegrityMetadataTest ++{ ++ @BeforeClass ++ public static void setupDD() ++ { ++ DatabaseDescriptor.daemonInitialization(); ++ } ++ ++ @Rule ++ public TemporaryFolder tempDir = new TemporaryFolder(); ++ ++ @Test ++ public void testNonNumericDigestValueFailsWithClearError() throws IOException ++ { ++ assertInvalidDigestValueFailsWithClearError("not-a-number"); ++ } ++ ++ @Test ++ public void testEmptyDigestFileFailsWithClearError() throws IOException ++ { ++ assertInvalidDigestValueFailsWithClearError(""); ++ } ++ ++ private void assertInvalidDigestValueFailsWithClearError(String digestContent) throws IOException ++ { ++ File dataFile = new File(tempDir.newFile("data.db")); ++ Files.write(dataFile.toPath(), "some bytes".getBytes(StandardCharsets.UTF_8)); ++ ++ File digestFile = new File(tempDir.newFile("data.crc32")); ++ Files.write(digestFile.toPath(), digestContent.getBytes(StandardCharsets.UTF_8)); ++ assertEquals("digest file fixture doesn't contain what this test expects", ++ digestContent.getBytes(StandardCharsets.UTF_8).length, digestFile.length()); ++ ++ assertThatThrownBy(() -> new DataIntegrityMetadata.FileDigestValidator(dataFile, digestFile).validate()) ++ .isInstanceOf(IOException.class) ++ .hasMessageContaining("Corrupted file: invalid digest value in " + digestFile) ++ .hasCauseInstanceOf(NumberFormatException.class); ++ } ++} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
