Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions ios/HouseScan/Runtime/ResultClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ protocol ResultClient: AnyObject {
/// the body and `Content-Type: application/json`. Only the JSON goes: the server's solver reads no
/// photos and skips the image check for a bare scene.json, so the keyframes stay on the phone
/// (and in the scan folder's `scan.zip`, which leaves only if the homeowner shares it). The
/// response body is the result JSON.
/// response body is the result JSON, and it is returned only when it names the scene sent: its
/// `stats.input_sha256` must be present and be the hash of these exact bytes (`ResultBinding`).
/// Any other answer throws, and the upload screen treats it as an answer it couldn't read.
@MainActor
final class HTTPResultClient: ResultClient {
let serverURL: URL
Expand Down Expand Up @@ -42,6 +44,7 @@ final class HTTPResultClient: ResultClient {
status: http.statusCode, body: String(decoding: data.prefix(300), as: UTF8.self),
retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
}
try ResultBinding.check(answer: data, submittedScene: scene)
progress(1)
return data
}
Expand Down Expand Up @@ -137,7 +140,9 @@ final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable {
}

/// Answers with the bundled SampleResult.json, for tests and demos without a server. The result
/// is flagged `isSample` so the screen says it is not a real analysis.
/// is flagged `isSample` so the screen says it is not a real analysis. It answers no particular
/// scene (its `input_sha256` is all zeros), so it is deliberately not checked with
/// `ResultBinding`.
@MainActor
final class SampleResultClient: ResultClient {
let isSample = true
Expand Down
58 changes: 58 additions & 0 deletions ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift
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 ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift
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)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

@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)
}
}
}
Loading