| 1 | package com.rox.save; | |
| 2 | ||
| 3 | import java.io.ByteArrayInputStream; | |
| 4 | import java.io.ByteArrayOutputStream; | |
| 5 | import java.io.DataInputStream; | |
| 6 | import java.io.DataOutputStream; | |
| 7 | import java.io.EOFException; | |
| 8 | import java.io.IOException; | |
| 9 | import java.io.UncheckedIOException; | |
| 10 | import java.nio.ByteBuffer; | |
| 11 | import java.nio.channels.FileChannel; | |
| 12 | import java.nio.file.Files; | |
| 13 | import java.nio.file.Path; | |
| 14 | import java.nio.file.StandardCopyOption; | |
| 15 | import java.nio.file.StandardOpenOption; | |
| 16 | import java.time.Instant; | |
| 17 | import java.util.Arrays; | |
| 18 | import java.util.Optional; | |
| 19 | import java.util.zip.CRC32; | |
| 20 | ||
| 21 | /** | |
| 22 | * Low-level byte format shared by every save type - the magic/version/metadata header, a trailing | |
| 23 | * whole-file CRC32, and atomic disk writes. {@link SaveFileFormat} is the public, save-type-aware API | |
| 24 | * built on top of this. | |
| 25 | * | |
| 26 | * <pre> | |
| 27 | * offset size field | |
| 28 | * 0 4 magic: 'R','O','X','S' | |
| 29 | * 4 2 format version (short) | |
| 30 | * 6 1 save type ordinal (byte) | |
| 31 | * 7 8 createdAt, epoch millis (long) - fixed for the file's life | |
| 32 | * 15 8 accumulatedGameTimeMillis (long) - rewritten every save | |
| 33 | * 23 4 payload length (int) | |
| 34 | * 27 N payload | |
| 35 | * 27+N 4 CRC32 of bytes [0, 27+N) | |
| 36 | * </pre> | |
| 37 | */ | |
| 38 | final class SaveCodec { | |
| 39 | private static final byte[] MAGIC = {'R', 'O', 'X', 'S'}; | |
| 40 | private static final short VERSION = 1; | |
| 41 | ||
| 42 | private SaveCodec(){ | |
| 43 | } | |
| 44 | ||
| 45 | record Decoded(SaveType type, SaveMetadata metadata, byte[] payload){ | |
| 46 | } | |
| 47 | ||
| 48 | /** Builds a complete save file's bytes: header + payload + trailing CRC32 of everything before it. */ | |
| 49 | static byte[] encode(final SaveType type, final SaveMetadata metadata, final byte[] payload){ | |
| 50 | final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); | |
| 51 | try (DataOutputStream out = new DataOutputStream(buffer)){ | |
| 52 |
1
1. encode : removed call to java/io/DataOutputStream::write → KILLED |
out.write(MAGIC); |
| 53 |
1
1. encode : removed call to java/io/DataOutputStream::writeShort → KILLED |
out.writeShort(VERSION); |
| 54 |
1
1. encode : removed call to java/io/DataOutputStream::writeByte → KILLED |
out.writeByte(type.ordinal()); |
| 55 |
1
1. encode : removed call to java/io/DataOutputStream::writeLong → KILLED |
out.writeLong(metadata.createdAt().toEpochMilli()); |
| 56 |
1
1. encode : removed call to java/io/DataOutputStream::writeLong → KILLED |
out.writeLong(metadata.accumulatedGameTimeMillis()); |
| 57 |
1
1. encode : removed call to java/io/DataOutputStream::writeInt → KILLED |
out.writeInt(payload.length); |
| 58 |
1
1. encode : removed call to java/io/DataOutputStream::write → KILLED |
out.write(payload); |
| 59 | //no explicit flush() needed - DataOutputStream forwards every write() straight through to | |
| 60 | //the underlying ByteArrayOutputStream with no internal buffering to flush | |
| 61 | final CRC32 crc = new CRC32(); | |
| 62 |
1
1. encode : removed call to java/util/zip/CRC32::update → KILLED |
crc.update(buffer.toByteArray()); |
| 63 |
1
1. encode : removed call to java/io/DataOutputStream::writeInt → KILLED |
out.writeInt((int) crc.getValue()); |
| 64 | } catch (IOException e){ | |
| 65 | //a DataOutputStream wrapping a ByteArrayOutputStream never actually does real I/O - purely | |
| 66 | //in-memory arithmetic, so this can't genuinely fail | |
| 67 | throw new UncheckedIOException(e); | |
| 68 | } | |
| 69 |
1
1. encode : replaced return value with null for com/rox/save/SaveCodec::encode → KILLED |
return buffer.toByteArray(); |
| 70 | } | |
| 71 | ||
| 72 | /** Empty on truncated/malformed input, an unrecognised magic/version, or a CRC mismatch - all treated as "not a usable save file". */ | |
| 73 | static Optional<Decoded> decode(final byte[] bytes){ | |
| 74 | try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(bytes))){ | |
| 75 | final byte[] magic = new byte[MAGIC.length]; | |
| 76 |
1
1. decode : removed call to java/io/DataInputStream::readFully → KILLED |
in.readFully(magic); |
| 77 |
1
1. decode : negated conditional → KILLED |
if (!Arrays.equals(magic, MAGIC)){ |
| 78 | return Optional.empty(); | |
| 79 | } | |
| 80 |
1
1. decode : negated conditional → KILLED |
if (in.readShort() != VERSION){ |
| 81 | return Optional.empty(); | |
| 82 | } | |
| 83 | final int typeOrdinal = in.readUnsignedByte(); | |
| 84 |
2
1. decode : changed conditional boundary → KILLED 2. decode : negated conditional → KILLED |
if (typeOrdinal >= SaveType.values().length){ |
| 85 | return Optional.empty(); | |
| 86 | } | |
| 87 | final SaveType type = SaveType.values()[typeOrdinal]; | |
| 88 | final long createdAtEpochMillis = in.readLong(); | |
| 89 | final long accumulatedGameTimeMillis = in.readLong(); | |
| 90 | final int payloadLength = in.readInt(); | |
| 91 | //reject before allocating - a corrupt/malicious file declaring a huge payloadLength must | |
| 92 | //not reach `new byte[payloadLength]` (risking OutOfMemoryError) before the length is even | |
| 93 | //checked against what's actually left in the file (the payload itself, plus the trailing CRC) | |
| 94 |
4
1. decode : Replaced integer subtraction with addition → KILLED 2. decode : changed conditional boundary → KILLED 3. decode : negated conditional → KILLED 4. decode : negated conditional → KILLED |
if (payloadLength < 0 || payloadLength != in.available() - Integer.BYTES){ |
| 95 | return Optional.empty(); | |
| 96 | } | |
| 97 | final byte[] payload = new byte[payloadLength]; | |
| 98 |
1
1. decode : removed call to java/io/DataInputStream::readFully → KILLED |
in.readFully(payload); |
| 99 | final int storedCrc = in.readInt(); | |
| 100 | ||
| 101 |
1
1. decode : Replaced integer subtraction with addition → KILLED |
final int crcCoveredLength = bytes.length - Integer.BYTES; |
| 102 | final CRC32 crc = new CRC32(); | |
| 103 |
1
1. decode : removed call to java/util/zip/CRC32::update → KILLED |
crc.update(bytes, 0, crcCoveredLength); |
| 104 |
1
1. decode : negated conditional → KILLED |
if ((int) crc.getValue() != storedCrc){ |
| 105 | return Optional.empty(); | |
| 106 | } | |
| 107 | ||
| 108 | final SaveMetadata metadata = new SaveMetadata(type, Instant.ofEpochMilli(createdAtEpochMillis), accumulatedGameTimeMillis); | |
| 109 |
1
1. decode : replaced return value with Optional.empty for com/rox/save/SaveCodec::decode → KILLED |
return Optional.of(new Decoded(type, metadata, payload)); |
| 110 | } catch (EOFException e){ | |
| 111 | return Optional.empty(); //truncated file | |
| 112 | } catch (IOException e){ | |
| 113 | //a DataInputStream wrapping a ByteArrayInputStream only ever short-reads (EOFException, | |
| 114 | //caught above) - it has no real I/O to genuinely fail with any other IOException | |
| 115 | throw new UncheckedIOException(e); | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Writes {@code content} to {@code target} without ever leaving a torn/partial file behind, even on | |
| 121 | * a crash mid-write. The temp file's content is forced to the storage device ({@link FileChannel#force}) before | |
| 122 | * the atomic rename, so a crash or power loss after this method returns can't lose or corrupt what | |
| 123 | * was just written. This does not separately sync the containing directory's own metadata (no | |
| 124 | * portable Java API for that) - an exceedingly narrow window where a literal power loss in the | |
| 125 | * instant right after the rename could still show the previous file on some filesystems, distinct | |
| 126 | * from (and far rarer than) the crash/kill/disk-full scenarios this guards against. | |
| 127 | */ | |
| 128 | static void writeAtomically(final Path target, final byte[] content) throws IOException { | |
| 129 | Files.createDirectories(target.getParent()); | |
| 130 | final Path tmp = target.resolveSibling(target.getFileName() + ".tmp"); | |
| 131 | try (FileChannel channel = FileChannel.open(tmp, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)){ | |
| 132 | final ByteBuffer buffer = ByteBuffer.wrap(content); | |
| 133 |
1
1. writeAtomically : negated conditional → KILLED |
while (buffer.hasRemaining()){ |
| 134 | channel.write(buffer); | |
| 135 | } | |
| 136 | //XXX force()'s effect (data genuinely reaching the storage device) isn't observable from a | |
| 137 | //test without simulating a real power loss - accepted gap, same category as | |
| 138 | //SpeakerAudioOutput.openDefaultLine()'s own real-hardware sliver | |
| 139 |
1
1. writeAtomically : removed call to java/nio/channels/FileChannel::force → SURVIVED |
channel.force(true); |
| 140 | } | |
| 141 | Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); | |
| 142 | } | |
| 143 | } | |
Mutations | ||
| 52 |
1.1 |
|
| 53 |
1.1 |
|
| 54 |
1.1 |
|
| 55 |
1.1 |
|
| 56 |
1.1 |
|
| 57 |
1.1 |
|
| 58 |
1.1 |
|
| 62 |
1.1 |
|
| 63 |
1.1 |
|
| 69 |
1.1 |
|
| 76 |
1.1 |
|
| 77 |
1.1 |
|
| 80 |
1.1 |
|
| 84 |
1.1 2.2 |
|
| 94 |
1.1 2.2 3.3 4.4 |
|
| 98 |
1.1 |
|
| 101 |
1.1 |
|
| 103 |
1.1 |
|
| 104 |
1.1 |
|
| 109 |
1.1 |
|
| 133 |
1.1 |
|
| 139 |
1.1 |