-
-
Notifications
You must be signed in to change notification settings - Fork 6
Protect against crashes during header write #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
05feae3
4d3be01
31e6caa
3e51da8
0b63b1e
4d6b753
c1ce688
f56d321
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,7 +79,7 @@ struct FileHeader { | |
| static let version: UInt8 = 1 | ||
|
|
||
| /// Calculates the offset in the file where the header should end. | ||
| static var expectedEndOfHeaderInFile = Field(rawValue: Field.allCases.endIndex)!.expectedEndOfFieldInFile | ||
| static let expectedEndOfHeaderInFile = Field(rawValue: Field.allCases.endIndex)!.expectedEndOfFieldInFile | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change is unrelated to the overall PR but I saw it and couldn't unsee it. |
||
|
|
||
| func data(for field: Field) -> Data { | ||
| switch field { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,31 +96,114 @@ final class CacheAdvanceTests: XCTestCase { | |
| XCTAssertEqual(messages, []) | ||
| } | ||
|
|
||
| func test_messages_throwsFileCorruptedWhenOffsetInFileAtEndOfNewsetMessageOutOfSync() throws { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test was passing before I deleted it in 4d3be01, but it executed the same code path as
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I'm understanding is this deleted test is basically a special case of
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct! |
||
| let randomHighValue: UInt64 = 10_1000 | ||
| func test_messages_whenOffsetInFileAtEndOfNewestMessageIsBeyondEndOfNewestMessageButBeforeEndOfFile_throwsFileCorrupted() throws { | ||
| let message: TestableMessage = "This is a test" | ||
| let requiredByteCount = try self.requiredByteCount(for: [message]) | ||
| let maximumBytes = requiredByteCount + 2 | ||
|
bachand marked this conversation as resolved.
|
||
| let header = try CacheHeaderHandle( | ||
| forReadingFrom: testFileLocation, | ||
| maximumBytes: randomHighValue, | ||
| maximumBytes: maximumBytes, | ||
| overwritesOldMessages: true) | ||
| let cache = CacheAdvance<TestableMessage>( | ||
| fileURL: testFileLocation, | ||
| writer: try FileHandle(forWritingTo: testFileLocation), | ||
| reader: try CacheReader(forReadingFrom: testFileLocation), | ||
| header: try CacheHeaderHandle( | ||
| forReadingFrom: testFileLocation, | ||
| maximumBytes: header.maximumBytes, | ||
| overwritesOldMessages: header.overwritesOldMessages), | ||
| decoder: JSONDecoder(), | ||
| encoder: JSONEncoder()) | ||
|
|
||
| // Make sure the header data is persisted before we read it as part of the `messages()` call below. | ||
| try header.synchronizeHeaderData() | ||
| // Our file is empty. Make the file corrupted by setting the offset at end of newest message to be further in the file. | ||
| // This should never happen, but past versions of this repo could lead to a file having this kind of inconsistency if a crash occurred at the wrong time. | ||
|
|
||
| func makeCache() throws -> CacheAdvance<TestableMessage> { | ||
| return CacheAdvance<TestableMessage>( | ||
| fileURL: testFileLocation, | ||
| writer: try FileHandle(forWritingTo: testFileLocation), | ||
| reader: try CacheReader( | ||
| forReadingFrom: testFileLocation, | ||
| maximumBytes: maximumBytes), | ||
| header: header, | ||
| decoder: JSONDecoder(), | ||
| encoder: JSONEncoder()) | ||
| } | ||
| let writingCache = try makeCache() | ||
| try writingCache.append(message: message) | ||
|
|
||
| // Make the file corrupted by setting the offset at end of newest message to be further in the file. | ||
| // This could happen if a crash occurred during a write of `header.offsetInFileAtEndOfNewestMessage` on a big-endian device. | ||
|
bachand marked this conversation as resolved.
|
||
| // Big-endian devices write the most significant digits first, meaning that if we were offsetInFileAtEndOfNewestMessage from 00001010 to 00010000, it would be possible to crash with the following bytes written to disk: 00011010. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Love it. |
||
| // The 00011010 value is a larger value what we intended to write, which would lead to file corruption. | ||
| try header.updateOffsetInFileAtEndOfNewestMessage( | ||
| to: requiredByteCount + 1) | ||
|
|
||
| // Create a new cache instance that uses the corrupted data persisted to disk | ||
| let corruptedReadingCache = try makeCache() | ||
|
|
||
| XCTAssertThrowsError(try corruptedReadingCache.messages()) { | ||
|
bachand marked this conversation as resolved.
|
||
| XCTAssertEqual($0 as? CacheAdvanceError, CacheAdvanceError.fileCorrupted) | ||
| } | ||
| } | ||
|
|
||
| func test_messages_whenOffsetInFileAtEndOfNewestMessageIsBeyondEndOfFile_throwsFileCorrupted() throws { | ||
| let message: TestableMessage = "This is a test" | ||
| let maximumBytes = try requiredByteCount(for: [message]) | ||
| let header = try CacheHeaderHandle( | ||
| forReadingFrom: testFileLocation, | ||
| maximumBytes: maximumBytes, | ||
| overwritesOldMessages: true) | ||
|
|
||
| func makeCache() throws -> CacheAdvance<TestableMessage> { | ||
| return CacheAdvance<TestableMessage>( | ||
| fileURL: testFileLocation, | ||
| writer: try FileHandle(forWritingTo: testFileLocation), | ||
| reader: try CacheReader( | ||
| forReadingFrom: testFileLocation, | ||
| maximumBytes: maximumBytes), | ||
| header: header, | ||
| decoder: JSONDecoder(), | ||
| encoder: JSONEncoder()) | ||
| } | ||
| let writingCache = try makeCache() | ||
| try writingCache.append(message: message) | ||
|
|
||
| // Make the file corrupted by setting the offset at end of newest message to be further in the file. | ||
| // This could happen if a crash occurred during a write of `header.offsetInFileAtEndOfNewestMessage` on a big-endian device. | ||
| // Big-endian devices write the most significant digits first, meaning that if we were offsetInFileAtEndOfNewestMessage from 00001010 to 00010000, it would be possible to crash with the following bytes written to disk: 00011010. | ||
| // The 00011010 value is a larger value what we intended to write, which would lead to file corruption. | ||
| try header.updateOffsetInFileAtEndOfNewestMessage( | ||
| to: header.offsetInFileAtEndOfNewestMessage + 1) | ||
|
|
||
| // Create a new cache instance that uses the corrupted data persisted to disk | ||
| let corruptedReadingCache = try makeCache() | ||
|
|
||
| XCTAssertThrowsError(try corruptedReadingCache.messages()) { | ||
| XCTAssertEqual($0 as? CacheAdvanceError, CacheAdvanceError.fileCorrupted) | ||
| } | ||
| } | ||
|
|
||
| func test_messages_whenOffsetInFileAtEndOfNewestMessageIsBeforeEndOfNewestMessage_throwsFileCorrupted() throws { | ||
| let message: TestableMessage = "This is a test" | ||
| let maximumBytes = try requiredByteCount(for: [message]) | ||
| let header = try CacheHeaderHandle( | ||
| forReadingFrom: testFileLocation, | ||
| maximumBytes: maximumBytes, | ||
| overwritesOldMessages: true) | ||
|
|
||
| func makeCache() throws -> CacheAdvance<TestableMessage> { | ||
| return CacheAdvance<TestableMessage>( | ||
| fileURL: testFileLocation, | ||
| writer: try FileHandle(forWritingTo: testFileLocation), | ||
| reader: try CacheReader( | ||
| forReadingFrom: testFileLocation, | ||
| maximumBytes: maximumBytes), | ||
| header: header, | ||
| decoder: JSONDecoder(), | ||
| encoder: JSONEncoder()) | ||
| } | ||
| let writingCache = try makeCache() | ||
| try writingCache.append(message: message) | ||
|
|
||
| // Make the file corrupted by setting the offset at end of newest message to be earlier in the file. | ||
| // This could happen if a crash occurred during a write of `header.offsetInFileAtEndOfNewestMessage` on a little-endian device. | ||
| // Little-endian devices write the lest significant digits first, meaning that if we were offsetInFileAtEndOfNewestMessage from 01010000 to 00001000, it would be possible to crash with the following bytes written to disk: 00010000. | ||
| // The 00010000 value is a smaller value what we intended to write, which would lead to file corruption. | ||
| try header.updateOffsetInFileAtEndOfNewestMessage( | ||
| to: FileHeader.expectedEndOfHeaderInFile + 1) | ||
| to: header.offsetInFileAtEndOfNewestMessage - 1) | ||
|
|
||
| // Create a new cache instance that uses the corrupted data persisted to disk | ||
| let corruptedReadingCache = try makeCache() | ||
|
|
||
| XCTAssertThrowsError(try cache.messages()) { | ||
| XCTAssertThrowsError(try corruptedReadingCache.messages()) { | ||
|
bachand marked this conversation as resolved.
|
||
| XCTAssertEqual($0 as? CacheAdvanceError, CacheAdvanceError.fileCorrupted) | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The case we previously caught with
previousReadWasEmptyis now caught by our new checks below!