Repository navigation
ios: bind placement answers to the submitted scene #198
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
Merged
SamGu-NRX
merged 2 commits into
codex/public-assembly-20260930
from
codex/result-input-binding
Oct 2, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
58 changes: 58 additions & 0 deletions
58
ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import Foundation | ||
|
|
||
| /// Ties a placement answer to the scene this phone sent. The server writes the SHA-256 of the | ||
| /// scene bytes it parsed into `stats.input_sha256`: for a bare scene.json that is the request body | ||
| /// itself (server/api.py `_parse` passes the body to `parse_scene`, which hashes it). The result | ||
| /// schema requires the field as 64 lowercase hex digits (server/schemas/result.schema.json). | ||
| /// | ||
| /// An answer is accepted only when it carries that hash and the hash is of the bytes sent. An | ||
| /// answer for another scene, or one whose hash is missing or unreadable, is never shown. This is | ||
| /// a defence against a mismatched response; nothing shows the current server sending one. | ||
| public enum ResultBinding { | ||
| public enum Refusal: Error, Equatable, CustomStringConvertible { | ||
| /// The answer has no `stats.input_sha256` string, or isn't JSON this check can read. | ||
| case noInputHash | ||
| /// `stats.input_sha256` isn't 64 lowercase hex digits. | ||
| case malformedInputHash(String) | ||
| /// The answer names another scene. Both hashes are lowercase hex. | ||
| case mismatch(submitted: String, declared: String) | ||
|
|
||
| /// Goes to the device log, so it names what went wrong but never a hash: a scene's hash | ||
| /// fingerprints a homeowner's scan. The values stay in the cases for callers and tests. | ||
| public var description: String { | ||
| switch self { | ||
| case .noInputHash: "answer has no readable stats.input_sha256" | ||
| case .malformedInputHash(let value): "answer's stats.input_sha256 is malformed (\(value.utf8.count) bytes)" | ||
| case .mismatch: "answer's stats.input_sha256 is not the hash of the scene sent" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Throws a `Refusal` unless `answer` declares `stats.input_sha256` as the SHA-256 of `scene`. | ||
| /// Anything this can't read refuses the answer: a check that skipped unreadable metadata would | ||
| /// let a mismatched answer through whenever the general decoder is more lenient than this one. | ||
| public static func check(answer: Data, submittedScene scene: Data) throws { | ||
| let declared: String | ||
| do { | ||
| declared = try JSONDecoder().decode(Answer.self, from: answer).stats.inputSHA256 | ||
| } catch { | ||
| throw Refusal.noInputHash | ||
| } | ||
| guard isSHA256Hex(declared) else { throw Refusal.malformedInputHash(declared) } | ||
| let submitted = PacketFiles.sha256(scene) | ||
| guard declared == submitted else { throw Refusal.mismatch(submitted: submitted, declared: declared) } | ||
| } | ||
|
|
||
| /// The schema's form for the field, `^[0-9a-f]{64}$`. | ||
| static func isSHA256Hex(_ text: String) -> Bool { | ||
| text.utf8.count == 64 && text.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x61...0x66).contains($0) } | ||
| } | ||
|
|
||
| private struct Answer: Decodable { | ||
| struct Stats: Decodable { | ||
| var inputSHA256: String | ||
| enum CodingKeys: String, CodingKey { case inputSHA256 = "input_sha256" } | ||
| } | ||
| var stats: Stats | ||
| } | ||
| } |
102 changes: 102 additions & 0 deletions
102
ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import Foundation | ||
| import HouseScanKit | ||
| import Testing | ||
|
|
||
| /// A placement answer from the server is shown only for the scene that was sent: its | ||
| /// `stats.input_sha256` must be present, in the schema's form, and the SHA-256 of those exact | ||
| /// bytes. Built from a real server answer to the synthetic replay. | ||
| @Suite struct ResultBindingTests { | ||
| static let scene = Data(#"{"note":"the bytes this phone sent"}"#.utf8) | ||
| static let fixtureHash = "2cb165cd4d4e9340496d90a589678ccbbe8ae6720d9d73bcae427a6b22075cb7" | ||
| static let hashLine = "\"input_sha256\": \"\(fixtureHash)\"," | ||
|
|
||
| static func answer() throws -> String { | ||
| let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent() | ||
| .appendingPathComponent("Schemas/server-answer-synthetic-wall.json") | ||
| let text = String(decoding: try Data(contentsOf: url), as: UTF8.self) | ||
| #expect(text.contains(hashLine)) | ||
| return text | ||
| } | ||
|
|
||
| /// The real answer with its `input_sha256` line replaced by `line` (empty removes it). | ||
| static func answer(replacingHashLineWith line: String) throws -> Data { | ||
| Data(try answer().replacingOccurrences(of: hashLine, with: line).utf8) | ||
| } | ||
|
|
||
| static func answer(declaring hash: String) throws -> Data { | ||
| try answer(replacingHashLineWith: "\"input_sha256\": \"\(hash)\",") | ||
| } | ||
|
|
||
| static func refusal(_ data: Data) throws -> ResultBinding.Refusal { | ||
| let refusal = try #require(throws: ResultBinding.Refusal.self) { | ||
| try ResultBinding.check(answer: data, submittedScene: Self.scene) | ||
| } | ||
| // Every refusal is the "couldn't read the answer" failure: back to the review, no retry. | ||
| #expect(UploadFailureKind.classify(refusal) == .unreadableAnswer) | ||
| #expect(!UploadFailureKind.classify(refusal).retryable) | ||
| return refusal | ||
| } | ||
|
|
||
| @Test func anAnswerForTheSceneSentPassesAndDecodes() throws { | ||
| let data = try Self.answer(declaring: PacketFiles.sha256(Self.scene)) | ||
| try ResultBinding.check(answer: data, submittedScene: Self.scene) | ||
| #expect(try PlacementResult.decode(data).decision == .manualReview) | ||
| } | ||
|
|
||
| @Test func anAnswerForAnotherSceneIsRefused() throws { | ||
| let other = PacketFiles.sha256(Data("another scene".utf8)) | ||
| #expect(try Self.refusal(Self.answer(declaring: other)) == .mismatch(submitted: PacketFiles.sha256(Self.scene), declared: other)) | ||
| } | ||
|
|
||
| /// The upload logs a refusal's description publicly, so it carries no scene hash. | ||
| @Test func aRefusalsDescriptionNamesNoHash() throws { | ||
| let sent = PacketFiles.sha256(Self.scene) | ||
| let other = PacketFiles.sha256(Data("another scene".utf8)) | ||
| let refusals: [ResultBinding.Refusal] = [ | ||
| try Self.refusal(Self.answer(declaring: other)), | ||
| try Self.refusal(Self.answer(declaring: String(other.dropLast()))), | ||
| try Self.refusal(Self.answer(replacingHashLineWith: "")), | ||
| ] | ||
| for refusal in refusals { | ||
| let text = String(describing: refusal) | ||
| #expect(!text.contains(sent) && !text.contains(other) && !text.contains(String(other.dropLast()))) | ||
| } | ||
| } | ||
|
|
||
| /// No readable hash refuses the answer, whatever the general decoder would make of it. | ||
| @Test(arguments: [ | ||
| ("absent", ""), | ||
| ("null", "\"input_sha256\": null,"), | ||
| ("a number", "\"input_sha256\": 7,"), | ||
| ]) | ||
| func anAnswerWithoutAHashIsRefused(_ kind: String, line: String) throws { | ||
| #expect(try Self.refusal(Self.answer(replacingHashLineWith: line)) == .noInputHash) | ||
| } | ||
|
|
||
| /// The schema's form is 64 lowercase hex digits; the right digits in capitals, or too few, | ||
| /// are refused rather than compared. | ||
| @Test(arguments: ["UPPER", "short", "notHex"]) | ||
| func aMalformedHashIsRefused(_ kind: String) throws { | ||
| let good = PacketFiles.sha256(Self.scene) | ||
| let value = switch kind { | ||
| case "UPPER": good.uppercased() | ||
| case "short": String(good.dropLast()) | ||
| default: String(good.dropLast()) + "z" | ||
| } | ||
| #expect(try Self.refusal(Self.answer(declaring: value)) == .malformedInputHash(value)) | ||
| } | ||
|
|
||
| @Test func anAnswerThatIsNotJSONIsRefused() throws { | ||
| #expect(try Self.refusal(Data("not json".utf8)) == .noInputHash) | ||
| } | ||
|
|
||
| /// A field this check doesn't read can't make it skip the comparison: an answer for another | ||
| /// scene that also carries a number too large for a Double is still refused. | ||
| @Test func anUnusualIgnoredFieldDoesNotSkipTheCheck() throws { | ||
| let other = PacketFiles.sha256(Data("another scene".utf8)) | ||
| let text = try Self.answer().replacingOccurrences(of: Self.hashLine, with: "\"input_sha256\": \"\(other)\", \"extra\": 1e400,") | ||
| #expect(throws: ResultBinding.Refusal.self) { | ||
| try ResultBinding.check(answer: Data(text.utf8), submittedScene: Self.scene) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.