SaveCodec.java

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
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/io/DataOutputStream::write → KILLED

53

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/io/DataOutputStream::writeShort → KILLED

54

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/io/DataOutputStream::writeByte → KILLED

55

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeRejectsANegativePayloadLength()]
removed call to java/io/DataOutputStream::writeLong → KILLED

56

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeRejectsANegativePayloadLength()]
removed call to java/io/DataOutputStream::writeLong → KILLED

57

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/io/DataOutputStream::writeInt → KILLED

58

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:encodedBytesDecodeBackToTheSameTypeMetadataAndPayload()]
removed call to java/io/DataOutputStream::write → KILLED

62

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/util/zip/CRC32::update → KILLED

63

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/io/DataOutputStream::writeInt → KILLED

69

1.1
Location : encode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeRejectsAnUnrecognisedVersion()]
replaced return value with null for com/rox/save/SaveCodec::encode → KILLED

76

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/io/DataInputStream::readFully → KILLED

77

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
negated conditional → KILLED

80

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
negated conditional → KILLED

84

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeRejectsAnOutOfRangeSaveTypeOrdinal()]
changed conditional boundary → KILLED

2.2
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeRejectsAnOutOfRangeSaveTypeOrdinal()]
negated conditional → KILLED

94

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
Replaced integer subtraction with addition → KILLED

2.2
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
changed conditional boundary → KILLED

3.3
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
negated conditional → KILLED

4.4
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
negated conditional → KILLED

98

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:encodedBytesDecodeBackToTheSameTypeMetadataAndPayload()]
removed call to java/io/DataInputStream::readFully → KILLED

101

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeRejectsACorruptedPayloadByte()]
Replaced integer subtraction with addition → KILLED

103

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
removed call to java/util/zip/CRC32::update → KILLED

104

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeRejectsACorruptedPayloadByte()]
negated conditional → KILLED

109

1.1
Location : decode
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:decodeAcceptsAZeroLengthPayload()]
replaced return value with Optional.empty for com/rox/save/SaveCodec::decode → KILLED

133

1.1
Location : writeAtomically
Killed by : com.rox.save.SaveCodecTest.[engine:junit-jupiter]/[class:com.rox.save.SaveCodecTest]/[method:writeAtomicallyProducesAReadableFileAndLeavesNoTmpFileBehind(java.nio.file.Path)]
negated conditional → KILLED

139

1.1
Location : writeAtomically
Killed by : none
removed call to java/nio/channels/FileChannel::force → SURVIVED
Covering tests

Active mutators

Tests examined


Report generated by PIT 1.22.1