Add game data capture vertical slice
This commit is contained in:
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copy this file to $XDG_CONFIG_HOME/shd-capture/config.toml and adjust local limits.
|
||||
# The utility reads the OpenAI key from the named environment variable only.
|
||||
|
||||
[server]
|
||||
# Only loopback IP literals are valid: 127.0.0.1 or ::1.
|
||||
host = "127.0.0.1"
|
||||
port = 8765
|
||||
|
||||
[lifecycle]
|
||||
# One local process owns this instance name and releases its runtime lock on shutdown.
|
||||
instance_name = "shd-capture"
|
||||
lock_timeout_seconds = 5
|
||||
shutdown_timeout_seconds = 30
|
||||
|
||||
[limits]
|
||||
max_image_bytes = 33554432
|
||||
max_video_bytes = 4294967296
|
||||
max_queue_items = 64
|
||||
max_ocr_text_characters = 20000
|
||||
|
||||
[openai]
|
||||
# Set this name in the process environment. Do not put the key in this file.
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
model = "gpt-4.1-mini"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Game Data Capture Contracts
|
||||
|
||||
RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence model, archive manifest, and ISAC handoff boundary.
|
||||
|
||||
## Files
|
||||
|
||||
- `domain-contract-v1.json` lists every family/view pair, record type, Gear slot, abstention reason, state, and allowed transition.
|
||||
- `screenshot-classification.schema.json` validates one hierarchical screen classification.
|
||||
- `evidence-archive-manifest-v1.schema.json` validates a private evidence archive manifest.
|
||||
- `isac-handoff-v1.schema.json` validates a bounded draft handoff for authenticated ISAC intake.
|
||||
- `examples/classification/` contains one valid and one invalid classification for each family, including explicit `unknown` abstention.
|
||||
- `examples/archive/` and `examples/handoff/` exercise each versioned document schema.
|
||||
|
||||
The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard formats, and no implementation-specific keywords. Python `jsonschema` and TypeScript validators such as Ajv can compile the files without model conversion. Consumers must reject a document unless `schema_version` equals the schema's `const` value. Consumers must also run semantic reference validation because JSON Schema cannot prove that an ID points to an item in another array.
|
||||
|
||||
## Evidence Rules
|
||||
|
||||
Capture workers store source text, a normalized value, confidence, engine identity, an image digest, a bounded region, and append-only correction history for each evidence value. A null normalized value requires a `missing_reason`. A present normalized value requires a null `missing_reason`. Each correction's `before` state must match the preceding `after` state. Timestamps must increase, and the final `after` state must match the current evidence value.
|
||||
|
||||
Workers record the configured item shown on screen under `observed_instances`, including its visible level, quality, tier, upgrade state, and values. Workers place proposed reusable definitions under `canonical_candidates` and handoff `records`. A worker must not copy an observed roll, relationship, mechanic, range endpoint, or option list into a canonical candidate unless evidence establishes that canonical claim.
|
||||
|
||||
## Trust Boundary
|
||||
|
||||
Archive and handoff documents contain content hashes and logical artifact IDs. They exclude host paths and credentials. The handoff declares `mutation_authority: "none"`. Importing or inspecting it grants no permission and performs no mutation. An authenticated ISAC action must revalidate each draft and preserve contributor moderation and audit behavior.
|
||||
|
||||
Archive images and handoff images allow at most 64 MiB. Private archive videos allow at most 16 GiB, matching the configured capture ceiling. Schema validation enforces media-specific byte limits. Semantic validation enforces unique entity and correction IDs, local references, evidence artifact hashes, correction chains, and normalized image bounds.
|
||||
|
||||
The contracts assign explicit states or reasons to cancellation, resource exhaustion, ambiguous classification, unsupported content, and extraction failure. Recovery requires a listed transition. Unknown transitions and future schema versions fail closed.
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"contract_version": 1,
|
||||
"families": {
|
||||
"weapon": {
|
||||
"gear_slot": false,
|
||||
"views": {
|
||||
"detail": ["weapon", "weapon_talent"],
|
||||
"blueprint": ["weapon", "weapon_talent"],
|
||||
"crafting": ["weapon"],
|
||||
"stat_range": ["weapon"],
|
||||
"talent_grid": ["weapon_talent"],
|
||||
"augmentation": ["weapon_augmentation_profile"]
|
||||
}
|
||||
},
|
||||
"gear": {
|
||||
"gear_slot": true,
|
||||
"views": {
|
||||
"detail": ["gear", "brand_set"],
|
||||
"blueprint": ["gear", "brand_set"],
|
||||
"talent": ["gear_talent"],
|
||||
"augmentation": ["gear_augmentation_profile"]
|
||||
}
|
||||
},
|
||||
"skill_chip": {
|
||||
"gear_slot": false,
|
||||
"views": {
|
||||
"detail": ["skill_chip_set", "skill_chip_roll_pool"],
|
||||
"set_bonus": ["skill_chip_set_bonus"],
|
||||
"talent": ["skill_chip_talent"]
|
||||
}
|
||||
},
|
||||
"os_protocol": {
|
||||
"gear_slot": false,
|
||||
"views": {
|
||||
"detail": ["os_protocol"],
|
||||
"secondary_profile": ["os_protocol_secondary_profile"],
|
||||
"talent": ["os_protocol_talent"]
|
||||
}
|
||||
},
|
||||
"specialization": {
|
||||
"gear_slot": false,
|
||||
"views": {
|
||||
"overview": ["specialization"],
|
||||
"details": ["specialization"],
|
||||
"ability": ["specialization_ability"],
|
||||
"focus_details": ["specialization_focus"],
|
||||
"focus_tree": ["specialization_focus", "specialization_tree_node"],
|
||||
"rank_details": ["specialization_tree_rank"]
|
||||
}
|
||||
},
|
||||
"unknown": {
|
||||
"gear_slot": false,
|
||||
"views": {"unknown": []}
|
||||
}
|
||||
},
|
||||
"gear_slots": ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"],
|
||||
"abstention_reasons": [
|
||||
"low_confidence",
|
||||
"ambiguous_family",
|
||||
"ambiguous_view",
|
||||
"unsupported_family",
|
||||
"unsupported_view",
|
||||
"insufficient_visual_evidence",
|
||||
"occluded",
|
||||
"corrupt_image",
|
||||
"resource_limit",
|
||||
"cancelled"
|
||||
],
|
||||
"missing_reasons": [
|
||||
"not_visible",
|
||||
"occluded",
|
||||
"unreadable",
|
||||
"not_present_on_screen",
|
||||
"unsupported",
|
||||
"ambiguous",
|
||||
"extraction_failed",
|
||||
"not_applicable",
|
||||
"redacted"
|
||||
],
|
||||
"states": {
|
||||
"capture": {
|
||||
"received": ["spooling", "deduplicated", "failed", "cancelled"],
|
||||
"spooling": ["stored", "failed", "cancelled"],
|
||||
"stored": ["queued", "failed"],
|
||||
"deduplicated": [],
|
||||
"queued": [],
|
||||
"failed": ["spooling", "cancelled"],
|
||||
"cancelled": []
|
||||
},
|
||||
"job": {
|
||||
"queued": ["running", "cancelled"],
|
||||
"running": ["succeeded", "retry_wait", "failed", "cancelled"],
|
||||
"retry_wait": ["queued", "failed", "cancelled"],
|
||||
"succeeded": [],
|
||||
"failed": ["queued"],
|
||||
"cancelled": []
|
||||
},
|
||||
"extraction": {
|
||||
"pending": ["local_processing", "cancelled"],
|
||||
"local_processing": ["candidate_ready", "fallback_pending", "abstained", "failed", "cancelled"],
|
||||
"fallback_pending": ["fallback_processing", "cancelled"],
|
||||
"fallback_processing": ["candidate_ready", "abstained", "failed", "cancelled"],
|
||||
"candidate_ready": [],
|
||||
"abstained": [],
|
||||
"failed": ["pending"],
|
||||
"cancelled": []
|
||||
},
|
||||
"grouping": {
|
||||
"ungrouped": ["proposed", "cancelled"],
|
||||
"proposed": ["grouped", "rejected", "cancelled"],
|
||||
"grouped": ["split", "superseded"],
|
||||
"split": ["superseded"],
|
||||
"superseded": [],
|
||||
"rejected": ["ungrouped"],
|
||||
"cancelled": []
|
||||
},
|
||||
"review": {
|
||||
"pending": ["in_review", "cancelled"],
|
||||
"in_review": ["changes_requested", "approved", "rejected", "cancelled"],
|
||||
"changes_requested": ["in_review", "rejected", "cancelled"],
|
||||
"approved": ["in_review"],
|
||||
"rejected": ["in_review"],
|
||||
"cancelled": []
|
||||
},
|
||||
"export": {
|
||||
"not_requested": ["queued"],
|
||||
"queued": ["building", "cancelled"],
|
||||
"building": ["ready", "failed", "cancelled"],
|
||||
"ready": ["expired"],
|
||||
"failed": ["queued", "cancelled"],
|
||||
"cancelled": [],
|
||||
"expired": ["queued"]
|
||||
},
|
||||
"error": {
|
||||
"open": ["retryable", "terminal", "dismissed"],
|
||||
"retryable": ["retrying", "terminal", "dismissed"],
|
||||
"retrying": ["recovered", "retryable", "terminal"],
|
||||
"recovered": [],
|
||||
"terminal": ["dismissed"],
|
||||
"dismissed": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://shd.build/schemas/game-data-capture/evidence-archive-manifest-v1.json",
|
||||
"title": "Game data capture evidence archive manifest v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "archive_id", "created_at", "generator", "artifacts", "captures",
|
||||
"screens", "jobs", "extractions", "groups", "reviews", "exports", "errors",
|
||||
"evidence_values", "observed_instances", "canonical_candidates"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"archive_id": {"$ref": "#/$defs/id"},
|
||||
"created_at": {"$ref": "#/$defs/timestamp"},
|
||||
"generator": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "version", "policy_version"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"version": {"type": "string", "minLength": 1, "maxLength": 50},
|
||||
"policy_version": {"type": "string", "minLength": 1, "maxLength": 50}
|
||||
}
|
||||
},
|
||||
"artifacts": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/artifact"}},
|
||||
"captures": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/capture"}},
|
||||
"screens": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/screen"}},
|
||||
"jobs": {"type": "array", "maxItems": 20000, "items": {"$ref": "#/$defs/job"}},
|
||||
"extractions": {"type": "array", "maxItems": 20000, "items": {"$ref": "#/$defs/extraction"}},
|
||||
"groups": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/group"}},
|
||||
"reviews": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/review"}},
|
||||
"exports": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/export"}},
|
||||
"errors": {"type": "array", "maxItems": 20000, "items": {"$ref": "#/$defs/error"}},
|
||||
"evidence_values": {"type": "array", "maxItems": 100000, "items": {"$ref": "#/$defs/evidenceValue"}},
|
||||
"observed_instances": {"type": "array", "maxItems": 20000, "items": {"$ref": "#/$defs/observedInstance"}},
|
||||
"canonical_candidates": {"type": "array", "maxItems": 20000, "items": {"$ref": "#/$defs/canonicalCandidate"}}
|
||||
},
|
||||
"$defs": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||
"timestamp": {"type": "string", "format": "date-time", "maxLength": 40},
|
||||
"family": {"enum": ["weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"]},
|
||||
"recordType": {"enum": [
|
||||
"weapon", "weapon_talent", "weapon_augmentation_profile", "gear", "brand_set",
|
||||
"gear_talent", "gear_augmentation_profile", "skill_chip_set", "skill_chip_set_bonus",
|
||||
"skill_chip_roll_pool", "skill_chip_talent", "os_protocol", "os_protocol_secondary_profile",
|
||||
"os_protocol_talent", "specialization", "specialization_focus", "specialization_ability",
|
||||
"specialization_tree_node", "specialization_tree_rank"
|
||||
]},
|
||||
"missingReason": {"enum": [
|
||||
"not_visible", "occluded", "unreadable", "not_present_on_screen", "unsupported",
|
||||
"ambiguous", "extraction_failed", "not_applicable", "redacted"
|
||||
]},
|
||||
"jsonValue": {
|
||||
"oneOf": [
|
||||
{"type": "null"}, {"type": "boolean"}, {"type": "number"},
|
||||
{"type": "string", "maxLength": 8192},
|
||||
{"type": "array", "maxItems": 500, "items": {"$ref": "#/$defs/jsonValue"}},
|
||||
{"type": "object", "maxProperties": 500, "additionalProperties": {"$ref": "#/$defs/jsonValue"}}
|
||||
]
|
||||
},
|
||||
"sourceEngine": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "engine_id", "engine_version"],
|
||||
"properties": {
|
||||
"kind": {"enum": ["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]},
|
||||
"engine_id": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"engine_version": {"type": "string", "minLength": 1, "maxLength": 100}
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
||||
"properties": {
|
||||
"coordinate_space": {"const": "normalized"},
|
||||
"x": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"y": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"width": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
|
||||
"height": {"type": "number", "exclusiveMinimum": 0, "maximum": 1}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
||||
"properties": {
|
||||
"coordinate_space": {"const": "pixels"},
|
||||
"x": {"type": "integer", "minimum": 0, "maximum": 100000},
|
||||
"y": {"type": "integer", "minimum": 0, "maximum": 100000},
|
||||
"width": {"type": "integer", "minimum": 1, "maximum": 100000},
|
||||
"height": {"type": "integer", "minimum": 1, "maximum": 100000}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"valueState": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["raw_text", "normalized_value", "missing_reason"],
|
||||
"properties": {
|
||||
"raw_text": {"type": ["string", "null"], "maxLength": 8192},
|
||||
"normalized_value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"normalized_value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"normalized_value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
]
|
||||
},
|
||||
"correction": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["correction_id", "corrected_at", "actor", "reason", "before", "after"],
|
||||
"properties": {
|
||||
"correction_id": {"$ref": "#/$defs/id"},
|
||||
"corrected_at": {"$ref": "#/$defs/timestamp"},
|
||||
"actor": {"enum": ["local_user", "system_reprocess"]},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||
"before": {"$ref": "#/$defs/valueState"},
|
||||
"after": {"$ref": "#/$defs/valueState"}
|
||||
}
|
||||
},
|
||||
"evidenceValue": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": [
|
||||
"evidence_id", "field_path", "raw_text", "normalized_value", "missing_reason",
|
||||
"confidence", "source_engine", "image_artifact_id", "image_sha256", "region", "correction_history"
|
||||
],
|
||||
"properties": {
|
||||
"evidence_id": {"$ref": "#/$defs/id"},
|
||||
"field_path": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$"},
|
||||
"raw_text": {"type": ["string", "null"], "maxLength": 8192},
|
||||
"normalized_value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"source_engine": {"$ref": "#/$defs/sourceEngine"},
|
||||
"image_artifact_id": {"$ref": "#/$defs/id"},
|
||||
"image_sha256": {"$ref": "#/$defs/sha256"},
|
||||
"region": {"$ref": "#/$defs/region"},
|
||||
"correction_history": {"type": "array", "maxItems": 100, "items": {"$ref": "#/$defs/correction"}}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"normalized_value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"normalized_value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
]
|
||||
},
|
||||
"fieldClaim": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["path", "value", "missing_reason", "evidence_refs", "uncertainty_note"],
|
||||
"properties": {
|
||||
"path": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$"},
|
||||
"value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]},
|
||||
"evidence_refs": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"uncertainty_note": {"type": ["string", "null"], "maxLength": 2000}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
]
|
||||
},
|
||||
"classification": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"],
|
||||
"properties": {
|
||||
"family": {"$ref": "#/$defs/family"}, "view": {"type": "string"},
|
||||
"gear_slot": {"type": ["string", "null"]},
|
||||
"record_types": {"type": "array", "uniqueItems": true, "maxItems": 8, "items": {"$ref": "#/$defs/recordType"}},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"abstention_reason": {"type": ["string", "null"]}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"family": {"const": "weapon"}, "view": {"enum": ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"]}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "items": {"enum": ["weapon", "weapon_talent", "weapon_augmentation_profile"]}}, "abstention_reason": {"const": null}}},
|
||||
{"properties": {"family": {"const": "gear"}, "view": {"enum": ["detail", "blueprint", "talent", "augmentation"]}, "gear_slot": {"enum": ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"]}, "record_types": {"minItems": 1, "items": {"enum": ["gear", "brand_set", "gear_talent", "gear_augmentation_profile"]}}, "abstention_reason": {"const": null}}},
|
||||
{"properties": {"family": {"const": "skill_chip"}, "view": {"enum": ["detail", "set_bonus", "talent"]}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "items": {"enum": ["skill_chip_set", "skill_chip_set_bonus", "skill_chip_roll_pool", "skill_chip_talent"]}}, "abstention_reason": {"const": null}}},
|
||||
{"properties": {"family": {"const": "os_protocol"}, "view": {"enum": ["detail", "secondary_profile", "talent"]}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "items": {"enum": ["os_protocol", "os_protocol_secondary_profile", "os_protocol_talent"]}}, "abstention_reason": {"const": null}}},
|
||||
{"properties": {"family": {"const": "specialization"}, "view": {"enum": ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"]}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "items": {"enum": ["specialization", "specialization_focus", "specialization_ability", "specialization_tree_node", "specialization_tree_rank"]}}, "abstention_reason": {"const": null}}},
|
||||
{"properties": {"family": {"const": "unknown"}, "view": {"const": "unknown"}, "gear_slot": {"const": null}, "record_types": {"maxItems": 0}, "abstention_reason": {"enum": ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"]}}}
|
||||
]
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["artifact_id", "sha256", "media_type", "byte_size", "sensitivity", "parent_artifact_id", "video_timestamp_ms"],
|
||||
"properties": {
|
||||
"artifact_id": {"$ref": "#/$defs/id"}, "sha256": {"$ref": "#/$defs/sha256"},
|
||||
"media_type": {"enum": ["image/png", "image/webp", "image/avif", "video/mp4", "video/webm", "video/x-matroska"]},
|
||||
"byte_size": {"type": "integer", "minimum": 1},
|
||||
"sensitivity": {"const": "private"},
|
||||
"parent_artifact_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/id"}]},
|
||||
"video_timestamp_ms": {"type": ["integer", "null"], "minimum": 0, "maximum": 86400000}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"media_type": {"enum": ["image/png", "image/webp", "image/avif"]},
|
||||
"byte_size": {"maximum": 67108864}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"media_type": {"enum": ["video/mp4", "video/webm", "video/x-matroska"]},
|
||||
"byte_size": {"maximum": 17179869184}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"capture": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["capture_id", "artifact_id", "state", "received_at", "failure_reason"],
|
||||
"properties": {
|
||||
"capture_id": {"$ref": "#/$defs/id"}, "artifact_id": {"$ref": "#/$defs/id"},
|
||||
"state": {"enum": ["received", "spooling", "stored", "deduplicated", "queued", "failed", "cancelled"]},
|
||||
"received_at": {"$ref": "#/$defs/timestamp"},
|
||||
"failure_reason": {"type": ["string", "null"], "maxLength": 2000}
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["screen_id", "artifact_id", "classified_at", "classification"],
|
||||
"properties": {
|
||||
"screen_id": {"$ref": "#/$defs/id"}, "artifact_id": {"$ref": "#/$defs/id"},
|
||||
"classified_at": {"$ref": "#/$defs/timestamp"}, "classification": {"$ref": "#/$defs/classification"}
|
||||
}
|
||||
},
|
||||
"job": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["job_id", "kind", "state", "attempt", "max_attempts", "last_error_id"],
|
||||
"properties": {
|
||||
"job_id": {"$ref": "#/$defs/id"},
|
||||
"kind": {"enum": ["classify", "ocr", "vision_fallback", "frame_extract", "export"]},
|
||||
"state": {"enum": ["queued", "running", "retry_wait", "succeeded", "failed", "cancelled"]},
|
||||
"attempt": {"type": "integer", "minimum": 0, "maximum": 20},
|
||||
"max_attempts": {"type": "integer", "minimum": 1, "maximum": 20},
|
||||
"last_error_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/id"}]}
|
||||
}
|
||||
},
|
||||
"extraction": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["extraction_id", "screen_id", "state", "evidence_refs", "abstention_reason"],
|
||||
"properties": {
|
||||
"extraction_id": {"$ref": "#/$defs/id"}, "screen_id": {"$ref": "#/$defs/id"},
|
||||
"state": {"enum": ["pending", "local_processing", "fallback_pending", "fallback_processing", "candidate_ready", "abstained", "failed", "cancelled"]},
|
||||
"evidence_refs": {"type": "array", "maxItems": 1000, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"abstention_reason": {"type": ["string", "null"], "maxLength": 100}
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["group_id", "state", "screen_ids", "supersedes_group_ids"],
|
||||
"properties": {
|
||||
"group_id": {"$ref": "#/$defs/id"},
|
||||
"state": {"enum": ["ungrouped", "proposed", "grouped", "split", "superseded", "rejected", "cancelled"]},
|
||||
"screen_ids": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"supersedes_group_ids": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}}
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["review_id", "candidate_id", "state", "updated_at", "note"],
|
||||
"properties": {
|
||||
"review_id": {"$ref": "#/$defs/id"}, "candidate_id": {"$ref": "#/$defs/id"},
|
||||
"state": {"enum": ["pending", "in_review", "changes_requested", "approved", "rejected", "cancelled"]},
|
||||
"updated_at": {"$ref": "#/$defs/timestamp"}, "note": {"type": ["string", "null"], "maxLength": 4000}
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["export_id", "state", "artifact_id", "failure_reason"],
|
||||
"properties": {
|
||||
"export_id": {"$ref": "#/$defs/id"},
|
||||
"state": {"enum": ["not_requested", "queued", "building", "ready", "failed", "cancelled", "expired"]},
|
||||
"artifact_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/id"}]},
|
||||
"failure_reason": {"type": ["string", "null"], "maxLength": 2000}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["error_id", "state", "code", "message", "retry_after"],
|
||||
"properties": {
|
||||
"error_id": {"$ref": "#/$defs/id"},
|
||||
"state": {"enum": ["open", "retryable", "retrying", "recovered", "terminal", "dismissed"]},
|
||||
"code": {"enum": ["cancelled", "resource_limit", "timeout", "invalid_input", "engine_failure", "storage_failure", "network_failure", "schema_rejected", "unsupported", "ambiguous"]},
|
||||
"message": {"type": "string", "minLength": 1, "maxLength": 2000},
|
||||
"retry_after": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/timestamp"}]}
|
||||
}
|
||||
},
|
||||
"observedContext": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["level", "quality", "tier", "upgrade_state"],
|
||||
"properties": {
|
||||
"level": {"$ref": "#/$defs/fieldClaim"}, "quality": {"$ref": "#/$defs/fieldClaim"},
|
||||
"tier": {"$ref": "#/$defs/fieldClaim"}, "upgrade_state": {"$ref": "#/$defs/fieldClaim"}
|
||||
}
|
||||
},
|
||||
"observedInstance": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["instance_id", "family", "source_screen_ids", "observed_context", "fields"],
|
||||
"properties": {
|
||||
"instance_id": {"$ref": "#/$defs/id"}, "family": {"$ref": "#/$defs/family"},
|
||||
"source_screen_ids": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"observed_context": {"$ref": "#/$defs/observedContext"},
|
||||
"fields": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/fieldClaim"}}
|
||||
}
|
||||
},
|
||||
"canonicalCandidate": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["candidate_id", "record_type", "source_instance_ids", "evidence_refs", "fields", "completeness", "relationship_status", "mechanic_status"],
|
||||
"properties": {
|
||||
"candidate_id": {"$ref": "#/$defs/id"}, "record_type": {"$ref": "#/$defs/recordType"},
|
||||
"source_instance_ids": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"evidence_refs": {"type": "array", "minItems": 1, "maxItems": 1000, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"fields": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/fieldClaim"}},
|
||||
"completeness": {"enum": ["complete", "incomplete", "unsupported"]},
|
||||
"relationship_status": {"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]},
|
||||
"mechanic_status": {"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"archive_id": "future-archive"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"archive_id": "archive-001",
|
||||
"created_at": "2026-07-31T12:00:00Z",
|
||||
"generator": {"name": "game-data-capture", "version": "0.1.0", "policy_version": "1"},
|
||||
"artifacts": [
|
||||
{
|
||||
"artifact_id": "image-001",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"media_type": "image/png",
|
||||
"byte_size": 2048,
|
||||
"sensitivity": "private",
|
||||
"parent_artifact_id": null,
|
||||
"video_timestamp_ms": null
|
||||
}
|
||||
],
|
||||
"captures": [
|
||||
{"capture_id": "capture-001", "artifact_id": "image-001", "state": "queued", "received_at": "2026-07-31T11:59:55Z", "failure_reason": null}
|
||||
],
|
||||
"screens": [
|
||||
{
|
||||
"screen_id": "screen-001",
|
||||
"artifact_id": "image-001",
|
||||
"classified_at": "2026-07-31T11:59:57Z",
|
||||
"classification": {
|
||||
"family": "weapon",
|
||||
"view": "detail",
|
||||
"gear_slot": null,
|
||||
"record_types": ["weapon", "weapon_talent"],
|
||||
"confidence": 0.97,
|
||||
"abstention_reason": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"jobs": [],
|
||||
"extractions": [],
|
||||
"groups": [],
|
||||
"reviews": [
|
||||
{"review_id": "review-001", "candidate_id": "candidate-001", "state": "approved", "updated_at": "2026-07-31T12:00:00Z", "note": "Name verified against the image."}
|
||||
],
|
||||
"exports": [],
|
||||
"errors": [],
|
||||
"evidence_values": [
|
||||
{
|
||||
"evidence_id": "evidence-name",
|
||||
"field_path": "weapon.name",
|
||||
"raw_text": "LVOA-C",
|
||||
"normalized_value": "LVOA-C",
|
||||
"missing_reason": null,
|
||||
"confidence": 0.99,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "normalized", "x": 0.1, "y": 0.1, "width": 0.3, "height": 0.08},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
"evidence_id": "evidence-level",
|
||||
"field_path": "observed.level",
|
||||
"raw_text": "20",
|
||||
"normalized_value": 20,
|
||||
"missing_reason": null,
|
||||
"confidence": 0.98,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 50, "y": 50, "width": 80, "height": 30},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
"evidence_id": "evidence-quality",
|
||||
"field_path": "observed.quality",
|
||||
"raw_text": "High-end",
|
||||
"normalized_value": "High-end",
|
||||
"missing_reason": null,
|
||||
"confidence": 0.96,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 50, "y": 85, "width": 140, "height": 30},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
"evidence_id": "evidence-tier",
|
||||
"field_path": "observed.tier",
|
||||
"raw_text": "T1",
|
||||
"normalized_value": 1,
|
||||
"missing_reason": null,
|
||||
"confidence": 0.97,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 200, "y": 85, "width": 60, "height": 30},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
"evidence_id": "evidence-upgrade",
|
||||
"field_path": "observed.upgrade_state",
|
||||
"raw_text": null,
|
||||
"normalized_value": null,
|
||||
"missing_reason": "not_visible",
|
||||
"confidence": 0,
|
||||
"source_engine": {"kind": "manual", "engine_id": "review", "engine_version": "1"},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "normalized", "x": 0, "y": 0, "width": 1, "height": 1},
|
||||
"correction_history": []
|
||||
},
|
||||
{
|
||||
"evidence_id": "evidence-damage",
|
||||
"field_path": "observed.damage",
|
||||
"raw_text": "12,400",
|
||||
"normalized_value": 12400,
|
||||
"missing_reason": null,
|
||||
"confidence": 1,
|
||||
"source_engine": {"kind": "manual", "engine_id": "review", "engine_version": "1"},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "pixels", "x": 300, "y": 200, "width": 160, "height": 40},
|
||||
"correction_history": [
|
||||
{
|
||||
"correction_id": "correction-001",
|
||||
"corrected_at": "2026-07-31T12:00:00Z",
|
||||
"actor": "local_user",
|
||||
"reason": "Corrected a missing zero.",
|
||||
"before": {"raw_text": "1,240", "normalized_value": 1240, "missing_reason": null},
|
||||
"after": {"raw_text": "12,400", "normalized_value": 12400, "missing_reason": null}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"observed_instances": [
|
||||
{
|
||||
"instance_id": "instance-001",
|
||||
"family": "weapon",
|
||||
"source_screen_ids": ["screen-001"],
|
||||
"observed_context": {
|
||||
"level": {"path": "observed.level", "value": 20, "missing_reason": null, "evidence_refs": ["evidence-level"], "uncertainty_note": null},
|
||||
"quality": {"path": "observed.quality", "value": "High-end", "missing_reason": null, "evidence_refs": ["evidence-quality"], "uncertainty_note": null},
|
||||
"tier": {"path": "observed.tier", "value": 1, "missing_reason": null, "evidence_refs": ["evidence-tier"], "uncertainty_note": null},
|
||||
"upgrade_state": {"path": "observed.upgrade_state", "value": null, "missing_reason": "not_visible", "evidence_refs": ["evidence-upgrade"], "uncertainty_note": "The screen does not show an upgrade indicator."}
|
||||
},
|
||||
"fields": [
|
||||
{"path": "observed.damage", "value": 12400, "missing_reason": null, "evidence_refs": ["evidence-damage"], "uncertainty_note": "This is the configured level-20 instance value, not a canonical range endpoint."}
|
||||
]
|
||||
}
|
||||
],
|
||||
"canonical_candidates": [
|
||||
{
|
||||
"candidate_id": "candidate-001",
|
||||
"record_type": "weapon",
|
||||
"source_instance_ids": ["instance-001"],
|
||||
"evidence_refs": ["evidence-name"],
|
||||
"fields": [
|
||||
{"path": "weapon.name", "value": "LVOA-C", "missing_reason": null, "evidence_refs": ["evidence-name"], "uncertainty_note": null}
|
||||
],
|
||||
"completeness": "incomplete",
|
||||
"relationship_status": "unresolved",
|
||||
"mechanic_status": "not_applicable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "gear",
|
||||
"view": "detail",
|
||||
"gear_slot": "Vest",
|
||||
"record_types": ["gear"],
|
||||
"confidence": 0.95,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "gear",
|
||||
"view": "talent",
|
||||
"gear_slot": "Body Armor",
|
||||
"record_types": ["gear_talent"],
|
||||
"confidence": 0.95,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "os_protocol",
|
||||
"view": "secondary_profile",
|
||||
"gear_slot": null,
|
||||
"record_types": ["os_protocol_secondary_profile"],
|
||||
"confidence": 1.3,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "os_protocol",
|
||||
"view": "secondary_profile",
|
||||
"gear_slot": null,
|
||||
"record_types": ["os_protocol_secondary_profile"],
|
||||
"confidence": 0.93,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "skill_chip",
|
||||
"view": "detail",
|
||||
"gear_slot": null,
|
||||
"record_types": ["weapon"],
|
||||
"confidence": 0.94,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "skill_chip",
|
||||
"view": "set_bonus",
|
||||
"gear_slot": null,
|
||||
"record_types": ["skill_chip_set_bonus"],
|
||||
"confidence": 0.94,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "specialization",
|
||||
"view": "focus_tree",
|
||||
"gear_slot": null,
|
||||
"record_types": ["specialization_tree_node"],
|
||||
"confidence": 0.91,
|
||||
"abstention_reason": "ambiguous_view"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "specialization",
|
||||
"view": "focus_tree",
|
||||
"gear_slot": null,
|
||||
"record_types": ["specialization_focus", "specialization_tree_node"],
|
||||
"confidence": 0.91,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "unknown",
|
||||
"view": "unknown",
|
||||
"gear_slot": null,
|
||||
"record_types": ["weapon"],
|
||||
"confidence": 0.22,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "unknown",
|
||||
"view": "unknown",
|
||||
"gear_slot": null,
|
||||
"record_types": [],
|
||||
"confidence": 0.22,
|
||||
"abstention_reason": "low_confidence"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "weapon",
|
||||
"view": "talent",
|
||||
"gear_slot": null,
|
||||
"record_types": ["weapon"],
|
||||
"confidence": 0.9,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "weapon",
|
||||
"view": "detail",
|
||||
"gear_slot": null,
|
||||
"record_types": ["weapon", "weapon_talent"],
|
||||
"confidence": 0.97,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"handoff_id": "future-handoff"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"handoff_id": "handoff-001",
|
||||
"created_at": "2026-07-31T12:05:00Z",
|
||||
"source_archive": {
|
||||
"archive_id": "archive-001",
|
||||
"manifest_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"schema_version": 1
|
||||
},
|
||||
"trust_boundary": {
|
||||
"intended_destination": "authenticated_isac_intake",
|
||||
"mutation_authority": "none",
|
||||
"requires_authenticated_action": true,
|
||||
"preserves_moderation": true,
|
||||
"creates_audit_events": false
|
||||
},
|
||||
"source_artifacts": [
|
||||
{
|
||||
"artifact_id": "image-001",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"media_type": "image/png",
|
||||
"byte_size": 2048
|
||||
}
|
||||
],
|
||||
"evidence_values": [
|
||||
{
|
||||
"evidence_id": "evidence-name",
|
||||
"field_path": "weapon.name",
|
||||
"raw_text": "LVOA-C",
|
||||
"normalized_value": "LVOA-C",
|
||||
"missing_reason": null,
|
||||
"confidence": 0.99,
|
||||
"source_engine": {"kind": "local_ocr", "engine_id": "fixture-ocr", "engine_version": "1"},
|
||||
"image_artifact_id": "image-001",
|
||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"region": {"coordinate_space": "normalized", "x": 0.1, "y": 0.1, "width": 0.3, "height": 0.08},
|
||||
"correction_history": []
|
||||
}
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"handoff_record_id": "handoff-record-001",
|
||||
"record_type": "weapon",
|
||||
"intent": "create_draft",
|
||||
"target_id": null,
|
||||
"review_state": "approved",
|
||||
"source_candidate_ids": ["candidate-001"],
|
||||
"observed_instance_ids": ["instance-001"],
|
||||
"fields": [
|
||||
{"path": "weapon.name", "value": "LVOA-C", "missing_reason": null, "evidence_refs": ["evidence-name"], "uncertainty_note": null}
|
||||
],
|
||||
"relationships": {"status": "unresolved", "evidence_refs": [], "note": "ISAC must resolve and validate canonical relationships before submission."},
|
||||
"mechanics": {"status": "not_applicable", "evidence_refs": [], "note": null}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://shd.build/schemas/game-data-capture/isac-handoff-v1.json",
|
||||
"title": "Authenticated ISAC draft handoff v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "handoff_id", "created_at", "source_archive", "trust_boundary",
|
||||
"source_artifacts", "evidence_values", "records"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"handoff_id": {"$ref": "#/$defs/id"},
|
||||
"created_at": {"$ref": "#/$defs/timestamp"},
|
||||
"source_archive": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["archive_id", "manifest_sha256", "schema_version"],
|
||||
"properties": {
|
||||
"archive_id": {"$ref": "#/$defs/id"}, "manifest_sha256": {"$ref": "#/$defs/sha256"},
|
||||
"schema_version": {"const": 1}
|
||||
}
|
||||
},
|
||||
"trust_boundary": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["intended_destination", "mutation_authority", "requires_authenticated_action", "preserves_moderation", "creates_audit_events"],
|
||||
"properties": {
|
||||
"intended_destination": {"const": "authenticated_isac_intake"},
|
||||
"mutation_authority": {"const": "none"},
|
||||
"requires_authenticated_action": {"const": true},
|
||||
"preserves_moderation": {"const": true},
|
||||
"creates_audit_events": {"const": false}
|
||||
}
|
||||
},
|
||||
"source_artifacts": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/sourceArtifact"}},
|
||||
"evidence_values": {"type": "array", "maxItems": 100000, "items": {"$ref": "#/$defs/evidenceValue"}},
|
||||
"records": {"type": "array", "maxItems": 20000, "items": {"$ref": "#/$defs/handoffRecord"}}
|
||||
},
|
||||
"$defs": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||
"timestamp": {"type": "string", "format": "date-time", "maxLength": 40},
|
||||
"recordType": {"enum": [
|
||||
"weapon", "weapon_talent", "weapon_augmentation_profile", "gear", "brand_set",
|
||||
"gear_talent", "gear_augmentation_profile", "skill_chip_set", "skill_chip_set_bonus",
|
||||
"skill_chip_roll_pool", "skill_chip_talent", "os_protocol", "os_protocol_secondary_profile",
|
||||
"os_protocol_talent", "specialization", "specialization_focus", "specialization_ability",
|
||||
"specialization_tree_node", "specialization_tree_rank"
|
||||
]},
|
||||
"missingReason": {"enum": [
|
||||
"not_visible", "occluded", "unreadable", "not_present_on_screen", "unsupported",
|
||||
"ambiguous", "extraction_failed", "not_applicable", "redacted"
|
||||
]},
|
||||
"jsonValue": {
|
||||
"oneOf": [
|
||||
{"type": "null"}, {"type": "boolean"}, {"type": "number"},
|
||||
{"type": "string", "maxLength": 8192},
|
||||
{"type": "array", "maxItems": 500, "items": {"$ref": "#/$defs/jsonValue"}},
|
||||
{"type": "object", "maxProperties": 500, "additionalProperties": {"$ref": "#/$defs/jsonValue"}}
|
||||
]
|
||||
},
|
||||
"sourceEngine": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["kind", "engine_id", "engine_version"],
|
||||
"properties": {
|
||||
"kind": {"enum": ["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]},
|
||||
"engine_id": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"engine_version": {"type": "string", "minLength": 1, "maxLength": 100}
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
||||
"properties": {
|
||||
"coordinate_space": {"const": "normalized"},
|
||||
"x": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"y": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"width": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
|
||||
"height": {"type": "number", "exclusiveMinimum": 0, "maximum": 1}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
||||
"properties": {
|
||||
"coordinate_space": {"const": "pixels"},
|
||||
"x": {"type": "integer", "minimum": 0, "maximum": 100000},
|
||||
"y": {"type": "integer", "minimum": 0, "maximum": 100000},
|
||||
"width": {"type": "integer", "minimum": 1, "maximum": 100000},
|
||||
"height": {"type": "integer", "minimum": 1, "maximum": 100000}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"valueState": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["raw_text", "normalized_value", "missing_reason"],
|
||||
"properties": {
|
||||
"raw_text": {"type": ["string", "null"], "maxLength": 8192},
|
||||
"normalized_value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"normalized_value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"normalized_value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
]
|
||||
},
|
||||
"correction": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["correction_id", "corrected_at", "actor", "reason", "before", "after"],
|
||||
"properties": {
|
||||
"correction_id": {"$ref": "#/$defs/id"}, "corrected_at": {"$ref": "#/$defs/timestamp"},
|
||||
"actor": {"enum": ["local_user", "system_reprocess"]},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||
"before": {"$ref": "#/$defs/valueState"}, "after": {"$ref": "#/$defs/valueState"}
|
||||
}
|
||||
},
|
||||
"evidenceValue": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["evidence_id", "field_path", "raw_text", "normalized_value", "missing_reason", "confidence", "source_engine", "image_artifact_id", "image_sha256", "region", "correction_history"],
|
||||
"properties": {
|
||||
"evidence_id": {"$ref": "#/$defs/id"},
|
||||
"field_path": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$"},
|
||||
"raw_text": {"type": ["string", "null"], "maxLength": 8192},
|
||||
"normalized_value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"source_engine": {"$ref": "#/$defs/sourceEngine"},
|
||||
"image_artifact_id": {"$ref": "#/$defs/id"}, "image_sha256": {"$ref": "#/$defs/sha256"},
|
||||
"region": {"$ref": "#/$defs/region"},
|
||||
"correction_history": {"type": "array", "maxItems": 100, "items": {"$ref": "#/$defs/correction"}}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"normalized_value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"normalized_value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
]
|
||||
},
|
||||
"fieldClaim": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["path", "value", "missing_reason", "evidence_refs", "uncertainty_note"],
|
||||
"properties": {
|
||||
"path": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$"},
|
||||
"value": {"$ref": "#/$defs/jsonValue"},
|
||||
"missing_reason": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/missingReason"}]},
|
||||
"evidence_refs": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"uncertainty_note": {"type": ["string", "null"], "maxLength": 2000}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"value": {"type": "null"}, "missing_reason": {"$ref": "#/$defs/missingReason"}}},
|
||||
{"properties": {"value": {"not": {"type": "null"}}, "missing_reason": {"type": "null"}}}
|
||||
]
|
||||
},
|
||||
"sourceArtifact": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["artifact_id", "sha256", "media_type", "byte_size"],
|
||||
"properties": {
|
||||
"artifact_id": {"$ref": "#/$defs/id"}, "sha256": {"$ref": "#/$defs/sha256"},
|
||||
"media_type": {"enum": ["image/png", "image/webp", "image/avif"]},
|
||||
"byte_size": {"type": "integer", "minimum": 1, "maximum": 67108864}
|
||||
}
|
||||
},
|
||||
"evidenceSection": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["status", "evidence_refs", "note"],
|
||||
"properties": {
|
||||
"status": {"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]},
|
||||
"evidence_refs": {"type": "array", "maxItems": 1000, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"note": {"type": ["string", "null"], "maxLength": 4000}
|
||||
}
|
||||
},
|
||||
"handoffRecord": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": [
|
||||
"handoff_record_id", "record_type", "intent", "target_id", "review_state",
|
||||
"source_candidate_ids", "observed_instance_ids", "fields", "relationships", "mechanics"
|
||||
],
|
||||
"properties": {
|
||||
"handoff_record_id": {"$ref": "#/$defs/id"}, "record_type": {"$ref": "#/$defs/recordType"},
|
||||
"intent": {"enum": ["create_draft", "update_draft", "abstain"]},
|
||||
"target_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/id"}]},
|
||||
"review_state": {"enum": ["approved", "abstained"]},
|
||||
"source_candidate_ids": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"observed_instance_ids": {"type": "array", "minItems": 1, "maxItems": 100, "uniqueItems": true, "items": {"$ref": "#/$defs/id"}},
|
||||
"fields": {"type": "array", "maxItems": 1000, "items": {"$ref": "#/$defs/fieldClaim"}},
|
||||
"relationships": {"$ref": "#/$defs/evidenceSection"},
|
||||
"mechanics": {"$ref": "#/$defs/evidenceSection"}
|
||||
},
|
||||
"oneOf": [
|
||||
{"properties": {"intent": {"const": "create_draft"}, "target_id": {"const": null}, "review_state": {"const": "approved"}}},
|
||||
{"properties": {"intent": {"const": "update_draft"}, "target_id": {"$ref": "#/$defs/id"}, "review_state": {"const": "approved"}}},
|
||||
{"properties": {"intent": {"const": "abstain"}, "target_id": {"const": null}, "review_state": {"const": "abstained"}, "fields": {"maxItems": 0}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://shd.build/schemas/game-data-capture/screenshot-classification-v1.json",
|
||||
"title": "Screenshot classification v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"],
|
||||
"properties": {
|
||||
"family": {"enum": ["weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"]},
|
||||
"view": {"type": "string"},
|
||||
"gear_slot": {"type": ["string", "null"]},
|
||||
"record_types": {"type": "array", "uniqueItems": true, "maxItems": 8, "items": {"type": "string"}},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"abstention_reason": {"type": ["string", "null"]}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "weapon"},
|
||||
"view": {"enum": ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["weapon", "weapon_talent", "weapon_augmentation_profile"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "gear"},
|
||||
"view": {"enum": ["detail", "blueprint", "talent", "augmentation"]},
|
||||
"gear_slot": {"enum": ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"]},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["gear", "brand_set", "gear_talent", "gear_augmentation_profile"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "skill_chip"},
|
||||
"view": {"enum": ["detail", "set_bonus", "talent"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["skill_chip_set", "skill_chip_set_bonus", "skill_chip_roll_pool", "skill_chip_talent"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "os_protocol"},
|
||||
"view": {"enum": ["detail", "secondary_profile", "talent"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["os_protocol", "os_protocol_secondary_profile", "os_protocol_talent"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "specialization"},
|
||||
"view": {"enum": ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"]},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"minItems": 1, "items": {"enum": ["specialization", "specialization_focus", "specialization_ability", "specialization_tree_node", "specialization_tree_rank"]}},
|
||||
"abstention_reason": {"const": null}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"family": {"const": "unknown"},
|
||||
"view": {"const": "unknown"},
|
||||
"gear_slot": {"const": null},
|
||||
"record_types": {"maxItems": 0},
|
||||
"abstention_reason": {"enum": ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"]}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Additive utility-local schema. Migration content is fingerprinted at application time.
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE blobs (
|
||||
sha256 TEXT PRIMARY KEY CHECK(length(sha256) = 64),
|
||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||
media_type TEXT NOT NULL CHECK(length(media_type) <= 100),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE captures (
|
||||
capture_id TEXT PRIMARY KEY,
|
||||
blob_sha256 TEXT NOT NULL REFERENCES blobs(sha256) ON DELETE RESTRICT,
|
||||
state TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
receipt_token TEXT NOT NULL UNIQUE,
|
||||
failure_reason TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
capture_id TEXT REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL CHECK(length(kind) <= 100),
|
||||
state TEXT NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK(attempt >= 0 AND attempt <= 20),
|
||||
max_attempts INTEGER NOT NULL CHECK(max_attempts BETWEEN 1 AND 20),
|
||||
lease_owner TEXT, lease_expires_at TEXT, last_error_id TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX jobs_available ON jobs(state, lease_expires_at);
|
||||
CREATE TABLE ocr_runs (
|
||||
run_id TEXT PRIMARY KEY, capture_id TEXT NOT NULL REFERENCES captures(capture_id),
|
||||
engine_id TEXT NOT NULL, engine_version TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL, payload_json TEXT NOT NULL CHECK(length(payload_json) <= 1000000)
|
||||
);
|
||||
CREATE TABLE regions (
|
||||
region_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ocr_runs(run_id),
|
||||
region_json TEXT NOT NULL CHECK(length(region_json) <= 10000), created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE classifications (
|
||||
classification_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ocr_runs(run_id),
|
||||
classification_json TEXT NOT NULL CHECK(length(classification_json) <= 10000), created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE extraction_candidates (
|
||||
candidate_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ocr_runs(run_id),
|
||||
candidate_json TEXT NOT NULL CHECK(length(candidate_json) <= 1000000), created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE routing_decisions (
|
||||
decision_id TEXT PRIMARY KEY, candidate_id TEXT REFERENCES extraction_candidates(candidate_id),
|
||||
decision_json TEXT NOT NULL CHECK(length(decision_json) <= 10000), created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE review_events (
|
||||
event_id TEXT PRIMARY KEY, candidate_id TEXT REFERENCES extraction_candidates(candidate_id),
|
||||
event_json TEXT NOT NULL CHECK(length(event_json) <= 10000), created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE lineage (
|
||||
lineage_id TEXT PRIMARY KEY, child_id TEXT NOT NULL, parent_id TEXT NOT NULL,
|
||||
relation TEXT NOT NULL CHECK(length(relation) <= 100), created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE exports (
|
||||
export_id TEXT PRIMARY KEY, state TEXT NOT NULL, manifest_json TEXT NOT NULL CHECK(length(manifest_json) <= 1000000), created_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE runtime_failures (
|
||||
failure_id TEXT PRIMARY KEY, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL,
|
||||
code TEXT NOT NULL, message TEXT NOT NULL CHECK(length(message) <= 512), created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Durable browser-corpus projections and append-only review evidence.
|
||||
CREATE TABLE capture_labels (
|
||||
capture_id TEXT PRIMARY KEY REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
||||
classification_json TEXT NOT NULL CHECK(length(classification_json) BETWEEN 2 AND 4096),
|
||||
revision INTEGER NOT NULL DEFAULT 0 CHECK(revision >= 0),
|
||||
labeled_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE capture_label_evidence (
|
||||
evidence_id INTEGER PRIMARY KEY,
|
||||
capture_id TEXT NOT NULL REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
||||
replaced_revision INTEGER NOT NULL CHECK(replaced_revision >= 0),
|
||||
classification_json TEXT NOT NULL CHECK(length(classification_json) BETWEEN 2 AND 4096),
|
||||
replaced_at TEXT NOT NULL,
|
||||
UNIQUE(capture_id, replaced_revision)
|
||||
);
|
||||
CREATE INDEX capture_label_evidence_capture ON capture_label_evidence(capture_id, evidence_id);
|
||||
CREATE INDEX captures_blob_sha256 ON captures(blob_sha256);
|
||||
CREATE INDEX jobs_capture_id ON jobs(capture_id);
|
||||
CREATE INDEX ocr_runs_capture_id ON ocr_runs(capture_id);
|
||||
CREATE INDEX runtime_failures_capture_subject ON runtime_failures(subject_type, subject_id);
|
||||
CREATE INDEX lineage_child_id ON lineage(child_id);
|
||||
CREATE INDEX lineage_parent_id ON lineage(parent_id);
|
||||
CREATE TABLE capture_rejections (
|
||||
rejection_id INTEGER PRIMARY KEY,
|
||||
capture_id TEXT NOT NULL UNIQUE REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
||||
rejected_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE capture_deletion_intents (
|
||||
blob_sha256 TEXT PRIMARY KEY REFERENCES blobs(sha256) ON DELETE RESTRICT,
|
||||
capture_id TEXT NOT NULL UNIQUE CHECK(length(capture_id) BETWEEN 8 AND 64),
|
||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0 AND byte_size <= 67108864),
|
||||
capture_state TEXT NOT NULL CHECK(length(capture_state) BETWEEN 1 AND 32),
|
||||
received_at TEXT NOT NULL CHECK(length(received_at) BETWEEN 1 AND 40),
|
||||
receipt_token TEXT NOT NULL UNIQUE CHECK(length(receipt_token) BETWEEN 1 AND 128),
|
||||
failure_reason TEXT,
|
||||
revision INTEGER NOT NULL CHECK(revision >= 0),
|
||||
created_at TEXT NOT NULL CHECK(length(created_at) BETWEEN 1 AND 40)
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
[build-system]
|
||||
requires = ["hatchling==1.27.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "shd-game-data-capture"
|
||||
version = "0.1.0"
|
||||
description = "Local, privacy-preserving game-data capture utility contracts."
|
||||
requires-python = ">=3.12,<3.13"
|
||||
license = { text = "UNLICENSED" }
|
||||
authors = [{ name = "SHD.build" }]
|
||||
dependencies = [
|
||||
"fastapi==0.115.12",
|
||||
"jinja2==3.1.6",
|
||||
"python-multipart==0.0.20",
|
||||
"uvicorn==0.34.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
shd-capture = "game_data_capture.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx==0.28.1",
|
||||
"jsonschema==4.25.1",
|
||||
"mypy==1.15.0",
|
||||
"pytest==8.3.5",
|
||||
"ruff==0.11.0",
|
||||
"types-jsonschema==4.25.1.20251009",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/game_data_capture"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
mypy_path = ["src"]
|
||||
warn_unreachable = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "-ra"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Contracts for the local SHD.build game-data capture utility.
|
||||
|
||||
Importing this package does not inspect the environment or initialize runtime resources.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Module entry point for ``python -m game_data_capture``."""
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,163 @@
|
||||
"""CLI contracts for the capture utility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from game_data_capture.config import AppConfig, ConfigError, ServerConfig, load_config
|
||||
from game_data_capture.doctor import doctor_as_dict, run_doctor
|
||||
from game_data_capture.paths import PathConfigurationError, RuntimePaths, resolve_runtime_paths
|
||||
from game_data_capture.runtime import CaptureRuntime, RuntimeFailure
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_CONFIG_ERROR = 2
|
||||
EXIT_UNIMPLEMENTED = 3
|
||||
EXIT_RUNTIME_ERROR = 4
|
||||
|
||||
|
||||
def _add_config_argument(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--config", type=Path, help="absolute path to a TOML configuration file")
|
||||
|
||||
|
||||
def _port_argument(raw_value: str) -> int:
|
||||
try:
|
||||
port = int(raw_value)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535") from error
|
||||
if not 1024 <= port <= 65535:
|
||||
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535")
|
||||
return port
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the stable command surface without initializing runtime resources."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="shd-capture", description="Local SHD.build game-data capture"
|
||||
)
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
doctor = subcommands.add_parser("doctor", help="report local prerequisite status")
|
||||
_add_config_argument(doctor)
|
||||
doctor.add_argument("--json", action="store_true", help="emit JSON diagnostics")
|
||||
|
||||
serve = subcommands.add_parser("serve", help="start the localhost review service")
|
||||
_add_config_argument(serve)
|
||||
serve.add_argument("--host", help="loopback IP literal override")
|
||||
serve.add_argument("--port", type=_port_argument, help="TCP port override")
|
||||
|
||||
watch = subcommands.add_parser("watch", help="watch the Wayland clipboard for images")
|
||||
_add_config_argument(watch)
|
||||
watch_modes = watch.add_mutually_exclusive_group()
|
||||
watch_modes.add_argument("--arm", action="store_true", help="arm live clipboard capture")
|
||||
watch_modes.add_argument(
|
||||
"--dry-run", action="store_true", help="validate the capture runtime plan"
|
||||
)
|
||||
|
||||
ingest_image = subcommands.add_parser("ingest-image", help="stage one image for capture")
|
||||
_add_config_argument(ingest_image)
|
||||
ingest_image.add_argument("image", type=Path)
|
||||
|
||||
import_video = subcommands.add_parser("import-video", help="stage a video for frame extraction")
|
||||
_add_config_argument(import_video)
|
||||
import_video.add_argument("video", type=Path)
|
||||
|
||||
export = subcommands.add_parser("export", help="write a versioned ISAC handoff")
|
||||
_add_config_argument(export)
|
||||
export.add_argument("destination", type=Path)
|
||||
|
||||
verify = subcommands.add_parser("verify", help="verify an evidence archive")
|
||||
_add_config_argument(verify)
|
||||
verify.add_argument("archive", type=Path)
|
||||
|
||||
evaluate = subcommands.add_parser("eval", help="evaluate local OCR and classification")
|
||||
_add_config_argument(evaluate)
|
||||
evaluate.add_argument("corpus", type=Path)
|
||||
|
||||
purge = subcommands.add_parser("purge", help="purge retained local data")
|
||||
_add_config_argument(purge)
|
||||
purge.add_argument("--confirm", action="store_true", help="confirm a future destructive purge")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _load_cli_config(config_path: Path | None) -> tuple[AppConfig, RuntimePaths]:
|
||||
paths = resolve_runtime_paths()
|
||||
return load_config(config_path=config_path, paths=paths), paths
|
||||
|
||||
|
||||
def _doctor(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
diagnostics = doctor_as_dict(run_doctor(config, paths))
|
||||
if arguments.json:
|
||||
print(json.dumps(diagnostics, separators=(",", ":"), sort_keys=True))
|
||||
else:
|
||||
for diagnostic in diagnostics:
|
||||
print(f"{diagnostic['name']}: {diagnostic['status']} ({diagnostic['detail']})")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _serve_contract(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
host = arguments.host if arguments.host is not None else config.server.host
|
||||
port = arguments.port if arguments.port is not None else config.server.port
|
||||
configured = replace(config, server=ServerConfig(host=host, port=port))
|
||||
CaptureRuntime(configured, paths).serve()
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _unimplemented(command: str) -> int:
|
||||
print(
|
||||
f"{command}: subsystem is not implemented; requires gdc-runtime-integration",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_UNIMPLEMENTED
|
||||
|
||||
|
||||
def _watch(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
if not arguments.arm and not arguments.dry_run:
|
||||
return _unimplemented("watch")
|
||||
runtime = CaptureRuntime(config, paths)
|
||||
if arguments.dry_run:
|
||||
runtime.validate_plan()
|
||||
print("watch dry-run: capture runtime plan is valid")
|
||||
return EXIT_OK
|
||||
runtime.watch()
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _ingest_image(arguments: argparse.Namespace) -> int:
|
||||
config, paths = _load_cli_config(arguments.config)
|
||||
CaptureRuntime(config, paths).ingest_image(arguments.image)
|
||||
print("image capture stored")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run one CLI command and return a stable process exit status."""
|
||||
|
||||
parser = build_parser()
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
if arguments.command == "doctor":
|
||||
return _doctor(arguments)
|
||||
if arguments.command == "serve":
|
||||
return _serve_contract(arguments)
|
||||
if arguments.command == "watch":
|
||||
return _watch(arguments)
|
||||
if arguments.command == "ingest-image":
|
||||
return _ingest_image(arguments)
|
||||
_load_cli_config(arguments.config)
|
||||
return _unimplemented(arguments.command)
|
||||
except (ConfigError, PathConfigurationError) as error:
|
||||
print(f"configuration error: {error}", file=sys.stderr)
|
||||
return EXIT_CONFIG_ERROR
|
||||
except RuntimeFailure:
|
||||
print("capture runtime failed", file=sys.stderr)
|
||||
return EXIT_RUNTIME_ERROR
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Typed, bounded configuration for the capture runtime.
|
||||
|
||||
Configuration parsing reads an explicit TOML file only when a caller invokes
|
||||
``load_config``. Importing this module has no filesystem or environment effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from game_data_capture.paths import RuntimePaths, resolve_runtime_paths
|
||||
|
||||
_ENVIRONMENT_NAME = re.compile(r"^[A-Z_][A-Z0-9_]{0,63}$")
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""A bounded, safe-to-display configuration error."""
|
||||
|
||||
|
||||
def _error(field_name: str, constraint: str) -> ConfigError:
|
||||
return ConfigError(f"Invalid configuration: {field_name} {constraint}.")
|
||||
|
||||
|
||||
def _bounded_int(value: object, field_name: str, minimum: int, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
||||
raise _error(field_name, f"must be an integer from {minimum} to {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(value: object, field_name: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise _error(field_name, "must be a table")
|
||||
return value
|
||||
|
||||
|
||||
def _only_keys(section: Mapping[str, object], section_name: str, allowed: set[str]) -> None:
|
||||
if set(section) - allowed:
|
||||
raise _error(section_name, "contains an unsupported setting")
|
||||
|
||||
|
||||
def _string(value: object, field_name: str, maximum: int) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
raise _error(field_name, f"must be a non-empty string up to {maximum} characters")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServerConfig:
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
address = ipaddress.ip_address(self.host)
|
||||
except ValueError as error:
|
||||
raise _error("server.host", "must be a loopback IP literal") from error
|
||||
if not address.is_loopback:
|
||||
raise _error("server.host", "must be a loopback IP literal")
|
||||
_bounded_int(self.port, "server.port", 1024, 65535)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LifecycleConfig:
|
||||
"""One-instance settings consumed later by the runtime integration layer."""
|
||||
|
||||
instance_name: str = "shd-capture"
|
||||
lock_timeout_seconds: int = 5
|
||||
shutdown_timeout_seconds: int = 30
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", self.instance_name):
|
||||
raise _error(
|
||||
"lifecycle.instance_name", "must use lowercase letters, digits, and hyphens"
|
||||
)
|
||||
_bounded_int(self.lock_timeout_seconds, "lifecycle.lock_timeout_seconds", 0, 60)
|
||||
_bounded_int(self.shutdown_timeout_seconds, "lifecycle.shutdown_timeout_seconds", 1, 300)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureLimits:
|
||||
max_image_bytes: int = 32 * 1024 * 1024
|
||||
max_video_bytes: int = 4 * 1024 * 1024 * 1024
|
||||
max_queue_items: int = 64
|
||||
max_ocr_text_characters: int = 20_000
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_bounded_int(self.max_image_bytes, "limits.max_image_bytes", 1, 64 * 1024 * 1024)
|
||||
_bounded_int(self.max_video_bytes, "limits.max_video_bytes", 1, 16 * 1024 * 1024 * 1024)
|
||||
_bounded_int(self.max_queue_items, "limits.max_queue_items", 1, 256)
|
||||
_bounded_int(self.max_ocr_text_characters, "limits.max_ocr_text_characters", 1, 100_000)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpenAIConfig:
|
||||
api_key_env: str = "OPENAI_API_KEY"
|
||||
model: str = "gpt-4.1-mini"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _ENVIRONMENT_NAME.fullmatch(self.api_key_env):
|
||||
raise _error("openai.api_key_env", "must be an environment variable name")
|
||||
_string(self.model, "openai.model", 128)
|
||||
|
||||
def is_configured(self, environment: Mapping[str, str] | None = None) -> bool:
|
||||
env = os.environ if environment is None else environment
|
||||
return bool(env.get(self.api_key_env))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppConfig:
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
lifecycle: LifecycleConfig = field(default_factory=LifecycleConfig)
|
||||
limits: CaptureLimits = field(default_factory=CaptureLimits)
|
||||
openai: OpenAIConfig = field(default_factory=OpenAIConfig)
|
||||
|
||||
|
||||
def _section(document: Mapping[str, object], name: str, allowed: set[str]) -> Mapping[str, object]:
|
||||
value = document.get(name, {})
|
||||
section = _mapping(value, name)
|
||||
_only_keys(section, name, allowed)
|
||||
return section
|
||||
|
||||
|
||||
def parse_config(document: Mapping[str, object]) -> AppConfig:
|
||||
"""Parse a TOML-shaped mapping with an allowlist of configuration keys."""
|
||||
|
||||
_only_keys(document, "root", {"server", "lifecycle", "limits", "openai"})
|
||||
server = _section(document, "server", {"host", "port"})
|
||||
lifecycle = _section(
|
||||
document, "lifecycle", {"instance_name", "lock_timeout_seconds", "shutdown_timeout_seconds"}
|
||||
)
|
||||
limits = _section(
|
||||
document,
|
||||
"limits",
|
||||
{"max_image_bytes", "max_video_bytes", "max_queue_items", "max_ocr_text_characters"},
|
||||
)
|
||||
openai = _section(document, "openai", {"api_key_env", "model"})
|
||||
|
||||
return AppConfig(
|
||||
server=ServerConfig(
|
||||
host=_string(server.get("host", "127.0.0.1"), "server.host", 64),
|
||||
port=_bounded_int(server.get("port", 8765), "server.port", 1024, 65535),
|
||||
),
|
||||
lifecycle=LifecycleConfig(
|
||||
instance_name=_string(
|
||||
lifecycle.get("instance_name", "shd-capture"), "lifecycle.instance_name", 32
|
||||
),
|
||||
lock_timeout_seconds=_bounded_int(
|
||||
lifecycle.get("lock_timeout_seconds", 5), "lifecycle.lock_timeout_seconds", 0, 60
|
||||
),
|
||||
shutdown_timeout_seconds=_bounded_int(
|
||||
lifecycle.get("shutdown_timeout_seconds", 30),
|
||||
"lifecycle.shutdown_timeout_seconds",
|
||||
1,
|
||||
300,
|
||||
),
|
||||
),
|
||||
limits=CaptureLimits(
|
||||
max_image_bytes=_bounded_int(
|
||||
limits.get("max_image_bytes", 32 * 1024 * 1024),
|
||||
"limits.max_image_bytes",
|
||||
1,
|
||||
64 * 1024 * 1024,
|
||||
),
|
||||
max_video_bytes=_bounded_int(
|
||||
limits.get("max_video_bytes", 4 * 1024 * 1024 * 1024),
|
||||
"limits.max_video_bytes",
|
||||
1,
|
||||
16 * 1024 * 1024 * 1024,
|
||||
),
|
||||
max_queue_items=_bounded_int(
|
||||
limits.get("max_queue_items", 64), "limits.max_queue_items", 1, 256
|
||||
),
|
||||
max_ocr_text_characters=_bounded_int(
|
||||
limits.get("max_ocr_text_characters", 20_000),
|
||||
"limits.max_ocr_text_characters",
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
),
|
||||
openai=OpenAIConfig(
|
||||
api_key_env=_string(
|
||||
openai.get("api_key_env", "OPENAI_API_KEY"), "openai.api_key_env", 64
|
||||
),
|
||||
model=_string(openai.get("model", "gpt-4.1-mini"), "openai.model", 128),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None, paths: RuntimePaths | None = None) -> AppConfig:
|
||||
"""Read optional TOML configuration from the XDG config location.
|
||||
|
||||
An absent file selects safe defaults. Parse failures intentionally omit source values and paths.
|
||||
"""
|
||||
|
||||
selected_path = (
|
||||
config_path if config_path is not None else (paths or resolve_runtime_paths()).config_file
|
||||
)
|
||||
if not selected_path.is_absolute() or len(str(selected_path)) > 240:
|
||||
raise _error("config path", "must be an absolute path within 240 characters")
|
||||
if not selected_path.exists():
|
||||
return AppConfig()
|
||||
try:
|
||||
with selected_path.open("rb") as handle:
|
||||
document: Any = tomllib.load(handle)
|
||||
except (OSError, tomllib.TOMLDecodeError) as error:
|
||||
raise _error("config file", "could not be read") from error
|
||||
if not isinstance(document, dict):
|
||||
raise _error("root", "must be a table")
|
||||
return parse_config(document)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Side-effect-limited local diagnostics for capture prerequisites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from os import environ
|
||||
|
||||
from game_data_capture.config import AppConfig
|
||||
from game_data_capture.paths import (
|
||||
PathConfigurationError,
|
||||
RuntimePaths,
|
||||
prepare_private_directories,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Diagnostic:
|
||||
name: str
|
||||
status: str
|
||||
detail: str
|
||||
|
||||
|
||||
def _executable_diagnostic(name: str) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
name=name,
|
||||
status="available" if shutil.which(name) else "unavailable",
|
||||
detail="executable found on PATH" if shutil.which(name) else "executable not found on PATH",
|
||||
)
|
||||
|
||||
|
||||
def _gpu_diagnostic(environment: Mapping[str, str]) -> Diagnostic:
|
||||
configured_provider = environment.get("GDC_OCR_PROVIDER")
|
||||
if configured_provider:
|
||||
return Diagnostic(
|
||||
"gpu_provider", "unknown", "provider configured; runtime probe is deferred"
|
||||
)
|
||||
if importlib.util.find_spec("torch") is not None:
|
||||
return Diagnostic(
|
||||
"gpu_provider", "unknown", "torch is installed; runtime probe is deferred"
|
||||
)
|
||||
return Diagnostic("gpu_provider", "unknown", "no provider selected; runtime probe is deferred")
|
||||
|
||||
|
||||
def _storage_diagnostic(paths: RuntimePaths) -> Diagnostic:
|
||||
try:
|
||||
prepare_private_directories(paths)
|
||||
except (OSError, PathConfigurationError):
|
||||
return Diagnostic(
|
||||
"storage_permissions", "unavailable", "private XDG directories are not usable"
|
||||
)
|
||||
return Diagnostic(
|
||||
"storage_permissions", "available", "private XDG directories use owner-only access"
|
||||
)
|
||||
|
||||
|
||||
def run_doctor(
|
||||
config: AppConfig, paths: RuntimePaths, environment: Mapping[str, str] | None = None
|
||||
) -> tuple[Diagnostic, ...]:
|
||||
"""Inspect local prerequisites without launching processes or making network calls."""
|
||||
|
||||
env = environ if environment is None else environment
|
||||
return (
|
||||
_executable_diagnostic("niri"),
|
||||
_executable_diagnostic("wl-paste"),
|
||||
_executable_diagnostic("ffmpeg"),
|
||||
_gpu_diagnostic(env),
|
||||
_storage_diagnostic(paths),
|
||||
Diagnostic(
|
||||
"openai",
|
||||
"configured" if config.openai.is_configured(env) else "missing",
|
||||
"environment variable is present"
|
||||
if config.openai.is_configured(env)
|
||||
else "environment variable is absent",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def doctor_as_dict(diagnostics: tuple[Diagnostic, ...]) -> list[dict[str, str]]:
|
||||
"""Return diagnostics in a serialization-ready form without paths or secret values."""
|
||||
|
||||
return [asdict(diagnostic) for diagnostic in diagnostics]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Side-effect-free domain contracts for the game data capture utility."""
|
||||
|
||||
from .state_machines import InvalidStateTransition, StateMachine, require_transition
|
||||
from .taxonomy import (
|
||||
ABSTENTION_REASONS,
|
||||
GEAR_SLOTS,
|
||||
RECORD_TYPES_BY_VIEW,
|
||||
SCREEN_VIEWS,
|
||||
AbstentionReason,
|
||||
Family,
|
||||
GearSlot,
|
||||
RecordType,
|
||||
)
|
||||
from .validation import (
|
||||
ContractValidationError,
|
||||
ensure_supported_version,
|
||||
validate_archive_document,
|
||||
validate_archive_references,
|
||||
validate_classification,
|
||||
validate_handoff_document,
|
||||
validate_handoff_references,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ABSTENTION_REASONS",
|
||||
"GEAR_SLOTS",
|
||||
"RECORD_TYPES_BY_VIEW",
|
||||
"SCREEN_VIEWS",
|
||||
"AbstentionReason",
|
||||
"ContractValidationError",
|
||||
"Family",
|
||||
"GearSlot",
|
||||
"InvalidStateTransition",
|
||||
"RecordType",
|
||||
"StateMachine",
|
||||
"ensure_supported_version",
|
||||
"require_transition",
|
||||
"validate_archive_document",
|
||||
"validate_archive_references",
|
||||
"validate_classification",
|
||||
"validate_handoff_document",
|
||||
"validate_handoff_references",
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Typed Python views of the portable contract structures."""
|
||||
|
||||
import typing
|
||||
|
||||
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
|
||||
type MissingReason = typing.Literal[
|
||||
"not_visible",
|
||||
"occluded",
|
||||
"unreadable",
|
||||
"not_present_on_screen",
|
||||
"unsupported",
|
||||
"ambiguous",
|
||||
"extraction_failed",
|
||||
"not_applicable",
|
||||
"redacted",
|
||||
]
|
||||
|
||||
|
||||
class SourceEngine(typing.TypedDict):
|
||||
kind: typing.Literal["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]
|
||||
engine_id: str
|
||||
engine_version: str
|
||||
|
||||
|
||||
class NormalizedRegion(typing.TypedDict):
|
||||
coordinate_space: typing.Literal["normalized"]
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class PixelRegion(typing.TypedDict):
|
||||
coordinate_space: typing.Literal["pixels"]
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
type BoundingRegion = NormalizedRegion | PixelRegion
|
||||
|
||||
|
||||
class ValueState(typing.TypedDict):
|
||||
raw_text: str | None
|
||||
normalized_value: JsonValue
|
||||
missing_reason: MissingReason | None
|
||||
|
||||
|
||||
class Correction(typing.TypedDict):
|
||||
correction_id: str
|
||||
corrected_at: str
|
||||
actor: typing.Literal["local_user", "system_reprocess"]
|
||||
reason: str
|
||||
before: ValueState
|
||||
after: ValueState
|
||||
|
||||
|
||||
class EvidenceValue(ValueState):
|
||||
evidence_id: str
|
||||
field_path: str
|
||||
confidence: float
|
||||
source_engine: SourceEngine
|
||||
image_artifact_id: str
|
||||
image_sha256: str
|
||||
region: BoundingRegion
|
||||
correction_history: list[Correction]
|
||||
|
||||
|
||||
class FieldClaim(typing.TypedDict):
|
||||
path: str
|
||||
value: JsonValue
|
||||
missing_reason: MissingReason | None
|
||||
evidence_refs: list[str]
|
||||
uncertainty_note: str | None
|
||||
|
||||
|
||||
class ScreenshotClassification(typing.TypedDict):
|
||||
family: typing.Literal[
|
||||
"weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"
|
||||
]
|
||||
view: str
|
||||
gear_slot: (
|
||||
typing.Literal["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"] | None
|
||||
)
|
||||
record_types: list[str]
|
||||
confidence: float
|
||||
abstention_reason: str | None
|
||||
|
||||
|
||||
class ObservedContext(typing.TypedDict):
|
||||
level: FieldClaim
|
||||
quality: FieldClaim
|
||||
tier: FieldClaim
|
||||
upgrade_state: FieldClaim
|
||||
|
||||
|
||||
class ObservedItemInstance(typing.TypedDict):
|
||||
"""Values shown for one configured item; they are not reusable definitions."""
|
||||
|
||||
instance_id: str
|
||||
family: str
|
||||
source_screen_ids: list[str]
|
||||
observed_context: ObservedContext
|
||||
fields: list[FieldClaim]
|
||||
|
||||
|
||||
class CanonicalDefinitionCandidate(typing.TypedDict):
|
||||
"""Evidence-backed reusable claims, kept separate from the observed item."""
|
||||
|
||||
candidate_id: str
|
||||
record_type: str
|
||||
source_instance_ids: list[str]
|
||||
evidence_refs: list[str]
|
||||
fields: list[FieldClaim]
|
||||
completeness: typing.Literal["complete", "incomplete", "unsupported"]
|
||||
relationship_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
|
||||
mechanic_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Trust-boundary ports. Implementations belong to downstream tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from math import isfinite
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceBudget:
|
||||
deadline_monotonic: float | None
|
||||
max_input_bytes: int
|
||||
max_output_bytes: int
|
||||
max_items: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
limits: tuple[tuple[str, object], ...] = (
|
||||
("max_input_bytes", self.max_input_bytes),
|
||||
("max_output_bytes", self.max_output_bytes),
|
||||
("max_items", self.max_items),
|
||||
)
|
||||
for name, value in limits:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
deadline: object = self.deadline_monotonic
|
||||
if deadline is not None:
|
||||
if isinstance(deadline, bool) or not isinstance(deadline, int | float):
|
||||
raise ValueError("deadline_monotonic must be a finite number or null")
|
||||
if not isfinite(deadline):
|
||||
raise ValueError("deadline_monotonic must be finite or null")
|
||||
|
||||
|
||||
class CancellationToken(Protocol):
|
||||
def is_cancelled(self) -> bool: ...
|
||||
|
||||
def raise_if_cancelled(self) -> None: ...
|
||||
|
||||
|
||||
class Clock(Protocol):
|
||||
def now_utc(self) -> datetime: ...
|
||||
|
||||
def monotonic(self) -> float: ...
|
||||
|
||||
|
||||
class IdGenerator(Protocol):
|
||||
def new_id(self) -> str: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlobReceipt:
|
||||
sha256: str
|
||||
byte_size: int
|
||||
media_type: str
|
||||
|
||||
|
||||
class BlobStorage(Protocol):
|
||||
"""Stores immutable bytes by digest without exposing filesystem paths."""
|
||||
|
||||
def put(
|
||||
self,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
expected_sha256: str | None,
|
||||
media_type: str,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> BlobReceipt: ...
|
||||
|
||||
def read(
|
||||
self,
|
||||
sha256: str,
|
||||
*,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> Iterable[bytes]: ...
|
||||
|
||||
def exists(self, sha256: str) -> bool: ...
|
||||
|
||||
|
||||
class Repository[RecordT](Protocol):
|
||||
def get(self, record_id: str) -> RecordT | None: ...
|
||||
|
||||
def add(self, record: RecordT) -> None: ...
|
||||
|
||||
def replace(self, record: RecordT, *, expected_revision: int) -> None: ...
|
||||
|
||||
|
||||
type CaptureRepository = Repository[object]
|
||||
type JobRepository = Repository[object]
|
||||
type ExtractionRepository = Repository[object]
|
||||
type GroupingRepository = Repository[object]
|
||||
type ReviewRepository = Repository[object]
|
||||
type ExportRepository = Repository[object]
|
||||
type ErrorRepository = Repository[object]
|
||||
|
||||
|
||||
class UnitOfWork(Protocol):
|
||||
captures: CaptureRepository
|
||||
jobs: JobRepository
|
||||
extractions: ExtractionRepository
|
||||
groupings: GroupingRepository
|
||||
reviews: ReviewRepository
|
||||
exports: ExportRepository
|
||||
errors: ErrorRepository
|
||||
|
||||
def commit(self) -> None: ...
|
||||
|
||||
def rollback(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImageInput:
|
||||
artifact_id: str
|
||||
sha256: str
|
||||
media_type: str
|
||||
byte_size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineResult:
|
||||
engine_id: str
|
||||
engine_version: str
|
||||
payload: Mapping[str, object]
|
||||
|
||||
|
||||
class OcrEngine(Protocol):
|
||||
def extract(
|
||||
self,
|
||||
image: ImageInput,
|
||||
*,
|
||||
regions: Sequence[Mapping[str, int | float | str]],
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> EngineResult: ...
|
||||
|
||||
|
||||
class VisionFallback(Protocol):
|
||||
"""Accepts bounded artifacts and never receives credentials in a request."""
|
||||
|
||||
def extract(
|
||||
self,
|
||||
images: Sequence[ImageInput],
|
||||
*,
|
||||
response_schema: Mapping[str, object],
|
||||
request_fingerprint: str,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> EngineResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubprocessRequest:
|
||||
tool: SubprocessTool
|
||||
arguments: tuple[str, ...]
|
||||
environment: Mapping[SubprocessEnvironmentKey, str]
|
||||
stdin: bytes | None
|
||||
budget: ResourceBudget
|
||||
|
||||
|
||||
class SubprocessTool(StrEnum):
|
||||
WL_PASTE = "wl-paste"
|
||||
FFMPEG = "ffmpeg"
|
||||
FFPROBE = "ffprobe"
|
||||
|
||||
|
||||
class SubprocessEnvironmentKey(StrEnum):
|
||||
WAYLAND_DISPLAY = "WAYLAND_DISPLAY"
|
||||
XDG_RUNTIME_DIR = "XDG_RUNTIME_DIR"
|
||||
PATH = "PATH"
|
||||
HOME = "HOME"
|
||||
LANG = "LANG"
|
||||
LC_ALL = "LC_ALL"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubprocessResult:
|
||||
return_code: int
|
||||
stdout: bytes
|
||||
stderr: bytes
|
||||
timed_out: bool
|
||||
cancelled: bool
|
||||
|
||||
|
||||
class SubprocessRunner(Protocol):
|
||||
"""Runs an argument array without a shell and with an explicit environment."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
request: SubprocessRequest,
|
||||
*,
|
||||
cancellation: CancellationToken,
|
||||
) -> SubprocessResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrameScore:
|
||||
timestamp_ms: int
|
||||
stability: float
|
||||
sharpness: float
|
||||
ui_coverage: float
|
||||
|
||||
|
||||
class FrameScorer(Protocol):
|
||||
def score(
|
||||
self,
|
||||
frames: Sequence[ImageInput],
|
||||
*,
|
||||
budget: ResourceBudget,
|
||||
cancellation: CancellationToken,
|
||||
) -> Sequence[FrameScore]: ...
|
||||
@@ -0,0 +1,211 @@
|
||||
"""State vocabularies and allowed transitions for recoverable workflows."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
|
||||
class CaptureState(StrEnum):
|
||||
RECEIVED = "received"
|
||||
SPOOLING = "spooling"
|
||||
STORED = "stored"
|
||||
DEDUPLICATED = "deduplicated"
|
||||
QUEUED = "queued"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobState(StrEnum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
RETRY_WAIT = "retry_wait"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ExtractionState(StrEnum):
|
||||
PENDING = "pending"
|
||||
LOCAL_PROCESSING = "local_processing"
|
||||
FALLBACK_PENDING = "fallback_pending"
|
||||
FALLBACK_PROCESSING = "fallback_processing"
|
||||
CANDIDATE_READY = "candidate_ready"
|
||||
ABSTAINED = "abstained"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class GroupingState(StrEnum):
|
||||
UNGROUPED = "ungrouped"
|
||||
PROPOSED = "proposed"
|
||||
GROUPED = "grouped"
|
||||
SPLIT = "split"
|
||||
SUPERSEDED = "superseded"
|
||||
REJECTED = "rejected"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ReviewState(StrEnum):
|
||||
PENDING = "pending"
|
||||
IN_REVIEW = "in_review"
|
||||
CHANGES_REQUESTED = "changes_requested"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ExportState(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
QUEUED = "queued"
|
||||
BUILDING = "building"
|
||||
READY = "ready"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class ErrorState(StrEnum):
|
||||
OPEN = "open"
|
||||
RETRYABLE = "retryable"
|
||||
RETRYING = "retrying"
|
||||
RECOVERED = "recovered"
|
||||
TERMINAL = "terminal"
|
||||
DISMISSED = "dismissed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StateMachine:
|
||||
name: str
|
||||
transitions: Mapping[str, frozenset[str]]
|
||||
|
||||
def allows(self, current: str, target: str) -> bool:
|
||||
return target in self.transitions.get(current, frozenset())
|
||||
|
||||
|
||||
def _machine(name: str, transitions: dict[str, set[str]]) -> StateMachine:
|
||||
return StateMachine(
|
||||
name=name,
|
||||
transitions=MappingProxyType(
|
||||
{state: frozenset(targets) for state, targets in transitions.items()}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
STATE_MACHINES: Final[Mapping[str, StateMachine]] = MappingProxyType(
|
||||
{
|
||||
"capture": _machine(
|
||||
"capture",
|
||||
{
|
||||
"received": {"spooling", "deduplicated", "failed", "cancelled"},
|
||||
"spooling": {"stored", "failed", "cancelled"},
|
||||
"stored": {"queued", "failed"},
|
||||
"deduplicated": set(),
|
||||
"queued": set(),
|
||||
"failed": {"spooling", "cancelled"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"job": _machine(
|
||||
"job",
|
||||
{
|
||||
"queued": {"running", "cancelled"},
|
||||
"running": {"succeeded", "retry_wait", "failed", "cancelled"},
|
||||
"retry_wait": {"queued", "failed", "cancelled"},
|
||||
"succeeded": set(),
|
||||
"failed": {"queued"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"extraction": _machine(
|
||||
"extraction",
|
||||
{
|
||||
"pending": {"local_processing", "cancelled"},
|
||||
"local_processing": {
|
||||
"candidate_ready",
|
||||
"fallback_pending",
|
||||
"abstained",
|
||||
"failed",
|
||||
"cancelled",
|
||||
},
|
||||
"fallback_pending": {"fallback_processing", "cancelled"},
|
||||
"fallback_processing": {
|
||||
"candidate_ready",
|
||||
"abstained",
|
||||
"failed",
|
||||
"cancelled",
|
||||
},
|
||||
"candidate_ready": set(),
|
||||
"abstained": set(),
|
||||
"failed": {"pending"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"grouping": _machine(
|
||||
"grouping",
|
||||
{
|
||||
"ungrouped": {"proposed", "cancelled"},
|
||||
"proposed": {"grouped", "rejected", "cancelled"},
|
||||
"grouped": {"split", "superseded"},
|
||||
"split": {"superseded"},
|
||||
"superseded": set(),
|
||||
"rejected": {"ungrouped"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"review": _machine(
|
||||
"review",
|
||||
{
|
||||
"pending": {"in_review", "cancelled"},
|
||||
"in_review": {
|
||||
"changes_requested",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
},
|
||||
"changes_requested": {"in_review", "rejected", "cancelled"},
|
||||
"approved": {"in_review"},
|
||||
"rejected": {"in_review"},
|
||||
"cancelled": set(),
|
||||
},
|
||||
),
|
||||
"export": _machine(
|
||||
"export",
|
||||
{
|
||||
"not_requested": {"queued"},
|
||||
"queued": {"building", "cancelled"},
|
||||
"building": {"ready", "failed", "cancelled"},
|
||||
"ready": {"expired"},
|
||||
"failed": {"queued", "cancelled"},
|
||||
"cancelled": set(),
|
||||
"expired": {"queued"},
|
||||
},
|
||||
),
|
||||
"error": _machine(
|
||||
"error",
|
||||
{
|
||||
"open": {"retryable", "terminal", "dismissed"},
|
||||
"retryable": {"retrying", "terminal", "dismissed"},
|
||||
"retrying": {"recovered", "retryable", "terminal"},
|
||||
"recovered": set(),
|
||||
"terminal": {"dismissed"},
|
||||
"dismissed": set(),
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class InvalidStateTransition(ValueError):
|
||||
"""Raised when a caller attempts a transition absent from the contract."""
|
||||
|
||||
|
||||
def require_transition(machine_name: str, current: str, target: str) -> None:
|
||||
machine = STATE_MACHINES.get(machine_name)
|
||||
if machine is None:
|
||||
raise InvalidStateTransition(f"Unknown state machine: {machine_name}")
|
||||
if current not in machine.transitions:
|
||||
raise InvalidStateTransition(f"Unknown {machine_name} state: {current}")
|
||||
if not machine.allows(current, target):
|
||||
raise InvalidStateTransition(f"Invalid {machine_name} transition: {current} -> {target}")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Frozen screenshot taxonomy and canonical record vocabulary for contract v1."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Family(StrEnum):
|
||||
WEAPON = "weapon"
|
||||
GEAR = "gear"
|
||||
SKILL_CHIP = "skill_chip"
|
||||
OS_PROTOCOL = "os_protocol"
|
||||
SPECIALIZATION = "specialization"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class GearSlot(StrEnum):
|
||||
MASK = "Mask"
|
||||
GLOVES = "Gloves"
|
||||
BACKPACK = "Backpack"
|
||||
BODY_ARMOR = "Body Armor"
|
||||
HOLSTER = "Holster"
|
||||
KNEE_PADS = "Knee Pads"
|
||||
|
||||
|
||||
class AbstentionReason(StrEnum):
|
||||
LOW_CONFIDENCE = "low_confidence"
|
||||
AMBIGUOUS_FAMILY = "ambiguous_family"
|
||||
AMBIGUOUS_VIEW = "ambiguous_view"
|
||||
UNSUPPORTED_FAMILY = "unsupported_family"
|
||||
UNSUPPORTED_VIEW = "unsupported_view"
|
||||
INSUFFICIENT_VISUAL_EVIDENCE = "insufficient_visual_evidence"
|
||||
OCCLUDED = "occluded"
|
||||
CORRUPT_IMAGE = "corrupt_image"
|
||||
RESOURCE_LIMIT = "resource_limit"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class RecordType(StrEnum):
|
||||
WEAPON = "weapon"
|
||||
WEAPON_TALENT = "weapon_talent"
|
||||
WEAPON_AUGMENTATION_PROFILE = "weapon_augmentation_profile"
|
||||
GEAR = "gear"
|
||||
BRAND_SET = "brand_set"
|
||||
GEAR_TALENT = "gear_talent"
|
||||
GEAR_AUGMENTATION_PROFILE = "gear_augmentation_profile"
|
||||
SKILL_CHIP_SET = "skill_chip_set"
|
||||
SKILL_CHIP_SET_BONUS = "skill_chip_set_bonus"
|
||||
SKILL_CHIP_ROLL_POOL = "skill_chip_roll_pool"
|
||||
SKILL_CHIP_TALENT = "skill_chip_talent"
|
||||
OS_PROTOCOL = "os_protocol"
|
||||
OS_PROTOCOL_SECONDARY_PROFILE = "os_protocol_secondary_profile"
|
||||
OS_PROTOCOL_TALENT = "os_protocol_talent"
|
||||
SPECIALIZATION = "specialization"
|
||||
SPECIALIZATION_FOCUS = "specialization_focus"
|
||||
SPECIALIZATION_ABILITY = "specialization_ability"
|
||||
SPECIALIZATION_TREE_NODE = "specialization_tree_node"
|
||||
SPECIALIZATION_TREE_RANK = "specialization_tree_rank"
|
||||
|
||||
|
||||
SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType(
|
||||
{
|
||||
Family.WEAPON: (
|
||||
"detail",
|
||||
"blueprint",
|
||||
"crafting",
|
||||
"stat_range",
|
||||
"talent_grid",
|
||||
"augmentation",
|
||||
),
|
||||
Family.GEAR: ("detail", "blueprint", "talent", "augmentation"),
|
||||
Family.SKILL_CHIP: ("detail", "set_bonus", "talent"),
|
||||
Family.OS_PROTOCOL: ("detail", "secondary_profile", "talent"),
|
||||
Family.SPECIALIZATION: (
|
||||
"overview",
|
||||
"details",
|
||||
"ability",
|
||||
"focus_details",
|
||||
"focus_tree",
|
||||
"rank_details",
|
||||
),
|
||||
Family.UNKNOWN: ("unknown",),
|
||||
}
|
||||
)
|
||||
|
||||
GEAR_SLOTS: Final[tuple[str, ...]] = tuple(slot.value for slot in GearSlot)
|
||||
ABSTENTION_REASONS: Final[tuple[str, ...]] = tuple(reason.value for reason in AbstentionReason)
|
||||
|
||||
RECORD_TYPES_BY_VIEW: Final[Mapping[tuple[Family, str], tuple[RecordType, ...]]] = MappingProxyType(
|
||||
{
|
||||
(Family.WEAPON, "detail"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
|
||||
(Family.WEAPON, "blueprint"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
|
||||
(Family.WEAPON, "crafting"): (RecordType.WEAPON,),
|
||||
(Family.WEAPON, "stat_range"): (RecordType.WEAPON,),
|
||||
(Family.WEAPON, "talent_grid"): (RecordType.WEAPON_TALENT,),
|
||||
(Family.WEAPON, "augmentation"): (RecordType.WEAPON_AUGMENTATION_PROFILE,),
|
||||
(Family.GEAR, "detail"): (RecordType.GEAR, RecordType.BRAND_SET),
|
||||
(Family.GEAR, "blueprint"): (RecordType.GEAR, RecordType.BRAND_SET),
|
||||
(Family.GEAR, "talent"): (RecordType.GEAR_TALENT,),
|
||||
(Family.GEAR, "augmentation"): (RecordType.GEAR_AUGMENTATION_PROFILE,),
|
||||
(Family.SKILL_CHIP, "detail"): (
|
||||
RecordType.SKILL_CHIP_SET,
|
||||
RecordType.SKILL_CHIP_ROLL_POOL,
|
||||
),
|
||||
(Family.SKILL_CHIP, "set_bonus"): (RecordType.SKILL_CHIP_SET_BONUS,),
|
||||
(Family.SKILL_CHIP, "talent"): (RecordType.SKILL_CHIP_TALENT,),
|
||||
(Family.OS_PROTOCOL, "detail"): (RecordType.OS_PROTOCOL,),
|
||||
(Family.OS_PROTOCOL, "secondary_profile"): (RecordType.OS_PROTOCOL_SECONDARY_PROFILE,),
|
||||
(Family.OS_PROTOCOL, "talent"): (RecordType.OS_PROTOCOL_TALENT,),
|
||||
(Family.SPECIALIZATION, "overview"): (RecordType.SPECIALIZATION,),
|
||||
(Family.SPECIALIZATION, "details"): (RecordType.SPECIALIZATION,),
|
||||
(Family.SPECIALIZATION, "ability"): (RecordType.SPECIALIZATION_ABILITY,),
|
||||
(Family.SPECIALIZATION, "focus_details"): (RecordType.SPECIALIZATION_FOCUS,),
|
||||
(Family.SPECIALIZATION, "focus_tree"): (
|
||||
RecordType.SPECIALIZATION_FOCUS,
|
||||
RecordType.SPECIALIZATION_TREE_NODE,
|
||||
),
|
||||
(Family.SPECIALIZATION, "rank_details"): (RecordType.SPECIALIZATION_TREE_RANK,),
|
||||
(Family.UNKNOWN, "unknown"): (),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Semantic checks that portable JSON Schema cannot express."""
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime
|
||||
|
||||
from . import taxonomy
|
||||
|
||||
SUPPORTED_ARCHIVE_SCHEMA_VERSION = 1
|
||||
SUPPORTED_HANDOFF_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class ContractValidationError(ValueError):
|
||||
"""Raised when a document violates a version or referential contract."""
|
||||
|
||||
|
||||
def ensure_supported_version(document: Mapping[str, object], expected: int) -> None:
|
||||
version = document.get("schema_version")
|
||||
if type(version) is not int or version != expected:
|
||||
raise ContractValidationError(
|
||||
f"Unsupported schema_version {version!r}; expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
def _objects(value: object, field: str) -> Iterable[Mapping[str, object]]:
|
||||
if not isinstance(value, list):
|
||||
raise ContractValidationError(f"{field} must be an array")
|
||||
for index, item in enumerate(value):
|
||||
if not isinstance(item, Mapping):
|
||||
raise ContractValidationError(f"{field}[{index}] must be an object")
|
||||
yield item
|
||||
|
||||
|
||||
def _ids(items: Iterable[Mapping[str, object]], field: str) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for item in items:
|
||||
value = item.get(field)
|
||||
if not isinstance(value, str):
|
||||
raise ContractValidationError(f"{field} must be a string")
|
||||
if value in result:
|
||||
raise ContractValidationError(f"Duplicate {field}: {value}")
|
||||
result.add(value)
|
||||
return result
|
||||
|
||||
|
||||
def _references(item: Mapping[str, object], field: str) -> set[str]:
|
||||
raw = item.get(field, [])
|
||||
if not isinstance(raw, list) or any(not isinstance(value, str) for value in raw):
|
||||
raise ContractValidationError(f"{field} must contain string IDs")
|
||||
return set(raw)
|
||||
|
||||
|
||||
def _require_subset(values: set[str], allowed: set[str], context: str) -> None:
|
||||
dangling = values - allowed
|
||||
if dangling:
|
||||
raise ContractValidationError(f"{context} contains dangling references: {sorted(dangling)}")
|
||||
|
||||
|
||||
def validate_classification(classification: Mapping[str, object]) -> None:
|
||||
family_raw = classification.get("family")
|
||||
if not isinstance(family_raw, str):
|
||||
raise ContractValidationError("Unknown classification family")
|
||||
try:
|
||||
family = taxonomy.Family(family_raw)
|
||||
except ValueError as error:
|
||||
raise ContractValidationError("Unknown classification family") from error
|
||||
view = classification.get("view")
|
||||
if not isinstance(view, str) or view not in taxonomy.SCREEN_VIEWS[family]:
|
||||
raise ContractValidationError(f"Unknown {family.value} view: {view!r}")
|
||||
confidence = classification.get("confidence")
|
||||
if (
|
||||
isinstance(confidence, bool)
|
||||
or not isinstance(confidence, int | float)
|
||||
or not 0 <= confidence <= 1
|
||||
):
|
||||
raise ContractValidationError("Classification confidence must be between 0 and 1")
|
||||
gear_slot = classification.get("gear_slot")
|
||||
if family is taxonomy.Family.GEAR:
|
||||
if gear_slot not in taxonomy.GEAR_SLOTS:
|
||||
raise ContractValidationError(f"Unknown Gear slot: {gear_slot!r}")
|
||||
elif gear_slot is not None:
|
||||
raise ContractValidationError("Only Gear classifications may set gear_slot")
|
||||
record_types = classification.get("record_types")
|
||||
if not isinstance(record_types, list) or any(
|
||||
not isinstance(record_type, str) for record_type in record_types
|
||||
):
|
||||
raise ContractValidationError("record_types must contain strings")
|
||||
if len(record_types) != len(set(record_types)):
|
||||
raise ContractValidationError("record_types must be unique")
|
||||
allowed = {record_type.value for record_type in taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]}
|
||||
if family is taxonomy.Family.UNKNOWN:
|
||||
if record_types:
|
||||
raise ContractValidationError("Unknown screens cannot claim canonical record types")
|
||||
reason = classification.get("abstention_reason")
|
||||
if reason not in taxonomy.ABSTENTION_REASONS:
|
||||
raise ContractValidationError("Unknown screens require an abstention reason")
|
||||
else:
|
||||
if not record_types or not set(record_types).issubset(allowed):
|
||||
raise ContractValidationError(
|
||||
f"record_types are incompatible with {family.value}/{view}"
|
||||
)
|
||||
if classification.get("abstention_reason") is not None:
|
||||
raise ContractValidationError("Known screens cannot set abstention_reason")
|
||||
|
||||
|
||||
def _validate_evidence(value: Mapping[str, object]) -> None:
|
||||
_validate_value_state(value, "evidence")
|
||||
confidence = value.get("confidence")
|
||||
if (
|
||||
isinstance(confidence, bool)
|
||||
or not isinstance(confidence, int | float)
|
||||
or not 0 <= confidence <= 1
|
||||
):
|
||||
raise ContractValidationError("Evidence confidence must be between 0 and 1")
|
||||
region = value.get("region")
|
||||
if not isinstance(region, Mapping):
|
||||
raise ContractValidationError("Evidence region must be an object")
|
||||
coordinate_space = region.get("coordinate_space")
|
||||
numeric_coordinates: list[int | float] = []
|
||||
for name in ("x", "y", "width", "height"):
|
||||
number = region.get(name)
|
||||
if isinstance(number, bool) or not isinstance(number, int | float):
|
||||
raise ContractValidationError("Evidence bounds must be numeric")
|
||||
numeric_coordinates.append(number)
|
||||
x, y, width, height = numeric_coordinates
|
||||
if coordinate_space == "normalized":
|
||||
if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > 1 or y + height > 1:
|
||||
raise ContractValidationError("Normalized evidence bounds must fit within the image")
|
||||
elif coordinate_space == "pixels":
|
||||
if any(not isinstance(number, int) for number in numeric_coordinates):
|
||||
raise ContractValidationError("Pixel evidence bounds must be integers")
|
||||
if x < 0 or y < 0 or width <= 0 or height <= 0:
|
||||
raise ContractValidationError("Pixel evidence bounds must be positive and non-negative")
|
||||
else:
|
||||
raise ContractValidationError(f"Unknown coordinate space: {coordinate_space!r}")
|
||||
|
||||
|
||||
def _validate_value_state(value: Mapping[str, object], context: str) -> None:
|
||||
raw_text = value.get("raw_text")
|
||||
normalized = value.get("normalized_value")
|
||||
missing_reason = value.get("missing_reason")
|
||||
if raw_text is not None and not isinstance(raw_text, str):
|
||||
raise ContractValidationError(f"{context} raw_text must be a string or null")
|
||||
if normalized is None and not isinstance(missing_reason, str):
|
||||
raise ContractValidationError(f"Null {context} requires missing_reason")
|
||||
if normalized is not None and missing_reason is not None:
|
||||
raise ContractValidationError(f"Present {context} cannot set missing_reason")
|
||||
|
||||
|
||||
def _value_state(value: Mapping[str, object], context: str) -> tuple[object, object, object]:
|
||||
_validate_value_state(value, context)
|
||||
return value.get("raw_text"), value.get("normalized_value"), value.get("missing_reason")
|
||||
|
||||
|
||||
def _parse_correction_time(value: object) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise ContractValidationError("Correction corrected_at must be a timestamp")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ContractValidationError("Correction corrected_at must be a timestamp") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ContractValidationError("Correction corrected_at must include a UTC offset")
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_correction_history(
|
||||
evidence: Mapping[str, object], seen_correction_ids: set[str]
|
||||
) -> None:
|
||||
history = evidence.get("correction_history")
|
||||
if not isinstance(history, list):
|
||||
raise ContractValidationError("correction_history must be an array")
|
||||
previous_after: tuple[object, object, object] | None = None
|
||||
previous_time: datetime | None = None
|
||||
for index, correction in enumerate(history):
|
||||
if not isinstance(correction, Mapping):
|
||||
raise ContractValidationError(f"correction_history[{index}] must be an object")
|
||||
correction_id = correction.get("correction_id")
|
||||
if not isinstance(correction_id, str):
|
||||
raise ContractValidationError("correction_id must be a string")
|
||||
if correction_id in seen_correction_ids:
|
||||
raise ContractValidationError(f"Duplicate correction_id: {correction_id}")
|
||||
seen_correction_ids.add(correction_id)
|
||||
corrected_at = _parse_correction_time(correction.get("corrected_at"))
|
||||
if previous_time is not None and corrected_at <= previous_time:
|
||||
raise ContractValidationError("Correction history timestamps must be strictly ordered")
|
||||
before = correction.get("before")
|
||||
after = correction.get("after")
|
||||
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
|
||||
raise ContractValidationError("Correction before and after must be value states")
|
||||
before_state = _value_state(before, "correction before")
|
||||
after_state = _value_state(after, "correction after")
|
||||
if previous_after is not None and before_state != previous_after:
|
||||
raise ContractValidationError("Correction history contains a disconnected value state")
|
||||
previous_after = after_state
|
||||
previous_time = corrected_at
|
||||
if previous_after is not None and previous_after != _value_state(evidence, "evidence"):
|
||||
raise ContractValidationError("Correction history final state does not match evidence")
|
||||
|
||||
|
||||
def validate_archive_document(document: Mapping[str, object]) -> None:
|
||||
validate_archive_references(document)
|
||||
for screen in _objects(document.get("screens"), "screens"):
|
||||
classification = screen.get("classification")
|
||||
if not isinstance(classification, Mapping):
|
||||
raise ContractValidationError("screen.classification must be an object")
|
||||
validate_classification(classification)
|
||||
seen_correction_ids: set[str] = set()
|
||||
for value in _objects(document.get("evidence_values"), "evidence_values"):
|
||||
_validate_evidence(value)
|
||||
_validate_correction_history(value, seen_correction_ids)
|
||||
|
||||
|
||||
def validate_handoff_document(document: Mapping[str, object]) -> None:
|
||||
validate_handoff_references(document)
|
||||
expected_boundary: dict[str, object] = {
|
||||
"intended_destination": "authenticated_isac_intake",
|
||||
"mutation_authority": "none",
|
||||
"requires_authenticated_action": True,
|
||||
"preserves_moderation": True,
|
||||
"creates_audit_events": False,
|
||||
}
|
||||
if document.get("trust_boundary") != expected_boundary:
|
||||
raise ContractValidationError("The handoff trust boundary is invalid")
|
||||
seen_correction_ids: set[str] = set()
|
||||
for value in _objects(document.get("evidence_values"), "evidence_values"):
|
||||
_validate_evidence(value)
|
||||
_validate_correction_history(value, seen_correction_ids)
|
||||
|
||||
|
||||
def validate_archive_references(document: Mapping[str, object]) -> None:
|
||||
ensure_supported_version(document, SUPPORTED_ARCHIVE_SCHEMA_VERSION)
|
||||
artifacts = list(_objects(document.get("artifacts"), "artifacts"))
|
||||
captures = list(_objects(document.get("captures"), "captures"))
|
||||
screens = list(_objects(document.get("screens"), "screens"))
|
||||
jobs = list(_objects(document.get("jobs"), "jobs"))
|
||||
extractions = list(_objects(document.get("extractions"), "extractions"))
|
||||
groups = list(_objects(document.get("groups"), "groups"))
|
||||
reviews = list(_objects(document.get("reviews"), "reviews"))
|
||||
exports = list(_objects(document.get("exports"), "exports"))
|
||||
errors = list(_objects(document.get("errors"), "errors"))
|
||||
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
|
||||
instances = list(_objects(document.get("observed_instances"), "observed_instances"))
|
||||
candidates = list(_objects(document.get("canonical_candidates"), "canonical_candidates"))
|
||||
artifact_ids = _ids(artifacts, "artifact_id")
|
||||
_ids(captures, "capture_id")
|
||||
screen_ids = _ids(screens, "screen_id")
|
||||
_ids(jobs, "job_id")
|
||||
_ids(extractions, "extraction_id")
|
||||
group_ids = _ids(groups, "group_id")
|
||||
_ids(reviews, "review_id")
|
||||
_ids(exports, "export_id")
|
||||
error_ids = _ids(errors, "error_id")
|
||||
evidence_ids = _ids(evidence, "evidence_id")
|
||||
instance_ids = _ids(instances, "instance_id")
|
||||
candidate_ids = _ids(candidates, "candidate_id")
|
||||
artifact_hashes = {
|
||||
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
|
||||
}
|
||||
|
||||
for capture in captures:
|
||||
_require_subset({str(capture.get("artifact_id"))}, artifact_ids, "capture.artifact_id")
|
||||
for artifact in artifacts:
|
||||
parent_id = artifact.get("parent_artifact_id")
|
||||
if parent_id is not None:
|
||||
if str(parent_id) == str(artifact.get("artifact_id")):
|
||||
raise ContractValidationError("artifact.parent_artifact_id cannot reference itself")
|
||||
_require_subset({str(parent_id)}, artifact_ids, "artifact.parent_artifact_id")
|
||||
for screen in screens:
|
||||
_require_subset({str(screen.get("artifact_id"))}, artifact_ids, "screen.artifact_id")
|
||||
for value in evidence:
|
||||
artifact_id = str(value.get("image_artifact_id"))
|
||||
_require_subset(
|
||||
{artifact_id},
|
||||
artifact_ids,
|
||||
"evidence.image_artifact_id",
|
||||
)
|
||||
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
|
||||
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
|
||||
for job in jobs:
|
||||
last_error_id = job.get("last_error_id")
|
||||
if last_error_id is not None:
|
||||
_require_subset({str(last_error_id)}, error_ids, "job.last_error_id")
|
||||
for extraction in extractions:
|
||||
_require_subset({str(extraction.get("screen_id"))}, screen_ids, "extraction.screen_id")
|
||||
_require_subset(
|
||||
_references(extraction, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"extraction.evidence_refs",
|
||||
)
|
||||
for group in groups:
|
||||
_require_subset(_references(group, "screen_ids"), screen_ids, "group.screen_ids")
|
||||
supersedes_group_ids = _references(group, "supersedes_group_ids")
|
||||
if str(group.get("group_id")) in supersedes_group_ids:
|
||||
raise ContractValidationError("group.supersedes_group_ids cannot reference itself")
|
||||
_require_subset(
|
||||
supersedes_group_ids,
|
||||
group_ids,
|
||||
"group.supersedes_group_ids",
|
||||
)
|
||||
for instance in instances:
|
||||
_require_subset(
|
||||
_references(instance, "source_screen_ids"),
|
||||
screen_ids,
|
||||
"observed_instance.source_screen_ids",
|
||||
)
|
||||
for field in _objects(instance.get("fields"), "observed_instance.fields"):
|
||||
_require_subset(
|
||||
_references(field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"observed_instance.fields.evidence_refs",
|
||||
)
|
||||
context = instance.get("observed_context")
|
||||
if not isinstance(context, Mapping):
|
||||
raise ContractValidationError("observed_instance.observed_context must be an object")
|
||||
for context_name in ("level", "quality", "tier", "upgrade_state"):
|
||||
context_field = context.get(context_name)
|
||||
if not isinstance(context_field, Mapping):
|
||||
raise ContractValidationError(f"observed_context.{context_name} must be an object")
|
||||
_require_subset(
|
||||
_references(context_field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
f"observed_context.{context_name}.evidence_refs",
|
||||
)
|
||||
for candidate in candidates:
|
||||
_require_subset(
|
||||
_references(candidate, "source_instance_ids"),
|
||||
instance_ids,
|
||||
"canonical_candidate.source_instance_ids",
|
||||
)
|
||||
_require_subset(
|
||||
_references(candidate, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"canonical_candidate.evidence_refs",
|
||||
)
|
||||
for field in _objects(candidate.get("fields"), "canonical_candidate.fields"):
|
||||
_require_subset(
|
||||
_references(field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"canonical_candidate.fields.evidence_refs",
|
||||
)
|
||||
for review in reviews:
|
||||
_require_subset({str(review.get("candidate_id"))}, candidate_ids, "review.candidate_id")
|
||||
for export in exports:
|
||||
export_artifact_id = export.get("artifact_id")
|
||||
if export_artifact_id is not None:
|
||||
_require_subset({str(export_artifact_id)}, artifact_ids, "export.artifact_id")
|
||||
|
||||
|
||||
def validate_handoff_references(document: Mapping[str, object]) -> None:
|
||||
ensure_supported_version(document, SUPPORTED_HANDOFF_SCHEMA_VERSION)
|
||||
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
|
||||
records = list(_objects(document.get("records"), "records"))
|
||||
evidence_ids = _ids(evidence, "evidence_id")
|
||||
artifacts = list(_objects(document.get("source_artifacts"), "source_artifacts"))
|
||||
artifact_ids = _ids(artifacts, "artifact_id")
|
||||
artifact_hashes = {
|
||||
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
|
||||
}
|
||||
_ids(records, "handoff_record_id")
|
||||
for value in evidence:
|
||||
artifact_id = str(value.get("image_artifact_id"))
|
||||
_require_subset(
|
||||
{artifact_id},
|
||||
artifact_ids,
|
||||
"evidence.image_artifact_id",
|
||||
)
|
||||
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
|
||||
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
|
||||
for record in records:
|
||||
for field in _objects(record.get("fields"), "record.fields"):
|
||||
_require_subset(
|
||||
_references(field, "evidence_refs"),
|
||||
evidence_ids,
|
||||
"record.fields.evidence_refs",
|
||||
)
|
||||
for section_name in ("relationships", "mechanics"):
|
||||
section = record.get(section_name)
|
||||
if not isinstance(section, Mapping):
|
||||
raise ContractValidationError(f"record.{section_name} must be an object")
|
||||
_require_subset(
|
||||
_references(section, "evidence_refs"),
|
||||
evidence_ids,
|
||||
f"record.{section_name}.evidence_refs",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Wayland clipboard intake and private spool boundaries.
|
||||
|
||||
Submodules stay unimported so the stdin helper can start independently.
|
||||
"""
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Bounded, shell-free supervision for Wayland PNG clipboard intake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final, cast
|
||||
|
||||
from game_data_capture.ingest.spool_helper import (
|
||||
PNG_SIGNATURE,
|
||||
acknowledge_receipt,
|
||||
durable_receipts,
|
||||
reconcile_spool,
|
||||
)
|
||||
|
||||
_SAFE_ENV: Final = frozenset(
|
||||
{"PATH", "HOME", "LANG", "LC_ALL", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"}
|
||||
)
|
||||
_DENIED_ENV = re.compile(
|
||||
r"(?:openai|authorization|token|secret|python(?:home|path|startup|userbase))", re.I
|
||||
)
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_MAX_METADATA = 16 * 1024
|
||||
_MAX_RECEIPT = 1024
|
||||
|
||||
|
||||
def _integer(value: object, minimum: int, maximum: int) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum
|
||||
|
||||
|
||||
def _finite(value: object, minimum: float, maximum: float) -> bool:
|
||||
return (
|
||||
isinstance(value, int | float)
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(value)
|
||||
and minimum < value <= maximum
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WatchConfig:
|
||||
spool: Path
|
||||
max_image_bytes: int
|
||||
max_queue_items: int
|
||||
policy_version: str
|
||||
max_restarts: int = 3
|
||||
restart_backoff_seconds: float = 0.25
|
||||
shutdown_timeout_seconds: float = 2.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.spool.is_absolute() or len(str(self.spool)) > 240:
|
||||
raise ValueError("spool must be a bounded absolute path")
|
||||
if not _integer(self.max_image_bytes, 8, 64 * 1024 * 1024):
|
||||
raise ValueError("invalid image limit")
|
||||
if not _integer(self.max_queue_items, 1, 256) or not _integer(self.max_restarts, 0, 10):
|
||||
raise ValueError("invalid queue or restart limit")
|
||||
if not _finite(self.restart_backoff_seconds, 0, 60) or not _finite(
|
||||
self.shutdown_timeout_seconds, 0, 30
|
||||
):
|
||||
raise ValueError("invalid timeout")
|
||||
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", self.policy_version):
|
||||
raise ValueError("invalid policy version")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FocusedWindowMetadata:
|
||||
app_id: str | None
|
||||
title: str | None
|
||||
window_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntakeReceipt:
|
||||
occurrence_id: str
|
||||
receipt_token: str
|
||||
sha256: str
|
||||
byte_size: int
|
||||
media_type: str
|
||||
spool_path: Path
|
||||
policy_version: str
|
||||
processing_version: str
|
||||
focused_window: FocusedWindowMetadata
|
||||
|
||||
|
||||
class VerifiedSpoolReader:
|
||||
"""Descriptor-verified stream for handing one receipt to GDC-003 safely."""
|
||||
|
||||
def __init__(self, receipt: IntakeReceipt) -> None:
|
||||
self.receipt = receipt
|
||||
self._fd: int | None = None
|
||||
|
||||
def __enter__(self) -> VerifiedSpoolReader:
|
||||
self._fd = _verify_spool_file(self.receipt)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
if self._fd is None:
|
||||
raise RuntimeError("verified reader is not open")
|
||||
while chunk := os.read(self._fd, 64 * 1024):
|
||||
yield chunk
|
||||
|
||||
def close(self) -> None:
|
||||
if self._fd is not None:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
|
||||
|
||||
def restricted_environment(environment: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
source = os.environ if environment is None else environment
|
||||
return {
|
||||
key: value[:4096]
|
||||
for key, value in source.items()
|
||||
if key in _SAFE_ENV
|
||||
and not _DENIED_ENV.search(key)
|
||||
and isinstance(value, str)
|
||||
and "\x00" not in value
|
||||
}
|
||||
|
||||
|
||||
def _bounded_text(value: object) -> str | None:
|
||||
return " ".join(value.split())[:256] or None if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _bounded_command_output(
|
||||
arguments: list[str], environment: Mapping[str, str], timeout: float
|
||||
) -> bytes | None:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
shell=False,
|
||||
env=environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
output = bytearray()
|
||||
done = threading.Event()
|
||||
|
||||
def collect() -> None:
|
||||
if process.stdout is not None:
|
||||
while len(output) <= _MAX_METADATA:
|
||||
chunk = process.stdout.read(min(4096, _MAX_METADATA + 1 - len(output)))
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
done.set()
|
||||
|
||||
reader = threading.Thread(target=collect, daemon=True)
|
||||
reader.start()
|
||||
if not done.wait(timeout):
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
with suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=timeout)
|
||||
if process.poll() is None:
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
if process.stdout is not None:
|
||||
process.stdout.close()
|
||||
reader.join(timeout)
|
||||
return (
|
||||
bytes(output)
|
||||
if done.is_set() and process.returncode == 0 and len(output) <= _MAX_METADATA
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def focused_window(
|
||||
*, environment: Mapping[str, str] | None = None, timeout_seconds: float = 0.5
|
||||
) -> FocusedWindowMetadata:
|
||||
if not _finite(timeout_seconds, 0, 30):
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
raw = _bounded_command_output(
|
||||
["niri", "msg", "-j", "focused-window"],
|
||||
restricted_environment(environment),
|
||||
float(timeout_seconds),
|
||||
)
|
||||
if raw is None:
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
if not isinstance(payload, dict):
|
||||
return FocusedWindowMetadata(None, None, None)
|
||||
identifier = payload.get("id")
|
||||
return FocusedWindowMetadata(
|
||||
_bounded_text(payload.get("app_id")),
|
||||
_bounded_text(payload.get("title")),
|
||||
identifier if _integer(identifier, 0, 2**63 - 1) else None,
|
||||
)
|
||||
|
||||
|
||||
def _verify_spool_file(receipt: IntakeReceipt) -> int:
|
||||
spool = receipt.spool_path.parent
|
||||
if spool.is_symlink() or not spool.is_dir() or (spool.stat().st_mode & 0o777) != 0o700:
|
||||
raise ValueError("unsafe spool")
|
||||
fd = os.open(receipt.spool_path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or (info.st_mode & 0o777) != 0o600
|
||||
or info.st_size != receipt.byte_size
|
||||
):
|
||||
raise ValueError("invalid spool file")
|
||||
first = os.read(fd, len(PNG_SIGNATURE))
|
||||
if first != PNG_SIGNATURE:
|
||||
raise ValueError("invalid spool png")
|
||||
hasher = hashlib.sha256(first)
|
||||
while chunk := os.read(fd, 64 * 1024):
|
||||
hasher.update(chunk)
|
||||
if hasher.hexdigest() != receipt.sha256:
|
||||
raise ValueError("spool digest mismatch")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
return fd
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
class ClipboardIntake:
|
||||
def __init__(
|
||||
self, config: WatchConfig, *, environment: Mapping[str, str] | None = None
|
||||
) -> None:
|
||||
self.config, self.environment = config, restricted_environment(environment)
|
||||
self.armed = self.paused = False
|
||||
self._watcher: subprocess.Popen[bytes] | None = None
|
||||
self._reader: threading.Thread | None = None
|
||||
self._queue: deque[IntakeReceipt] = deque()
|
||||
self._helper_payloads: deque[dict[str, object]] = deque()
|
||||
self._seen_tokens: set[str] = set()
|
||||
self._seen_order: deque[str] = deque()
|
||||
self._recovered_tokens: set[str] = set()
|
||||
self._restart_count = 0
|
||||
self._restart_after = 0.0
|
||||
self._restarts_exhausted = False
|
||||
|
||||
@property
|
||||
def queue_depth(self) -> int:
|
||||
return len(self._queue)
|
||||
|
||||
@property
|
||||
def helper_queue_depth(self) -> int:
|
||||
return len(self._helper_payloads)
|
||||
|
||||
def arm(self) -> None:
|
||||
self.armed, self.paused = True, False
|
||||
self._start_if_due(time.monotonic())
|
||||
|
||||
def pause(self) -> None:
|
||||
self.paused = True
|
||||
self._stop_watcher()
|
||||
|
||||
def resume(self) -> None:
|
||||
if self.armed:
|
||||
self.paused = False
|
||||
self._start_if_due(time.monotonic())
|
||||
|
||||
def disarm(self) -> None:
|
||||
self.armed = self.paused = False
|
||||
self._stop_watcher()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.shutdown()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.disarm()
|
||||
self._queue.clear()
|
||||
self._helper_payloads.clear()
|
||||
self._seen_tokens.clear()
|
||||
self._seen_order.clear()
|
||||
self._recovered_tokens.clear()
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
current = time.monotonic() if now is None else now
|
||||
if self._watcher is not None and self._watcher.poll() is not None:
|
||||
self._stop_watcher()
|
||||
self._crashed(current)
|
||||
self._start_if_due(current)
|
||||
|
||||
def take(self) -> IntakeReceipt | None:
|
||||
return self._queue.popleft() if self._queue else None
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None:
|
||||
return self._helper_payloads.popleft() if self._helper_payloads else None
|
||||
|
||||
def unreferenced_spool_candidates(self) -> list[Path]:
|
||||
queued = {item.spool_path.name for item in self._queue}
|
||||
return [
|
||||
path
|
||||
for path in self.config.spool.glob("*.png")
|
||||
if not path.is_symlink() and path.name not in queued
|
||||
]
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> VerifiedSpoolReader:
|
||||
return VerifiedSpoolReader(receipt)
|
||||
|
||||
def accept_helper_receipt(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
occurrence_id: str,
|
||||
processing_version: str,
|
||||
metadata: FocusedWindowMetadata | None = None,
|
||||
) -> bool:
|
||||
token, digest, size, name = (
|
||||
payload.get("receipt_token"),
|
||||
payload.get("sha256"),
|
||||
payload.get("byte_size"),
|
||||
payload.get("spool_name"),
|
||||
)
|
||||
if (
|
||||
not self.armed
|
||||
or self.paused
|
||||
or len(self._queue) >= self.config.max_queue_items
|
||||
or payload.get("receipt_version") != 1
|
||||
or not isinstance(token, str)
|
||||
or not _ID.fullmatch(token)
|
||||
or token in self._seen_tokens
|
||||
or not isinstance(digest, str)
|
||||
or not _SHA256.fullmatch(digest)
|
||||
or not _integer(size, 8, self.config.max_image_bytes)
|
||||
or payload.get("media_type") != "image/png"
|
||||
or payload.get("policy_version") != self.config.policy_version
|
||||
or not isinstance(name, str)
|
||||
or name != f"{digest}.png"
|
||||
or not isinstance(occurrence_id, str)
|
||||
or not _ID.fullmatch(occurrence_id)
|
||||
or not isinstance(processing_version, str)
|
||||
or not _ID.fullmatch(processing_version)
|
||||
):
|
||||
return False
|
||||
recovered = token in self._recovered_tokens
|
||||
receipt = IntakeReceipt(
|
||||
occurrence_id,
|
||||
token,
|
||||
digest,
|
||||
cast(int, size),
|
||||
"image/png",
|
||||
self.config.spool / name,
|
||||
self.config.policy_version,
|
||||
processing_version,
|
||||
FocusedWindowMetadata(None, None, None)
|
||||
if recovered
|
||||
else focused_window()
|
||||
if metadata is None
|
||||
else metadata,
|
||||
)
|
||||
try:
|
||||
verified_fd = _verify_spool_file(receipt)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
os.close(verified_fd)
|
||||
self._seen_tokens.add(token)
|
||||
self._seen_order.append(token)
|
||||
if len(self._seen_order) > self.config.max_queue_items:
|
||||
self._seen_tokens.remove(self._seen_order.popleft())
|
||||
self._queue.append(receipt)
|
||||
return True
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
removed = reconcile_spool(self.config.spool)
|
||||
capacity = self.config.max_queue_items - len(self._helper_payloads)
|
||||
if capacity <= 0:
|
||||
return removed
|
||||
for payload in durable_receipts(self.config.spool, max_items=256):
|
||||
if len(self._helper_payloads) >= self.config.max_queue_items:
|
||||
break
|
||||
token = payload["receipt_token"]
|
||||
if isinstance(token, str) and token not in self._seen_tokens:
|
||||
self._helper_payloads.append(payload)
|
||||
self._recovered_tokens.add(token)
|
||||
return removed
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
"""Delete this occurrence's durable receipt after repository confirmation only."""
|
||||
if (
|
||||
receipt.spool_path.parent != self.config.spool
|
||||
or receipt.policy_version != self.config.policy_version
|
||||
):
|
||||
return False
|
||||
payload: dict[str, object] = {
|
||||
"receipt_version": 1,
|
||||
"receipt_token": receipt.receipt_token,
|
||||
"sha256": receipt.sha256,
|
||||
"byte_size": receipt.byte_size,
|
||||
"media_type": receipt.media_type,
|
||||
"spool_name": receipt.spool_path.name,
|
||||
"policy_version": receipt.policy_version,
|
||||
}
|
||||
acknowledged = acknowledge_receipt(self.config.spool, payload)
|
||||
if acknowledged:
|
||||
self._recovered_tokens.discard(receipt.receipt_token)
|
||||
return acknowledged
|
||||
|
||||
def _crashed(self, now: float) -> None:
|
||||
if self.armed and not self.paused and self._restart_count < self.config.max_restarts:
|
||||
self._restart_count += 1
|
||||
self._restart_after = now + float(self.config.restart_backoff_seconds) * 2 ** (
|
||||
self._restart_count - 1
|
||||
)
|
||||
else:
|
||||
self._restarts_exhausted = True
|
||||
|
||||
def _start_if_due(self, now: float) -> None:
|
||||
if (
|
||||
not self.armed
|
||||
or self.paused
|
||||
or self._watcher is not None
|
||||
or self._restarts_exhausted
|
||||
or now < self._restart_after
|
||||
):
|
||||
return
|
||||
try:
|
||||
self._watcher = subprocess.Popen(
|
||||
[
|
||||
"wl-paste",
|
||||
"--type",
|
||||
"image/png",
|
||||
"--watch",
|
||||
sys.executable,
|
||||
"-m",
|
||||
"game_data_capture.ingest.spool_helper",
|
||||
"--spool",
|
||||
str(self.config.spool),
|
||||
"--max-bytes",
|
||||
str(self.config.max_image_bytes),
|
||||
"--policy-version",
|
||||
self.config.policy_version,
|
||||
"--media-type",
|
||||
"image/png",
|
||||
],
|
||||
shell=False,
|
||||
env=self.environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError:
|
||||
self._crashed(now)
|
||||
return
|
||||
self._reader = threading.Thread(
|
||||
target=self._read_receipts, args=(self._watcher,), daemon=True
|
||||
)
|
||||
self._reader.start()
|
||||
|
||||
def _read_receipts(self, process: subprocess.Popen[bytes]) -> None:
|
||||
if process.stdout is None:
|
||||
return
|
||||
while True:
|
||||
line = process.stdout.readline(_MAX_RECEIPT + 1)
|
||||
if not line:
|
||||
return
|
||||
if len(line) > _MAX_RECEIPT and not line.endswith(b"\n"):
|
||||
while line and not line.endswith(b"\n"):
|
||||
line = process.stdout.readline(_MAX_RECEIPT + 1)
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
if (
|
||||
isinstance(payload, dict)
|
||||
and all(isinstance(key, str) for key in payload)
|
||||
and len(self._helper_payloads) < self.config.max_queue_items
|
||||
):
|
||||
self._helper_payloads.append(payload)
|
||||
|
||||
def _stop_watcher(self) -> None:
|
||||
process, self._watcher = self._watcher, None
|
||||
if process is None:
|
||||
return
|
||||
if process.poll() is None:
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=float(self.config.shutdown_timeout_seconds))
|
||||
except subprocess.TimeoutExpired:
|
||||
with suppress(OSError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
if process.stdout is not None:
|
||||
process.stdout.close()
|
||||
if self._reader is not None:
|
||||
self._reader.join(timeout=float(self.config.shutdown_timeout_seconds))
|
||||
self._reader = None
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Small stdin-only PNG spool helper; intentionally independent of application services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR, S_ISREG
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
RECEIPT_VERSION = 1
|
||||
_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_CHUNK_SIZE = 64 * 1024
|
||||
_MAX_RECEIPT_BYTES = 1024
|
||||
_MAX_SIDECARS = 256
|
||||
_MAX_RECONCILE_ENTRIES = 1024
|
||||
_LOCK_NAME = ".spool.lock"
|
||||
_LOCK_TIMEOUT_SECONDS = 1.0
|
||||
_LOCK_RETRY_SECONDS = 0.01
|
||||
|
||||
|
||||
class SpoolError(ValueError):
|
||||
"""The supplied image or spool boundary is invalid."""
|
||||
|
||||
|
||||
def _private_spool(path: Path) -> None:
|
||||
if not path.is_absolute() or len(str(path)) > 240:
|
||||
raise SpoolError("invalid spool")
|
||||
if path.is_symlink():
|
||||
raise SpoolError("unsafe spool")
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = path.lstat().st_mode
|
||||
if path.is_symlink() or not S_ISDIR(mode):
|
||||
raise SpoolError("unsafe spool")
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def _write_all(fd: int, data: bytes) -> None:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise OSError("short spool write")
|
||||
view = view[written:]
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _spool_lock(spool: Path) -> Iterator[None]:
|
||||
"""Hold the private spool lock for one bounded receipt installation."""
|
||||
try:
|
||||
fd = os.open(spool / _LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
except OSError as error:
|
||||
raise SpoolError("unsafe spool lock") from error
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
|
||||
raise SpoolError("unsafe spool lock")
|
||||
deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise SpoolError("spool busy") from None
|
||||
time.sleep(_LOCK_RETRY_SECONDS)
|
||||
yield
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _verify_existing(path: Path, digest: str, size: int) -> bool:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600 or info.st_size != size:
|
||||
return False
|
||||
hasher = hashlib.sha256()
|
||||
prefix = os.read(fd, len(PNG_SIGNATURE))
|
||||
if prefix != PNG_SIGNATURE:
|
||||
return False
|
||||
hasher.update(prefix)
|
||||
while chunk := os.read(fd, _CHUNK_SIZE):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest() == digest
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def receipt_name(token: str) -> str:
|
||||
if not _TOKEN.fullmatch(token):
|
||||
raise SpoolError("invalid receipt identity")
|
||||
return f"{token}.receipt.json"
|
||||
|
||||
|
||||
def _pending_name(token: str) -> str:
|
||||
return f".{token}.receipt.pending"
|
||||
|
||||
|
||||
def _receipt_bytes(receipt: dict[str, object]) -> bytes:
|
||||
encoded = json.dumps(receipt, separators=(",", ":"), sort_keys=True).encode("ascii")
|
||||
if len(encoded) > _MAX_RECEIPT_BYTES:
|
||||
raise SpoolError("receipt exceeds limit")
|
||||
return encoded
|
||||
|
||||
|
||||
def _valid_receipt(payload: object, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
|
||||
if not isinstance(payload, dict) or set(payload) != {
|
||||
"byte_size",
|
||||
"media_type",
|
||||
"policy_version",
|
||||
"receipt_token",
|
||||
"receipt_version",
|
||||
"sha256",
|
||||
"spool_name",
|
||||
}:
|
||||
return None
|
||||
token, digest, size, name = (
|
||||
payload.get("receipt_token"),
|
||||
payload.get("sha256"),
|
||||
payload.get("byte_size"),
|
||||
payload.get("spool_name"),
|
||||
)
|
||||
if (
|
||||
payload.get("receipt_version") != RECEIPT_VERSION
|
||||
or not isinstance(token, str)
|
||||
or not _TOKEN.fullmatch(token)
|
||||
or not isinstance(digest, str)
|
||||
or not _SHA256.fullmatch(digest)
|
||||
or isinstance(size, bool)
|
||||
or not isinstance(size, int)
|
||||
or size < len(PNG_SIGNATURE)
|
||||
or payload.get("media_type") != "image/png"
|
||||
or not isinstance(payload.get("policy_version"), str)
|
||||
or not _TOKEN.fullmatch(payload["policy_version"])
|
||||
or not isinstance(name, str)
|
||||
or name != f"{digest}.png"
|
||||
):
|
||||
return None
|
||||
if verify_png and not _verify_existing(spool / name, digest, size):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _read_sidecar(path: Path, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if (
|
||||
not S_ISREG(info.st_mode)
|
||||
or (info.st_mode & 0o777) != 0o600
|
||||
or info.st_size > _MAX_RECEIPT_BYTES
|
||||
):
|
||||
return None
|
||||
raw = os.read(fd, _MAX_RECEIPT_BYTES + 1)
|
||||
if len(raw) != info.st_size or len(raw) > _MAX_RECEIPT_BYTES:
|
||||
return None
|
||||
try:
|
||||
return _valid_receipt(json.loads(raw), spool=spool, verify_png=verify_png)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _install_pending_sidecar(spool: Path, receipt: dict[str, object]) -> Path:
|
||||
token = receipt["receipt_token"]
|
||||
assert isinstance(token, str)
|
||||
pending = spool / _pending_name(token)
|
||||
encoded = _receipt_bytes(receipt)
|
||||
fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
_write_all(fd, encoded)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
# Persist the recoverable pending name before promotion. A crash after this
|
||||
# point cannot claim recovery until its PNG is immutably installed.
|
||||
_fsync_directory(spool)
|
||||
return pending
|
||||
|
||||
|
||||
def _same_receipt(left: dict[str, object], right: dict[str, object]) -> bool:
|
||||
return _receipt_bytes(left) == _receipt_bytes(right)
|
||||
|
||||
|
||||
def _final_sidecar(spool: Path, receipt: dict[str, object]) -> dict[str, object] | None:
|
||||
token = receipt["receipt_token"]
|
||||
assert isinstance(token, str)
|
||||
final = spool / receipt_name(token)
|
||||
if not final.exists() and not final.is_symlink():
|
||||
return None
|
||||
stored = _read_sidecar(final, spool=spool, verify_png=True)
|
||||
if stored is None or not _same_receipt(stored, receipt):
|
||||
raise SpoolError("receipt token conflict")
|
||||
return stored
|
||||
|
||||
|
||||
def _receipt_capacity_available(spool: Path) -> bool:
|
||||
"""Count bounded valid receipt sidecars while removing malformed regular entries."""
|
||||
valid = 0
|
||||
candidates = 0
|
||||
for entry in spool.iterdir():
|
||||
if entry.is_symlink() or not entry.is_file():
|
||||
continue
|
||||
pending = entry.name.startswith(".") and entry.name.endswith(".receipt.pending")
|
||||
final = entry.name.endswith(".receipt.json")
|
||||
if not pending and not final:
|
||||
continue
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
return False
|
||||
token = (
|
||||
entry.name[1 : -len(".receipt.pending")]
|
||||
if pending
|
||||
else entry.name[: -len(".receipt.json")]
|
||||
)
|
||||
receipt = _read_sidecar(entry, spool=spool, verify_png=final)
|
||||
if receipt is None or receipt.get("receipt_token") != token:
|
||||
entry.unlink()
|
||||
_fsync_directory(spool)
|
||||
continue
|
||||
valid += 1
|
||||
if valid >= _MAX_SIDECARS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _promote_sidecar(spool: Path, receipt: dict[str, object], pending: Path) -> None:
|
||||
token = receipt["receipt_token"]
|
||||
assert isinstance(token, str)
|
||||
final = spool / receipt_name(token)
|
||||
try:
|
||||
os.link(pending, final, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
try:
|
||||
_final_sidecar(spool, receipt)
|
||||
except SpoolError:
|
||||
pending.unlink()
|
||||
_fsync_directory(spool)
|
||||
raise
|
||||
else:
|
||||
_fsync_directory(spool)
|
||||
pending.unlink()
|
||||
_fsync_directory(spool)
|
||||
|
||||
|
||||
def reconcile_spool(spool: Path) -> list[str]:
|
||||
"""Remove incomplete files and promote only receipt sidecars backed by immutable PNGs."""
|
||||
_private_spool(spool)
|
||||
removed: list[str] = []
|
||||
promoted = 0
|
||||
candidates = 0
|
||||
for entry in spool.iterdir():
|
||||
if entry.is_symlink() or not entry.is_file():
|
||||
continue
|
||||
if entry.name.endswith(".partial"):
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
break
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
elif entry.name.startswith(".") and entry.name.endswith(".receipt.pending"):
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
break
|
||||
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
|
||||
token = entry.name[1 : -len(".receipt.pending")]
|
||||
if receipt is not None and receipt.get("receipt_token") == token:
|
||||
final = spool / receipt_name(token)
|
||||
if final.exists() or final.is_symlink():
|
||||
with suppress(SpoolError):
|
||||
_final_sidecar(spool, receipt)
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
elif promoted >= _MAX_SIDECARS:
|
||||
continue
|
||||
else:
|
||||
_promote_sidecar(spool, receipt, entry)
|
||||
promoted += 1
|
||||
continue
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
return removed
|
||||
|
||||
|
||||
def durable_receipts(spool: Path, *, max_items: int) -> Iterator[dict[str, object]]:
|
||||
"""Yield at most ``max_items`` validated final receipt sidecars without following links."""
|
||||
if (
|
||||
isinstance(max_items, bool)
|
||||
or not isinstance(max_items, int)
|
||||
or not 1 <= max_items <= _MAX_SIDECARS
|
||||
):
|
||||
raise SpoolError("invalid receipt limit")
|
||||
_private_spool(spool)
|
||||
yielded = 0
|
||||
valid = 0
|
||||
candidates = 0
|
||||
for entry in spool.iterdir():
|
||||
if entry.is_symlink() or not entry.is_file() or not entry.name.endswith(".receipt.json"):
|
||||
continue
|
||||
candidates += 1
|
||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
||||
break
|
||||
token = entry.name[: -len(".receipt.json")]
|
||||
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
|
||||
if receipt is not None and receipt.get("receipt_token") == token:
|
||||
valid += 1
|
||||
if valid > _MAX_SIDECARS:
|
||||
break
|
||||
if yielded < max_items:
|
||||
yielded += 1
|
||||
yield receipt
|
||||
else:
|
||||
try:
|
||||
info = entry.lstat()
|
||||
if S_ISREG(info.st_mode):
|
||||
entry.unlink()
|
||||
_fsync_directory(spool)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def acknowledge_receipt(spool: Path, receipt: dict[str, object]) -> bool:
|
||||
"""Remove exactly one verified durable sidecar; immutable PNG data is never removed here."""
|
||||
_private_spool(spool)
|
||||
token = receipt.get("receipt_token")
|
||||
if not isinstance(token, str) or not _TOKEN.fullmatch(token):
|
||||
return False
|
||||
path = spool / receipt_name(token)
|
||||
stored = _read_sidecar(path, spool=spool, verify_png=True)
|
||||
if stored is None:
|
||||
return False
|
||||
if _receipt_bytes(stored) != _receipt_bytes(receipt):
|
||||
return False
|
||||
try:
|
||||
info = path.lstat()
|
||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
|
||||
return False
|
||||
path.unlink()
|
||||
_fsync_directory(spool)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def spool_png(
|
||||
input_stream: object,
|
||||
*,
|
||||
spool: Path,
|
||||
max_bytes: int,
|
||||
receipt_token: str,
|
||||
policy_version: str,
|
||||
media_type: str = "image/png",
|
||||
) -> dict[str, object]:
|
||||
"""Install immutable PNG bytes, then fsync a durable occurrence receipt before stdout."""
|
||||
if (
|
||||
isinstance(max_bytes, bool)
|
||||
or not isinstance(max_bytes, int)
|
||||
or not 8 <= max_bytes <= 64 * 1024 * 1024
|
||||
):
|
||||
raise SpoolError("invalid byte limit")
|
||||
if (
|
||||
media_type != "image/png"
|
||||
or not _TOKEN.fullmatch(receipt_token)
|
||||
or not _TOKEN.fullmatch(policy_version)
|
||||
):
|
||||
raise SpoolError("invalid receipt identity")
|
||||
_private_spool(spool)
|
||||
temporary = spool / f".{secrets.token_hex(16)}.partial"
|
||||
pending: Path | None = None
|
||||
immutable_installed = False
|
||||
hasher = hashlib.sha256()
|
||||
size = 0
|
||||
try:
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
prefix = bytearray()
|
||||
while True:
|
||||
chunk = input_stream.read(_CHUNK_SIZE) # type: ignore[attr-defined]
|
||||
if not isinstance(chunk, bytes):
|
||||
raise SpoolError("invalid input stream")
|
||||
if not chunk:
|
||||
break
|
||||
prefix.extend(chunk[: max(0, len(PNG_SIGNATURE) - len(prefix))])
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
raise SpoolError("image exceeds byte limit")
|
||||
hasher.update(chunk)
|
||||
_write_all(fd, chunk)
|
||||
if len(prefix) < len(PNG_SIGNATURE) or bytes(prefix) != PNG_SIGNATURE:
|
||||
raise SpoolError("invalid png")
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
digest = hasher.hexdigest()
|
||||
name = f"{digest}.png"
|
||||
receipt: dict[str, object] = {
|
||||
"receipt_version": RECEIPT_VERSION,
|
||||
"receipt_token": receipt_token,
|
||||
"sha256": digest,
|
||||
"byte_size": size,
|
||||
"media_type": "image/png",
|
||||
"spool_name": name,
|
||||
"policy_version": policy_version,
|
||||
}
|
||||
with _spool_lock(spool):
|
||||
if _final_sidecar(spool, receipt) is not None:
|
||||
return receipt
|
||||
if not _receipt_capacity_available(spool):
|
||||
raise SpoolError("spool receipt capacity reached")
|
||||
pending = _install_pending_sidecar(spool, receipt)
|
||||
destination = spool / name
|
||||
if destination.exists():
|
||||
if not _verify_existing(destination, digest, size):
|
||||
raise SpoolError("spool collision")
|
||||
else:
|
||||
os.replace(temporary, destination)
|
||||
os.chmod(destination, 0o600)
|
||||
_fsync_directory(spool)
|
||||
immutable_installed = True
|
||||
_promote_sidecar(spool, receipt, pending)
|
||||
return receipt
|
||||
finally:
|
||||
if temporary.exists() and not temporary.is_symlink():
|
||||
temporary.unlink()
|
||||
if (
|
||||
pending is not None
|
||||
and not immutable_installed
|
||||
and pending.exists()
|
||||
and not pending.is_symlink()
|
||||
):
|
||||
pending.unlink()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("--spool", required=True)
|
||||
parser.add_argument("--max-bytes", required=True, type=int)
|
||||
parser.add_argument("--receipt-token", default=secrets.token_hex(16))
|
||||
parser.add_argument("--policy-version", required=True)
|
||||
parser.add_argument("--media-type", default="image/png")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
receipt = spool_png(
|
||||
sys.stdin.buffer,
|
||||
spool=Path(args.spool),
|
||||
max_bytes=args.max_bytes,
|
||||
receipt_token=args.receipt_token,
|
||||
policy_version=args.policy_version,
|
||||
media_type=args.media_type,
|
||||
)
|
||||
except (OSError, SpoolError):
|
||||
return 2
|
||||
sys.stdout.write(json.dumps(receipt, separators=(",", ":")) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Structured logging helpers that remove sensitive capture material."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
_SENSITIVE_FIELD = re.compile(
|
||||
r"(?:api[_-]?key|authorization|auth[_-]?header|token|secret|password|"
|
||||
r"image|payload|base64|data[_-]?url|ocr[_-]?text|recognized[_-]?text)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_AUTHORIZATION_VALUE = re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?\S+")
|
||||
_KEY_VALUE = re.compile(r"(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*)\S+")
|
||||
_OPENAI_KEY = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
"""Remove common secret-bearing fragments from a log message."""
|
||||
|
||||
value = _AUTHORIZATION_VALUE.sub(r"\1[REDACTED]", value)
|
||||
value = _KEY_VALUE.sub(r"\1[REDACTED]", value)
|
||||
return _OPENAI_KEY.sub("[REDACTED]", value)
|
||||
|
||||
|
||||
def redact_log_data(value: object, field_name: str | None = None) -> object:
|
||||
"""Redact secrets, image data, and unbounded OCR text from structured fields."""
|
||||
|
||||
if field_name is not None and _SENSITIVE_FIELD.search(field_name):
|
||||
return "[REDACTED]"
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): redact_log_data(item, str(key)) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple | set | frozenset):
|
||||
return [redact_log_data(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return redact_text(value[:512])
|
||||
return value
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""Formatter for static messages plus redacted structured event data."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
original_message = record.msg
|
||||
original_args = record.args
|
||||
original_exception = record.exc_info
|
||||
original_exception_text = record.exc_text
|
||||
original_stack = record.stack_info
|
||||
try:
|
||||
if isinstance(record.msg, str):
|
||||
record.msg = redact_text(record.msg)
|
||||
if isinstance(record.args, Mapping):
|
||||
record.args = {
|
||||
str(key): redact_log_data(value, str(key)) for key, value in record.args.items()
|
||||
}
|
||||
elif record.args:
|
||||
record.args = tuple(redact_log_data(item) for item in record.args)
|
||||
record.exc_info = None
|
||||
record.exc_text = None
|
||||
record.stack_info = None
|
||||
output = redact_text(super().format(record))
|
||||
if original_exception is not None or original_exception_text is not None:
|
||||
return f"{output}\n[REDACTED EXCEPTION]"
|
||||
return output
|
||||
finally:
|
||||
record.msg = original_message
|
||||
record.args = original_args
|
||||
record.exc_info = original_exception
|
||||
record.exc_text = original_exception_text
|
||||
record.stack_info = original_stack
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> logging.Logger:
|
||||
"""Create an isolated logger that writes redacted records to stderr."""
|
||||
|
||||
logger = logging.getLogger("game_data_capture")
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(RedactingFormatter("%(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def log_event(logger: logging.Logger, event: str, **fields: Any) -> None:
|
||||
"""Log a stable event name and scrubbed structured fields.
|
||||
|
||||
Callers must keep event names static and place external text in named fields.
|
||||
"""
|
||||
|
||||
logger.info("%s %s", event, redact_log_data(fields))
|
||||
@@ -0,0 +1,108 @@
|
||||
"""XDG path resolution and private-directory preparation.
|
||||
|
||||
Path resolution is pure. Call :func:`prepare_private_directories` only at a runtime
|
||||
boundary that is allowed to create the utility's private directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR, S_ISLNK, S_ISREG
|
||||
|
||||
|
||||
class PathConfigurationError(ValueError):
|
||||
"""A configured path violates the local runtime contract."""
|
||||
|
||||
|
||||
def _bounded_path_error(kind: str) -> PathConfigurationError:
|
||||
return PathConfigurationError(f"{kind} path must be an absolute path within 240 characters")
|
||||
|
||||
|
||||
def _read_xdg_path(environment: Mapping[str, str], key: str, fallback: Path, kind: str) -> Path:
|
||||
raw_value = environment.get(key)
|
||||
candidate = fallback if raw_value is None or raw_value == "" else Path(raw_value).expanduser()
|
||||
if not candidate.is_absolute() or len(str(candidate)) > 240:
|
||||
raise _bounded_path_error(kind)
|
||||
return candidate
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimePaths:
|
||||
"""Private filesystem locations owned by one local capture installation."""
|
||||
|
||||
config_dir: Path
|
||||
data_dir: Path
|
||||
cache_dir: Path
|
||||
runtime_dir: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for path in (self.config_dir, self.data_dir, self.cache_dir, self.runtime_dir):
|
||||
if not path.is_absolute() or len(str(path)) > 240:
|
||||
raise _bounded_path_error("runtime")
|
||||
|
||||
@property
|
||||
def config_file(self) -> Path:
|
||||
return self.config_dir / "config.toml"
|
||||
|
||||
@property
|
||||
def instance_lock_file(self) -> Path:
|
||||
return self.runtime_dir / "instance.lock"
|
||||
|
||||
|
||||
def resolve_runtime_paths(environment: Mapping[str, str] | None = None) -> RuntimePaths:
|
||||
"""Resolve XDG paths without creating or inspecting filesystem entries."""
|
||||
|
||||
env = environ if environment is None else environment
|
||||
home = Path(env.get("HOME", "~")).expanduser()
|
||||
if not home.is_absolute() or len(str(home)) > 200:
|
||||
raise _bounded_path_error("home")
|
||||
|
||||
config_root = _read_xdg_path(env, "XDG_CONFIG_HOME", home / ".config", "XDG config")
|
||||
data_root = _read_xdg_path(env, "XDG_DATA_HOME", home / ".local" / "share", "XDG data")
|
||||
cache_root = _read_xdg_path(env, "XDG_CACHE_HOME", home / ".cache", "XDG cache")
|
||||
runtime_root = _read_xdg_path(env, "XDG_RUNTIME_DIR", data_root / "runtime", "XDG runtime")
|
||||
|
||||
return RuntimePaths(
|
||||
config_dir=config_root / "shd-capture",
|
||||
data_dir=data_root / "shd-capture",
|
||||
cache_dir=cache_root / "shd-capture",
|
||||
runtime_dir=runtime_root / "shd-capture",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_private_directory(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = path.lstat().st_mode
|
||||
if S_ISLNK(mode) or not S_ISDIR(mode):
|
||||
raise PathConfigurationError("runtime path must name a directory")
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def prepare_private_directories(paths: RuntimePaths) -> None:
|
||||
"""Create utility directories with owner-only access at an explicit runtime boundary."""
|
||||
|
||||
for path in (paths.config_dir, paths.data_dir, paths.cache_dir, paths.runtime_dir):
|
||||
_ensure_private_directory(path)
|
||||
|
||||
|
||||
def prepare_private_file(path: Path) -> None:
|
||||
"""Create a runtime-owned regular file with owner-only access.
|
||||
|
||||
The lifecycle layer uses this contract for its instance lock and any future
|
||||
state files. Callers must prepare the parent directory first.
|
||||
"""
|
||||
|
||||
if not path.is_absolute() or len(str(path)) > 240:
|
||||
raise _bounded_path_error("runtime")
|
||||
if path.is_symlink():
|
||||
raise PathConfigurationError("runtime file must be a regular file")
|
||||
if path.exists():
|
||||
mode = path.lstat().st_mode
|
||||
if S_ISLNK(mode) or not S_ISREG(mode):
|
||||
raise PathConfigurationError("runtime file must be a regular file")
|
||||
else:
|
||||
path.touch(mode=0o600, exist_ok=False)
|
||||
path.chmod(0o600)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Bounded durable leases that follow the frozen job state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from game_data_capture.domain.state_machines import require_transition
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import StorageError, _id
|
||||
|
||||
_KINDS = {"classify", "ocr", "vision_fallback", "frame_extract", "export"}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _lease(seconds: int) -> None:
|
||||
if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= 300:
|
||||
raise ValueError("lease duration must be from 1 to 300 seconds")
|
||||
|
||||
|
||||
class JobLeases:
|
||||
def __init__(self, database: StorageDatabase) -> None:
|
||||
self.database = database
|
||||
|
||||
def create(
|
||||
self, job_id: str, kind: str, capture_id: str | None = None, max_attempts: int = 3
|
||||
) -> None:
|
||||
_id(job_id)
|
||||
if capture_id is not None:
|
||||
_id(capture_id)
|
||||
if (
|
||||
kind not in _KINDS
|
||||
or isinstance(max_attempts, bool)
|
||||
or not isinstance(max_attempts, int)
|
||||
or not 1 <= max_attempts <= 20
|
||||
):
|
||||
raise ValueError("invalid job definition")
|
||||
with self.database.connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO jobs(job_id, capture_id, kind, state, max_attempts) "
|
||||
"VALUES (?, ?, ?, 'queued', ?)",
|
||||
(job_id, capture_id, kind, max_attempts),
|
||||
)
|
||||
|
||||
def _recover_expired(self, connection: sqlite3.Connection, now: datetime) -> None:
|
||||
# State changes use the contract path: running -> retry_wait -> queued/failed.
|
||||
rows = list(
|
||||
connection.execute(
|
||||
"SELECT job_id, attempt, max_attempts FROM jobs "
|
||||
"WHERE state = 'running' AND lease_expires_at < ?",
|
||||
(now.isoformat(),),
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
require_transition("job", "running", "retry_wait")
|
||||
changed = connection.execute(
|
||||
"UPDATE jobs SET state = 'retry_wait', lease_owner = NULL, "
|
||||
"lease_expires_at = NULL, revision = revision + 1 "
|
||||
"WHERE job_id = ? AND state = 'running'",
|
||||
(row["job_id"],),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise StorageError("expired job changed concurrently")
|
||||
target = "failed" if row["attempt"] >= row["max_attempts"] else "queued"
|
||||
require_transition("job", "retry_wait", target)
|
||||
changed = connection.execute(
|
||||
"UPDATE jobs SET state = ?, revision = revision + 1 "
|
||||
"WHERE job_id = ? AND state = 'retry_wait'",
|
||||
(target, row["job_id"]),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise StorageError("expired job changed concurrently")
|
||||
|
||||
def acquire(self, owner: str, lease_seconds: int) -> str | None:
|
||||
_id(owner)
|
||||
_lease(lease_seconds)
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._recover_expired(connection, now)
|
||||
row = connection.execute(
|
||||
"SELECT job_id FROM jobs WHERE state = 'queued' "
|
||||
"AND attempt < max_attempts ORDER BY job_id LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute("COMMIT")
|
||||
return None
|
||||
result = connection.execute(
|
||||
"UPDATE jobs SET state = 'running', lease_owner = ?, lease_expires_at = ?, "
|
||||
"attempt = attempt + 1, revision = revision + 1 WHERE job_id = ? "
|
||||
"AND state = 'queued' AND attempt < max_attempts",
|
||||
(owner, (now + timedelta(seconds=lease_seconds)).isoformat(), row["job_id"]),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return str(row["job_id"]) if result.rowcount == 1 else None
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def renew(self, job_id: str, owner: str, lease_seconds: int) -> None:
|
||||
_id(job_id)
|
||||
_id(owner)
|
||||
_lease(lease_seconds)
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
result = connection.execute(
|
||||
"UPDATE jobs SET lease_expires_at = ?, revision = revision + 1 "
|
||||
"WHERE job_id = ? AND state = 'running' AND lease_owner = ? "
|
||||
"AND lease_expires_at >= ?",
|
||||
(
|
||||
(now + timedelta(seconds=lease_seconds)).isoformat(),
|
||||
job_id,
|
||||
owner,
|
||||
now.isoformat(),
|
||||
),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise StorageError("lease is not owned or has expired")
|
||||
|
||||
def finish(self, job_id: str, owner: str, target: str) -> None:
|
||||
_id(job_id)
|
||||
_id(owner)
|
||||
if target not in {"succeeded", "failed", "cancelled"}:
|
||||
raise ValueError("job finish target must be terminal")
|
||||
require_transition("job", "running", target)
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
result = connection.execute(
|
||||
"UPDATE jobs SET state = ?, lease_owner = NULL, lease_expires_at = NULL, "
|
||||
"revision = revision + 1 WHERE job_id = ? AND state = 'running' "
|
||||
"AND lease_owner = ? AND lease_expires_at >= ?",
|
||||
(target, job_id, owner, now.isoformat()),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise StorageError("lease is not owned or has expired")
|
||||
|
||||
def recover(self) -> int:
|
||||
now = _now()
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
before = connection.execute(
|
||||
"SELECT count(*) FROM jobs WHERE state = 'running' AND lease_expires_at < ?",
|
||||
(now.isoformat(),),
|
||||
).fetchone()[0]
|
||||
self._recover_expired(connection, now)
|
||||
connection.execute("COMMIT")
|
||||
return int(before)
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Bounded lifecycle wiring for local capture intake and utility storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import stat
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping
|
||||
from contextlib import AbstractContextManager, contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event, RLock, Thread, current_thread, main_thread
|
||||
from typing import Protocol, cast
|
||||
|
||||
import uvicorn
|
||||
|
||||
from game_data_capture.config import AppConfig
|
||||
from game_data_capture.ingest.clipboard import ClipboardIntake, IntakeReceipt, WatchConfig
|
||||
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE
|
||||
from game_data_capture.paths import RuntimePaths, prepare_private_directories, prepare_private_file
|
||||
from game_data_capture.pipeline.jobs import JobLeases
|
||||
from game_data_capture.storage import (
|
||||
CaptureCorpus,
|
||||
CaptureRepository,
|
||||
ContentAddressedBlobStore,
|
||||
StorageDatabase,
|
||||
)
|
||||
from game_data_capture.web.app import (
|
||||
CaptureControlPort,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import new_bootstrap_token
|
||||
|
||||
_POLICY_VERSION = "capture-v1"
|
||||
_PROCESSING_VERSION = "capture-v1"
|
||||
_POLL_SECONDS = 0.1
|
||||
|
||||
|
||||
class RuntimeFailure(RuntimeError):
|
||||
"""A safe-to-display runtime failure that does not disclose local inputs."""
|
||||
|
||||
|
||||
class IntakePort(Protocol):
|
||||
"""The accepted media descriptor boundary consumed by this runtime."""
|
||||
|
||||
def reconcile(self) -> list[str]: ...
|
||||
|
||||
def arm(self) -> None: ...
|
||||
|
||||
def tick(self) -> None: ...
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None: ...
|
||||
|
||||
def accept_helper_receipt(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
occurrence_id: str,
|
||||
processing_version: str,
|
||||
) -> bool: ...
|
||||
|
||||
def take(self) -> IntakeReceipt | None: ...
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]: ...
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool: ...
|
||||
|
||||
def shutdown(self) -> None: ...
|
||||
|
||||
|
||||
class InstanceLock:
|
||||
"""Descriptor-held ownership that safely reuses an unlocked stale lock file."""
|
||||
|
||||
def __init__(self, file_descriptor: int) -> None:
|
||||
self._file_descriptor: int | None = file_descriptor
|
||||
|
||||
@classmethod
|
||||
def acquire(cls, path: Path, timeout_seconds: int) -> InstanceLock:
|
||||
try:
|
||||
prepare_private_file(path)
|
||||
descriptor = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError as error:
|
||||
raise RuntimeFailure("capture instance lock is unavailable") from error
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600:
|
||||
raise RuntimeFailure("capture instance lock is unsafe")
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return cls(descriptor)
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeFailure("another capture instance is active") from None
|
||||
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
||||
except Exception:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
def release(self) -> None:
|
||||
if self._file_descriptor is None:
|
||||
return
|
||||
descriptor, self._file_descriptor = self._file_descriptor, None
|
||||
with suppress(OSError):
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
with suppress(OSError):
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Resources:
|
||||
repository: CaptureRepository
|
||||
corpus: CaptureCorpus
|
||||
lock: InstanceLock
|
||||
|
||||
def close(self) -> None:
|
||||
self.lock.release()
|
||||
|
||||
|
||||
class _CaptureControl(CaptureControlPort):
|
||||
"""Serialize one optional armed intake beneath a running local service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime: CaptureRuntime,
|
||||
repository: CaptureRepository,
|
||||
) -> None:
|
||||
self._runtime = runtime
|
||||
self._repository = repository
|
||||
self._lock = RLock()
|
||||
self._cancelled: Event | None = None
|
||||
self._intake: IntakePort | None = None
|
||||
self._worker: Thread | None = None
|
||||
self._state = "disarmed"
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
intake = self._intake
|
||||
depth = self._queue_depth(intake)
|
||||
return CaptureSessionStatus(
|
||||
armed=self._state in {"armed", "stopping"},
|
||||
queue_depth=depth,
|
||||
queue_capacity=self._runtime.config.limits.max_queue_items,
|
||||
)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
if self._state == "armed":
|
||||
return "already_armed"
|
||||
if self._state == "stopping":
|
||||
return "conflict"
|
||||
if self._worker is not None:
|
||||
return "conflict"
|
||||
intake: IntakePort | None = None
|
||||
try:
|
||||
intake = self._runtime.intake_factory(self._runtime._watch_config())
|
||||
# Durable media always becomes eligible before the watcher is armed.
|
||||
intake.reconcile()
|
||||
intake.arm()
|
||||
except Exception:
|
||||
if intake is not None:
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
self._state = "disarmed"
|
||||
return "conflict"
|
||||
assert intake is not None
|
||||
cancelled = Event()
|
||||
worker = Thread(
|
||||
target=self._drain_loop,
|
||||
args=(intake, cancelled),
|
||||
name="shd-capture-drain",
|
||||
daemon=True,
|
||||
)
|
||||
self._intake = intake
|
||||
self._cancelled = cancelled
|
||||
self._worker = worker
|
||||
self._state = "armed"
|
||||
try:
|
||||
worker.start()
|
||||
except Exception:
|
||||
self._state = "disarmed"
|
||||
self._intake = self._cancelled = self._worker = None
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
return "conflict"
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
worker = self._worker
|
||||
if worker is None:
|
||||
return "already_stopped"
|
||||
if self._state == "stopping":
|
||||
return "conflict"
|
||||
self._state = "stopping"
|
||||
cancelled = self._cancelled
|
||||
intake = self._intake
|
||||
return "stopped" if self._stop_worker(worker, intake, cancelled) else "conflict"
|
||||
|
||||
def shutdown(self) -> bool:
|
||||
"""Request child shutdown before the service gives up its instance lock."""
|
||||
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
worker = self._worker
|
||||
if worker is None:
|
||||
return True
|
||||
self._state = "stopping"
|
||||
intake = self._intake
|
||||
cancelled = self._cancelled
|
||||
return self._stop_worker(worker, intake, cancelled)
|
||||
|
||||
def defer_resource_close(self, close: Callable[[], None]) -> None:
|
||||
"""Release the service lock only after a timed-out drain worker exits."""
|
||||
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
worker = self._worker
|
||||
if worker is None:
|
||||
close()
|
||||
return
|
||||
|
||||
def wait_for_worker() -> None:
|
||||
worker.join()
|
||||
close()
|
||||
|
||||
Thread(target=wait_for_worker, name="shd-capture-release", daemon=True).start()
|
||||
|
||||
def _stop_worker(
|
||||
self,
|
||||
worker: Thread,
|
||||
intake: IntakePort | None,
|
||||
cancelled: Event | None,
|
||||
) -> bool:
|
||||
if cancelled is not None:
|
||||
cancelled.set()
|
||||
timeout = float(self._runtime.config.lifecycle.shutdown_timeout_seconds)
|
||||
initial_timeout = timeout / 2
|
||||
# Never hold the control lock while waiting for a drain or child cleanup.
|
||||
worker.join(timeout=initial_timeout)
|
||||
if worker.is_alive():
|
||||
# A receipt in flight remains durable because cancellation prevents its
|
||||
# acknowledgement. Stopping intake unblocks watcher/helper cleanup.
|
||||
if intake is not None:
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
worker.join(timeout=timeout - initial_timeout)
|
||||
with self._lock:
|
||||
self._reap_finished_worker()
|
||||
return not worker.is_alive()
|
||||
|
||||
def _drain_loop(self, intake: IntakePort, cancelled: Event) -> None:
|
||||
try:
|
||||
while not cancelled.is_set():
|
||||
intake.tick()
|
||||
self._runtime._drain(intake, self._repository, cancelled.is_set)
|
||||
cancelled.wait(_POLL_SECONDS)
|
||||
except Exception:
|
||||
# The HTTP boundary exposes only bounded armed/queue state, not an
|
||||
# intake, storage, helper, or media exception.
|
||||
pass
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
intake.shutdown()
|
||||
with self._lock:
|
||||
if self._intake is intake:
|
||||
self._state = "disarmed"
|
||||
|
||||
def _reap_finished_worker(self) -> None:
|
||||
if self._worker is not None and not self._worker.is_alive():
|
||||
self._worker = self._cancelled = self._intake = None
|
||||
self._state = "disarmed"
|
||||
|
||||
def _queue_depth(self, intake: IntakePort | None) -> int:
|
||||
if intake is None:
|
||||
return 0
|
||||
depths: list[int] = []
|
||||
for name in ("queue_depth", "helper_queue_depth"):
|
||||
try:
|
||||
value = getattr(intake, name, 0)
|
||||
except Exception:
|
||||
value = 0
|
||||
if not isinstance(value, bool) and isinstance(value, int) and value >= 0:
|
||||
depths.append(value)
|
||||
return min(sum(depths), self._runtime.config.limits.max_queue_items)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _verified_png_file(path: Path, max_bytes: int) -> Iterator[Iterable[bytes]]:
|
||||
"""Yield a one-descriptor PNG stream and reject unsafe input without path disclosure."""
|
||||
|
||||
candidate = path.absolute()
|
||||
if len(str(candidate)) > 240:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
try:
|
||||
descriptor = os.open(candidate, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
except OSError as error:
|
||||
raise RuntimeFailure("input image is not a safe PNG") from error
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or stat.S_IMODE(info.st_mode) & 0o022
|
||||
or not 8 <= info.st_size <= max_bytes
|
||||
):
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
|
||||
def chunks() -> Iterator[bytes]:
|
||||
first = os.read(descriptor, len(PNG_SIGNATURE))
|
||||
if first != PNG_SIGNATURE:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
total = len(first)
|
||||
yield first
|
||||
while chunk := os.read(descriptor, 64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
yield chunk
|
||||
if total != info.st_size:
|
||||
raise RuntimeFailure("input image is not a safe PNG")
|
||||
|
||||
yield chunks()
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
class CaptureRuntime:
|
||||
"""Own utility-local storage, then stream verified media receipts into it."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
paths: RuntimePaths,
|
||||
*,
|
||||
migrations_dir: Path | None = None,
|
||||
intake_factory: Callable[[WatchConfig], IntakePort] = ClipboardIntake,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.paths = paths
|
||||
self.migrations_dir = (
|
||||
Path(__file__).resolve().parents[2] / "migrations"
|
||||
if migrations_dir is None
|
||||
else migrations_dir
|
||||
)
|
||||
self.intake_factory = intake_factory
|
||||
|
||||
def _watch_config(self) -> WatchConfig:
|
||||
return WatchConfig(
|
||||
spool=self.paths.cache_dir / "spool",
|
||||
max_image_bytes=self.config.limits.max_image_bytes,
|
||||
max_queue_items=self.config.limits.max_queue_items,
|
||||
policy_version=_POLICY_VERSION,
|
||||
shutdown_timeout_seconds=float(min(30, self.config.lifecycle.shutdown_timeout_seconds)),
|
||||
)
|
||||
|
||||
def validate_plan(self) -> None:
|
||||
"""Validate static configuration and paths without creating runtime resources."""
|
||||
|
||||
self._watch_config()
|
||||
if not self.migrations_dir.is_absolute() or not self.migrations_dir.is_dir():
|
||||
raise RuntimeFailure("capture runtime resources are unavailable")
|
||||
StorageDatabase(self.paths.data_dir, self.migrations_dir)
|
||||
ContentAddressedBlobStore(self.paths.data_dir)
|
||||
|
||||
def _start(self) -> _Resources:
|
||||
self.validate_plan()
|
||||
prepare_private_directories(self.paths)
|
||||
lock = InstanceLock.acquire(
|
||||
self.paths.instance_lock_file, self.config.lifecycle.lock_timeout_seconds
|
||||
)
|
||||
try:
|
||||
database = StorageDatabase(self.paths.data_dir, self.migrations_dir)
|
||||
database.migrate()
|
||||
blobs = ContentAddressedBlobStore(self.paths.data_dir)
|
||||
blobs.reconcile()
|
||||
JobLeases(database).recover()
|
||||
corpus = CaptureCorpus(
|
||||
database,
|
||||
blobs,
|
||||
max_media_bytes=self.config.limits.max_image_bytes,
|
||||
)
|
||||
corpus.reconcile_deletions()
|
||||
return _Resources(CaptureRepository(database, blobs), corpus, lock)
|
||||
except RuntimeFailure:
|
||||
lock.release()
|
||||
raise
|
||||
except Exception as error:
|
||||
lock.release()
|
||||
raise RuntimeFailure("capture runtime initialization failed") from error
|
||||
|
||||
def ingest_image(self, image: Path) -> str:
|
||||
"""Persist one descriptor-verified PNG as a unique capture occurrence."""
|
||||
|
||||
resources = self._start()
|
||||
token = secrets.token_hex(16)
|
||||
try:
|
||||
with _verified_png_file(image, self.config.limits.max_image_bytes) as stream:
|
||||
return resources.repository.receive(
|
||||
f"capture-{token}",
|
||||
f"image-{token}",
|
||||
stream,
|
||||
media_type="image/png",
|
||||
max_bytes=self.config.limits.max_image_bytes,
|
||||
)
|
||||
except RuntimeFailure:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeFailure("image capture could not be stored") from error
|
||||
finally:
|
||||
resources.close()
|
||||
|
||||
def _drain(
|
||||
self,
|
||||
intake: IntakePort,
|
||||
repository: CaptureRepository,
|
||||
cancellation_requested: Callable[[], bool],
|
||||
) -> None:
|
||||
while (
|
||||
not cancellation_requested() and (payload := intake.take_helper_receipt()) is not None
|
||||
):
|
||||
intake.accept_helper_receipt(
|
||||
payload,
|
||||
occurrence_id=f"capture-{secrets.token_hex(16)}",
|
||||
processing_version=_PROCESSING_VERSION,
|
||||
)
|
||||
while not cancellation_requested() and (receipt := intake.take()) is not None:
|
||||
with intake.open_verified(receipt) as reader:
|
||||
repository.receive(
|
||||
receipt.occurrence_id,
|
||||
receipt.receipt_token,
|
||||
reader,
|
||||
media_type="image/png",
|
||||
max_bytes=self.config.limits.max_image_bytes,
|
||||
)
|
||||
if cancellation_requested():
|
||||
return
|
||||
if not intake.acknowledge(receipt):
|
||||
raise RuntimeFailure("capture receipt acknowledgement failed")
|
||||
|
||||
def serve(self) -> None:
|
||||
"""Run one local web service with unarmed, durable capture control."""
|
||||
|
||||
bootstrap_token = new_bootstrap_token()
|
||||
resources = self._start()
|
||||
control = _CaptureControl(self, resources.repository)
|
||||
authority = (
|
||||
f"[{self.config.server.host}]"
|
||||
if ":" in self.config.server.host
|
||||
else self.config.server.host
|
||||
)
|
||||
try:
|
||||
app = create_app(
|
||||
config=WebConfig(
|
||||
server=self.config.server,
|
||||
max_capture_items=min(64, self.config.limits.max_queue_items),
|
||||
max_image_bytes=self.config.limits.max_image_bytes,
|
||||
),
|
||||
capture_control=control,
|
||||
corpus=resources.corpus,
|
||||
bootstrap_token=bootstrap_token,
|
||||
)
|
||||
print(
|
||||
f"Open local review service: http://{authority}:{self.config.server.port}/"
|
||||
f"#bootstrap={bootstrap_token}"
|
||||
)
|
||||
print("H1: capture is disarmed; use Start Session before taking a screenshot.")
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=self.config.server.host,
|
||||
port=self.config.server.port,
|
||||
reload=False,
|
||||
workers=1,
|
||||
proxy_headers=False,
|
||||
)
|
||||
except SystemExit as error:
|
||||
if error.code not in (None, 0):
|
||||
raise RuntimeFailure("capture service failed") from error
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
except RuntimeFailure:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeFailure("capture service failed") from error
|
||||
finally:
|
||||
if control.shutdown():
|
||||
resources.close()
|
||||
else:
|
||||
control.defer_resource_close(resources.close)
|
||||
raise RuntimeFailure("capture service shutdown failed")
|
||||
|
||||
def watch(self, *, cancelled: Event | None = None, poll_seconds: float = _POLL_SECONDS) -> None:
|
||||
"""Run the armed intake until cancellation, signal, or keyboard interruption."""
|
||||
|
||||
if not 0 < poll_seconds <= 1:
|
||||
raise ValueError("invalid runtime poll interval")
|
||||
resources = self._start()
|
||||
intake: IntakePort | None = None
|
||||
stop_requested = Event()
|
||||
handlers: dict[signal.Signals, object] = {}
|
||||
|
||||
def request_stop(_signal_number: int, _frame: object) -> None:
|
||||
stop_requested.set()
|
||||
|
||||
try:
|
||||
if current_thread() is main_thread():
|
||||
for configured_signal in (signal.SIGINT, signal.SIGTERM):
|
||||
handlers[configured_signal] = signal.signal(configured_signal, request_stop)
|
||||
intake = self.intake_factory(self._watch_config())
|
||||
intake.reconcile()
|
||||
intake.arm()
|
||||
while not stop_requested.is_set() and (cancelled is None or not cancelled.is_set()):
|
||||
intake.tick()
|
||||
self._drain(
|
||||
intake,
|
||||
resources.repository,
|
||||
lambda: (
|
||||
stop_requested.is_set() or (cancelled is not None and cancelled.is_set())
|
||||
),
|
||||
)
|
||||
time.sleep(poll_seconds)
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
except RuntimeFailure:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeFailure("capture watch failed") from error
|
||||
finally:
|
||||
if intake is not None:
|
||||
with suppress(OSError, RuntimeError):
|
||||
intake.shutdown()
|
||||
for restored_signal, handler in handlers.items():
|
||||
signal.signal(restored_signal, cast(signal.Handlers, handler))
|
||||
resources.close()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Utility-local durable storage; it never opens the SHD.build application database."""
|
||||
|
||||
from game_data_capture.storage.blobs import BlobCorruptionError, ContentAddressedBlobStore
|
||||
from game_data_capture.storage.corpus import CaptureCorpus
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import CaptureRepository, StorageError
|
||||
|
||||
__all__ = [
|
||||
"BlobCorruptionError",
|
||||
"CaptureCorpus",
|
||||
"CaptureRepository",
|
||||
"ContentAddressedBlobStore",
|
||||
"StorageDatabase",
|
||||
"StorageError",
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Verified immutable blobs with no symlink traversal at storage boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Iterable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from stat import S_IMODE, S_ISDIR, S_ISREG
|
||||
|
||||
|
||||
class BlobCorruptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BlobLimitError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class BlobLockError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _digest(value: str) -> None:
|
||||
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
||||
raise ValueError("invalid SHA-256 digest")
|
||||
|
||||
|
||||
class ContentAddressedBlobStore:
|
||||
def __init__(self, root: Path) -> None:
|
||||
if not root.is_absolute():
|
||||
raise ValueError("blob root must be absolute")
|
||||
self.root = root
|
||||
self.blobs = root / "blobs"
|
||||
self.temporary = root / "tmp"
|
||||
self.lock_path = root / ".blob-operation.lock"
|
||||
|
||||
@contextmanager
|
||||
def operation_lock(self, *, timeout_seconds: float = 1.0) -> Iterator[None]:
|
||||
"""Hold the private cross-process lock for receipt finalization or deletion."""
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, int | float)
|
||||
or not 0 < timeout_seconds <= 5
|
||||
):
|
||||
raise ValueError("blob lock timeout is invalid")
|
||||
self._directory(self.root, create=True)
|
||||
try:
|
||||
fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
except OSError as error:
|
||||
raise BlobLockError("blob operation lock is unavailable") from error
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not S_ISREG(info.st_mode) or S_IMODE(info.st_mode) != 0o600:
|
||||
raise BlobLockError("blob operation lock is unsafe")
|
||||
import fcntl
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise BlobLockError("blob operation lock is unavailable") from None
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise BlobLockError("blob operation lock is unavailable") from None
|
||||
time.sleep(min(0.02, remaining))
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def _directory(self, path: Path, *, create: bool) -> None:
|
||||
if path.is_symlink():
|
||||
raise BlobCorruptionError("storage directory is unsafe")
|
||||
if create:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if not path.exists() or path.is_symlink() or not S_ISDIR(path.lstat().st_mode):
|
||||
raise BlobCorruptionError("storage directory is unsafe")
|
||||
path.chmod(0o700)
|
||||
|
||||
def _path(self, digest: str) -> Path:
|
||||
_digest(digest)
|
||||
return self.blobs / digest[:2] / digest[2:]
|
||||
|
||||
def _fsync_directory(self, path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise BlobCorruptionError("storage directory is unsafe")
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def _verify_fd(self, fd: int, digest: str, expected_size: int | None) -> int:
|
||||
stat = os.fstat(fd)
|
||||
if not S_ISREG(stat.st_mode):
|
||||
raise BlobCorruptionError("blob is not a regular file")
|
||||
if expected_size is not None and stat.st_size != expected_size:
|
||||
raise BlobCorruptionError("blob content does not match receipt")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
hasher = hashlib.sha256()
|
||||
size = 0
|
||||
while chunk := os.read(fd, 1024 * 1024):
|
||||
size += len(chunk)
|
||||
hasher.update(chunk)
|
||||
if hasher.hexdigest() != digest or (expected_size is not None and size != expected_size):
|
||||
raise BlobCorruptionError("blob content does not match receipt")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
return size
|
||||
|
||||
def put(
|
||||
self, chunks: Iterable[bytes], *, max_bytes: int, expected_sha256: str | None = None
|
||||
) -> tuple[str, int]:
|
||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
||||
raise BlobLimitError("blob limit must be positive")
|
||||
if expected_sha256 is not None:
|
||||
_digest(expected_sha256)
|
||||
self._directory(self.root, create=True)
|
||||
self._directory(self.blobs, create=True)
|
||||
self._directory(self.temporary, create=True)
|
||||
temporary = self.temporary / f".{secrets.token_hex(16)}.partial"
|
||||
hasher = hashlib.sha256()
|
||||
size = 0
|
||||
try:
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
for chunk in chunks:
|
||||
if not isinstance(chunk, bytes) or not chunk:
|
||||
raise BlobLimitError("blob chunks must be non-empty bytes")
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
raise BlobLimitError("blob exceeds configured size limit")
|
||||
hasher.update(chunk)
|
||||
view = memoryview(chunk)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise OSError("short blob write")
|
||||
view = view[written:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
digest = hasher.hexdigest()
|
||||
if size == 0 or (expected_sha256 is not None and digest != expected_sha256):
|
||||
raise BlobCorruptionError("blob digest verification failed")
|
||||
with self.operation_lock():
|
||||
destination = self._path(digest)
|
||||
new_prefix = not destination.parent.exists()
|
||||
self._directory(destination.parent, create=True)
|
||||
if new_prefix:
|
||||
self._fsync_directory(self.blobs)
|
||||
directory_fd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
if destination.exists():
|
||||
self.verify(digest, expected_size=size)
|
||||
else:
|
||||
os.replace(temporary, destination)
|
||||
os.chmod(destination, 0o600)
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
self.verify(digest, expected_size=size)
|
||||
return digest, size
|
||||
finally:
|
||||
if temporary.exists() and not temporary.is_symlink():
|
||||
temporary.unlink()
|
||||
|
||||
def _open_verified(self, digest: str, expected_size: int | None = None) -> tuple[int, int]:
|
||||
path = self._path(digest)
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError as error:
|
||||
raise BlobCorruptionError("blob is missing or unsafe") from error
|
||||
try:
|
||||
return fd, self._verify_fd(fd, digest, expected_size)
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
def verify(self, digest: str, *, expected_size: int | None = None) -> int:
|
||||
fd, size = self._open_verified(digest, expected_size)
|
||||
os.close(fd)
|
||||
return size
|
||||
|
||||
def read(
|
||||
self, digest: str, *, max_bytes: int, expected_size: int | None = None
|
||||
) -> Iterator[bytes]:
|
||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
||||
raise BlobLimitError("blob limit must be positive")
|
||||
fd, size = self._open_verified(digest, expected_size)
|
||||
if size > max_bytes:
|
||||
os.close(fd)
|
||||
raise BlobLimitError("blob exceeds read limit")
|
||||
try:
|
||||
while chunk := os.read(fd, 1024 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def delete_verified(self, digest: str, *, expected_size: int) -> bool:
|
||||
"""Unlink one verified immutable blob without following a replacement path."""
|
||||
if (
|
||||
isinstance(expected_size, bool)
|
||||
or not isinstance(expected_size, int)
|
||||
or expected_size <= 0
|
||||
):
|
||||
raise BlobLimitError("blob size is invalid")
|
||||
fd, _ = self._open_verified(digest, expected_size)
|
||||
path = self._path(digest)
|
||||
try:
|
||||
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
current = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
opened = os.fstat(fd)
|
||||
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
||||
raise BlobCorruptionError("blob changed during deletion")
|
||||
os.unlink(path.name, dir_fd=parent_fd)
|
||||
os.fsync(parent_fd)
|
||||
return True
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
except OSError as error:
|
||||
raise BlobCorruptionError("blob is missing or unsafe") from error
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
if not self.temporary.exists() or self.temporary.is_symlink():
|
||||
return []
|
||||
self._directory(self.temporary, create=False)
|
||||
removed = []
|
||||
for entry in self.temporary.iterdir():
|
||||
if not entry.is_symlink() and entry.is_file() and entry.name.endswith(".partial"):
|
||||
entry.unlink()
|
||||
removed.append(entry.name)
|
||||
return removed
|
||||
|
||||
def purge_dry_run(self, digests: set[str]) -> list[Path]:
|
||||
if not self.blobs.exists() or self.blobs.is_symlink():
|
||||
return []
|
||||
self._directory(self.blobs, create=False)
|
||||
candidates = []
|
||||
for prefix in self.blobs.iterdir():
|
||||
if prefix.is_symlink() or not prefix.is_dir():
|
||||
continue
|
||||
for entry in prefix.iterdir():
|
||||
if (
|
||||
not entry.is_symlink()
|
||||
and entry.is_file()
|
||||
and prefix.name + entry.name not in digests
|
||||
):
|
||||
candidates.append(entry.relative_to(self.root))
|
||||
return candidates
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Durable, safe projections for the reviewed local capture corpus port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from game_data_capture.domain.validation import (
|
||||
ContractValidationError,
|
||||
validate_classification,
|
||||
)
|
||||
from game_data_capture.storage.blobs import (
|
||||
BlobCorruptionError,
|
||||
BlobLockError,
|
||||
ContentAddressedBlobStore,
|
||||
)
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import StorageError, _now
|
||||
from game_data_capture.web.app import CaptureRecord, MediaDescriptor, MutationOutcome
|
||||
|
||||
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
|
||||
_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
|
||||
_CLASSIFICATION_KEYS = frozenset(
|
||||
{"family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"}
|
||||
)
|
||||
_MAX_MEDIA_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _capture_id(value: str) -> bool:
|
||||
return isinstance(value, str) and bool(_CAPTURE_ID.fullmatch(value))
|
||||
|
||||
|
||||
def _classification(value: Mapping[str, object]) -> str:
|
||||
if not isinstance(value, Mapping) or set(value) != _CLASSIFICATION_KEYS:
|
||||
raise ValueError("classification does not match the frozen taxonomy shape")
|
||||
payload = dict(value)
|
||||
try:
|
||||
validate_classification(payload)
|
||||
confidence = payload["confidence"]
|
||||
record_types = payload["record_types"]
|
||||
if isinstance(confidence, bool) or not isinstance(confidence, int | float):
|
||||
raise ValueError("classification confidence is invalid")
|
||||
if not isinstance(record_types, list) or any(
|
||||
not isinstance(record_type, str) for record_type in record_types
|
||||
):
|
||||
raise ValueError("classification record types are invalid")
|
||||
canonical_confidence = 0.0 if confidence == 0 else float(confidence)
|
||||
canonical = {
|
||||
"family": payload["family"],
|
||||
"view": payload["view"],
|
||||
"gear_slot": payload["gear_slot"],
|
||||
"record_types": sorted(record_types),
|
||||
"confidence": canonical_confidence,
|
||||
"abstention_reason": payload["abstention_reason"],
|
||||
}
|
||||
encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||||
except (ContractValidationError, TypeError, ValueError) as error:
|
||||
raise ValueError("classification does not match the frozen taxonomy shape") from error
|
||||
if not 2 <= len(encoded) <= 4096:
|
||||
raise ValueError("classification does not match the frozen taxonomy shape")
|
||||
return encoded
|
||||
|
||||
|
||||
def _stored_classification(value: object) -> Mapping[str, object]:
|
||||
if not isinstance(value, str) or not 2 <= len(value) <= 4096:
|
||||
raise StorageError("stored corpus record is invalid")
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StorageError("stored corpus record is invalid") from error
|
||||
if not isinstance(decoded, dict) or set(decoded) != _CLASSIFICATION_KEYS:
|
||||
raise StorageError("stored corpus record is invalid")
|
||||
try:
|
||||
validate_classification(decoded)
|
||||
except ContractValidationError as error:
|
||||
raise StorageError("stored corpus record is invalid") from error
|
||||
return decoded
|
||||
|
||||
|
||||
class CaptureCorpus:
|
||||
"""SQLite-backed implementation of the reviewed ``CaptureCorpusPort``.
|
||||
|
||||
The corpus exposes only opaque capture identifiers. It serializes writes with
|
||||
``BEGIN IMMEDIATE`` so repeated labels and rejections have deterministic
|
||||
idempotence, while differing labels preserve the replaced value as evidence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database: StorageDatabase,
|
||||
blobs: ContentAddressedBlobStore,
|
||||
*,
|
||||
max_media_bytes: int = _MAX_MEDIA_BYTES,
|
||||
) -> None:
|
||||
if (
|
||||
isinstance(max_media_bytes, bool)
|
||||
or not isinstance(max_media_bytes, int)
|
||||
or not 1 <= max_media_bytes <= _MAX_MEDIA_BYTES
|
||||
):
|
||||
raise ValueError("corpus media limit is invalid")
|
||||
self.database = database
|
||||
self.blobs = blobs
|
||||
self.max_media_bytes = max_media_bytes
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 256:
|
||||
raise ValueError("capture list limit is invalid")
|
||||
with self.database.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
|
||||
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
|
||||
"ORDER BY c.received_at DESC, c.capture_id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
return [self._record(row) for row in rows]
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
if not _capture_id(capture_id):
|
||||
return None
|
||||
with self.database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
|
||||
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
|
||||
"WHERE c.capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
return None if row is None else self._record(row)
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
if not _capture_id(capture_id):
|
||||
return None
|
||||
with self.database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT b.sha256, b.byte_size, b.media_type FROM captures AS c "
|
||||
"JOIN blobs AS b ON b.sha256 = c.blob_sha256 WHERE c.capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
digest, size, media_type = row["sha256"], row["byte_size"], row["media_type"]
|
||||
if (
|
||||
not isinstance(digest, str)
|
||||
or isinstance(size, bool)
|
||||
or not isinstance(size, int)
|
||||
or not 1 <= size <= self.max_media_bytes
|
||||
or not isinstance(media_type, str)
|
||||
or media_type not in _MEDIA_TYPES
|
||||
):
|
||||
raise StorageError("stored corpus media is invalid")
|
||||
try:
|
||||
chunks = self.blobs.read(
|
||||
digest,
|
||||
max_bytes=self.max_media_bytes,
|
||||
expected_size=size,
|
||||
)
|
||||
except (BlobCorruptionError, ValueError) as error:
|
||||
raise StorageError("stored corpus media is unavailable") from error
|
||||
return MediaDescriptor(media_type=media_type, byte_size=size, chunks=chunks)
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
if not _capture_id(capture_id):
|
||||
return "not_found"
|
||||
encoded = _classification(classification)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if not self._exists(connection, capture_id):
|
||||
connection.execute("COMMIT")
|
||||
return "not_found"
|
||||
if self._has_rejection(connection, capture_id):
|
||||
connection.execute("COMMIT")
|
||||
return "conflict"
|
||||
current = connection.execute(
|
||||
"SELECT classification_json, revision FROM capture_labels WHERE capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
if current is None:
|
||||
connection.execute(
|
||||
"INSERT INTO capture_labels(capture_id, classification_json, revision, "
|
||||
"labeled_at) VALUES (?, ?, 0, ?)",
|
||||
(capture_id, encoded, _now()),
|
||||
)
|
||||
else:
|
||||
current_json = current["classification_json"]
|
||||
_stored_classification(current_json)
|
||||
if current_json == encoded:
|
||||
connection.execute("COMMIT")
|
||||
return "updated"
|
||||
revision = current["revision"]
|
||||
revision_is_valid = (
|
||||
not isinstance(revision, bool)
|
||||
and isinstance(revision, int)
|
||||
and revision >= 0
|
||||
)
|
||||
if not revision_is_valid:
|
||||
raise StorageError("stored corpus label is invalid")
|
||||
assert isinstance(revision, int) and not isinstance(revision, bool)
|
||||
connection.execute(
|
||||
"INSERT INTO capture_label_evidence(capture_id, replaced_revision, "
|
||||
"classification_json, replaced_at) VALUES (?, ?, ?, ?)",
|
||||
(capture_id, revision, current_json, _now()),
|
||||
)
|
||||
result = connection.execute(
|
||||
"UPDATE capture_labels SET classification_json = ?, "
|
||||
"revision = revision + 1, labeled_at = ? "
|
||||
"WHERE capture_id = ? AND revision = ?",
|
||||
(encoded, _now(), capture_id, revision),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return "conflict"
|
||||
connection.execute("COMMIT")
|
||||
return "updated"
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
if not _capture_id(capture_id):
|
||||
return "not_found"
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if not self._exists(connection, capture_id):
|
||||
connection.execute("COMMIT")
|
||||
return "not_found"
|
||||
if not self._has_rejection(connection, capture_id):
|
||||
connection.execute(
|
||||
"INSERT INTO capture_rejections(capture_id, rejected_at) VALUES (?, ?)",
|
||||
(capture_id, _now()),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return "updated"
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
if not _capture_id(capture_id):
|
||||
return "not_found"
|
||||
try:
|
||||
with self.blobs.operation_lock():
|
||||
return self._delete_locked(capture_id)
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
|
||||
def _delete_locked(self, capture_id: str) -> MutationOutcome:
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT blob_sha256, state, received_at, receipt_token, "
|
||||
"failure_reason, revision "
|
||||
"FROM captures WHERE capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute("COMMIT")
|
||||
return "not_found"
|
||||
if self._retained(connection, capture_id) or not self._deletable_capture(row):
|
||||
connection.execute("COMMIT")
|
||||
return "retained"
|
||||
digest = row["blob_sha256"]
|
||||
blob = connection.execute(
|
||||
"SELECT byte_size FROM blobs WHERE sha256 = ?", (digest,)
|
||||
).fetchone()
|
||||
if (
|
||||
not isinstance(digest, str)
|
||||
or blob is None
|
||||
or isinstance(blob["byte_size"], bool)
|
||||
or not isinstance(blob["byte_size"], int)
|
||||
or not 1 <= blob["byte_size"] <= self.max_media_bytes
|
||||
):
|
||||
connection.execute("COMMIT")
|
||||
return "retained"
|
||||
try:
|
||||
self.blobs.verify(digest, expected_size=blob["byte_size"])
|
||||
except (BlobCorruptionError, ValueError):
|
||||
connection.execute("COMMIT")
|
||||
return "retained"
|
||||
references = connection.execute(
|
||||
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
|
||||
).fetchone()[0]
|
||||
if not isinstance(references, int) or references < 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return "conflict"
|
||||
if references == 1:
|
||||
connection.execute(
|
||||
"INSERT INTO capture_deletion_intents(blob_sha256, capture_id, byte_size, "
|
||||
"capture_state, received_at, receipt_token, failure_reason, revision, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
digest,
|
||||
capture_id,
|
||||
blob["byte_size"],
|
||||
row["state"],
|
||||
row["received_at"],
|
||||
row["receipt_token"],
|
||||
row["failure_reason"],
|
||||
row["revision"],
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
result = connection.execute(
|
||||
"DELETE FROM captures WHERE capture_id = ?", (capture_id,)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return "conflict"
|
||||
connection.execute("COMMIT")
|
||||
if references > 1:
|
||||
return "updated"
|
||||
return (
|
||||
"updated"
|
||||
if self._reconcile_locked(limit=1, restore_on_failure=True)
|
||||
else "retained"
|
||||
)
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _exists(connection: sqlite3.Connection, capture_id: str) -> bool:
|
||||
return (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM captures WHERE capture_id = ?", (capture_id,)
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_rejection(connection: sqlite3.Connection, capture_id: str) -> bool:
|
||||
return (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM capture_rejections WHERE capture_id = ?", (capture_id,)
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retained(connection: sqlite3.Connection, capture_id: str) -> bool:
|
||||
tables = (
|
||||
"jobs",
|
||||
"ocr_runs",
|
||||
"capture_labels",
|
||||
"capture_label_evidence",
|
||||
"capture_rejections",
|
||||
)
|
||||
if any(
|
||||
connection.execute(
|
||||
f"SELECT 1 FROM {table} WHERE capture_id = ? LIMIT 1", (capture_id,)
|
||||
).fetchone()
|
||||
is not None
|
||||
for table in tables
|
||||
):
|
||||
return True
|
||||
if (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM runtime_failures WHERE subject_type = 'capture' "
|
||||
"AND subject_id = ? LIMIT 1",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
is not None
|
||||
):
|
||||
return True
|
||||
return (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM lineage WHERE child_id = ? OR parent_id = ? LIMIT 1",
|
||||
(capture_id, capture_id),
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _deletable_capture(row: sqlite3.Row) -> bool:
|
||||
return row["state"] in {"stored", "deduplicated"} and row["failure_reason"] is None
|
||||
|
||||
def reconcile_deletions(self, *, limit: int = 64) -> int:
|
||||
"""Finish bounded committed deletion intents after a crash or unlink failure."""
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise ValueError("deletion reconciliation limit is invalid")
|
||||
try:
|
||||
with self.blobs.operation_lock():
|
||||
return self._reconcile_locked(limit=limit, restore_on_failure=False)
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
|
||||
def _reconcile_locked(self, *, limit: int, restore_on_failure: bool) -> int:
|
||||
with self.database.connect() as connection:
|
||||
intents = list(
|
||||
connection.execute(
|
||||
"SELECT blob_sha256, byte_size, capture_id, capture_state, received_at, "
|
||||
"receipt_token, failure_reason, revision FROM capture_deletion_intents "
|
||||
"ORDER BY created_at, capture_id LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
)
|
||||
completed = 0
|
||||
for intent in intents:
|
||||
digest, size = intent["blob_sha256"], intent["byte_size"]
|
||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
||||
continue
|
||||
try:
|
||||
self.blobs.delete_verified(digest, expected_size=size)
|
||||
except BlobCorruptionError:
|
||||
try:
|
||||
self.blobs._path(digest).lstat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
if restore_on_failure and self._restore_intent(intent):
|
||||
continue
|
||||
if restore_on_failure:
|
||||
raise StorageError("blob deletion could not be completed") from None
|
||||
continue
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
active = connection.execute(
|
||||
"SELECT 1 FROM captures WHERE blob_sha256 = ?", (digest,)
|
||||
).fetchone()
|
||||
if active is not None:
|
||||
connection.execute("ROLLBACK")
|
||||
continue
|
||||
result = connection.execute(
|
||||
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
|
||||
)
|
||||
connection.execute("DELETE FROM blobs WHERE sha256 = ?", (digest,))
|
||||
connection.execute("COMMIT")
|
||||
completed += result.rowcount
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
return completed
|
||||
|
||||
def _restore_intent(self, intent: sqlite3.Row) -> bool:
|
||||
digest = intent["blob_sha256"]
|
||||
size = intent["byte_size"]
|
||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
||||
return False
|
||||
try:
|
||||
self.blobs.verify(digest, expected_size=size)
|
||||
except (BlobCorruptionError, ValueError):
|
||||
return False
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
|
||||
"receipt_token, failure_reason, revision) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
intent["capture_id"],
|
||||
digest,
|
||||
intent["capture_state"],
|
||||
intent["received_at"],
|
||||
intent["receipt_token"],
|
||||
intent["failure_reason"],
|
||||
intent["revision"],
|
||||
),
|
||||
)
|
||||
result = connection.execute(
|
||||
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return result.rowcount == 1
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _record(row: sqlite3.Row) -> CaptureRecord:
|
||||
capture_id, state, received_at = (
|
||||
row["capture_id"],
|
||||
row["state"],
|
||||
row["received_at"],
|
||||
)
|
||||
if (
|
||||
not _capture_id(capture_id)
|
||||
or not isinstance(state, str)
|
||||
or not isinstance(received_at, str)
|
||||
or not 1 <= len(received_at) <= 40
|
||||
or "\r" in received_at
|
||||
or "\n" in received_at
|
||||
):
|
||||
raise StorageError("stored corpus record is invalid")
|
||||
raw_label = row["classification_json"]
|
||||
return CaptureRecord(
|
||||
capture_id=capture_id,
|
||||
state=state,
|
||||
received_at=received_at,
|
||||
classification=None if raw_label is None else _stored_classification(raw_label),
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Private SQLite setup and immutable ordered utility migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR, S_ISLNK
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
"""The local migration history cannot safely be applied."""
|
||||
|
||||
|
||||
def _private_directory(path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise MigrationError("utility root must not be a symlink")
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = path.lstat().st_mode
|
||||
if S_ISLNK(mode) or not S_ISDIR(mode):
|
||||
raise MigrationError("utility root must be a directory")
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
class StorageDatabase:
|
||||
def __init__(self, root: Path, migrations_dir: Path) -> None:
|
||||
if not root.is_absolute() or not migrations_dir.is_absolute():
|
||||
raise ValueError("storage paths must be absolute")
|
||||
self.root = root
|
||||
self.migrations_dir = migrations_dir
|
||||
self.path = root / "capture.sqlite3"
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
_private_directory(self.root)
|
||||
if self.path.is_symlink():
|
||||
raise MigrationError("database file must not be a symlink")
|
||||
connection: sqlite3.Connection | None = None
|
||||
try:
|
||||
connection = sqlite3.connect(self.path, isolation_level=None, timeout=5.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
journal = connection.execute("PRAGMA journal_mode = WAL").fetchone()[0]
|
||||
connection.execute("PRAGMA busy_timeout = 5000")
|
||||
connection.execute("PRAGMA synchronous = FULL")
|
||||
checks = (
|
||||
connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1,
|
||||
journal == "wal",
|
||||
connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5000,
|
||||
connection.execute("PRAGMA synchronous").fetchone()[0] == 2,
|
||||
)
|
||||
if not all(checks):
|
||||
raise MigrationError("SQLite connection invariants were not enabled")
|
||||
for candidate in (
|
||||
self.path,
|
||||
self.path.with_name(self.path.name + "-wal"),
|
||||
self.path.with_name(self.path.name + "-shm"),
|
||||
):
|
||||
if candidate.exists() and not candidate.is_symlink():
|
||||
candidate.chmod(0o600)
|
||||
return connection
|
||||
except Exception:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
raise
|
||||
|
||||
def _migrations(self) -> list[tuple[int, Path, str]]:
|
||||
found: list[tuple[int, Path, str]] = []
|
||||
versions: set[int] = set()
|
||||
for path in self.migrations_dir.glob("*.sql"):
|
||||
if len(path.name) < 5 or not path.name[:3].isdigit() or path.name[3] != "_":
|
||||
raise MigrationError("migration filename is invalid")
|
||||
version = int(path.name[:3])
|
||||
if version in versions:
|
||||
raise MigrationError("migration versions must be unique")
|
||||
versions.add(version)
|
||||
checksum = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
found.append((version, path, checksum))
|
||||
found.sort(key=lambda item: item[0])
|
||||
if not found:
|
||||
raise MigrationError("no storage migrations found")
|
||||
return found
|
||||
|
||||
def migrate(self) -> None:
|
||||
migrations = self._migrations()
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||
"version INTEGER PRIMARY KEY, filename TEXT NOT NULL UNIQUE, "
|
||||
"checksum TEXT NOT NULL, applied_at TEXT NOT NULL)"
|
||||
)
|
||||
applied = list(
|
||||
connection.execute(
|
||||
"SELECT version, filename, checksum FROM schema_migrations ORDER BY version"
|
||||
)
|
||||
)
|
||||
if len(applied) > len(migrations):
|
||||
raise MigrationError("applied migration is no longer present")
|
||||
expected_prefix = migrations[: len(applied)]
|
||||
for row, expected in zip(applied, expected_prefix, strict=True):
|
||||
version, path, checksum = expected
|
||||
if (row["version"], row["filename"], row["checksum"]) != (
|
||||
version,
|
||||
path.name,
|
||||
checksum,
|
||||
):
|
||||
raise MigrationError("migration history is not an exact ordered prefix")
|
||||
for version, path, checksum in migrations[len(applied) :]:
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
applied_at = datetime.now(UTC).isoformat().replace("'", "''")
|
||||
connection.executescript(
|
||||
"BEGIN IMMEDIATE;\n"
|
||||
+ sql
|
||||
+ "\nINSERT INTO schema_migrations VALUES "
|
||||
+ f"({version}, '{path.name}', '{checksum}', '{applied_at}');"
|
||||
+ "\nCOMMIT;"
|
||||
)
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
os.chmod(self.path, 0o600)
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Utility-local capture receipts and bounded insert-only provenance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from game_data_capture.domain.state_machines import require_transition
|
||||
from game_data_capture.storage.blobs import (
|
||||
BlobCorruptionError,
|
||||
BlobLockError,
|
||||
ContentAddressedBlobStore,
|
||||
)
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_MEDIA = re.compile(r"^(?:image/(?:png|webp|avif)|video/(?:mp4|webm|x-matroska))$")
|
||||
_SECRET = re.compile(
|
||||
r"(?i)(?:sk-[a-z0-9_-]{8,}|authorization\s*[:=]\s*bearer\s+\S+|"
|
||||
r"(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+|bearer\s+\S+|/[^\s]+)"
|
||||
)
|
||||
|
||||
|
||||
class StorageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _id(value: str) -> str:
|
||||
if not isinstance(value, str) or not _ID.fullmatch(value):
|
||||
raise ValueError("invalid local identifier")
|
||||
return value
|
||||
|
||||
|
||||
def _json(value: object, maximum: int) -> str:
|
||||
encoded = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
||||
if len(encoded) > maximum:
|
||||
raise ValueError("provenance payload exceeds its bound")
|
||||
return encoded
|
||||
|
||||
|
||||
def sanitize_failure(code: str, message: str) -> tuple[str, str]:
|
||||
safe_code = re.sub(r"[^a-z0-9_]", "_", code.lower())[:64] or "storage_failure"
|
||||
compact = " ".join(message.split())
|
||||
compact = _SECRET.sub("[REDACTED]", compact)
|
||||
if len(compact) > 512 or (len(compact) > 160 and " " not in compact[:160]):
|
||||
compact = "operation failed [payload redacted]"
|
||||
return safe_code, compact[:512] or "operation failed"
|
||||
|
||||
|
||||
class CaptureRepository:
|
||||
def __init__(self, database: StorageDatabase, blobs: ContentAddressedBlobStore) -> None:
|
||||
self.database = database
|
||||
self.blobs = blobs
|
||||
|
||||
def receive(
|
||||
self,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
_id(capture_id)
|
||||
_id(receipt_token)
|
||||
if not _MEDIA.fullmatch(media_type):
|
||||
raise ValueError("unsupported media type")
|
||||
try:
|
||||
with self.blobs.operation_lock(), self.database.connect() as connection:
|
||||
pending = connection.execute(
|
||||
"SELECT 1 FROM capture_deletion_intents WHERE receipt_token = ?",
|
||||
(receipt_token,),
|
||||
).fetchone()
|
||||
if pending is not None:
|
||||
raise StorageError("blob deletion is pending")
|
||||
existing = connection.execute(
|
||||
"SELECT capture_id FROM captures WHERE receipt_token = ?", (receipt_token,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return str(existing["capture_id"])
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
try:
|
||||
digest, size = self.blobs.put(chunks, max_bytes=max_bytes)
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
try:
|
||||
with self.blobs.operation_lock():
|
||||
self.blobs.verify(digest, expected_size=size)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
pending = connection.execute(
|
||||
"SELECT 1 FROM capture_deletion_intents WHERE blob_sha256 = ?",
|
||||
(digest,),
|
||||
).fetchone()
|
||||
if pending is not None:
|
||||
raise StorageError("blob deletion is pending")
|
||||
existing = connection.execute(
|
||||
"SELECT capture_id FROM captures WHERE receipt_token = ?",
|
||||
(receipt_token,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.execute("COMMIT")
|
||||
return str(existing["capture_id"])
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO blobs(sha256, byte_size, media_type, "
|
||||
"created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(digest, size, media_type, _now()),
|
||||
)
|
||||
duplicate = connection.execute(
|
||||
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
|
||||
).fetchone()[0]
|
||||
connection.execute(
|
||||
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
|
||||
"receipt_token) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
capture_id,
|
||||
digest,
|
||||
"deduplicated" if duplicate else "stored",
|
||||
_now(),
|
||||
receipt_token,
|
||||
),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return capture_id
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
except BlobLockError as error:
|
||||
raise StorageError("blob operation is unavailable") from error
|
||||
|
||||
def transition(self, capture_id: str, target: str, expected_revision: int) -> int:
|
||||
_id(capture_id)
|
||||
with self.database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT state FROM captures WHERE capture_id = ? AND revision = ?",
|
||||
(capture_id, expected_revision),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise StorageError("stale or missing capture")
|
||||
require_transition("capture", str(row["state"]), target)
|
||||
result = connection.execute(
|
||||
"UPDATE captures SET state = ?, revision = revision + 1 "
|
||||
"WHERE capture_id = ? AND revision = ?",
|
||||
(target, capture_id, expected_revision),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise StorageError("stale capture transition")
|
||||
return expected_revision + 1
|
||||
|
||||
def append_ocr_run(
|
||||
self, record_id: str, capture_id: str, engine_id: str, engine_version: str, payload: object
|
||||
) -> None:
|
||||
if not 0 < len(engine_id) <= 100 or not 0 < len(engine_version) <= 100:
|
||||
raise ValueError("OCR engine identity is invalid")
|
||||
self._insert(
|
||||
"ocr_runs",
|
||||
(
|
||||
record_id,
|
||||
capture_id,
|
||||
engine_id,
|
||||
engine_version,
|
||||
_now(),
|
||||
"completed",
|
||||
_json(payload, 1_000_000),
|
||||
),
|
||||
)
|
||||
|
||||
def append_region(self, record_id: str, run_id: str, payload: object) -> None:
|
||||
self._insert("regions", (record_id, run_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_classification(self, record_id: str, run_id: str, payload: object) -> None:
|
||||
self._insert("classifications", (record_id, run_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_candidate(self, record_id: str, run_id: str, payload: object) -> None:
|
||||
self._insert(
|
||||
"extraction_candidates", (record_id, run_id, _json(payload, 1_000_000), _now())
|
||||
)
|
||||
|
||||
def append_routing(self, record_id: str, candidate_id: str, payload: object) -> None:
|
||||
self._insert("routing_decisions", (record_id, candidate_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_review(self, record_id: str, candidate_id: str, payload: object) -> None:
|
||||
self._insert("review_events", (record_id, candidate_id, _json(payload, 10_000), _now()))
|
||||
|
||||
def append_lineage(self, record_id: str, child_id: str, parent_id: str, relation: str) -> None:
|
||||
if not 0 < len(relation) <= 100:
|
||||
raise ValueError("lineage relation is invalid")
|
||||
self._insert("lineage", (record_id, child_id, parent_id, relation, _now()))
|
||||
|
||||
def append_export(self, record_id: str, state: str, manifest: object) -> None:
|
||||
if not state or len(state) > 100:
|
||||
raise ValueError("export state is invalid")
|
||||
self._insert("exports", (record_id, state, _json(manifest, 1_000_000), _now()))
|
||||
|
||||
def _insert(self, table: str, values: tuple[object, ...]) -> None:
|
||||
specs = {
|
||||
"ocr_runs": (
|
||||
"run_id,capture_id,engine_id,engine_version,created_at,state,payload_json",
|
||||
(0, 1),
|
||||
),
|
||||
"regions": ("region_id,run_id,region_json,created_at", (0, 1)),
|
||||
"classifications": ("classification_id,run_id,classification_json,created_at", (0, 1)),
|
||||
"extraction_candidates": ("candidate_id,run_id,candidate_json,created_at", (0, 1)),
|
||||
"routing_decisions": ("decision_id,candidate_id,decision_json,created_at", (0, 1)),
|
||||
"review_events": ("event_id,candidate_id,event_json,created_at", (0, 1)),
|
||||
"lineage": ("lineage_id,child_id,parent_id,relation,created_at", (0, 1, 2)),
|
||||
"exports": ("export_id,state,manifest_json,created_at", (0,)),
|
||||
}
|
||||
columns, positions = specs[table]
|
||||
if len(values) != len(columns.split(",")):
|
||||
raise ValueError("provenance record arity is invalid")
|
||||
for position in positions:
|
||||
_id(str(values[position]))
|
||||
placeholders = ", ".join("?" for _ in values)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute(f"INSERT INTO {table} ({columns}) VALUES ({placeholders})", values)
|
||||
|
||||
def report(self) -> dict[str, int]:
|
||||
tables = ("blobs", "captures", "jobs", "ocr_runs", "runtime_failures")
|
||||
with self.database.connect() as connection:
|
||||
return {
|
||||
table: int(connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0])
|
||||
for table in tables
|
||||
}
|
||||
|
||||
def verify(self) -> list[str]:
|
||||
with self.database.connect() as connection:
|
||||
return [
|
||||
str(row["sha256"])
|
||||
for row in connection.execute("SELECT sha256, byte_size FROM blobs")
|
||||
if self._invalid_blob(row)
|
||||
]
|
||||
|
||||
def _invalid_blob(self, row: sqlite3.Row) -> bool:
|
||||
try:
|
||||
digest = row["sha256"]
|
||||
size = row["byte_size"]
|
||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
||||
return True
|
||||
self.blobs.verify(digest, expected_size=size)
|
||||
except BlobCorruptionError:
|
||||
return True
|
||||
return False
|
||||
|
||||
def record_failure(
|
||||
self, failure_id: str, subject_type: str, subject_id: str, code: str, message: str
|
||||
) -> None:
|
||||
if not re.fullmatch(r"[a-z_]{1,64}", subject_type):
|
||||
raise ValueError("failure subject type is invalid")
|
||||
code, message = sanitize_failure(code, message)
|
||||
with self.database.connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO runtime_failures VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(_id(failure_id), subject_type, _id(subject_id), code, message, _now()),
|
||||
)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""FastAPI factory for the local capture-and-labeling boundary.
|
||||
|
||||
Construction only retains injected ports and process-local session state. It does
|
||||
not bind a listener, initialize runtime paths, open SQLite, arm capture, read a
|
||||
clipboard, or create a child process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal, Protocol, TypeVar
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.domain.state_machines import CaptureState
|
||||
from game_data_capture.domain.validation import ContractValidationError, validate_classification
|
||||
from game_data_capture.web.security import (
|
||||
AuthenticatedSession,
|
||||
AuthenticationError,
|
||||
BodyLimitExceeded,
|
||||
ClientDisconnected,
|
||||
LocalAuthority,
|
||||
LocalRequestLimitsMiddleware,
|
||||
RequestLimits,
|
||||
SecurityError,
|
||||
SessionStore,
|
||||
response_security_headers,
|
||||
set_session_cookie,
|
||||
single_header,
|
||||
validate_origin,
|
||||
)
|
||||
|
||||
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
|
||||
_STATE_VALUES = frozenset(state.value for state in CaptureState)
|
||||
_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
|
||||
_OUTCOME_VALUES = frozenset({"updated", "not_found", "conflict", "retained"})
|
||||
_PortResult = TypeVar("_PortResult")
|
||||
|
||||
|
||||
class ServiceUnavailable(RuntimeError):
|
||||
"""An injected port cannot safely complete this local request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebConfig:
|
||||
"""Static local service settings supplied by runtime integration."""
|
||||
|
||||
server: ServerConfig
|
||||
request_limits: RequestLimits = field(default_factory=RequestLimits)
|
||||
max_capture_items: int = 64
|
||||
max_image_bytes: int = 64 * 1024 * 1024
|
||||
bootstrap_ttl_seconds: int = 600
|
||||
session_ttl_seconds: int = 900
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value, maximum in (
|
||||
(self.max_capture_items, 256),
|
||||
(self.max_image_bytes, 64 * 1024 * 1024),
|
||||
(self.bootstrap_ttl_seconds, 3600),
|
||||
(self.session_ttl_seconds, 3600),
|
||||
):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
||||
raise ValueError("web service configuration is invalid")
|
||||
|
||||
@property
|
||||
def authority(self) -> LocalAuthority:
|
||||
return LocalAuthority(self.server.host, self.server.port)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureSessionStatus:
|
||||
armed: bool
|
||||
queue_depth: int
|
||||
queue_capacity: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureRecord:
|
||||
"""A safe browser projection; ports must never place paths or OCR text here."""
|
||||
|
||||
capture_id: str
|
||||
state: str
|
||||
received_at: str
|
||||
classification: Mapping[str, object] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MediaDescriptor:
|
||||
"""An opaque capture-media stream supplied by a corpus port, never a path."""
|
||||
|
||||
media_type: str
|
||||
byte_size: int
|
||||
chunks: Iterable[bytes]
|
||||
|
||||
|
||||
MutationOutcome = Literal["updated", "not_found", "conflict", "retained"]
|
||||
ControlOutcome = Literal["started", "already_armed", "stopped", "already_stopped", "conflict"]
|
||||
|
||||
|
||||
class CaptureControlPort(Protocol):
|
||||
"""Narrow control boundary implemented by later runtime integration."""
|
||||
|
||||
def status(self) -> CaptureSessionStatus: ...
|
||||
|
||||
def start_session(self) -> ControlOutcome: ...
|
||||
|
||||
def stop_session(self) -> ControlOutcome: ...
|
||||
|
||||
|
||||
class CaptureCorpusPort(Protocol):
|
||||
"""Narrow durable corpus boundary; routes never access SQLite or paths."""
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]: ...
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None: ...
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None: ...
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome: ...
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome: ...
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome: ...
|
||||
|
||||
|
||||
def _response(payload: Mapping[str, object], *, status_code: int = 200) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
payload,
|
||||
status_code=status_code,
|
||||
headers={"Cache-Control": "no-store", **response_security_headers()},
|
||||
)
|
||||
|
||||
|
||||
def _error(status_code: int, code: str) -> JSONResponse:
|
||||
return _response({"error": code}, status_code=status_code)
|
||||
|
||||
|
||||
def _call_port(operation: Callable[[], _PortResult]) -> _PortResult:
|
||||
"""Translate every injected-port failure without retaining its raw message."""
|
||||
|
||||
try:
|
||||
return operation()
|
||||
except Exception as error:
|
||||
raise ServiceUnavailable("local service operation failed") from error
|
||||
|
||||
|
||||
def _valid_capture_id(capture_id: str) -> bool:
|
||||
return bool(_CAPTURE_ID.fullmatch(capture_id))
|
||||
|
||||
|
||||
def _status_projection(status: CaptureSessionStatus) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(status.armed, bool)
|
||||
or isinstance(status.queue_depth, bool)
|
||||
or isinstance(status.queue_capacity, bool)
|
||||
or not isinstance(status.queue_depth, int)
|
||||
or not isinstance(status.queue_capacity, int)
|
||||
or not 0 <= status.queue_depth <= status.queue_capacity <= 256
|
||||
):
|
||||
raise ServiceUnavailable("capture control returned an invalid status")
|
||||
return {
|
||||
"armed": status.armed,
|
||||
"queue_depth": status.queue_depth,
|
||||
"queue_capacity": status.queue_capacity,
|
||||
}
|
||||
|
||||
|
||||
def _classification_projection(classification: Mapping[str, object]) -> dict[str, object]:
|
||||
# Reconstruct only the frozen contract keys before returning port data to a browser.
|
||||
if not isinstance(classification, Mapping) or set(classification) != {
|
||||
"family",
|
||||
"view",
|
||||
"gear_slot",
|
||||
"record_types",
|
||||
"confidence",
|
||||
"abstention_reason",
|
||||
}:
|
||||
raise ServiceUnavailable("corpus returned an invalid classification")
|
||||
payload = dict(classification)
|
||||
try:
|
||||
validate_classification(payload)
|
||||
except ContractValidationError as error:
|
||||
raise ServiceUnavailable("corpus returned an invalid classification") from error
|
||||
family = payload["family"]
|
||||
view = payload["view"]
|
||||
gear_slot = payload["gear_slot"]
|
||||
record_types = payload["record_types"]
|
||||
confidence = payload["confidence"]
|
||||
reason = payload["abstention_reason"]
|
||||
if (
|
||||
not isinstance(family, str)
|
||||
or not isinstance(view, str)
|
||||
or (gear_slot is not None and not isinstance(gear_slot, str))
|
||||
or not isinstance(record_types, list)
|
||||
or any(not isinstance(record_type, str) for record_type in record_types)
|
||||
or isinstance(confidence, bool)
|
||||
or not isinstance(confidence, int | float)
|
||||
or (reason is not None and not isinstance(reason, str))
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned an invalid classification")
|
||||
return {
|
||||
"family": family,
|
||||
"view": view,
|
||||
"gear_slot": gear_slot,
|
||||
"record_types": record_types,
|
||||
"confidence": confidence,
|
||||
"abstention_reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _record_projection(record: CaptureRecord) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(record.capture_id, str)
|
||||
or not _valid_capture_id(record.capture_id)
|
||||
or not isinstance(record.state, str)
|
||||
or record.state not in _STATE_VALUES
|
||||
or not isinstance(record.received_at, str)
|
||||
or not 1 <= len(record.received_at) <= 40
|
||||
or any(character in record.received_at for character in "\r\n")
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned an invalid capture")
|
||||
payload: dict[str, object] = {
|
||||
"capture_id": record.capture_id,
|
||||
"state": record.state,
|
||||
"received_at": record.received_at,
|
||||
"classification": None,
|
||||
}
|
||||
if record.classification is not None:
|
||||
payload["classification"] = _classification_projection(record.classification)
|
||||
return payload
|
||||
|
||||
|
||||
def _json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> object:
|
||||
raise ValueError(f"unsupported JSON constant {value}")
|
||||
|
||||
|
||||
async def _read_json(request: Request, *, maximum: int) -> dict[str, object]:
|
||||
content_type = single_header(request, b"content-type", required=True)
|
||||
if content_type is None:
|
||||
raise SecurityError("unsupported request content type")
|
||||
try:
|
||||
media_type = content_type.decode("ascii")
|
||||
except UnicodeDecodeError as error:
|
||||
raise SecurityError("unsupported request content type") from error
|
||||
if media_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||
raise SecurityError("unsupported request content type")
|
||||
body = await request.body()
|
||||
if not 2 <= len(body) <= maximum:
|
||||
raise SecurityError("request JSON size is invalid")
|
||||
try:
|
||||
value = json.loads(
|
||||
body.decode("utf-8"),
|
||||
object_pairs_hook=_json_pairs,
|
||||
parse_constant=_reject_json_constant,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise SecurityError("request JSON is invalid") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SecurityError("request JSON must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _bootstrap_payload(value: Mapping[str, object]) -> str:
|
||||
if set(value) != {"token"}:
|
||||
raise SecurityError("bootstrap payload is invalid")
|
||||
token = value.get("token")
|
||||
if not isinstance(token, str) or not 32 <= len(token) <= 128:
|
||||
raise SecurityError("bootstrap payload is invalid")
|
||||
return token
|
||||
|
||||
|
||||
def _confirmation_payload(value: Mapping[str, object], confirmation: str) -> None:
|
||||
if set(value) != {"confirmation"} or value.get("confirmation") != confirmation:
|
||||
raise SecurityError("confirmation payload is invalid")
|
||||
|
||||
|
||||
def _label_payload(value: Mapping[str, object]) -> dict[str, object]:
|
||||
expected = {
|
||||
"family",
|
||||
"view",
|
||||
"gear_slot",
|
||||
"record_types",
|
||||
"confidence",
|
||||
"abstention_reason",
|
||||
}
|
||||
if set(value) != expected:
|
||||
raise SecurityError("label payload is invalid")
|
||||
family = value.get("family")
|
||||
view = value.get("view")
|
||||
record_types = value.get("record_types")
|
||||
reason = value.get("abstention_reason")
|
||||
if (
|
||||
not isinstance(family, str)
|
||||
or not 1 <= len(family) <= 32
|
||||
or not isinstance(view, str)
|
||||
or not 1 <= len(view) <= 32
|
||||
or not isinstance(record_types, list)
|
||||
or not 0 <= len(record_types) <= 8
|
||||
or any(
|
||||
not isinstance(record_type, str) or not 1 <= len(record_type) <= 64
|
||||
for record_type in record_types
|
||||
)
|
||||
or (reason is not None and (not isinstance(reason, str) or not 1 <= len(reason) <= 64))
|
||||
):
|
||||
raise SecurityError("label payload is invalid")
|
||||
payload = dict(value)
|
||||
try:
|
||||
validate_classification(payload)
|
||||
except ContractValidationError as error:
|
||||
raise SecurityError("label payload is invalid") from error
|
||||
return payload
|
||||
|
||||
|
||||
def _outcome_response(outcome: MutationOutcome) -> tuple[int, str]:
|
||||
if outcome not in _OUTCOME_VALUES:
|
||||
raise ServiceUnavailable("corpus returned an invalid mutation outcome")
|
||||
if outcome == "updated":
|
||||
return 200, "updated"
|
||||
if outcome == "not_found":
|
||||
return 404, "not_found"
|
||||
if outcome == "conflict":
|
||||
return 409, "conflict"
|
||||
return 409, "retained"
|
||||
|
||||
|
||||
def _media_bytes(media: MediaDescriptor, maximum: int) -> tuple[str, bytes]:
|
||||
"""Materialize and validate bounded port media before response headers exist."""
|
||||
|
||||
try:
|
||||
if (
|
||||
media.media_type not in _IMAGE_MEDIA_TYPES
|
||||
or isinstance(media.byte_size, bool)
|
||||
or not isinstance(media.byte_size, int)
|
||||
or not 1 <= media.byte_size <= maximum
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
materialized = bytearray()
|
||||
for chunk in media.chunks:
|
||||
if not isinstance(chunk, bytes) or not chunk:
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
if (
|
||||
len(materialized) + len(chunk) > media.byte_size
|
||||
or len(materialized) + len(chunk) > maximum
|
||||
):
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
materialized.extend(chunk)
|
||||
if len(materialized) != media.byte_size:
|
||||
raise ServiceUnavailable("corpus returned unsafe media")
|
||||
return media.media_type, bytes(materialized)
|
||||
except ServiceUnavailable:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ServiceUnavailable("corpus media stream failed") from error
|
||||
|
||||
|
||||
def _session_response(
|
||||
payload: dict[str, object],
|
||||
session_store: SessionStore,
|
||||
session: AuthenticatedSession,
|
||||
config: WebConfig,
|
||||
) -> JSONResponse:
|
||||
rotated = session_store.rotate(session)
|
||||
payload["csrf_token"] = rotated.csrf_token
|
||||
response = _response(payload)
|
||||
set_session_cookie(response, rotated, max_age=config.session_ttl_seconds)
|
||||
return response
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
config: WebConfig,
|
||||
capture_control: CaptureControlPort,
|
||||
corpus: CaptureCorpusPort,
|
||||
bootstrap_token: str,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
) -> FastAPI:
|
||||
"""Create an unbound app using only narrow, caller-injected local ports."""
|
||||
|
||||
authority = config.authority
|
||||
sessions = SessionStore(
|
||||
bootstrap_token,
|
||||
now=now,
|
||||
bootstrap_ttl_seconds=config.bootstrap_ttl_seconds,
|
||||
session_ttl_seconds=config.session_ttl_seconds,
|
||||
)
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.add_middleware(
|
||||
LocalRequestLimitsMiddleware, authority=authority, limits=config.request_limits
|
||||
)
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(
|
||||
directory=Path(__file__).with_name("static"),
|
||||
check_dir=False,
|
||||
follow_symlink=False,
|
||||
),
|
||||
name="static",
|
||||
)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).with_name("templates")))
|
||||
|
||||
@app.exception_handler(AuthenticationError)
|
||||
async def authentication_error(_: Request, __: AuthenticationError) -> JSONResponse:
|
||||
return _error(401, "authentication_required")
|
||||
|
||||
@app.exception_handler(BodyLimitExceeded)
|
||||
async def body_limit_error(_: Request, __: BodyLimitExceeded) -> JSONResponse:
|
||||
return _error(413, "request_too_large")
|
||||
|
||||
@app.exception_handler(ClientDisconnected)
|
||||
async def disconnect_error(_: Request, __: ClientDisconnected) -> JSONResponse:
|
||||
return _error(400, "request_rejected")
|
||||
|
||||
@app.exception_handler(SecurityError)
|
||||
async def security_error(_: Request, __: SecurityError) -> JSONResponse:
|
||||
return _error(403, "request_forbidden")
|
||||
|
||||
@app.exception_handler(ServiceUnavailable)
|
||||
async def service_error(_: Request, __: ServiceUnavailable) -> JSONResponse:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unexpected_error(_: Request, __: Exception) -> JSONResponse:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
return _response({"status": "ok"})
|
||||
|
||||
@app.get("/", response_class=Response)
|
||||
async def review_page(request: Request) -> Response:
|
||||
return templates.TemplateResponse(request=request, name="index.html", context={})
|
||||
|
||||
@app.post("/api/bootstrap")
|
||||
async def bootstrap(request: Request) -> JSONResponse:
|
||||
validate_origin(request, authority)
|
||||
session = sessions.bootstrap(_bootstrap_payload(await _read_json(request, maximum=256)))
|
||||
response = _response({"csrf_token": session.csrf_token})
|
||||
set_session_cookie(response, session, max_age=config.session_ttl_seconds)
|
||||
return response
|
||||
|
||||
@app.get("/api/session")
|
||||
async def session_status(request: Request) -> JSONResponse:
|
||||
session = sessions.authenticate(request)
|
||||
payload = _status_projection(_call_port(capture_control.status))
|
||||
payload["csrf_token"] = session.csrf_token
|
||||
return _response(payload)
|
||||
|
||||
@app.post("/api/session/start")
|
||||
async def start_session(request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "START")
|
||||
outcome = _call_port(capture_control.start_session)
|
||||
if outcome not in {"started", "already_armed", "conflict"}:
|
||||
raise ServiceUnavailable("capture control returned an invalid start outcome")
|
||||
if outcome == "conflict":
|
||||
return _error(409, "conflict")
|
||||
payload = _status_projection(_call_port(capture_control.status))
|
||||
payload["result"] = outcome
|
||||
return _session_response(payload, sessions, session, config)
|
||||
|
||||
@app.post("/api/session/stop")
|
||||
async def stop_session(request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "STOP")
|
||||
outcome = _call_port(capture_control.stop_session)
|
||||
if outcome not in {"stopped", "already_stopped", "conflict"}:
|
||||
raise ServiceUnavailable("capture control returned an invalid stop outcome")
|
||||
if outcome == "conflict":
|
||||
return _error(409, "conflict")
|
||||
payload = _status_projection(_call_port(capture_control.status))
|
||||
payload["result"] = outcome
|
||||
return _session_response(payload, sessions, session, config)
|
||||
|
||||
@app.get("/api/captures")
|
||||
async def list_captures(request: Request) -> JSONResponse:
|
||||
sessions.authenticate(request)
|
||||
|
||||
def list_projection() -> list[dict[str, object]]:
|
||||
records = corpus.list_captures(limit=config.max_capture_items)
|
||||
if len(records) > config.max_capture_items:
|
||||
raise ValueError("corpus returned too many captures")
|
||||
return [_record_projection(record) for record in records]
|
||||
|
||||
return _response({"captures": _call_port(list_projection)})
|
||||
|
||||
@app.get("/api/captures/{capture_id}")
|
||||
async def get_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
sessions.authenticate(request)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
record = _call_port(lambda: corpus.get_capture(capture_id))
|
||||
if record is None:
|
||||
return _error(404, "not_found")
|
||||
return _response({"capture": _call_port(lambda: _record_projection(record))})
|
||||
|
||||
@app.get("/api/captures/{capture_id}/media")
|
||||
async def capture_media(capture_id: str, request: Request) -> Response:
|
||||
sessions.authenticate(request)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
media = _call_port(lambda: corpus.open_media(capture_id))
|
||||
if media is None:
|
||||
return _error(404, "not_found")
|
||||
media_type, content = _call_port(lambda: _media_bytes(media, config.max_image_bytes))
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/api/captures/{capture_id}/label")
|
||||
async def label_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
classification = _label_payload(await _read_json(request, maximum=4096))
|
||||
outcome = _call_port(lambda: corpus.label(capture_id, classification))
|
||||
status, result = _outcome_response(outcome)
|
||||
if status != 200:
|
||||
return _error(status, result)
|
||||
return _session_response({"result": result}, sessions, session, config)
|
||||
|
||||
@app.post("/api/captures/{capture_id}/reject")
|
||||
async def reject_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "REJECT")
|
||||
outcome = _call_port(lambda: corpus.reject_preserving_evidence(capture_id))
|
||||
status, result = _outcome_response(outcome)
|
||||
if status != 200:
|
||||
return _error(status, result)
|
||||
return _session_response({"result": result}, sessions, session, config)
|
||||
|
||||
@app.post("/api/captures/{capture_id}/delete")
|
||||
async def delete_capture(capture_id: str, request: Request) -> JSONResponse:
|
||||
session = sessions.require_mutation(request, authority)
|
||||
if not _valid_capture_id(capture_id):
|
||||
return _error(404, "not_found")
|
||||
_confirmation_payload(await _read_json(request, maximum=64), "DELETE")
|
||||
outcome = _call_port(lambda: corpus.delete_with_retention(capture_id))
|
||||
status, result = _outcome_response(outcome)
|
||||
if status != 200:
|
||||
return _error(status, result)
|
||||
return _session_response({"result": result}, sessions, session, config)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Local-only request guards and process-local session state.
|
||||
|
||||
This module intentionally has no runtime, storage, filesystem, or server imports.
|
||||
The runtime integration layer owns listener construction and must disable Uvicorn
|
||||
proxy headers so forwarded request headers never affect this boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
_SECRET = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
|
||||
_COOKIE_NAME = "capture_session"
|
||||
_RESPONSE_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = (
|
||||
(
|
||||
b"content-security-policy",
|
||||
b"default-src 'self'; base-uri 'none'; object-src 'none'; frame-src 'none'; "
|
||||
b"frame-ancestors 'none'; "
|
||||
b"script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; "
|
||||
b"media-src 'self'; connect-src 'self'; form-action 'self'",
|
||||
),
|
||||
(b"referrer-policy", b"no-referrer"),
|
||||
(b"x-content-type-options", b"nosniff"),
|
||||
(b"x-frame-options", b"DENY"),
|
||||
)
|
||||
|
||||
|
||||
def response_security_headers() -> dict[str, str]:
|
||||
"""Return response headers for errors generated outside the ASGI wrapper."""
|
||||
|
||||
return {
|
||||
name.decode("ascii"): value.decode("ascii") for name, value in _RESPONSE_SECURITY_HEADERS
|
||||
}
|
||||
|
||||
|
||||
class SecurityError(ValueError):
|
||||
"""A request failed a security boundary without exposing its input."""
|
||||
|
||||
|
||||
class AuthenticationError(SecurityError):
|
||||
"""The process-local authenticated session is absent or expired."""
|
||||
|
||||
|
||||
class BodyLimitExceeded(SecurityError):
|
||||
"""The declared or streamed request body exceeded the configured limit."""
|
||||
|
||||
|
||||
class ClientDisconnected(SecurityError):
|
||||
"""The client disconnected before a route obtained a complete request body."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalAuthority:
|
||||
"""The single exact loopback authority accepted by the HTTP boundary."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
address = ipaddress.ip_address(self.host)
|
||||
except ValueError as error:
|
||||
raise ValueError("web host must be a loopback IP literal") from error
|
||||
if not address.is_loopback or not 1024 <= self.port <= 65535:
|
||||
raise ValueError("web authority is invalid")
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
return f"[{self.host}]:{self.port}" if ":" in self.host else f"{self.host}:{self.port}"
|
||||
|
||||
@property
|
||||
def origin(self) -> str:
|
||||
return f"http://{self.authority}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RequestLimits:
|
||||
max_header_bytes: int = 16 * 1024
|
||||
max_headers: int = 64
|
||||
max_body_bytes: int = 16 * 1024
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for value in (self.max_header_bytes, self.max_headers, self.max_body_bytes):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValueError("request limits must be positive integers")
|
||||
|
||||
|
||||
def new_bootstrap_token() -> str:
|
||||
"""Create a fragment-safe, one-time token for the local bootstrap mutation."""
|
||||
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _header_values(scope: Scope, name: bytes) -> list[bytes]:
|
||||
return [value for key, value in scope.get("headers", []) if key.lower() == name]
|
||||
|
||||
|
||||
def _single_header(scope: Scope, name: bytes, *, required: bool) -> bytes | None:
|
||||
values = _header_values(scope, name)
|
||||
if len(values) != 1:
|
||||
if not values and not required:
|
||||
return None
|
||||
raise SecurityError("ambiguous request header")
|
||||
return values[0]
|
||||
|
||||
|
||||
def single_header(request: Request, name: bytes, *, required: bool) -> bytes | None:
|
||||
"""Return one unambiguous request header without trusting framework merging."""
|
||||
|
||||
return _single_header(request.scope, name, required=required)
|
||||
|
||||
|
||||
def _ascii(value: bytes) -> str:
|
||||
try:
|
||||
return value.decode("ascii")
|
||||
except UnicodeDecodeError as error:
|
||||
raise SecurityError("invalid request header") from error
|
||||
|
||||
|
||||
def validate_host(scope: Scope, authority: LocalAuthority) -> None:
|
||||
value = _single_header(scope, b"host", required=True)
|
||||
assert value is not None
|
||||
if not secrets.compare_digest(_ascii(value), authority.authority):
|
||||
raise SecurityError("unexpected request authority")
|
||||
|
||||
|
||||
def validate_origin(request: Request, authority: LocalAuthority) -> None:
|
||||
value = _single_header(request.scope, b"origin", required=True)
|
||||
assert value is not None
|
||||
if not secrets.compare_digest(_ascii(value), authority.origin):
|
||||
raise SecurityError("unexpected request origin")
|
||||
|
||||
|
||||
def _validate_content_length(scope: Scope, maximum: int) -> None:
|
||||
value = _single_header(scope, b"content-length", required=False)
|
||||
if value is None:
|
||||
return
|
||||
raw = _ascii(value)
|
||||
if not raw.isascii() or not raw.isdecimal():
|
||||
raise SecurityError("invalid content length")
|
||||
if int(raw) > maximum:
|
||||
raise BodyLimitExceeded("request body is too large")
|
||||
|
||||
|
||||
async def _send_rejection(send: Send, status: int, code: str) -> None:
|
||||
body = json.dumps({"error": code}, separators=(",", ":")).encode("ascii")
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(body)).encode("ascii")),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
|
||||
class LocalRequestLimitsMiddleware:
|
||||
"""Validate headers and cap all ASGI body chunks before application parsing."""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, authority: LocalAuthority, limits: RequestLimits) -> None:
|
||||
self.app = app
|
||||
self.authority = authority
|
||||
self.limits = limits
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def secured_send(message: Message) -> None:
|
||||
if message["type"] != "http.response.start":
|
||||
await send(message)
|
||||
return
|
||||
headers = list(message.get("headers", []))
|
||||
present = {name.lower() for name, _ in headers}
|
||||
headers.extend(
|
||||
(name, value) for name, value in _RESPONSE_SECURITY_HEADERS if name not in present
|
||||
)
|
||||
protected = dict(message)
|
||||
protected["headers"] = headers
|
||||
await send(protected)
|
||||
|
||||
headers = scope.get("headers", [])
|
||||
if len(headers) > self.limits.max_headers or any(
|
||||
len(name) + len(value) > self.limits.max_header_bytes for name, value in headers
|
||||
):
|
||||
await _send_rejection(secured_send, 431, "request_rejected")
|
||||
return
|
||||
if sum(len(name) + len(value) for name, value in headers) > self.limits.max_header_bytes:
|
||||
await _send_rejection(secured_send, 431, "request_rejected")
|
||||
return
|
||||
try:
|
||||
validate_host(scope, self.authority)
|
||||
_validate_content_length(scope, self.limits.max_body_bytes)
|
||||
except BodyLimitExceeded:
|
||||
await _send_rejection(secured_send, 413, "request_too_large")
|
||||
return
|
||||
except SecurityError:
|
||||
await _send_rejection(secured_send, 400, "request_rejected")
|
||||
return
|
||||
|
||||
received = 0
|
||||
request_complete = False
|
||||
|
||||
async def limited_receive() -> Message:
|
||||
nonlocal received, request_complete
|
||||
message = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
if not request_complete:
|
||||
raise ClientDisconnected("client disconnected")
|
||||
return message
|
||||
if message["type"] != "http.request":
|
||||
return message
|
||||
body = message.get("body", b"")
|
||||
if not isinstance(body, bytes):
|
||||
raise SecurityError("invalid request body")
|
||||
received += len(body)
|
||||
if received > self.limits.max_body_bytes:
|
||||
raise BodyLimitExceeded("request body is too large")
|
||||
if not message.get("more_body", False):
|
||||
request_complete = True
|
||||
return message
|
||||
|
||||
await self.app(scope, limited_receive, secured_send)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedSession:
|
||||
token: str
|
||||
csrf_token: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""An intentionally non-durable session store invalidated on process restart."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bootstrap_token: str,
|
||||
*,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
bootstrap_ttl_seconds: int = 600,
|
||||
session_ttl_seconds: int = 900,
|
||||
) -> None:
|
||||
if not isinstance(bootstrap_token, str) or not _SECRET.fullmatch(bootstrap_token):
|
||||
raise ValueError("bootstrap token is invalid")
|
||||
if (
|
||||
isinstance(bootstrap_ttl_seconds, bool)
|
||||
or not isinstance(bootstrap_ttl_seconds, int)
|
||||
or isinstance(session_ttl_seconds, bool)
|
||||
or not isinstance(session_ttl_seconds, int)
|
||||
or not 1 <= bootstrap_ttl_seconds <= 3600
|
||||
or not 1 <= session_ttl_seconds <= 3600
|
||||
):
|
||||
raise ValueError("session lifetime is invalid")
|
||||
self._bootstrap_token = bootstrap_token.encode("ascii")
|
||||
self._bootstrap_expires_at = now() + bootstrap_ttl_seconds
|
||||
self._bootstrap_used = False
|
||||
self._now = now
|
||||
self._session_ttl_seconds = session_ttl_seconds
|
||||
self._sessions: dict[str, AuthenticatedSession] = {}
|
||||
|
||||
def bootstrap(self, submitted: str) -> AuthenticatedSession:
|
||||
if (
|
||||
self._bootstrap_used
|
||||
or self._now() > self._bootstrap_expires_at
|
||||
or not _SECRET.fullmatch(submitted)
|
||||
or not secrets.compare_digest(submitted.encode("ascii"), self._bootstrap_token)
|
||||
):
|
||||
raise AuthenticationError("bootstrap authentication failed")
|
||||
self._bootstrap_used = True
|
||||
return self._new_session()
|
||||
|
||||
def authenticate(self, request: Request) -> AuthenticatedSession:
|
||||
token = _session_cookie(request)
|
||||
session = self._sessions.get(token)
|
||||
if session is None or self._now() > session.expires_at:
|
||||
self._sessions.pop(token, None)
|
||||
raise AuthenticationError("session authentication failed")
|
||||
return session
|
||||
|
||||
def require_mutation(self, request: Request, authority: LocalAuthority) -> AuthenticatedSession:
|
||||
session = self.authenticate(request)
|
||||
validate_origin(request, authority)
|
||||
csrf = _single_header(request.scope, b"x-csrf-token", required=True)
|
||||
assert csrf is not None
|
||||
submitted = _ascii(csrf)
|
||||
if not _SECRET.fullmatch(submitted) or not secrets.compare_digest(
|
||||
submitted.encode("ascii"), session.csrf_token.encode("ascii")
|
||||
):
|
||||
raise SecurityError("csrf validation failed")
|
||||
return session
|
||||
|
||||
def rotate(self, session: AuthenticatedSession) -> AuthenticatedSession:
|
||||
current = self._sessions.get(session.token)
|
||||
if current != session or self._now() > session.expires_at:
|
||||
self._sessions.pop(session.token, None)
|
||||
raise AuthenticationError("session authentication failed")
|
||||
del self._sessions[session.token]
|
||||
return self._new_session()
|
||||
|
||||
def _new_session(self) -> AuthenticatedSession:
|
||||
session = AuthenticatedSession(
|
||||
token=secrets.token_urlsafe(32),
|
||||
csrf_token=secrets.token_urlsafe(32),
|
||||
expires_at=self._now() + self._session_ttl_seconds,
|
||||
)
|
||||
self._sessions[session.token] = session
|
||||
return session
|
||||
|
||||
|
||||
def _session_cookie(request: Request) -> str:
|
||||
raw = _single_header(request.scope, b"cookie", required=True)
|
||||
assert raw is not None
|
||||
pairs: dict[str, str] = {}
|
||||
for segment in _ascii(raw).split(";"):
|
||||
name, separator, value = segment.strip().partition("=")
|
||||
if not separator or not name or name in pairs:
|
||||
raise AuthenticationError("session authentication failed")
|
||||
pairs[name] = value
|
||||
token = pairs.get(_COOKIE_NAME)
|
||||
if token is None or not _SECRET.fullmatch(token):
|
||||
raise AuthenticationError("session authentication failed")
|
||||
return token
|
||||
|
||||
|
||||
def set_session_cookie(response: Any, session: AuthenticatedSession, *, max_age: int) -> None:
|
||||
"""Set a host-only HTTP-only local session cookie without a Domain attribute."""
|
||||
|
||||
response.set_cookie(
|
||||
key=_COOKIE_NAME,
|
||||
value=session.token,
|
||||
max_age=max_age,
|
||||
path="/",
|
||||
secure=False,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
:root {
|
||||
--canvas: #0a0c0d;
|
||||
--surface: #111415;
|
||||
--surface-raised: #141718;
|
||||
--well: #0d0f10;
|
||||
--line: #303536;
|
||||
--line-strong: #50595b;
|
||||
--text: #ecebe5;
|
||||
--muted: #a8afb1;
|
||||
--dim: #7e8789;
|
||||
--orange: #f6a821;
|
||||
--orange-hover: #ffc45d;
|
||||
--success: #8bd2a0;
|
||||
--danger: #f09a86;
|
||||
--info: #8cc8df;
|
||||
--focus: #ffc45d;
|
||||
--space: 1rem;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body { margin: 0; min-width: 20rem; background: var(--canvas); color: var(--text); }
|
||||
|
||||
button, input { font: inherit; }
|
||||
|
||||
button, input[type="radio"], input[type="checkbox"] { accent-color: var(--orange); }
|
||||
|
||||
button { min-height: 44px; border: 1px solid var(--line-strong); border-radius: 0; background: var(--surface); color: var(--text); cursor: pointer; padding: .65rem .9rem; font-weight: 700; }
|
||||
button:hover:not(:disabled) { background: #1b2021; border-color: var(--text); }
|
||||
button:active:not(:disabled) { transform: translateY(1px); }
|
||||
button:disabled { color: var(--dim); cursor: not-allowed; border-color: var(--line); background: #101213; }
|
||||
.button-primary { background: var(--orange); color: #12100b; border-color: var(--orange); }
|
||||
.button-primary:hover:not(:disabled) { background: var(--orange-hover); border-color: var(--orange-hover); }
|
||||
.button-danger { color: var(--danger); }
|
||||
.quiet-danger { border-color: #6f4941; }
|
||||
|
||||
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
|
||||
|
||||
.skip-link { position: fixed; z-index: 2; top: 0; left: .5rem; transform: translateY(-120%); background: var(--orange); color: #12100b; padding: .65rem; font-weight: 700; }
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.shell { width: min(100% - 2rem, 100rem); margin: 0 auto; padding: 1.5rem 0 3rem; }
|
||||
.command-header, .control-strip, .panel-heading { display: flex; gap: var(--space); justify-content: space-between; align-items: center; }
|
||||
.command-header { border-bottom: 1px solid var(--line); padding: 0 0 1.25rem; }
|
||||
.kicker { margin: 0 0 .35rem; color: var(--muted); font-size: .74rem; font-weight: 800; letter-spacing: .08em; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 0; font-size: clamp(1.65rem, 3vw, 2.35rem); letter-spacing: -.025em; }
|
||||
h2 { margin-bottom: 0; font-size: 1.15rem; }
|
||||
.header-state { text-align: right; }
|
||||
.header-state strong, #capture-state, #review-state { display: block; font-size: .88rem; font-weight: 800; letter-spacing: .04em; }
|
||||
.header-state span, #queue-state, #capture-count, .field-help, .action-help, figcaption { color: var(--muted); font-size: .88rem; }
|
||||
|
||||
.notice { display: flex; gap: .75rem; border: 1px solid var(--line); border-left: 2px solid var(--info); margin: 1rem 0; padding: .8rem 1rem; background: var(--well); }
|
||||
.notice strong { color: var(--info); font-size: .78rem; letter-spacing: .06em; }
|
||||
.notice[data-kind="success"] { border-left-color: var(--success); }
|
||||
.notice[data-kind="success"] strong { color: var(--success); }
|
||||
.notice[data-kind="error"], .notice[data-kind="conflict"] { border-left-color: var(--danger); }
|
||||
.notice[data-kind="error"] strong, .notice[data-kind="conflict"] strong { color: var(--danger); }
|
||||
.notice[data-kind="retained"] { border-left-color: var(--info); }
|
||||
.notice[data-kind="retained"] strong { color: var(--info); }
|
||||
|
||||
.control-strip { border: 1px solid var(--line); background: var(--surface-raised); padding: 1rem; margin-bottom: 1rem; }
|
||||
.control-strip p:last-child { margin: 0; }
|
||||
.control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
|
||||
.workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); }
|
||||
.capture-panel { border-right: 1px solid var(--line); min-height: 42rem; }
|
||||
.capture-panel, .review-panel { padding: 1rem; }
|
||||
.panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; }
|
||||
.empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; }
|
||||
.capture-list { margin: 0; padding: 0; list-style: none; }
|
||||
.capture-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
|
||||
.capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); }
|
||||
.capture-list .capture-id { overflow-wrap: anywhere; }
|
||||
.capture-list .capture-meta { color: var(--muted); font-size: .8rem; }
|
||||
|
||||
.review-content { padding-top: 1rem; }
|
||||
.evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; }
|
||||
.evidence-frame img { display: block; width: 100%; max-height: 31rem; object-fit: contain; background: #000; }
|
||||
.evidence-frame figcaption { padding-top: .65rem; overflow-wrap: anywhere; }
|
||||
fieldset { margin: 0 0 1rem; padding: .75rem; border: 1px solid var(--line); min-width: 0; }
|
||||
fieldset:disabled { opacity: .65; }
|
||||
legend { padding: 0 .35rem; font-weight: 700; }
|
||||
.choice-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
|
||||
.choice { position: relative; display: inline-flex; min-height: 44px; align-items: center; border: 1px solid var(--line-strong); background: var(--well); cursor: pointer; }
|
||||
.choice:hover { border-color: var(--text); }
|
||||
.choice input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||
.choice span { padding: .65rem .75rem; }
|
||||
.choice input:checked + span { background: #3c2a0c; color: var(--text); box-shadow: inset 0 -2px var(--orange); }
|
||||
.choice input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.validation-message { min-height: 1.5rem; margin: -.35rem 0 .75rem; color: var(--danger); }
|
||||
.action-help { max-width: 70ch; margin: .8rem 0 0; line-height: 1.45; }
|
||||
|
||||
dialog { width: min(calc(100% - 2rem), 34rem); border: 1px solid var(--orange); border-radius: 0; padding: 1.3rem; color: var(--text); background: var(--surface-raised); box-shadow: 0 18px 60px #000; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / 72%); }
|
||||
dialog p { line-height: 1.5; }
|
||||
|
||||
@media (max-width: 52rem) {
|
||||
.shell { width: min(100% - 1rem, 100rem); padding-top: 1rem; }
|
||||
.command-header, .control-strip { align-items: flex-start; flex-direction: column; }
|
||||
.header-state { text-align: left; }
|
||||
.workspace-grid { grid-template-columns: 1fr; }
|
||||
.capture-panel { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; }
|
||||
.capture-list button { margin-top: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 33rem) {
|
||||
.control-actions, .form-actions, .dialog-actions { display: grid; grid-template-columns: 1fr; width: 100%; }
|
||||
.control-actions button, .form-actions button, .dialog-actions button { width: 100%; }
|
||||
.notice { align-items: flex-start; flex-direction: column; gap: .25rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
button, fieldset, .choice, .notice, .workspace-grid, .evidence-frame { border: 1px solid CanvasText; }
|
||||
.button-primary, .choice input:checked + span { forced-color-adjust: none; background: Highlight; color: HighlightText; }
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
const taxonomy = Object.freeze({
|
||||
weapon: { views: ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"], records: { detail: ["weapon", "weapon_talent"], blueprint: ["weapon", "weapon_talent"], crafting: ["weapon"], stat_range: ["weapon"], talent_grid: ["weapon_talent"], augmentation: ["weapon_augmentation_profile"] } },
|
||||
gear: { views: ["detail", "blueprint", "talent", "augmentation"], records: { detail: ["gear", "brand_set"], blueprint: ["gear", "brand_set"], talent: ["gear_talent"], augmentation: ["gear_augmentation_profile"] } },
|
||||
skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } },
|
||||
os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } },
|
||||
specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } },
|
||||
unknown: { views: ["unknown"], records: { unknown: [] } }
|
||||
});
|
||||
const families = Object.keys(taxonomy);
|
||||
const gearSlots = ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"];
|
||||
const abstentionReasons = ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"];
|
||||
const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const els = Object.freeze({
|
||||
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
|
||||
});
|
||||
|
||||
let csrfToken = null;
|
||||
let activeCapture = null;
|
||||
let captures = [];
|
||||
let armed = null;
|
||||
let pending = false;
|
||||
let returnFocus = null;
|
||||
|
||||
function setText(element, value) { element.textContent = value; }
|
||||
function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
|
||||
function label(value) { return value.replaceAll("_", " "); }
|
||||
function notice(kind, title, copy) { els.notice.dataset.kind = kind; setText(els.noticeTitle, title); setText(els.noticeCopy, copy); }
|
||||
function hasValidCsrf(value) { return typeof value === "string" && csrfPattern.test(value); }
|
||||
function selected(name) { return document.querySelector(`input[name="${name}"]:checked`)?.value || null; }
|
||||
function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); }
|
||||
function currentFamily() { return selected("family"); }
|
||||
function currentView() { return selected("view"); }
|
||||
|
||||
function syncControls() {
|
||||
const locked = !csrfToken;
|
||||
els.start.disabled = pending || locked || armed === true;
|
||||
els.stop.disabled = pending || locked || armed !== true;
|
||||
els.refresh.disabled = pending || locked;
|
||||
els.save.disabled = pending || locked || !activeCapture || !isValidLabel();
|
||||
els.reject.disabled = pending || locked || !activeCapture;
|
||||
els.remove.disabled = pending || locked || !activeCapture;
|
||||
document.querySelectorAll("#capture-list button").forEach((button) => {
|
||||
button.disabled = pending || locked;
|
||||
});
|
||||
document.querySelectorAll("#label-form input").forEach((input) => {
|
||||
const fieldset = input.closest("fieldset");
|
||||
input.disabled = pending || locked || !activeCapture || Boolean(fieldset?.disabled || fieldset?.hidden);
|
||||
});
|
||||
els.labelForm.setAttribute("aria-busy", String(pending));
|
||||
}
|
||||
function setBusy(value) { pending = value; syncControls(); }
|
||||
function clearActiveDetail() {
|
||||
activeCapture = null;
|
||||
els.media.removeAttribute("src");
|
||||
els.reviewContent.hidden = true;
|
||||
els.reviewEmpty.hidden = false;
|
||||
els.reviewTitle.textContent = "No capture selected";
|
||||
els.reviewState.textContent = csrfToken ? "READY" : "LOCKED";
|
||||
renderLabelForm(null);
|
||||
}
|
||||
function lock(message) {
|
||||
csrfToken = null;
|
||||
armed = null;
|
||||
captures = [];
|
||||
clearActiveDetail();
|
||||
els.sessionState.textContent = "LOCKED";
|
||||
els.captureState.textContent = "DISARMED";
|
||||
els.queueState.textContent = "Queue unavailable";
|
||||
els.listEmpty.hidden = false;
|
||||
setText(els.listEmpty, message);
|
||||
clearNode(els.list);
|
||||
els.count.textContent = "0 items";
|
||||
setBusy(false);
|
||||
}
|
||||
function validSession(payload) {
|
||||
return Boolean(payload) && hasValidCsrf(payload.csrf_token) && typeof payload.armed === "boolean" && Number.isInteger(payload.queue_depth) && Number.isInteger(payload.queue_capacity) && payload.queue_depth >= 0 && payload.queue_depth <= payload.queue_capacity && payload.queue_capacity <= 256;
|
||||
}
|
||||
function updateSession(payload) {
|
||||
armed = payload.armed;
|
||||
els.sessionState.textContent = "UNLOCKED";
|
||||
els.captureState.textContent = armed ? "ARMED" : "DISARMED";
|
||||
els.queueState.textContent = `Queue ${payload.queue_depth} / ${payload.queue_capacity}`;
|
||||
if (!activeCapture) els.reviewState.textContent = "READY";
|
||||
syncControls();
|
||||
}
|
||||
function safeJson(response) { return response.json().catch(() => null); }
|
||||
async function request(path, options = {}, mutation = false) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (mutation) { headers.set("Content-Type", "application/json"); headers.set("X-CSRF-Token", csrfToken || ""); }
|
||||
let response;
|
||||
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } catch { notice("error", "NETWORK ERROR", "The local service did not respond. Your current review remains unchanged."); return null; }
|
||||
if (response.status === 401 || response.status === 403) { lock("Local session expired or restarted. Open the provided local address again."); notice("error", "SESSION LOCKED", "The local session is no longer available. No action was retried."); return null; }
|
||||
const payload = await safeJson(response);
|
||||
if (payload === null) { notice("error", "RESPONSE ERROR", "The local service returned an unusable response. Your current review remains unchanged."); return null; }
|
||||
if (!response.ok) {
|
||||
const retained = response.status === 409 && payload.error === "retained";
|
||||
notice(retained ? "retained" : response.status === 409 ? "conflict" : "error", retained ? "RETAINED" : response.status === 409 ? "CONFLICT" : "ACTION FAILED", retained ? "Retention policy kept this evidence. No deletion occurred." : "The local service could not complete that action.");
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
function acceptMutationCsrf(payload) {
|
||||
if (hasValidCsrf(payload?.csrf_token)) { csrfToken = payload.csrf_token; return true; }
|
||||
lock("The local service returned an invalid session update. Open the provided local address again.");
|
||||
notice("error", "SESSION LOCKED", "The confirmed action was not retried.");
|
||||
return false;
|
||||
}
|
||||
function addChoice(container, type, name, value, checked, onChange) {
|
||||
const wrapper = document.createElement("label"); wrapper.className = "choice";
|
||||
const input = document.createElement("input"); input.type = type; input.name = name; input.value = value; input.checked = checked; input.addEventListener("change", onChange);
|
||||
const text = document.createElement("span"); text.textContent = label(value);
|
||||
wrapper.append(input, text); container.append(wrapper);
|
||||
}
|
||||
function setChecked(name, value) {
|
||||
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => { input.checked = input.value === value; });
|
||||
}
|
||||
function setCheckedMembers(name, values) {
|
||||
const selectedValues = new Set(values);
|
||||
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => {
|
||||
input.checked = selectedValues.has(input.value);
|
||||
});
|
||||
}
|
||||
function renderFamilies() {
|
||||
clearNode(els.family);
|
||||
families.forEach((family) => addChoice(els.family, "radio", "family", family, false, () => { renderViews(); renderDependentFields(); }));
|
||||
}
|
||||
function renderViews() {
|
||||
const family = currentFamily();
|
||||
clearNode(els.view);
|
||||
els.viewFieldset.disabled = !family;
|
||||
if (!family) return;
|
||||
taxonomy[family].views.forEach((view) => addChoice(els.view, "radio", "view", view, false, renderDependentFields));
|
||||
}
|
||||
function renderDependentFields() {
|
||||
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown"; const isGear = family === "gear";
|
||||
els.gearFieldset.hidden = !isGear; els.gearFieldset.disabled = !isGear;
|
||||
els.abstentionFieldset.hidden = !unknown; els.abstentionFieldset.disabled = !unknown;
|
||||
els.recordFieldset.disabled = !family || !view || unknown;
|
||||
clearNode(els.gear); clearNode(els.records); clearNode(els.abstention);
|
||||
if (isGear) gearSlots.forEach((slot) => addChoice(els.gear, "radio", "gear_slot", slot, false, validateLabel));
|
||||
if (unknown) abstentionReasons.forEach((reason) => addChoice(els.abstention, "radio", "abstention_reason", reason, false, validateLabel));
|
||||
if (!unknown && family && view) (taxonomy[family].records[view] || []).forEach((record) => addChoice(els.records, "checkbox", "record_type", record, false, validateLabel));
|
||||
validateLabel();
|
||||
}
|
||||
function labelPayload() {
|
||||
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown";
|
||||
return { family, view, gear_slot: family === "gear" ? selected("gear_slot") : null, record_types: unknown ? [] : selectedAll("record_type"), confidence: 1.0, abstention_reason: unknown ? selected("abstention_reason") : null };
|
||||
}
|
||||
function isValidLabel() {
|
||||
const payload = labelPayload();
|
||||
if (!payload.family || !payload.view) return false;
|
||||
if (payload.family === "unknown") return payload.view === "unknown" && !payload.gear_slot && payload.record_types.length === 0 && Boolean(payload.abstention_reason);
|
||||
return payload.record_types.length > 0 && (payload.family !== "gear" || Boolean(payload.gear_slot));
|
||||
}
|
||||
function validateLabel() {
|
||||
const payload = labelPayload(); let message = "";
|
||||
if (!payload.family) message = "Select the evidence family.";
|
||||
else if (!payload.view) message = "Select the screen view.";
|
||||
else if (payload.family === "unknown" && !payload.abstention_reason) message = "Select an explicit abstention reason for an unknown screen.";
|
||||
else if (payload.family === "gear" && !payload.gear_slot) message = "Select the Gear slot shown by this evidence.";
|
||||
else if (payload.family !== "unknown" && payload.record_types.length === 0) message = "Select at least one compatible canonical record type.";
|
||||
setText(els.validation, message);
|
||||
if (!pending) syncControls();
|
||||
return !message;
|
||||
}
|
||||
function renderLabelForm(classification) {
|
||||
renderFamilies();
|
||||
if (!classification) { renderViews(); renderDependentFields(); return; }
|
||||
setChecked("family", classification.family);
|
||||
renderViews();
|
||||
setChecked("view", classification.view);
|
||||
renderDependentFields();
|
||||
if (classification.gear_slot) setChecked("gear_slot", classification.gear_slot);
|
||||
setCheckedMembers("record_type", classification.record_types || []);
|
||||
if (classification.abstention_reason) setChecked("abstention_reason", classification.abstention_reason);
|
||||
validateLabel();
|
||||
}
|
||||
function renderCaptures() {
|
||||
clearNode(els.list); els.count.textContent = `${captures.length} ${captures.length === 1 ? "item" : "items"}`; els.listEmpty.hidden = captures.length > 0;
|
||||
if (!captures.length) { setText(els.listEmpty, "EMPTY QUEUE: capture evidence will appear after a manual refresh."); return; }
|
||||
captures.forEach((capture) => {
|
||||
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-current", String(activeCapture?.capture_id === capture.capture_id));
|
||||
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = capture.capture_id;
|
||||
const meta = document.createElement("span"); meta.className = "capture-meta"; meta.textContent = `${capture.state} · ${capture.received_at}`;
|
||||
button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item);
|
||||
});
|
||||
syncControls();
|
||||
}
|
||||
function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); }
|
||||
async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) {
|
||||
if (!csrfToken || pending) return false;
|
||||
setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue.");
|
||||
const payload = await request("/api/captures");
|
||||
if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; }
|
||||
captures = payload.captures;
|
||||
if (activeCapture && !captures.some((capture) => capture.capture_id === activeCapture.capture_id)) clearActiveDetail();
|
||||
renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true;
|
||||
}
|
||||
async function selectCapture(captureId, { announce = true } = {}) {
|
||||
if (!csrfToken || pending) return;
|
||||
setBusy(true); if (announce) notice("info", "LOADING", "Loading selected private evidence.");
|
||||
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}`);
|
||||
if (!payload?.capture) { setBusy(false); return; }
|
||||
activeCapture = payload.capture; els.reviewContent.hidden = false; els.reviewEmpty.hidden = true; els.reviewTitle.textContent = activeCapture.capture_id; els.reviewState.textContent = activeCapture.state;
|
||||
els.meta.textContent = `State: ${activeCapture.state} · Received: ${activeCapture.received_at}`;
|
||||
els.media.removeAttribute("src"); els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
|
||||
renderCaptures(); renderLabelForm(activeCapture.classification); if (announce) notice("success", "CAPTURE READY", "Review the evidence, then save a manual label or choose a separate evidence action."); setBusy(false);
|
||||
}
|
||||
function routeForAction(action) {
|
||||
if (action === "start") return ["/api/session/start", "START"];
|
||||
if (action === "stop") return ["/api/session/stop", "STOP"];
|
||||
if ((action === "reject" || action === "delete") && activeCapture) return [`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`, action === "reject" ? "REJECT" : "DELETE"];
|
||||
return null;
|
||||
}
|
||||
function showConfirmation(action, source) {
|
||||
if ((action === "reject" || action === "delete") && !activeCapture) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); return; }
|
||||
const messages = { start: ["START SESSION", "Start capture now? This is the only action that can arm capture.", "Start Session"], stop: ["STOP SESSION", "Stop capture now? New screenshots will not enter the queue after the session stops.", "Stop Session"], reject: ["REJECT EVIDENCE", "Reject this evidence? The evidence remains retained for review and will not be deleted.", "Reject evidence"], delete: ["DELETE CAPTURE", "Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.", "Delete capture"] };
|
||||
const message = messages[action]; if (!message) return;
|
||||
returnFocus = source; els.dialog.returnValue = "cancel"; els.dialog.dataset.action = action;
|
||||
els.dialogKicker.textContent = "EXPLICIT CONFIRMATION"; els.dialogTitle.textContent = message[0]; els.dialogCopy.textContent = message[1]; els.dialogSubmit.textContent = message[2]; els.dialogSubmit.className = action === "delete" || action === "reject" ? "button button-danger" : "button button-primary";
|
||||
els.dialog.showModal(); els.dialogSubmit.focus();
|
||||
}
|
||||
async function confirmed(action, fallbackFocus) {
|
||||
if (pending) return;
|
||||
const route = routeForAction(action);
|
||||
if (!route) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); fallbackFocus?.focus(); return; }
|
||||
setBusy(true); notice("info", "PENDING", "The local service is processing your confirmed action.");
|
||||
const [path, confirmation] = route; const payload = await request(path, { method: "POST", body: JSON.stringify({ confirmation }) }, true);
|
||||
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); fallbackFocus?.focus(); return; }
|
||||
if ((action === "start" || action === "stop") && !validSession(payload)) { lock("The local service returned an invalid session update. Open the provided local address again."); setBusy(false); return; }
|
||||
if (action === "delete") clearActiveDetail();
|
||||
if (action === "start" || action === "stop") updateSession(payload);
|
||||
const successFocus = action === "start" ? els.stop : action === "stop" ? els.start : action === "delete" ? els.refresh : fallbackFocus;
|
||||
setBusy(false); if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(successFocus); return; }
|
||||
notice("success", "SUCCESS", action === "reject" ? "Evidence rejected and retained." : action === "delete" ? "Deletion request completed." : `Capture session ${action === "start" ? "armed" : "disarmed"}.`);
|
||||
focusIfAvailable(successFocus);
|
||||
}
|
||||
async function initialize() {
|
||||
renderLabelForm(null);
|
||||
let fragment = window.location.hash; let token = null;
|
||||
if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; }
|
||||
if (fragment) history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
|
||||
fragment = "";
|
||||
if (token) {
|
||||
const bootstrap = await request("/api/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); token = null;
|
||||
if (!hasValidCsrf(bootstrap?.csrf_token)) { lock("The local service could not establish a safe session. Open the provided local address again."); return; }
|
||||
csrfToken = bootstrap.csrf_token;
|
||||
}
|
||||
const session = await request("/api/session");
|
||||
if (!validSession(session)) { lock("LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session."); return; }
|
||||
csrfToken = session.csrf_token; updateSession(session); if (!(await refreshCaptures({ announce: false }))) return;
|
||||
const startupCopy = session.armed ? "Capture is ARMED. Stop Session requires explicit confirmation." : "Capture is DISARMED. Start Session requires explicit confirmation.";
|
||||
notice("success", "LOCAL SESSION READY", startupCopy);
|
||||
}
|
||||
|
||||
els.start.addEventListener("click", () => showConfirmation("start", els.start));
|
||||
els.stop.addEventListener("click", () => showConfirmation("stop", els.stop));
|
||||
els.refresh.addEventListener("click", () => refreshCaptures({ fallbackFocus: els.refresh }));
|
||||
els.labelForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeCapture || !validateLabel() || pending) return;
|
||||
const captureId = activeCapture.capture_id;
|
||||
setBusy(true); notice("info", "PENDING", "Saving the manual reviewed label.");
|
||||
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}/label`, { method: "POST", body: JSON.stringify(labelPayload()) }, true);
|
||||
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; }
|
||||
setBusy(false);
|
||||
if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; }
|
||||
if (captures.some((capture) => capture.capture_id === captureId)) await selectCapture(captureId, { announce: false });
|
||||
notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0.");
|
||||
focusIfAvailable(els.save);
|
||||
});
|
||||
els.reject.addEventListener("click", () => showConfirmation("reject", els.reject));
|
||||
els.remove.addEventListener("click", () => showConfirmation("delete", els.remove));
|
||||
els.dialog.addEventListener("close", () => {
|
||||
const action = els.dialog.dataset.action; const shouldConfirm = els.dialog.returnValue === "confirm";
|
||||
els.dialog.dataset.action = "";
|
||||
const focusTarget = returnFocus;
|
||||
if (focusTarget) focusTarget.focus();
|
||||
returnFocus = null;
|
||||
if (shouldConfirm && action) confirmed(action, focusTarget);
|
||||
});
|
||||
els.dialog.addEventListener("cancel", () => { els.dialog.returnValue = "cancel"; });
|
||||
els.media.addEventListener("error", () => notice("error", "MEDIA UNAVAILABLE", "The private evidence image could not be loaded. Refresh captures before retrying."));
|
||||
initialize();
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Local Capture Review</title>
|
||||
<link rel="stylesheet" href="/static/review.css" />
|
||||
<script type="module" src="/static/review.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#workspace">Skip to review workspace</a>
|
||||
<main class="shell" id="workspace">
|
||||
<header class="command-header">
|
||||
<div>
|
||||
<p class="kicker">LOCAL OPERATIONS / CAPTURE REVIEW</p>
|
||||
<h1>Evidence intake</h1>
|
||||
</div>
|
||||
<div class="header-state" aria-label="Session status">
|
||||
<strong id="session-state">LOCKED</strong>
|
||||
<span id="queue-state">Queue unavailable</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="notice" id="notice" aria-live="polite" aria-atomic="true">
|
||||
<strong id="notice-title">STARTUP</strong>
|
||||
<span id="notice-copy">Checking this local session.</span>
|
||||
</section>
|
||||
|
||||
<section class="control-strip" aria-label="Capture controls">
|
||||
<div>
|
||||
<p class="kicker">CAPTURE SESSION</p>
|
||||
<p id="capture-state">DISARMED</p>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button class="button button-primary" id="start-button" type="button" disabled>Start Session</button>
|
||||
<button class="button" id="stop-button" type="button" disabled>Stop Session</button>
|
||||
<button class="button" id="refresh-button" type="button" disabled>Refresh captures</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-grid" aria-label="Capture review workspace">
|
||||
<aside class="capture-panel" aria-labelledby="capture-list-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="kicker">PRIVATE QUEUE</p>
|
||||
<h2 id="capture-list-title">Captures</h2>
|
||||
</div>
|
||||
<span id="capture-count">0 items</span>
|
||||
</div>
|
||||
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
|
||||
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
|
||||
</aside>
|
||||
|
||||
<section class="review-panel" aria-labelledby="review-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="kicker">MANUAL REVIEW</p>
|
||||
<h2 id="review-title">No capture selected</h2>
|
||||
</div>
|
||||
<span id="review-state">LOCKED</span>
|
||||
</div>
|
||||
|
||||
<div class="review-empty" id="review-empty">
|
||||
<p>Select a capture after the local session is unlocked. Media remains private to this browser session.</p>
|
||||
</div>
|
||||
|
||||
<div class="review-content" id="review-content" hidden>
|
||||
<figure class="evidence-frame">
|
||||
<img id="capture-media" alt="Private captured game screen" />
|
||||
<figcaption id="capture-meta"></figcaption>
|
||||
</figure>
|
||||
|
||||
<form id="label-form" novalidate>
|
||||
<fieldset id="family-fieldset">
|
||||
<legend>Evidence family</legend>
|
||||
<div class="choice-grid" id="family-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="view-fieldset" disabled>
|
||||
<legend>Screen view</legend>
|
||||
<div class="choice-grid" id="view-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="gear-slot-fieldset" hidden disabled>
|
||||
<legend>Gear slot</legend>
|
||||
<div class="choice-grid" id="gear-slot-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="record-types-fieldset" disabled>
|
||||
<legend>Canonical records evidenced</legend>
|
||||
<p class="field-help" id="record-types-help">Choose every record type shown by this screen.</p>
|
||||
<div class="choice-grid" id="record-type-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="abstention-fieldset" hidden disabled>
|
||||
<legend>Unknown screen reason</legend>
|
||||
<div class="choice-grid" id="abstention-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<p class="validation-message" id="label-validation" role="status"></p>
|
||||
<div class="form-actions">
|
||||
<button class="button button-primary" id="save-label-button" type="submit" disabled>Save reviewed label</button>
|
||||
<button class="button button-danger" id="reject-button" type="button" disabled>Reject evidence</button>
|
||||
<button class="button button-danger quiet-danger" id="delete-button" type="button" disabled>Delete capture</button>
|
||||
</div>
|
||||
<p class="action-help">Rejecting retains evidence. Deletion requires separate confirmation and may be retained by policy.</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog class="confirmation-dialog" id="confirmation-dialog" aria-labelledby="confirmation-title">
|
||||
<form method="dialog" id="confirmation-form">
|
||||
<p class="kicker" id="confirmation-kicker">CONFIRMATION REQUIRED</p>
|
||||
<h2 id="confirmation-title">Confirm action</h2>
|
||||
<p id="confirmation-copy"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" id="confirmation-cancel" value="cancel" type="submit">Cancel</button>
|
||||
<button class="button button-primary" id="confirmation-submit" value="confirm" type="submit">Confirm</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,505 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
from jsonschema.exceptions import ValidationError
|
||||
from jsonschema.protocols import Validator
|
||||
|
||||
from game_data_capture.domain.ports import ResourceBudget
|
||||
from game_data_capture.domain.state_machines import (
|
||||
STATE_MACHINES,
|
||||
InvalidStateTransition,
|
||||
require_transition,
|
||||
)
|
||||
from game_data_capture.domain.taxonomy import RECORD_TYPES_BY_VIEW, SCREEN_VIEWS, Family
|
||||
from game_data_capture.domain.validation import (
|
||||
ContractValidationError,
|
||||
validate_archive_document,
|
||||
validate_classification,
|
||||
validate_handoff_document,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONTRACTS = ROOT / "contracts"
|
||||
CLASSIFICATION_EXAMPLES = CONTRACTS / "examples" / "classification"
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
assert isinstance(value, dict)
|
||||
return value
|
||||
|
||||
|
||||
def schema_validator(schema_name: str) -> Validator:
|
||||
schema = load_json(CONTRACTS / schema_name)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return Draft202012Validator(schema, format_checker=FormatChecker())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema_name",
|
||||
[
|
||||
"screenshot-classification.schema.json",
|
||||
"evidence-archive-manifest-v1.schema.json",
|
||||
"isac-handoff-v1.schema.json",
|
||||
],
|
||||
)
|
||||
def test_schemas_compile_as_portable_draft_2020_12(schema_name: str) -> None:
|
||||
schema = load_json(CONTRACTS / schema_name)
|
||||
assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
||||
assert not any(key.startswith("x-python") for key in _all_keys(schema))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
validator = Draft202012Validator(schema)
|
||||
assert validator.schema == schema
|
||||
|
||||
|
||||
def _all_keys(value: object) -> list[str]:
|
||||
keys: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
keys.append(key)
|
||||
keys.extend(_all_keys(child))
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
keys.extend(_all_keys(child))
|
||||
return keys
|
||||
|
||||
|
||||
def test_domain_contract_and_python_taxonomy_cover_every_family_view() -> None:
|
||||
contract = load_json(CONTRACTS / "domain-contract-v1.json")
|
||||
families = contract["families"]
|
||||
for family, views in SCREEN_VIEWS.items():
|
||||
assert set(families[family.value]["views"]) == set(views)
|
||||
for view in views:
|
||||
expected = [record_type.value for record_type in RECORD_TYPES_BY_VIEW[(family, view)]]
|
||||
assert families[family.value]["views"][view] == expected
|
||||
|
||||
|
||||
def test_domain_contract_and_python_state_machines_match() -> None:
|
||||
contract_states = load_json(CONTRACTS / "domain-contract-v1.json")["states"]
|
||||
assert set(contract_states) == set(STATE_MACHINES)
|
||||
for machine_name, machine in STATE_MACHINES.items():
|
||||
expected = {state: sorted(targets) for state, targets in machine.transitions.items()}
|
||||
actual = {
|
||||
state: sorted(targets) for state, targets in contract_states[machine_name].items()
|
||||
}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", [family.value for family in Family])
|
||||
def test_each_family_has_a_valid_and_invalid_example(family: str) -> None:
|
||||
validator = schema_validator("screenshot-classification.schema.json")
|
||||
valid = load_json(CLASSIFICATION_EXAMPLES / f"{family}.valid.json")
|
||||
invalid = load_json(CLASSIFICATION_EXAMPLES / f"{family}.invalid.json")
|
||||
validator.validate(valid)
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate(invalid)
|
||||
validate_classification(valid)
|
||||
with pytest.raises(ContractValidationError):
|
||||
validate_classification(invalid)
|
||||
|
||||
|
||||
def test_composite_classification_preserves_multiple_record_types() -> None:
|
||||
classification = load_json(CLASSIFICATION_EXAMPLES / "weapon.valid.json")
|
||||
validate_classification(classification)
|
||||
assert classification["record_types"] == ["weapon", "weapon_talent"]
|
||||
|
||||
|
||||
def test_unknown_enum_value_is_rejected() -> None:
|
||||
classification = load_json(CLASSIFICATION_EXAMPLES / "gear.valid.json")
|
||||
classification["gear_slot"] = "Vest"
|
||||
with pytest.raises(ValidationError):
|
||||
schema_validator("screenshot-classification.schema.json").validate(classification)
|
||||
with pytest.raises(ContractValidationError, match="Gear slot"):
|
||||
validate_classification(classification)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema_name", "valid_fixture", "invalid_fixture"),
|
||||
[
|
||||
(
|
||||
"evidence-archive-manifest-v1.schema.json",
|
||||
"archive/manifest.valid.json",
|
||||
"archive/manifest.invalid.json",
|
||||
),
|
||||
(
|
||||
"isac-handoff-v1.schema.json",
|
||||
"handoff/isac-handoff.valid.json",
|
||||
"handoff/isac-handoff.invalid.json",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_versioned_schema_fixtures_validate_and_reject(
|
||||
schema_name: str, valid_fixture: str, invalid_fixture: str
|
||||
) -> None:
|
||||
validator = schema_validator(schema_name)
|
||||
validator.validate(load_json(CONTRACTS / "examples" / valid_fixture))
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate(load_json(CONTRACTS / "examples" / invalid_fixture))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema_name", "valid_fixture"),
|
||||
[
|
||||
("evidence-archive-manifest-v1.schema.json", "archive/manifest.valid.json"),
|
||||
("isac-handoff-v1.schema.json", "handoff/isac-handoff.valid.json"),
|
||||
],
|
||||
)
|
||||
def test_versioned_schemas_reject_future_versions_from_otherwise_valid_documents(
|
||||
schema_name: str, valid_fixture: str
|
||||
) -> None:
|
||||
document = load_json(CONTRACTS / "examples" / valid_fixture)
|
||||
document["schema_version"] = 2
|
||||
with pytest.raises(ValidationError):
|
||||
schema_validator(schema_name).validate(document)
|
||||
|
||||
|
||||
def test_valid_archive_preserves_observed_values_without_canonicalizing_them() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
|
||||
validate_archive_document(archive)
|
||||
observed_paths = {field["path"] for field in archive["observed_instances"][0]["fields"]}
|
||||
canonical_paths = {field["path"] for field in archive["canonical_candidates"][0]["fields"]}
|
||||
assert "observed.damage" in observed_paths
|
||||
assert "observed.damage" not in canonical_paths
|
||||
assert archive["observed_instances"][0]["observed_context"]["upgrade_state"] == {
|
||||
"path": "observed.upgrade_state",
|
||||
"value": None,
|
||||
"missing_reason": "not_visible",
|
||||
"evidence_refs": ["evidence-upgrade"],
|
||||
"uncertainty_note": "The screen does not show an upgrade indicator.",
|
||||
}
|
||||
|
||||
|
||||
def test_malformed_evidence_bounds_are_rejected() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
archive["evidence_values"][0]["region"] = {
|
||||
"coordinate_space": "normalized",
|
||||
"x": 0.8,
|
||||
"y": 0.1,
|
||||
"width": 0.3,
|
||||
"height": 0.1,
|
||||
}
|
||||
with pytest.raises(ContractValidationError, match="bounds"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_archive_schema_rejects_nonpositive_evidence_bounds() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
archive["evidence_values"][0]["region"]["width"] = 0
|
||||
with pytest.raises(ValidationError):
|
||||
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
|
||||
|
||||
|
||||
def test_null_evidence_without_missing_reason_is_rejected() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
missing = next(
|
||||
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-upgrade"
|
||||
)
|
||||
missing["missing_reason"] = None
|
||||
with pytest.raises(ValidationError):
|
||||
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
|
||||
with pytest.raises(ContractValidationError, match="missing_reason"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_archive_video_limit_matches_the_configured_16_gib_maximum() -> None:
|
||||
validator = schema_validator("evidence-archive-manifest-v1.schema.json")
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
video = {
|
||||
"artifact_id": "video-limit",
|
||||
"sha256": "c" * 64,
|
||||
"media_type": "video/mp4",
|
||||
"byte_size": 16 * 1024 * 1024 * 1024,
|
||||
"sensitivity": "private",
|
||||
"parent_artifact_id": None,
|
||||
"video_timestamp_ms": None,
|
||||
}
|
||||
archive["artifacts"].append(video)
|
||||
validator.validate(archive)
|
||||
video_byte_size = video["byte_size"]
|
||||
assert isinstance(video_byte_size, int)
|
||||
oversized_video_bytes = video_byte_size + 1
|
||||
video["byte_size"] = oversized_video_bytes
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate(archive)
|
||||
|
||||
|
||||
def test_archive_image_limit_is_64_mib() -> None:
|
||||
validator = schema_validator("evidence-archive-manifest-v1.schema.json")
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
artifact = archive["artifacts"][0]
|
||||
artifact["byte_size"] = 64 * 1024 * 1024
|
||||
validator.validate(archive)
|
||||
artifact["byte_size"] += 1
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate(archive)
|
||||
|
||||
|
||||
def test_image_handoff_limit_is_64_mib() -> None:
|
||||
validator = schema_validator("isac-handoff-v1.schema.json")
|
||||
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
|
||||
artifact = handoff["source_artifacts"][0]
|
||||
artifact["byte_size"] = 64 * 1024 * 1024
|
||||
validator.validate(handoff)
|
||||
artifact["byte_size"] += 1
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate(handoff)
|
||||
|
||||
|
||||
def test_dangling_evidence_reference_is_rejected() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
archive["canonical_candidates"][0]["fields"][0]["evidence_refs"] = ["not-present"]
|
||||
with pytest.raises(ContractValidationError, match="dangling references"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("collection", "id_field", "new_item"),
|
||||
[
|
||||
("artifacts", "artifact_id", None),
|
||||
("captures", "capture_id", None),
|
||||
("screens", "screen_id", None),
|
||||
("jobs", "job_id", {"job_id": "duplicate", "last_error_id": None}),
|
||||
(
|
||||
"extractions",
|
||||
"extraction_id",
|
||||
{
|
||||
"extraction_id": "duplicate",
|
||||
"screen_id": "screen-001",
|
||||
"evidence_refs": [],
|
||||
},
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
"group_id",
|
||||
{
|
||||
"group_id": "duplicate",
|
||||
"screen_ids": ["screen-001"],
|
||||
"supersedes_group_ids": [],
|
||||
},
|
||||
),
|
||||
("reviews", "review_id", None),
|
||||
("exports", "export_id", {"export_id": "duplicate", "artifact_id": None}),
|
||||
("errors", "error_id", {"error_id": "duplicate"}),
|
||||
("evidence_values", "evidence_id", None),
|
||||
("observed_instances", "instance_id", None),
|
||||
("canonical_candidates", "candidate_id", None),
|
||||
],
|
||||
)
|
||||
def test_archive_rejects_duplicate_entity_ids(
|
||||
collection: str, id_field: str, new_item: dict[str, Any] | None
|
||||
) -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
item = new_item if new_item is not None else archive[collection][0]
|
||||
archive[collection] = [copy.deepcopy(item), copy.deepcopy(item)]
|
||||
with pytest.raises(ContractValidationError, match=f"Duplicate {id_field}"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_archive_rejects_dangling_job_error_reference() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
archive["jobs"].append({"job_id": "job-001", "last_error_id": "missing-error"})
|
||||
with pytest.raises(ContractValidationError, match=r"job\.last_error_id"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("supersedes", [["missing-group"], ["group-001"]])
|
||||
def test_archive_rejects_invalid_group_supersession(supersedes: list[str]) -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
archive["groups"].append(
|
||||
{
|
||||
"group_id": "group-001",
|
||||
"screen_ids": ["screen-001"],
|
||||
"supersedes_group_ids": supersedes,
|
||||
}
|
||||
)
|
||||
with pytest.raises(ContractValidationError, match="supersedes_group_ids"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_archive_rejects_dangling_export_artifact_reference() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
archive["exports"].append({"export_id": "export-001", "artifact_id": "missing-artifact"})
|
||||
with pytest.raises(ContractValidationError, match=r"export\.artifact_id"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_archive_rejects_self_referential_artifact_parent() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
archive["artifacts"][0]["parent_artifact_id"] = "image-001"
|
||||
with pytest.raises(ContractValidationError, match="cannot reference itself"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_correction_history_final_state_must_match_current_evidence() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
evidence = next(
|
||||
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
|
||||
)
|
||||
evidence["normalized_value"] = 12401
|
||||
with pytest.raises(ContractValidationError, match="final state"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_correction_history_rejects_disconnected_value_states() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
evidence = next(
|
||||
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
|
||||
)
|
||||
evidence["correction_history"].append(
|
||||
{
|
||||
"correction_id": "correction-002",
|
||||
"corrected_at": "2026-07-31T12:01:00Z",
|
||||
"actor": "local_user",
|
||||
"reason": "Disconnected fixture.",
|
||||
"before": {"raw_text": "wrong", "normalized_value": 0, "missing_reason": None},
|
||||
"after": {
|
||||
"raw_text": evidence["raw_text"],
|
||||
"normalized_value": evidence["normalized_value"],
|
||||
"missing_reason": evidence["missing_reason"],
|
||||
},
|
||||
}
|
||||
)
|
||||
with pytest.raises(ContractValidationError, match="disconnected"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_correction_history_rejects_out_of_order_timestamps() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
evidence = next(
|
||||
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
|
||||
)
|
||||
current_state = {
|
||||
"raw_text": evidence["raw_text"],
|
||||
"normalized_value": evidence["normalized_value"],
|
||||
"missing_reason": evidence["missing_reason"],
|
||||
}
|
||||
evidence["correction_history"].append(
|
||||
{
|
||||
"correction_id": "correction-002",
|
||||
"corrected_at": "2026-07-31T11:59:00Z",
|
||||
"actor": "local_user",
|
||||
"reason": "Out-of-order fixture.",
|
||||
"before": copy.deepcopy(current_state),
|
||||
"after": copy.deepcopy(current_state),
|
||||
}
|
||||
)
|
||||
with pytest.raises(ContractValidationError, match="strictly ordered"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
def test_correction_ids_are_unique_across_the_archive() -> None:
|
||||
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
|
||||
evidence = next(
|
||||
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
|
||||
)
|
||||
duplicate = copy.deepcopy(evidence)
|
||||
duplicate["evidence_id"] = "evidence-damage-copy"
|
||||
archive["evidence_values"].append(duplicate)
|
||||
with pytest.raises(ContractValidationError, match="Duplicate correction_id"):
|
||||
validate_archive_document(archive)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("example", "validator"),
|
||||
[
|
||||
("archive/manifest.invalid.json", validate_archive_document),
|
||||
("handoff/isac-handoff.invalid.json", validate_handoff_document),
|
||||
],
|
||||
)
|
||||
def test_future_schema_versions_fail_closed(example: str, validator: Any) -> None:
|
||||
with pytest.raises(ContractValidationError, match="Unsupported schema_version"):
|
||||
validator(load_json(CONTRACTS / "examples" / example))
|
||||
|
||||
|
||||
def test_valid_handoff_has_no_mutation_authority_and_resolves_evidence() -> None:
|
||||
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
|
||||
schema_validator("isac-handoff-v1.schema.json").validate(handoff)
|
||||
validate_handoff_document(handoff)
|
||||
assert handoff["trust_boundary"] == {
|
||||
"intended_destination": "authenticated_isac_intake",
|
||||
"mutation_authority": "none",
|
||||
"requires_authenticated_action": True,
|
||||
"preserves_moderation": True,
|
||||
"creates_audit_events": False,
|
||||
}
|
||||
|
||||
|
||||
def test_handoff_cannot_claim_mutation_authority() -> None:
|
||||
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
|
||||
handoff["trust_boundary"]["mutation_authority"] = "canonical_write"
|
||||
with pytest.raises(ValidationError):
|
||||
schema_validator("isac-handoff-v1.schema.json").validate(handoff)
|
||||
with pytest.raises(ContractValidationError, match="trust boundary"):
|
||||
validate_handoff_document(handoff)
|
||||
|
||||
|
||||
def test_handoff_dangling_evidence_reference_is_rejected() -> None:
|
||||
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
|
||||
broken = copy.deepcopy(handoff)
|
||||
broken["records"][0]["fields"][0]["evidence_refs"] = ["missing"]
|
||||
with pytest.raises(ContractValidationError, match="dangling references"):
|
||||
validate_handoff_document(broken)
|
||||
|
||||
|
||||
def test_handoff_source_archive_provenance_does_not_require_local_entities() -> None:
|
||||
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
|
||||
handoff["records"][0]["source_candidate_ids"] = ["archive-candidate-not-embedded"]
|
||||
handoff["records"][0]["observed_instance_ids"] = ["archive-instance-not-embedded"]
|
||||
validate_handoff_document(handoff)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["max_input_bytes", "max_output_bytes", "max_items"])
|
||||
def test_resource_budget_rejects_negative_limits(field: str) -> None:
|
||||
with pytest.raises(ValueError, match="non-negative integer"):
|
||||
ResourceBudget(
|
||||
deadline_monotonic=1.0,
|
||||
max_input_bytes=-1 if field == "max_input_bytes" else 1,
|
||||
max_output_bytes=-1 if field == "max_output_bytes" else 1,
|
||||
max_items=-1 if field == "max_items" else 1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deadline", [math.nan, math.inf, -math.inf])
|
||||
def test_resource_budget_rejects_nonfinite_deadlines(deadline: float) -> None:
|
||||
with pytest.raises(ValueError, match="finite or null"):
|
||||
ResourceBudget(
|
||||
deadline_monotonic=deadline,
|
||||
max_input_bytes=1,
|
||||
max_output_bytes=1,
|
||||
max_items=1,
|
||||
)
|
||||
|
||||
|
||||
def test_resource_budget_allows_zero_limits_and_no_deadline() -> None:
|
||||
assert (
|
||||
ResourceBudget(
|
||||
deadline_monotonic=None,
|
||||
max_input_bytes=0,
|
||||
max_output_bytes=0,
|
||||
max_items=0,
|
||||
).deadline_monotonic
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("machine", sorted(STATE_MACHINES))
|
||||
def test_each_state_machine_rejects_an_invalid_transition(machine: str) -> None:
|
||||
first_state = next(iter(STATE_MACHINES[machine].transitions))
|
||||
with pytest.raises(InvalidStateTransition):
|
||||
require_transition(machine, first_state, "not-a-state")
|
||||
|
||||
|
||||
def test_terminal_transition_is_rejected_and_recovery_transition_is_allowed() -> None:
|
||||
with pytest.raises(InvalidStateTransition, match="Invalid review transition"):
|
||||
require_transition("review", "cancelled", "approved")
|
||||
require_transition("job", "failed", "queued")
|
||||
require_transition("error", "retrying", "recovered")
|
||||
@@ -0,0 +1,458 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.storage import CaptureCorpus, CaptureRepository, ContentAddressedBlobStore
|
||||
from game_data_capture.storage.blobs import BlobCorruptionError, BlobLockError
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import StorageError
|
||||
|
||||
|
||||
def _label(*, view: str = "detail", records: list[str] | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"family": "weapon",
|
||||
"view": view,
|
||||
"gear_slot": None,
|
||||
"record_types": ["weapon"] if records is None else records,
|
||||
"confidence": 1.0,
|
||||
"abstention_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def corpus(tmp_path: Path) -> tuple[StorageDatabase, CaptureRepository, CaptureCorpus]:
|
||||
root = (tmp_path / "utility").resolve()
|
||||
database = StorageDatabase(
|
||||
root,
|
||||
(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
)
|
||||
database.migrate()
|
||||
blobs = ContentAddressedBlobStore(root)
|
||||
repository = CaptureRepository(database, blobs)
|
||||
return database, repository, CaptureCorpus(database, blobs, max_media_bytes=1024)
|
||||
|
||||
|
||||
def _receive(
|
||||
repository: CaptureRepository, capture_id: str, token: str, payload: bytes = b"png"
|
||||
) -> None:
|
||||
repository.receive(capture_id, token, [payload], media_type="image/png", max_bytes=1024)
|
||||
|
||||
|
||||
def _assert_media_unavailable(port: CaptureCorpus, capture_id: str) -> None:
|
||||
media = port.open_media(capture_id)
|
||||
assert media is not None
|
||||
with pytest.raises(BlobCorruptionError):
|
||||
next(iter(media.chunks))
|
||||
|
||||
|
||||
def _commit_deletion_intent(database: StorageDatabase, capture_id: str) -> None:
|
||||
with database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT c.blob_sha256, c.state, c.received_at, c.receipt_token, c.failure_reason, "
|
||||
"c.revision, b.byte_size FROM captures AS c JOIN blobs AS b "
|
||||
"ON b.sha256 = c.blob_sha256 WHERE c.capture_id = ?",
|
||||
(capture_id,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT INTO capture_deletion_intents(blob_sha256, capture_id, byte_size, "
|
||||
"capture_state, received_at, receipt_token, failure_reason, revision, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
row["blob_sha256"],
|
||||
capture_id,
|
||||
row["byte_size"],
|
||||
row["state"],
|
||||
row["received_at"],
|
||||
row["receipt_token"],
|
||||
row["failure_reason"],
|
||||
row["revision"],
|
||||
"time",
|
||||
),
|
||||
)
|
||||
connection.execute("DELETE FROM captures WHERE capture_id = ?", (capture_id,))
|
||||
connection.execute("COMMIT")
|
||||
|
||||
|
||||
def test_safe_bounded_listing_detail_and_verified_media(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
_, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
_receive(repository, "capture-0002", "receipt-0002")
|
||||
records = port.list_captures(limit=1)
|
||||
assert len(records) == 1 and records[0].capture_id == "capture-0002"
|
||||
assert port.get_capture("../capture-0001") is None
|
||||
assert port.open_media("../capture-0001") is None
|
||||
media = port.open_media("capture-0001")
|
||||
assert media is not None
|
||||
assert media.media_type == "image/png" and media.byte_size == 3
|
||||
chunks = media.chunks
|
||||
assert next(iter(chunks)) == b"png"
|
||||
close = getattr(chunks, "close", None)
|
||||
assert callable(close)
|
||||
close()
|
||||
for invalid in (0, 257, True):
|
||||
with pytest.raises(ValueError):
|
||||
port.list_captures(limit=invalid)
|
||||
|
||||
|
||||
def test_labels_preserve_prior_values_across_restart_and_reject_is_idempotent(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
first = _label()
|
||||
second = _label(view="blueprint", records=["weapon_talent"])
|
||||
with pytest.raises(ValueError):
|
||||
port.label("capture-0001", {"family": "weapon"})
|
||||
assert port.label("capture-0001", first) == "updated"
|
||||
assert port.label("capture-0001", first) == "updated"
|
||||
assert port.label("capture-0001", second) == "updated"
|
||||
restarted = CaptureCorpus(
|
||||
database, ContentAddressedBlobStore(database.root), max_media_bytes=1024
|
||||
)
|
||||
record = restarted.get_capture("capture-0001")
|
||||
assert record is not None and record.classification == second
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM capture_labels").fetchone()[0] == 1
|
||||
assert restarted.reject_preserving_evidence("capture-0001") == "updated"
|
||||
assert restarted.reject_preserving_evidence("capture-0001") == "updated"
|
||||
assert restarted.label("capture-0001", first) == "conflict"
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM capture_rejections").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_concurrent_label_writes_serialize_without_losing_prior_value(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
labels = (_label(), _label(view="blueprint", records=["weapon_talent"]))
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(executor.map(lambda value: port.label("capture-0001", value), labels))
|
||||
assert outcomes == ["updated", "updated"]
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM capture_labels").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_canonical_equivalent_labels_do_not_append_history(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
first = _label(records=["weapon", "weapon_talent"])
|
||||
equivalent = _label(records=["weapon_talent", "weapon"])
|
||||
equivalent["confidence"] = 1
|
||||
assert port.label("capture-0001", first) == "updated"
|
||||
assert port.label("capture-0001", equivalent) == "updated"
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_rejected_or_evidence_bearing_captures_are_retained(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
_, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
assert port.reject_preserving_evidence("capture-0001") == "updated"
|
||||
assert port.delete_with_retention("capture-0001") == "retained"
|
||||
_receive(repository, "capture-0002", "receipt-0002")
|
||||
repository.append_ocr_run("run-0002", "capture-0002", "ocr", "1", {})
|
||||
assert port.delete_with_retention("capture-0002") == "retained"
|
||||
assert port.delete_with_retention("bad") == "not_found"
|
||||
|
||||
|
||||
def test_label_and_job_evidence_retain_capture(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
assert port.label("capture-0001", _label()) == "updated"
|
||||
assert port.delete_with_retention("capture-0001") == "retained"
|
||||
_receive(repository, "capture-0002", "receipt-0002")
|
||||
with database.connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO jobs(job_id, capture_id, kind, state, max_attempts) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
("job-0002", "capture-0002", "ocr", "queued", 1),
|
||||
)
|
||||
assert port.delete_with_retention("capture-0002") == "retained"
|
||||
|
||||
|
||||
def test_receive_translates_put_lock_failure_without_capture(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
database, repository, _ = corpus
|
||||
|
||||
def locked(_: object, *, max_bytes: int) -> tuple[str, int]:
|
||||
raise BlobLockError("locked")
|
||||
|
||||
monkeypatch.setattr(repository.blobs, "put", locked)
|
||||
with pytest.raises(StorageError, match="unavailable"):
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "failure_reason"),
|
||||
(("failed", None), ("cancelled", None), ("stored", "capture failure"), ("unknown", None)),
|
||||
)
|
||||
def test_non_disposable_capture_rows_are_retained(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
state: str,
|
||||
failure_reason: str | None,
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
with database.connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE captures SET state = ?, failure_reason = ? WHERE capture_id = ?",
|
||||
(state, failure_reason, "capture-0001"),
|
||||
)
|
||||
assert port.delete_with_retention("capture-0001") == "retained"
|
||||
|
||||
|
||||
def test_capture_lineage_and_failure_evidence_are_retained(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
repository.append_lineage("lineage-0001", "capture-0001", "parent-0001", "derived")
|
||||
assert port.delete_with_retention("capture-0001") == "retained"
|
||||
_receive(repository, "capture-0002", "receipt-0002")
|
||||
repository.record_failure("failure-0002", "capture", "capture-0002", "fault", "bounded")
|
||||
assert port.delete_with_retention("capture-0002") == "retained"
|
||||
_receive(repository, "capture-0003", "receipt-0003")
|
||||
repository.append_lineage("lineage-0003", "child-0003", "capture-0003", "derived")
|
||||
assert port.delete_with_retention("capture-0003") == "retained"
|
||||
|
||||
|
||||
def test_shared_blob_deletion_keeps_remaining_capture_media(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
_, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001", b"shared")
|
||||
_receive(repository, "capture-0002", "receipt-0002", b"shared")
|
||||
assert port.delete_with_retention("capture-0001") == "updated"
|
||||
assert port.get_capture("capture-0001") is None
|
||||
media = port.open_media("capture-0002")
|
||||
assert media is not None and b"".join(media.chunks) == b"shared"
|
||||
with repository.database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_unshared_deletion_removes_media_metadata_and_intent(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
row = database.connect().execute("SELECT blob_sha256 FROM captures").fetchone()
|
||||
assert row is not None
|
||||
path = repository.blobs._path(str(row["blob_sha256"]))
|
||||
assert port.delete_with_retention("capture-0001") == "updated"
|
||||
assert not path.exists()
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 0
|
||||
assert (
|
||||
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
|
||||
)
|
||||
|
||||
|
||||
def test_committed_intent_before_unlink_blocks_replay_then_finalizes(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
row = database.connect().execute("SELECT blob_sha256 FROM captures").fetchone()
|
||||
assert row is not None
|
||||
path = repository.blobs._path(str(row["blob_sha256"]))
|
||||
_commit_deletion_intent(database, "capture-0001")
|
||||
assert path.exists()
|
||||
with pytest.raises(StorageError, match="deletion"):
|
||||
_receive(repository, "capture-0002", "receipt-0001")
|
||||
restarted = CaptureCorpus(
|
||||
database, ContentAddressedBlobStore(database.root), max_media_bytes=1024
|
||||
)
|
||||
assert restarted.reconcile_deletions() == 1
|
||||
assert not path.exists()
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 0
|
||||
assert (
|
||||
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
|
||||
)
|
||||
|
||||
|
||||
def test_interleaved_reinstall_waits_for_deletion_finalization(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001", b"shared")
|
||||
unlinked = threading.Event()
|
||||
release = threading.Event()
|
||||
original = repository.blobs.delete_verified
|
||||
outcomes: list[object] = []
|
||||
|
||||
def pause(digest: str, *, expected_size: int) -> bool:
|
||||
result = original(digest, expected_size=expected_size)
|
||||
unlinked.set()
|
||||
assert release.wait(0.5)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(repository.blobs, "delete_verified", pause)
|
||||
deletion = threading.Thread(
|
||||
target=lambda: outcomes.append(port.delete_with_retention("capture-0001"))
|
||||
)
|
||||
deletion.start()
|
||||
assert unlinked.wait(0.5)
|
||||
receipt = threading.Thread(
|
||||
target=lambda: outcomes.append(
|
||||
repository.receive(
|
||||
"capture-0002", "receipt-0002", [b"shared"], media_type="image/png", max_bytes=1024
|
||||
)
|
||||
)
|
||||
)
|
||||
receipt.start()
|
||||
release.set()
|
||||
deletion.join(1)
|
||||
receipt.join(1)
|
||||
assert not deletion.is_alive() and not receipt.is_alive()
|
||||
assert set(outcomes) == {"updated", "capture-0002"}
|
||||
with database.connect() as connection:
|
||||
assert (
|
||||
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
|
||||
)
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
|
||||
media = port.open_media("capture-0002")
|
||||
assert media is not None and b"".join(media.chunks) == b"shared"
|
||||
|
||||
|
||||
def test_unlink_then_sqlite_finalization_failure_recovers(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
row = database.connect().execute("SELECT sha256, byte_size FROM blobs").fetchone()
|
||||
assert row is not None
|
||||
path = repository.blobs._path(str(row["sha256"]))
|
||||
_commit_deletion_intent(database, "capture-0001")
|
||||
repository.blobs.delete_verified(str(row["sha256"]), expected_size=int(row["byte_size"]))
|
||||
assert not path.exists()
|
||||
with database.connect() as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER fail_finalization BEFORE DELETE ON capture_deletion_intents "
|
||||
"BEGIN SELECT RAISE(ABORT, 'fault'); END"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="fault"):
|
||||
port.reconcile_deletions()
|
||||
assert port.get_capture("capture-0001") is None
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
|
||||
assert (
|
||||
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 1
|
||||
)
|
||||
connection.execute("DROP TRIGGER fail_finalization")
|
||||
restarted = CaptureCorpus(
|
||||
database, ContentAddressedBlobStore(database.root), max_media_bytes=1024
|
||||
)
|
||||
assert restarted.reconcile_deletions() == 1
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 0
|
||||
assert (
|
||||
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
|
||||
)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_corrupt_missing_and_symlink_media_fail_closed(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus], tmp_path: Path
|
||||
) -> None:
|
||||
_, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
with repository.database.connect() as connection:
|
||||
row = connection.execute("SELECT blob_sha256 FROM captures").fetchone()
|
||||
assert row is not None
|
||||
path = repository.blobs._path(str(row["blob_sha256"]))
|
||||
path.write_bytes(b"x" * 2048)
|
||||
_assert_media_unavailable(port, "capture-0001")
|
||||
path.unlink()
|
||||
_assert_media_unavailable(port, "capture-0001")
|
||||
path.symlink_to(tmp_path / "outside")
|
||||
_assert_media_unavailable(port, "capture-0001")
|
||||
|
||||
|
||||
def test_label_transaction_rolls_back_on_evidence_failure(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
assert port.label("capture-0001", _label()) == "updated"
|
||||
with database.connect() as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER fail_label_evidence BEFORE INSERT ON capture_label_evidence "
|
||||
"BEGIN SELECT RAISE(ABORT, 'fault'); END"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="fault"):
|
||||
port.label(
|
||||
"capture-0001",
|
||||
_label(view="blueprint", records=["weapon_talent"]),
|
||||
)
|
||||
assert port.get_capture("capture-0001") is not None
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 0
|
||||
stored = connection.execute("SELECT classification_json FROM capture_labels").fetchone()
|
||||
assert stored is not None and '"view":"detail"' in stored["classification_json"]
|
||||
|
||||
|
||||
def test_deletion_intent_recovers_after_unlink_failure(
|
||||
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
database, repository, port = corpus
|
||||
_receive(repository, "capture-0001", "receipt-0001")
|
||||
with database.connect() as connection:
|
||||
original = tuple(
|
||||
connection.execute(
|
||||
"SELECT capture_id, blob_sha256, state, received_at, receipt_token, "
|
||||
"failure_reason, revision FROM captures"
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def fail_unlink(_: str, *, expected_size: int) -> bool:
|
||||
raise BlobCorruptionError("injected fault")
|
||||
|
||||
monkeypatch.setattr(repository.blobs, "delete_verified", fail_unlink)
|
||||
assert port.delete_with_retention("capture-0001") == "retained"
|
||||
with database.connect() as connection:
|
||||
assert (
|
||||
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
|
||||
)
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
|
||||
assert (
|
||||
tuple(
|
||||
connection.execute(
|
||||
"SELECT capture_id, blob_sha256, state, received_at, receipt_token, "
|
||||
"failure_reason, revision FROM captures"
|
||||
).fetchone()
|
||||
)
|
||||
== original
|
||||
)
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
|
||||
media = port.open_media("capture-0001")
|
||||
assert media is not None and b"".join(media.chunks) == b"png"
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.storage.database import MigrationError, StorageDatabase
|
||||
|
||||
|
||||
def _migrations(tmp_path: Path) -> Path:
|
||||
source = Path(__file__).parents[3] / "migrations" / "001_initial.sql"
|
||||
destination = tmp_path / "migrations"
|
||||
destination.mkdir()
|
||||
shutil.copy(source, destination / source.name)
|
||||
return destination
|
||||
|
||||
|
||||
def test_connection_pragmas_and_private_modes(tmp_path: Path) -> None:
|
||||
root = (tmp_path / "utility").resolve()
|
||||
database = StorageDatabase(root, _migrations(tmp_path).resolve())
|
||||
database.migrate()
|
||||
with database.connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("CREATE TABLE IF NOT EXISTS live_wal(value INTEGER)")
|
||||
assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1
|
||||
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
||||
assert connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5000
|
||||
assert connection.execute("PRAGMA synchronous").fetchone()[0] == 2
|
||||
for path in (
|
||||
database.path.with_name("capture.sqlite3-wal"),
|
||||
database.path.with_name("capture.sqlite3-shm"),
|
||||
):
|
||||
if path.exists():
|
||||
assert path.stat().st_mode & 0o077 == 0
|
||||
connection.execute("COMMIT")
|
||||
assert root.stat().st_mode & 0o777 == 0o700
|
||||
assert database.path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_database_symlink_is_rejected(tmp_path: Path) -> None:
|
||||
root = (tmp_path / "utility").resolve()
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_text("x")
|
||||
(root / "capture.sqlite3").symlink_to(outside)
|
||||
with pytest.raises(MigrationError):
|
||||
StorageDatabase(root, _migrations(tmp_path).resolve()).connect()
|
||||
|
||||
|
||||
def test_migration_history_and_failure_rollback(tmp_path: Path) -> None:
|
||||
root = (tmp_path / "utility").resolve()
|
||||
migrations = _migrations(tmp_path).resolve()
|
||||
database = StorageDatabase(root, migrations)
|
||||
database.migrate()
|
||||
first = migrations / "001_initial.sql"
|
||||
first.write_text(first.read_text() + "\n-- changed\n")
|
||||
with pytest.raises(MigrationError):
|
||||
database.migrate()
|
||||
first.write_text((Path(__file__).parents[3] / "migrations" / "001_initial.sql").read_text())
|
||||
bad = migrations / "002_bad.sql"
|
||||
bad.write_text("CREATE TABLE partial_test(value INTEGER); BAD SQL;")
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
database.migrate()
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] == 1
|
||||
assert (
|
||||
connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name='partial_test'"
|
||||
).fetchone()
|
||||
is None
|
||||
)
|
||||
bad.write_text("CREATE TABLE partial_test(value INTEGER);")
|
||||
database.migrate()
|
||||
database.migrate()
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_migration_removal_rename_and_duplicate_rejected(tmp_path: Path) -> None:
|
||||
root = (tmp_path / "utility").resolve()
|
||||
migrations = _migrations(tmp_path).resolve()
|
||||
database = StorageDatabase(root, migrations)
|
||||
database.migrate()
|
||||
path = migrations / "001_initial.sql"
|
||||
path.rename(migrations / "001_renamed.sql")
|
||||
with pytest.raises(MigrationError):
|
||||
database.migrate()
|
||||
(migrations / "001_renamed.sql").rename(path)
|
||||
path.unlink()
|
||||
with pytest.raises(MigrationError):
|
||||
database.migrate()
|
||||
shutil.copy(Path(__file__).parents[3] / "migrations" / "001_initial.sql", path)
|
||||
shutil.copy(path, migrations / "001_duplicate.sql")
|
||||
with pytest.raises(MigrationError):
|
||||
database.migrate()
|
||||
|
||||
|
||||
def test_corpus_migration_preserves_populated_initial_database(tmp_path: Path) -> None:
|
||||
root = (tmp_path / "utility").resolve()
|
||||
migrations = _migrations(tmp_path).resolve()
|
||||
database = StorageDatabase(root, migrations)
|
||||
database.migrate()
|
||||
with database.connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO blobs(sha256, byte_size, media_type, created_at) VALUES (?, 1, ?, ?)",
|
||||
("0" * 64, "image/png", "time"),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, receipt_token) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
("capture-0001", "0" * 64, "stored", "time", "receipt-0001"),
|
||||
)
|
||||
source = Path(__file__).parents[3] / "migrations" / "002_capture_corpus.sql"
|
||||
shutil.copy(source, migrations / source.name)
|
||||
database.migrate()
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM capture_labels").fetchone()[0] == 0
|
||||
assert tuple(
|
||||
connection.execute(
|
||||
"SELECT sha256, byte_size, media_type, created_at FROM blobs"
|
||||
).fetchone()
|
||||
) == ("0" * 64, 1, "image/png", "time")
|
||||
assert tuple(
|
||||
connection.execute(
|
||||
"SELECT capture_id, blob_sha256, state, received_at, receipt_token, "
|
||||
"failure_reason, revision FROM captures"
|
||||
).fetchone()
|
||||
) == ("capture-0001", "0" * 64, "stored", "time", "receipt-0001", None, 0)
|
||||
assert connection.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] == 2
|
||||
migration = connection.execute(
|
||||
"SELECT filename, checksum FROM schema_migrations WHERE version = 2"
|
||||
).fetchone()
|
||||
assert migration is not None
|
||||
assert migration["filename"] == "002_capture_corpus.sql"
|
||||
assert isinstance(migration["checksum"], str) and len(migration["checksum"]) == 64
|
||||
names = {row["name"] for row in connection.execute("SELECT name FROM sqlite_master")}
|
||||
assert {
|
||||
"captures_blob_sha256",
|
||||
"jobs_capture_id",
|
||||
"ocr_runs_capture_id",
|
||||
"runtime_failures_capture_subject",
|
||||
"lineage_child_id",
|
||||
"lineage_parent_id",
|
||||
}.issubset(names)
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.pipeline.jobs import JobLeases
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import StorageError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(tmp_path: Path) -> StorageDatabase:
|
||||
database = StorageDatabase(
|
||||
(tmp_path / "utility").resolve(), (Path(__file__).parents[3] / "migrations").resolve()
|
||||
)
|
||||
database.migrate()
|
||||
return database
|
||||
|
||||
|
||||
def _expire(database: StorageDatabase, job_id: str) -> None:
|
||||
with database.connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET lease_expires_at = ? WHERE job_id = ?",
|
||||
((datetime.now(UTC) - timedelta(seconds=1)).isoformat(), job_id),
|
||||
)
|
||||
|
||||
|
||||
def test_job_validation_and_exclusive_ownership(database: StorageDatabase) -> None:
|
||||
jobs = JobLeases(database)
|
||||
for job_id, kind, attempts in (
|
||||
("!", "ocr", 1),
|
||||
("job", "bad", 1),
|
||||
("job", "ocr", True),
|
||||
("job", "ocr", 0),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
jobs.create(job_id, kind, max_attempts=attempts)
|
||||
jobs.create("job-1", "ocr")
|
||||
for duration in (0, 301, True):
|
||||
with pytest.raises(ValueError):
|
||||
jobs.acquire("owner-1", duration)
|
||||
with pytest.raises(ValueError):
|
||||
jobs.acquire("!", 30)
|
||||
assert jobs.acquire("owner-1", 30) == "job-1"
|
||||
assert jobs.acquire("owner-2", 30) is None
|
||||
with pytest.raises(StorageError):
|
||||
jobs.renew("job-1", "owner-2", 30)
|
||||
with pytest.raises(StorageError):
|
||||
jobs.finish("job-1", "owner-2", "succeeded")
|
||||
for duration in (0, 301, True):
|
||||
with pytest.raises(ValueError):
|
||||
jobs.renew("job-1", "owner-1", duration)
|
||||
|
||||
|
||||
def test_expiry_recovery_exhaustion_and_terminal_jobs(database: StorageDatabase) -> None:
|
||||
jobs = JobLeases(database)
|
||||
jobs.create("retry", "ocr", max_attempts=2)
|
||||
assert jobs.acquire("owner-1", 30) == "retry"
|
||||
_expire(database, "retry")
|
||||
with pytest.raises(StorageError):
|
||||
jobs.renew("retry", "owner-1", 30)
|
||||
with pytest.raises(StorageError):
|
||||
jobs.finish("retry", "owner-1", "succeeded")
|
||||
assert jobs.recover() == 1
|
||||
with database.connect() as connection:
|
||||
assert tuple(
|
||||
connection.execute(
|
||||
"SELECT state, lease_owner, lease_expires_at FROM jobs WHERE job_id='retry'"
|
||||
).fetchone()
|
||||
) == ("queued", None, None)
|
||||
assert JobLeases(database).acquire("owner-2", 30) == "retry"
|
||||
with database.connect() as connection:
|
||||
assert tuple(
|
||||
connection.execute(
|
||||
"SELECT attempt, state, lease_owner FROM jobs WHERE job_id='retry'"
|
||||
).fetchone()
|
||||
) == (2, "running", "owner-2")
|
||||
jobs.create("exhaust", "ocr", max_attempts=1)
|
||||
assert jobs.acquire("owner-3", 30) == "exhaust"
|
||||
_expire(database, "exhaust")
|
||||
assert jobs.recover() == 1
|
||||
with database.connect() as connection:
|
||||
assert tuple(
|
||||
connection.execute(
|
||||
"SELECT state, attempt, lease_owner, lease_expires_at "
|
||||
"FROM jobs WHERE job_id='exhaust'"
|
||||
).fetchone()
|
||||
) == ("failed", 1, None, None)
|
||||
assert jobs.acquire("owner-4", 30) is None
|
||||
for job_id, target in (("ok", "succeeded"), ("fail", "failed"), ("cancel", "cancelled")):
|
||||
jobs.create(job_id, "ocr")
|
||||
assert jobs.acquire("owner-5", 30) == job_id
|
||||
jobs.finish(job_id, "owner-5", target)
|
||||
with database.connect() as connection:
|
||||
for job_id, target in (("ok", "succeeded"), ("fail", "failed"), ("cancel", "cancelled")):
|
||||
assert tuple(
|
||||
connection.execute(
|
||||
"SELECT state, lease_owner, lease_expires_at FROM jobs WHERE job_id = ?",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
) == (target, None, None)
|
||||
|
||||
|
||||
def test_retry_wait_finish_rejected_without_changing_lease(database: StorageDatabase) -> None:
|
||||
jobs = JobLeases(database)
|
||||
jobs.create("job-1", "ocr")
|
||||
jobs.acquire("owner-1", 30)
|
||||
with pytest.raises(ValueError):
|
||||
jobs.finish("job-1", "owner-1", "retry_wait")
|
||||
with database.connect() as connection:
|
||||
assert tuple(
|
||||
connection.execute(
|
||||
"SELECT state, lease_owner FROM jobs WHERE job_id='job-1'"
|
||||
).fetchone()
|
||||
) == ("running", "owner-1")
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.pipeline.jobs import JobLeases
|
||||
from game_data_capture.storage.blobs import ContentAddressedBlobStore
|
||||
from game_data_capture.storage.database import StorageDatabase
|
||||
from game_data_capture.storage.repository import CaptureRepository, StorageError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path: Path) -> tuple[StorageDatabase, CaptureRepository]:
|
||||
root = (tmp_path / "utility").resolve()
|
||||
migrations = Path(__file__).parents[3] / "migrations"
|
||||
database = StorageDatabase(root, migrations.resolve())
|
||||
database.migrate()
|
||||
return database, CaptureRepository(database, ContentAddressedBlobStore(root))
|
||||
|
||||
|
||||
def test_identical_occurrences_are_deduplicated_and_receipts_survive_restart(
|
||||
storage: tuple[StorageDatabase, CaptureRepository],
|
||||
) -> None:
|
||||
database, repository = storage
|
||||
assert (
|
||||
repository.receive(
|
||||
"capture-1", "receipt-1", [b"same"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
== "capture-1"
|
||||
)
|
||||
assert (
|
||||
repository.receive(
|
||||
"capture-2", "receipt-2", [b"same"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
== "capture-2"
|
||||
)
|
||||
# A simulated restart uses fresh repository objects and a repeated intake receipt.
|
||||
restarted = CaptureRepository(database, ContentAddressedBlobStore(database.root))
|
||||
assert (
|
||||
restarted.receive(
|
||||
"capture-other", "receipt-2", [b"same"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
== "capture-2"
|
||||
)
|
||||
assert restarted.report()["blobs"] == 1
|
||||
assert restarted.report()["captures"] == 2
|
||||
|
||||
|
||||
def test_capture_never_references_missing_blob_and_cas_rejects_stale(
|
||||
storage: tuple[StorageDatabase, CaptureRepository],
|
||||
) -> None:
|
||||
database, repository = storage
|
||||
repository.receive(
|
||||
"capture-1", "receipt-1", [b"complete"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
row = database.connect().execute("SELECT blob_sha256 FROM captures").fetchone()
|
||||
assert row is not None and repository.blobs.verify(str(row["blob_sha256"])) == len(b"complete")
|
||||
assert repository.transition("capture-1", "queued", 0) == 1
|
||||
with pytest.raises(StorageError):
|
||||
repository.transition("capture-1", "failed", 0)
|
||||
|
||||
|
||||
def test_reprocessing_is_append_only_and_leases_enforce_owner(
|
||||
storage: tuple[StorageDatabase, CaptureRepository],
|
||||
) -> None:
|
||||
database, repository = storage
|
||||
repository.receive(
|
||||
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
repository.append_ocr_run("run-1", "capture-1", "ocr", "1", {"raw": "one"})
|
||||
repository.append_ocr_run("run-2", "capture-1", "ocr", "2", {"raw": "two"})
|
||||
assert database.connect().execute("SELECT count(*) FROM ocr_runs").fetchone()[0] == 2
|
||||
jobs = JobLeases(database)
|
||||
jobs.create("job-1", "ocr", "capture-1")
|
||||
assert jobs.acquire("worker-a", 30) == "job-1"
|
||||
with pytest.raises(StorageError):
|
||||
jobs.finish("job-1", "worker-b", "succeeded")
|
||||
jobs.finish("job-1", "worker-a", "succeeded")
|
||||
|
||||
|
||||
def test_replayed_receipt_does_not_consume_payload(
|
||||
storage: tuple[StorageDatabase, CaptureRepository],
|
||||
) -> None:
|
||||
database, repository = storage
|
||||
repository.receive(
|
||||
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
|
||||
consumed = [False]
|
||||
|
||||
class MustNotConsume:
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
consumed[0] = True
|
||||
raise AssertionError("receipt replay consumed payload")
|
||||
|
||||
assert (
|
||||
repository.receive(
|
||||
"capture-2", "receipt-1", MustNotConsume(), media_type="image/png", max_bytes=100
|
||||
)
|
||||
== "capture-1"
|
||||
)
|
||||
assert not consumed[0]
|
||||
|
||||
|
||||
def test_every_provenance_kind_is_insert_only(
|
||||
storage: tuple[StorageDatabase, CaptureRepository],
|
||||
) -> None:
|
||||
database, repository = storage
|
||||
repository.receive(
|
||||
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
repository.append_ocr_run("run-1", "capture-1", "ocr", "1", {})
|
||||
repository.append_region("region-1", "run-1", {})
|
||||
repository.append_classification("class-1", "run-1", {})
|
||||
repository.append_candidate("candidate-1", "run-1", {})
|
||||
repository.append_routing("route-1", "candidate-1", {})
|
||||
repository.append_review("review-1", "candidate-1", {})
|
||||
repository.append_lineage("lineage-1", "child-1", "parent-1", "derived")
|
||||
repository.append_export("export-1", "ready", {})
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
repository.append_ocr_run("run-1", "capture-1", "ocr", "1", {})
|
||||
payloads = {
|
||||
"ocr_runs": "payload_json",
|
||||
"regions": "region_json",
|
||||
"classifications": "classification_json",
|
||||
"extraction_candidates": "candidate_json",
|
||||
"routing_decisions": "decision_json",
|
||||
"review_events": "event_json",
|
||||
"exports": "manifest_json",
|
||||
}
|
||||
with database.connect() as connection:
|
||||
for table in (
|
||||
"ocr_runs",
|
||||
"regions",
|
||||
"classifications",
|
||||
"extraction_candidates",
|
||||
"routing_decisions",
|
||||
"review_events",
|
||||
"lineage",
|
||||
"exports",
|
||||
):
|
||||
assert connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0] == 1
|
||||
for table, column in payloads.items():
|
||||
json.loads(connection.execute(f"SELECT {column} FROM {table}").fetchone()[0])
|
||||
assert (
|
||||
connection.execute("SELECT count(*) FROM ocr_runs WHERE run_id='run-1'").fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_failure_sanitization_and_provenance_bounds(
|
||||
storage: tuple[StorageDatabase, CaptureRepository],
|
||||
) -> None:
|
||||
database, repository = storage
|
||||
repository.receive(
|
||||
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
repository.append_ocr_run("run", "capture-1", "", "1", {})
|
||||
with pytest.raises(ValueError):
|
||||
repository.append_ocr_run("run", "capture-1", "x" * 101, "1", {})
|
||||
with pytest.raises(ValueError):
|
||||
repository.append_lineage("line", "child", "parent", "")
|
||||
with pytest.raises(ValueError):
|
||||
repository.append_export("export", "", {})
|
||||
with pytest.raises(ValueError):
|
||||
repository.append_candidate("candidate", "run", "x" * 1_000_001)
|
||||
long_token = "x" * 600
|
||||
short_message = (
|
||||
"sk-secret123 Authorization: Bearer auth-secret token=token-secret secret=hidden "
|
||||
"password=pw-secret api_key=key-secret /home/agent/private.png\n"
|
||||
)
|
||||
repository.record_failure("failure-1", "capture", "capture-1", "failure-code", short_message)
|
||||
repository.record_failure("failure-2", "capture", "capture-1", "failure-code", long_token)
|
||||
with database.connect() as connection:
|
||||
rows = list(
|
||||
connection.execute("SELECT code, message FROM runtime_failures ORDER BY failure_id")
|
||||
)
|
||||
assert len(rows) == 2
|
||||
for row in rows:
|
||||
assert len(row["message"]) <= 512 and len(row["code"]) <= 64
|
||||
assert "\n" not in row["message"]
|
||||
for value in (
|
||||
"sk-secret123",
|
||||
"auth-secret",
|
||||
"token-secret",
|
||||
"hidden",
|
||||
"pw-secret",
|
||||
"key-secret",
|
||||
"/home/agent/private.png",
|
||||
"\n",
|
||||
):
|
||||
assert value not in rows[0]["message"]
|
||||
assert long_token not in rows[1]["message"]
|
||||
with pytest.raises(ValueError):
|
||||
repository.record_failure("failure-2", "BAD", "capture-1", "code", "message")
|
||||
|
||||
|
||||
def test_complete_blob_after_capture_insert_failure_is_purgeable_orphan(
|
||||
storage: tuple[StorageDatabase, CaptureRepository],
|
||||
) -> None:
|
||||
database, repository = storage
|
||||
repository.receive(
|
||||
"capture-1", "receipt-1", [b"payload-a"], media_type="image/png", max_bytes=100
|
||||
)
|
||||
payload_b = b"payload-b"
|
||||
digest_b = hashlib.sha256(payload_b).hexdigest()
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
repository.receive(
|
||||
"capture-1", "receipt-2", [payload_b], media_type="image/png", max_bytes=100
|
||||
)
|
||||
assert repository.blobs.verify(digest_b) == len(payload_b)
|
||||
with database.connect() as connection:
|
||||
assert (
|
||||
connection.execute(
|
||||
"SELECT count(*) FROM blobs WHERE sha256 = ?", (digest_b,)
|
||||
).fetchone()[0]
|
||||
== 0
|
||||
)
|
||||
assert (
|
||||
connection.execute(
|
||||
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest_b,)
|
||||
).fetchone()[0]
|
||||
== 0
|
||||
)
|
||||
assert repository.blobs._path(digest_b).relative_to(
|
||||
repository.blobs.root
|
||||
) in repository.blobs.purge_dry_run(set())
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.web.app import (
|
||||
CaptureRecord,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
MediaDescriptor,
|
||||
MutationOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import new_bootstrap_token
|
||||
|
||||
_ID = "capture-00000000000000000000000000000002"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureControl:
|
||||
armed: bool = False
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
return CaptureSessionStatus(self.armed, 1, 64)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
self.armed = True
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
self.armed = False
|
||||
return "stopped"
|
||||
|
||||
|
||||
class Corpus:
|
||||
def __init__(self) -> None:
|
||||
self.record = CaptureRecord(_ID, "stored", "2026-07-31T00:00:00Z", None)
|
||||
self.rejected = False
|
||||
self.deleted = False
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
return (self.record,)
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
return self.record if capture_id == _ID else None
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
return MediaDescriptor("image/png", 3, [b"png"]) if capture_id == _ID else None
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
if capture_id != _ID:
|
||||
return "not_found"
|
||||
self.record = CaptureRecord(_ID, "stored", "2026-07-31T00:00:00Z", dict(classification))
|
||||
return "updated"
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
if capture_id != _ID:
|
||||
return "not_found"
|
||||
self.rejected = True
|
||||
return "updated"
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
if capture_id != _ID:
|
||||
return "not_found"
|
||||
self.deleted = True
|
||||
return "updated"
|
||||
|
||||
|
||||
def test_private_capture_labeling_flow_preserves_rejection_before_retention_delete() -> None:
|
||||
token = new_bootstrap_token()
|
||||
control = CaptureControl()
|
||||
corpus = Corpus()
|
||||
client = TestClient(
|
||||
create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=control,
|
||||
corpus=corpus,
|
||||
bootstrap_token=token,
|
||||
),
|
||||
base_url="http://127.0.0.1:8765",
|
||||
)
|
||||
origin = "http://127.0.0.1:8765"
|
||||
csrf = str(
|
||||
client.post("/api/bootstrap", headers={"Origin": origin}, json={"token": token}).json()[
|
||||
"csrf_token"
|
||||
]
|
||||
)
|
||||
label = {
|
||||
"family": "gear",
|
||||
"view": "detail",
|
||||
"gear_slot": "Mask",
|
||||
"record_types": ["gear", "brand_set"],
|
||||
"confidence": 1.0,
|
||||
"abstention_reason": None,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"/api/captures/{_ID}/label", headers={"Origin": origin, "X-CSRF-Token": csrf}, json=label
|
||||
)
|
||||
assert response.status_code == 200
|
||||
csrf = str(response.json()["csrf_token"])
|
||||
assert client.get(f"/api/captures/{_ID}").json()["capture"]["classification"] == label
|
||||
response = client.post(
|
||||
f"/api/captures/{_ID}/reject",
|
||||
headers={"Origin": origin, "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "REJECT"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
csrf = str(response.json()["csrf_token"])
|
||||
assert corpus.rejected is True
|
||||
response = client.post(
|
||||
f"/api/captures/{_ID}/delete",
|
||||
headers={"Origin": origin, "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "DELETE"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert corpus.deleted is True
|
||||
|
||||
|
||||
def test_review_ui_uses_local_external_assets_and_keeps_guardrails_in_static_source() -> None:
|
||||
web_root = Path(__file__).parents[2] / "src" / "game_data_capture" / "web"
|
||||
template = (web_root / "templates" / "index.html").read_text(encoding="utf-8")
|
||||
script = (web_root / "static" / "review.js").read_text(encoding="utf-8")
|
||||
stylesheet = (web_root / "static" / "review.css").read_text(encoding="utf-8")
|
||||
|
||||
assert 'href="/static/review.css"' in template
|
||||
assert 'src="/static/review.js"' in template
|
||||
assert "<style" not in template
|
||||
assert "<script>" not in template
|
||||
assert "http://" not in template + script + stylesheet
|
||||
assert "https://" not in template + script + stylesheet
|
||||
assert "innerHTML" not in script
|
||||
assert "Origin" not in script
|
||||
assert (
|
||||
'history.replaceState(null, "", `${window.location.pathname}${window.location.search}`)'
|
||||
in script
|
||||
)
|
||||
assert '"/api/session/start"' in script
|
||||
assert "JSON.stringify({ confirmation })" in script
|
||||
assert "confidence: 1.0" in script
|
||||
assert 'credentials: "same-origin"' in script
|
||||
assert "localStorage" not in script
|
||||
assert "sessionStorage" not in script
|
||||
for expected in (
|
||||
"weapon",
|
||||
"gear",
|
||||
"skill_chip",
|
||||
"os_protocol",
|
||||
"specialization",
|
||||
"unknown",
|
||||
"Mask",
|
||||
"Gloves",
|
||||
"Backpack",
|
||||
"Body Armor",
|
||||
"Holster",
|
||||
"Knee Pads",
|
||||
"prefers-reduced-motion",
|
||||
):
|
||||
assert expected in script + stylesheet
|
||||
|
||||
|
||||
def test_review_page_and_local_static_assets_serve_with_security_headers() -> None:
|
||||
client = TestClient(
|
||||
create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=CaptureControl(),
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=new_bootstrap_token(),
|
||||
),
|
||||
base_url="http://127.0.0.1:8765",
|
||||
)
|
||||
|
||||
page = client.get("/")
|
||||
stylesheet = client.get("/static/review.css")
|
||||
script = client.get("/static/review.js")
|
||||
|
||||
for response in (page, stylesheet, script):
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-security-policy"].startswith("default-src 'self'")
|
||||
assert response.headers["referrer-policy"] == "no-referrer"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
assert "http://" not in response.text
|
||||
assert "https://" not in response.text
|
||||
assert 'href="/static/review.css"' in page.text
|
||||
assert 'src="/static/review.js"' in page.text
|
||||
assert stylesheet.headers["content-type"].startswith("text/css")
|
||||
assert "csrfPattern" in script.text
|
||||
assert "setCheckedMembers" in script.text
|
||||
assert "announce: false" in script.text
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.ingest.clipboard import ClipboardIntake, WatchConfig
|
||||
|
||||
|
||||
def test_shutdown_reaps_real_child_process_group(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
real_popen = subprocess.Popen
|
||||
|
||||
def fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
|
||||
nonlocal child
|
||||
assert args[0] == "wl-paste" and kwargs["shell"] is False
|
||||
child = real_popen(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return child
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
intake = ClipboardIntake(
|
||||
WatchConfig((tmp_path / "spool").resolve(), 100, 1, "v1", 1, 0.01, 0.2)
|
||||
)
|
||||
intake.arm()
|
||||
assert child is not None and child.poll() is None
|
||||
intake.shutdown()
|
||||
for _ in range(20):
|
||||
if child.poll() is not None:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert child.poll() is not None
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_review_interface_marks_keyboard_modal_and_responsive_state_hooks() -> None:
|
||||
web_root = Path(__file__).parents[2] / "src" / "game_data_capture" / "web"
|
||||
template = (web_root / "templates" / "index.html").read_text(encoding="utf-8")
|
||||
script = (web_root / "static" / "review.js").read_text(encoding="utf-8")
|
||||
stylesheet = (web_root / "static" / "review.css").read_text(encoding="utf-8")
|
||||
|
||||
assert 'aria-live="polite"' in template
|
||||
assert '<dialog class="confirmation-dialog"' in template
|
||||
assert 'value="cancel"' in template
|
||||
assert 'type="radio"' not in template
|
||||
assert "input.type = type" in script
|
||||
assert 'input.type = "button"' not in script
|
||||
assert 'input.type = "radio"' not in script
|
||||
assert 'input.type = "checkbox"' not in script
|
||||
assert "input.type = type" in script
|
||||
assert "els.dialog.showModal()" in script
|
||||
assert 'els.dialog.returnValue = "cancel"' in script
|
||||
assert 'els.dialog.dataset.action = ""' in script
|
||||
assert "const focusTarget = returnFocus;" in script
|
||||
assert "if (focusTarget) focusTarget.focus();" in script
|
||||
assert "fallbackFocus?.focus();" in script
|
||||
assert 'els.media.addEventListener("error"' in script
|
||||
assert ":focus-visible" in stylesheet
|
||||
assert "min-height: 44px" in stylesheet
|
||||
assert "@media (max-width: 52rem)" in stylesheet
|
||||
assert "@media (prefers-reduced-motion: reduce)" in stylesheet
|
||||
assert "@media (forced-colors: active)" in stylesheet
|
||||
|
||||
|
||||
def test_review_script_guards_session_taxonomy_pending_and_selection_state() -> None:
|
||||
script = (
|
||||
Path(__file__).parents[2] / "src" / "game_data_capture" / "web" / "static" / "review.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert 'headers.set("Origin"' not in script
|
||||
assert "Origin:" not in script
|
||||
assert 'const bootstrap = await request("/api/bootstrap"' in script
|
||||
assert 'const session = await request("/api/session")' in script
|
||||
assert script.index('"/api/bootstrap"') < script.index('"/api/session"')
|
||||
assert "if (!validSession(session))" in script
|
||||
assert "family, false, () =>" in script
|
||||
assert "view, false, renderDependentFields" in script
|
||||
assert "if (!classification) { renderViews(); renderDependentFields(); return; }" in script
|
||||
assert "function routeForAction(action)" in script
|
||||
assert '(action === "reject" || action === "delete") && activeCapture' in script
|
||||
assert "const routes =" not in script
|
||||
assert 'document.querySelectorAll("#label-form input")' in script
|
||||
assert "input.disabled = pending || locked || !activeCapture" in script
|
||||
assert 'document.querySelectorAll("#capture-list button")' in script
|
||||
assert "button.disabled = pending || locked;" in script
|
||||
assert "armed === true" in script
|
||||
assert "armed !== true" in script
|
||||
assert "function acceptMutationCsrf(payload)" in script
|
||||
assert "if (!payload || !acceptMutationCsrf(payload))" in script
|
||||
assert 'retained ? "RETAINED"' in script
|
||||
assert "function setCheckedMembers(name, values)" in script
|
||||
assert "input.checked = selectedValues.has(input.value);" in script
|
||||
assert 'setCheckedMembers("record_type", classification.record_types || []);' in script
|
||||
assert 'forEach((record) => setChecked("record_type", record))' not in script
|
||||
assert (
|
||||
"async function refreshCaptures({ announce = true, fallbackFocus = null } = {})" in script
|
||||
)
|
||||
assert "async function selectCapture(captureId, { announce = true } = {})" in script
|
||||
assert 'if (announce) notice("success", "QUEUE REFRESHED"' in script
|
||||
assert 'if (announce) notice("success", "CAPTURE READY"' in script
|
||||
assert "refreshCaptures({ announce: false })" in script
|
||||
assert "selectCapture(captureId, { announce: false })" in script
|
||||
assert script.index("selectCapture(captureId, { announce: false })") < script.index(
|
||||
'notice("success", "LABEL SAVED"'
|
||||
)
|
||||
assert 'if (!activeCapture) els.reviewState.textContent = "READY";' in script
|
||||
assert '"Capture is ARMED. Stop Session requires explicit confirmation."' in script
|
||||
assert '"Capture is DISARMED. Start Session requires explicit confirmation."' in script
|
||||
assert 'action === "start" ? els.stop' in script
|
||||
assert 'action === "stop" ? els.start' in script
|
||||
assert 'action === "delete" ? els.refresh' in script
|
||||
assert "function focusIfAvailable(control)" in script
|
||||
assert "focusIfAvailable(successFocus);" in script
|
||||
assert "async function confirmed(action, fallbackFocus)" in script
|
||||
assert "fallbackFocus?.focus();" in script
|
||||
assert "function clearActiveDetail()" in script
|
||||
assert 'if (action === "delete") clearActiveDetail();' in script
|
||||
assert "captures.some((capture) => capture.capture_id === captureId)" in script
|
||||
assert "refreshCaptures({ fallbackFocus: els.refresh })" in script
|
||||
assert "focusIfAvailable(els.save);" in script
|
||||
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from collections.abc import Iterable, Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
import game_data_capture.cli as cli
|
||||
import game_data_capture.runtime as runtime_module
|
||||
from game_data_capture.config import AppConfig, LifecycleConfig, ServerConfig
|
||||
from game_data_capture.ingest.clipboard import (
|
||||
ClipboardIntake,
|
||||
FocusedWindowMetadata,
|
||||
IntakeReceipt,
|
||||
WatchConfig,
|
||||
)
|
||||
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE, receipt_name, spool_png
|
||||
from game_data_capture.paths import RuntimePaths
|
||||
from game_data_capture.runtime import CaptureRuntime, InstanceLock, RuntimeFailure, _CaptureControl
|
||||
from game_data_capture.storage import CaptureRepository, ContentAddressedBlobStore, StorageDatabase
|
||||
|
||||
_PNG = PNG_SIGNATURE + b"watch-png"
|
||||
|
||||
|
||||
class FakeIntake:
|
||||
def __init__(self, cancelled: Event, payloads: list[dict[str, object]]) -> None:
|
||||
self.cancelled = cancelled
|
||||
self.payloads = deque(payloads)
|
||||
self.receipts: deque[IntakeReceipt] = deque()
|
||||
self.armed = self.reconciled = self.shutdown_called = False
|
||||
self.opened = 0
|
||||
self.acknowledged: list[str] = []
|
||||
self.seen_tokens: set[str] = set()
|
||||
self._ticks = 0
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
self.reconciled = True
|
||||
return []
|
||||
|
||||
def arm(self) -> None:
|
||||
self.armed = True
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
self._ticks += 1
|
||||
if self._ticks > 1:
|
||||
self.cancelled.set()
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None:
|
||||
return self.payloads.popleft() if self.payloads else None
|
||||
|
||||
def accept_helper_receipt(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
occurrence_id: str,
|
||||
processing_version: str,
|
||||
) -> bool:
|
||||
token = str(payload["receipt_token"])
|
||||
if token in self.seen_tokens:
|
||||
return False
|
||||
self.seen_tokens.add(token)
|
||||
self.receipts.append(
|
||||
IntakeReceipt(
|
||||
occurrence_id,
|
||||
token,
|
||||
"0" * 64,
|
||||
len(_PNG),
|
||||
"image/png",
|
||||
Path("/private/spool.png"),
|
||||
"capture-v1",
|
||||
processing_version,
|
||||
FocusedWindowMetadata(None, None, None),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
def take(self) -> IntakeReceipt | None:
|
||||
return self.receipts.popleft() if self.receipts else None
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
|
||||
self.opened += 1
|
||||
return nullcontext([_PNG])
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
self.acknowledged.append(receipt.receipt_token)
|
||||
return True
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_called = True
|
||||
|
||||
|
||||
def _runtime(tmp_path: Path, intake: FakeIntake) -> CaptureRuntime:
|
||||
root = tmp_path / "xdg"
|
||||
paths = RuntimePaths(
|
||||
config_dir=(root / "config").resolve(),
|
||||
data_dir=(root / "data").resolve(),
|
||||
cache_dir=(root / "cache").resolve(),
|
||||
runtime_dir=(root / "runtime").resolve(),
|
||||
)
|
||||
return CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
paths,
|
||||
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
|
||||
|
||||
def _payload(token: str) -> dict[str, object]:
|
||||
return {
|
||||
"receipt_version": 1,
|
||||
"receipt_token": token,
|
||||
"sha256": "0" * 64,
|
||||
"byte_size": len(_PNG),
|
||||
"media_type": "image/png",
|
||||
"spool_name": "ignored.png",
|
||||
"policy_version": "capture-v1",
|
||||
}
|
||||
|
||||
|
||||
class PassiveClipboardIntake(ClipboardIntake):
|
||||
"""Use real private spool sidecars without launching a Wayland watcher."""
|
||||
|
||||
def __init__(
|
||||
self, config: WatchConfig, cancelled: Event, *, acknowledge_result: bool = True
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self.cancelled = cancelled
|
||||
self.acknowledge_result = acknowledge_result
|
||||
self.acknowledged: list[str] = []
|
||||
self.reconcile_calls = 0
|
||||
self._ticks = 0
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
self.reconcile_calls += 1
|
||||
return super().reconcile()
|
||||
|
||||
def arm(self) -> None:
|
||||
self.armed, self.paused = True, False
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
self._ticks += 1
|
||||
if self._ticks > 1:
|
||||
self.cancelled.set()
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
self.acknowledged.append(receipt.receipt_token)
|
||||
return self.acknowledge_result and super().acknowledge(receipt)
|
||||
|
||||
|
||||
class StopTimeoutReceiptIntake(PassiveClipboardIntake):
|
||||
def __init__(self, config: WatchConfig, cancelled: Event) -> None:
|
||||
super().__init__(config, cancelled)
|
||||
self.tick_entered = Event()
|
||||
self.release_tick = Event()
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
self.tick_entered.set()
|
||||
self.release_tick.wait()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.release_tick.set()
|
||||
super().shutdown()
|
||||
|
||||
|
||||
def _durable_runtime(
|
||||
tmp_path: Path, cancelled: Event, *, acknowledge_result: bool = True
|
||||
) -> tuple[CaptureRuntime, list[PassiveClipboardIntake]]:
|
||||
created: list[PassiveClipboardIntake] = []
|
||||
root = tmp_path / "durable-xdg"
|
||||
paths = RuntimePaths(
|
||||
config_dir=(root / "config").resolve(),
|
||||
data_dir=(root / "data").resolve(),
|
||||
cache_dir=(root / "cache").resolve(),
|
||||
runtime_dir=(root / "runtime").resolve(),
|
||||
)
|
||||
|
||||
def factory(config: WatchConfig) -> PassiveClipboardIntake:
|
||||
intake = PassiveClipboardIntake(config, cancelled, acknowledge_result=acknowledge_result)
|
||||
created.append(intake)
|
||||
return intake
|
||||
|
||||
return (
|
||||
CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
paths,
|
||||
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
|
||||
intake_factory=factory,
|
||||
),
|
||||
created,
|
||||
)
|
||||
|
||||
|
||||
def _durable_receipt(runtime: CaptureRuntime, token: str) -> None:
|
||||
spool_png(
|
||||
io.BytesIO(_PNG),
|
||||
spool=runtime.paths.cache_dir / "spool",
|
||||
max_bytes=100,
|
||||
receipt_token=token,
|
||||
policy_version="capture-v1",
|
||||
)
|
||||
|
||||
|
||||
def test_watch_streams_verified_receipts_and_replays_exact_tokens_idempotently(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
intake = FakeIntake(cancelled, [_payload("watch-receipt-1"), _payload("watch-receipt-1")])
|
||||
runtime = _runtime(tmp_path, intake)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
|
||||
assert intake.armed and intake.reconciled and intake.shutdown_called and intake.opened == 1
|
||||
assert intake.acknowledged == ["watch-receipt-1"]
|
||||
|
||||
|
||||
def test_watch_ignores_an_absent_helper_receipt_without_error_noise(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
intake = FakeIntake(cancelled, [])
|
||||
runtime = _runtime(tmp_path, intake)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
|
||||
assert intake.shutdown_called and intake.opened == 0
|
||||
|
||||
|
||||
def test_startup_reconciles_partial_blob_storage_before_arming(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
intake = FakeIntake(cancelled, [])
|
||||
runtime = _runtime(tmp_path, intake)
|
||||
blobs = ContentAddressedBlobStore(runtime.paths.data_dir)
|
||||
blobs.temporary.mkdir(parents=True)
|
||||
partial = blobs.temporary / ".leftover.partial"
|
||||
partial.write_bytes(b"partial")
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
assert not partial.exists()
|
||||
assert intake.reconciled and intake.shutdown_called
|
||||
|
||||
|
||||
def test_durable_receipt_is_acknowledged_once_after_repository_confirmation(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
runtime, created = _durable_runtime(tmp_path, cancelled)
|
||||
_durable_receipt(runtime, "confirmed")
|
||||
events: list[str] = []
|
||||
original_receive = CaptureRepository.receive
|
||||
original_acknowledge = PassiveClipboardIntake.acknowledge
|
||||
|
||||
def tracked_receive(
|
||||
self: CaptureRepository,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
events.append("receive")
|
||||
return original_receive(
|
||||
self,
|
||||
capture_id,
|
||||
receipt_token,
|
||||
chunks,
|
||||
media_type=media_type,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(CaptureRepository, "receive", tracked_receive)
|
||||
|
||||
def tracked_acknowledge(self: PassiveClipboardIntake, receipt: IntakeReceipt) -> bool:
|
||||
events.append("acknowledge")
|
||||
return original_acknowledge(self, receipt)
|
||||
|
||||
monkeypatch.setattr(PassiveClipboardIntake, "acknowledge", tracked_acknowledge)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
|
||||
assert events == ["receive", "acknowledge"]
|
||||
assert created[0].acknowledged == ["confirmed"]
|
||||
assert created[0].reconcile_calls == 1
|
||||
assert not (runtime.paths.cache_dir / "spool" / receipt_name("confirmed")).exists()
|
||||
|
||||
|
||||
def test_storage_failure_leaves_durable_receipt_unacknowledged(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
runtime, created = _durable_runtime(tmp_path, cancelled)
|
||||
_durable_receipt(runtime, "storage-failure")
|
||||
|
||||
def failed_receive(
|
||||
self: CaptureRepository,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
raise OSError("storage unavailable")
|
||||
|
||||
monkeypatch.setattr(CaptureRepository, "receive", failed_receive)
|
||||
with pytest.raises(RuntimeFailure, match="capture watch failed"):
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
|
||||
assert created[0].acknowledged == []
|
||||
assert (runtime.paths.cache_dir / "spool" / receipt_name("storage-failure")).exists()
|
||||
|
||||
|
||||
def test_cancellation_after_storage_confirmation_does_not_acknowledge(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
runtime, created = _durable_runtime(tmp_path, cancelled)
|
||||
_durable_receipt(runtime, "cancelled-after-storage")
|
||||
original_receive = CaptureRepository.receive
|
||||
|
||||
def cancel_after_receive(
|
||||
self: CaptureRepository,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
result = original_receive(
|
||||
self,
|
||||
capture_id,
|
||||
receipt_token,
|
||||
chunks,
|
||||
media_type=media_type,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
cancelled.set()
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(CaptureRepository, "receive", cancel_after_receive)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
|
||||
assert created[0].acknowledged == []
|
||||
assert (runtime.paths.cache_dir / "spool" / receipt_name("cancelled-after-storage")).exists()
|
||||
|
||||
|
||||
def test_acknowledgement_failure_replays_idempotently_and_releases_lock(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
runtime, failed = _durable_runtime(tmp_path, cancelled, acknowledge_result=False)
|
||||
_durable_receipt(runtime, "replay-token")
|
||||
with pytest.raises(RuntimeFailure, match="acknowledgement failed"):
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
assert failed[0].acknowledged == ["replay-token"]
|
||||
sidecar = runtime.paths.cache_dir / "spool" / receipt_name("replay-token")
|
||||
assert sidecar.exists()
|
||||
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
|
||||
reusable.release()
|
||||
|
||||
replay_cancelled = Event()
|
||||
replay_runtime, replayed = _durable_runtime(tmp_path, replay_cancelled)
|
||||
replay_runtime.watch(cancelled=replay_cancelled, poll_seconds=0.001)
|
||||
database = StorageDatabase(replay_runtime.paths.data_dir, replay_runtime.migrations_dir)
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
|
||||
assert replayed[0].acknowledged == ["replay-token"]
|
||||
assert not sidecar.exists()
|
||||
|
||||
|
||||
def test_service_stop_timeout_preserves_unacknowledged_durable_sidecar(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
created: list[StopTimeoutReceiptIntake] = []
|
||||
root = tmp_path / "stop-timeout-xdg"
|
||||
paths = RuntimePaths(
|
||||
config_dir=(root / "config").resolve(),
|
||||
data_dir=(root / "data").resolve(),
|
||||
cache_dir=(root / "cache").resolve(),
|
||||
runtime_dir=(root / "runtime").resolve(),
|
||||
)
|
||||
|
||||
def factory(config: WatchConfig) -> StopTimeoutReceiptIntake:
|
||||
intake = StopTimeoutReceiptIntake(config, cancelled)
|
||||
created.append(intake)
|
||||
return intake
|
||||
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0, shutdown_timeout_seconds=1)),
|
||||
paths,
|
||||
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
|
||||
intake_factory=factory,
|
||||
)
|
||||
_durable_receipt(runtime, "timeout-receipt")
|
||||
resources = runtime._start()
|
||||
control = _CaptureControl(runtime, resources.repository)
|
||||
try:
|
||||
assert control.start_session() == "started"
|
||||
assert created[0].tick_entered.wait(0.2)
|
||||
assert control.stop_session() == "stopped"
|
||||
assert created[0].acknowledged == []
|
||||
assert (runtime.paths.cache_dir / "spool" / receipt_name("timeout-receipt")).exists()
|
||||
finally:
|
||||
control.shutdown()
|
||||
resources.close()
|
||||
|
||||
|
||||
def test_cli_bare_watch_is_a_safe_refusal_and_modes_never_start_live_processes(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = tmp_path / "fresh-xdg"
|
||||
for key, name in (
|
||||
("HOME", "home"),
|
||||
("XDG_CONFIG_HOME", "config"),
|
||||
("XDG_DATA_HOME", "data"),
|
||||
("XDG_CACHE_HOME", "cache"),
|
||||
("XDG_RUNTIME_DIR", "runtime"),
|
||||
):
|
||||
monkeypatch.setenv(key, str(root / name))
|
||||
|
||||
def forbidden(*_arguments: object, **_kwargs: object) -> None:
|
||||
raise AssertionError("test started a process")
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", forbidden)
|
||||
assert cli.main(["watch"]) == cli.EXIT_UNIMPLEMENTED
|
||||
assert "requires gdc-runtime-integration" in capsys.readouterr().err
|
||||
assert cli.main(["watch", "--dry-run"]) == 0
|
||||
assert capsys.readouterr().out == "watch dry-run: capture runtime plan is valid\n"
|
||||
assert not root.exists()
|
||||
|
||||
|
||||
def test_cli_arm_reaches_only_a_mocked_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, _config: object, _paths: object) -> None:
|
||||
calls.append("created")
|
||||
|
||||
def watch(self) -> None:
|
||||
calls.append("watch")
|
||||
|
||||
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
|
||||
assert cli.main(["watch", "--arm"]) == 0
|
||||
assert calls == ["created", "watch"]
|
||||
|
||||
|
||||
def test_cli_serve_validates_before_constructing_resources_and_passes_exact_authority(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, object]] = []
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, config: AppConfig, _paths: object) -> None:
|
||||
calls.append(("created", config.server))
|
||||
|
||||
def serve(self) -> None:
|
||||
calls.append(("serve", None))
|
||||
|
||||
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
|
||||
assert cli.main(["serve", "--host", "127.0.0.1", "--port", "9877"]) == 0
|
||||
assert calls == [("created", ServerConfig("127.0.0.1", 9877)), ("serve", None)]
|
||||
|
||||
calls.clear()
|
||||
assert cli.main(["serve", "--host", "0.0.0.0"]) == cli.EXIT_CONFIG_ERROR
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_cli_serve_converts_uvicorn_startup_exit_without_leaking_its_token(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = tmp_path / "serve-xdg"
|
||||
for key, name in (
|
||||
("HOME", "home"),
|
||||
("XDG_CONFIG_HOME", "config"),
|
||||
("XDG_DATA_HOME", "data"),
|
||||
("XDG_CACHE_HOME", "cache"),
|
||||
("XDG_RUNTIME_DIR", "runtime"),
|
||||
):
|
||||
monkeypatch.setenv(key, str(root / name))
|
||||
token = "Z" * 43
|
||||
monkeypatch.setattr(runtime_module, "new_bootstrap_token", lambda: token)
|
||||
|
||||
def failed_server(*_: object, **__: object) -> None:
|
||||
raise SystemExit(2)
|
||||
|
||||
monkeypatch.setattr(uvicorn, "run", failed_server)
|
||||
assert cli.main(["serve"]) == cli.EXIT_RUNTIME_ERROR
|
||||
output = capsys.readouterr()
|
||||
assert output.err == "capture runtime failed\n"
|
||||
assert token not in output.err
|
||||
assert "SystemExit" not in output.err
|
||||
reusable = InstanceLock.acquire(root / "runtime" / "shd-capture" / "instance.lock", 0)
|
||||
reusable.release()
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.web.app import (
|
||||
CaptureRecord,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
MediaDescriptor,
|
||||
MutationOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import new_bootstrap_token
|
||||
|
||||
|
||||
class Control:
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
return CaptureSessionStatus(False, 0, 1)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
return "stopped"
|
||||
|
||||
|
||||
class EmptyCorpus:
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
return ()
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
return None
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
return None
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
|
||||
def test_bootstrap_is_one_time_same_origin_and_does_not_enable_permissive_cors() -> None:
|
||||
token = new_bootstrap_token()
|
||||
app = create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=Control(),
|
||||
corpus=EmptyCorpus(),
|
||||
bootstrap_token=token,
|
||||
)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
|
||||
cross_origin = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://evil.test"}, json={"token": token}
|
||||
)
|
||||
accepted = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
replay = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
|
||||
assert cross_origin.status_code == 403
|
||||
assert accepted.status_code == 200
|
||||
assert replay.status_code == 401
|
||||
assert "access-control-allow-origin" not in accepted.headers
|
||||
@@ -0,0 +1,479 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import game_data_capture.ingest.clipboard as clipboard
|
||||
from game_data_capture.ingest.clipboard import (
|
||||
ClipboardIntake,
|
||||
FocusedWindowMetadata,
|
||||
WatchConfig,
|
||||
focused_window,
|
||||
restricted_environment,
|
||||
)
|
||||
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE, spool_png
|
||||
|
||||
|
||||
def receipt(spool: Path, token: str = "receipt") -> dict[str, object]:
|
||||
return spool_png(
|
||||
__import__("io").BytesIO(PNG_SIGNATURE + b"png"),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token=token,
|
||||
policy_version="v1",
|
||||
)
|
||||
|
||||
|
||||
def unique_receipt(spool: Path, token: str, value: bytes) -> dict[str, object]:
|
||||
return spool_png(
|
||||
io.BytesIO(PNG_SIGNATURE + value),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token=token,
|
||||
policy_version="v1",
|
||||
)
|
||||
|
||||
|
||||
def config(tmp_path: Path, *, queue: int = 2, retries: int = 2) -> WatchConfig:
|
||||
return WatchConfig((tmp_path / "spool").resolve(), 100, queue, "v1", retries, 0.01, 0.1)
|
||||
|
||||
|
||||
def test_environment_is_allowlisted_and_secret_free() -> None:
|
||||
child = restricted_environment(
|
||||
{
|
||||
"PATH": "/bin",
|
||||
"XDG_RUNTIME_DIR": "/run/user/1",
|
||||
"OPENAI_API_KEY": "sk-secret",
|
||||
"HTTP_AUTHORIZATION": "Bearer secret",
|
||||
"PYTHONPATH": "/unsafe",
|
||||
"UNRELATED": "no",
|
||||
}
|
||||
)
|
||||
assert child == {"PATH": "/bin", "XDG_RUNTIME_DIR": "/run/user/1"}
|
||||
|
||||
|
||||
def test_text_clear_duplicate_and_backpressure_are_silent(tmp_path: Path) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path, queue=1))
|
||||
item = receipt(intake.config.spool)
|
||||
intake.armed = True
|
||||
metadata = FocusedWindowMetadata(None, None, None)
|
||||
assert not intake.accept_helper_receipt(
|
||||
{"media_type": "text/plain"}, occurrence_id="a", processing_version="p"
|
||||
)
|
||||
assert not intake.accept_helper_receipt({}, occurrence_id="a", processing_version="p")
|
||||
assert intake.accept_helper_receipt(
|
||||
item, occurrence_id="a", processing_version="p", metadata=metadata
|
||||
)
|
||||
assert not intake.accept_helper_receipt(
|
||||
item, occurrence_id="a", processing_version="p", metadata=metadata
|
||||
)
|
||||
other = receipt(intake.config.spool, "other")
|
||||
assert not intake.accept_helper_receipt(
|
||||
other, occurrence_id="b", processing_version="p", metadata=metadata
|
||||
)
|
||||
assert intake.queue_depth == 1
|
||||
|
||||
|
||||
def test_watcher_argv_places_options_before_watch_subcommand(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched: list[list[str]] = []
|
||||
|
||||
class Fake:
|
||||
pid = os.getpid()
|
||||
stdout = None
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return None
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
return 0
|
||||
|
||||
def popen(args: list[str], **kwargs: object) -> Fake:
|
||||
assert kwargs["shell"] is False
|
||||
launched.append(args)
|
||||
return Fake()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(os, "killpg", lambda _pid, _signal: None)
|
||||
intake = ClipboardIntake(config(tmp_path))
|
||||
intake.arm()
|
||||
command = launched[0]
|
||||
watch = command.index("--watch")
|
||||
assert command[:watch] == ["wl-paste", "--type", "image/png"]
|
||||
assert command[watch + 1] == sys.executable
|
||||
assert "--" not in command
|
||||
assert "game_data_capture.ingest.spool_helper" in command
|
||||
intake.pause()
|
||||
assert intake.paused
|
||||
intake.resume()
|
||||
assert len(launched) == 2
|
||||
intake.disarm()
|
||||
assert not intake.armed
|
||||
|
||||
|
||||
def test_cancellation_disarms_and_clears_queue(tmp_path: Path) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path))
|
||||
intake.armed = True
|
||||
assert intake.accept_helper_receipt(
|
||||
receipt(intake.config.spool),
|
||||
occurrence_id="occurrence",
|
||||
processing_version="processing-v1",
|
||||
metadata=FocusedWindowMetadata(None, None, None),
|
||||
)
|
||||
intake.cancel()
|
||||
assert not intake.armed and intake.take() is None
|
||||
|
||||
|
||||
def test_receipt_integrity_versions_and_bounded_duplicate_cache(tmp_path: Path) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path, queue=2))
|
||||
intake.armed = True
|
||||
metadata = FocusedWindowMetadata(None, None, None)
|
||||
first = receipt(intake.config.spool, "one")
|
||||
future = dict(first, receipt_version=2)
|
||||
assert not intake.accept_helper_receipt(
|
||||
future, occurrence_id="one", processing_version="p", metadata=metadata
|
||||
)
|
||||
assert intake.accept_helper_receipt(
|
||||
first, occurrence_id="one", processing_version="p", metadata=metadata
|
||||
)
|
||||
assert not intake.accept_helper_receipt(
|
||||
first, occurrence_id="one", processing_version="p", metadata=metadata
|
||||
)
|
||||
assert len(intake._seen_tokens) == 1
|
||||
intake.shutdown()
|
||||
assert not intake._seen_tokens and not intake._helper_payloads
|
||||
|
||||
|
||||
def test_durable_receipt_recovery_is_null_metadata_duplicate_safe_and_acknowledged(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path))
|
||||
item = receipt(intake.config.spool, "crash-token")
|
||||
intake.armed = True
|
||||
monkeypatch.setattr(
|
||||
clipboard,
|
||||
"focused_window",
|
||||
lambda: pytest.fail("recovered receipt must not query the current window"),
|
||||
)
|
||||
intake.reconcile()
|
||||
recovered = intake.take_helper_receipt()
|
||||
assert recovered == item
|
||||
assert intake.accept_helper_receipt(
|
||||
recovered, occurrence_id="recovered", processing_version="p"
|
||||
)
|
||||
accepted = intake.take()
|
||||
assert accepted is not None
|
||||
assert accepted.focused_window == FocusedWindowMetadata(None, None, None)
|
||||
assert not intake.accept_helper_receipt(item, occurrence_id="live-copy", processing_version="p")
|
||||
assert (intake.config.spool / "crash-token.receipt.json").exists()
|
||||
assert intake.acknowledge(accepted)
|
||||
assert not intake.acknowledge(accepted)
|
||||
assert not (intake.config.spool / "crash-token.receipt.json").exists()
|
||||
assert accepted.spool_path.exists()
|
||||
|
||||
|
||||
def test_reconcile_respects_helper_backpressure_and_retains_durable_receipts(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path, queue=1))
|
||||
first = unique_receipt(intake.config.spool, "first", b"first")
|
||||
second = unique_receipt(intake.config.spool, "second", b"second")
|
||||
intake.armed = True
|
||||
intake.reconcile()
|
||||
queued = intake.take_helper_receipt()
|
||||
assert queued in (first, second)
|
||||
assert intake.accept_helper_receipt(
|
||||
queued,
|
||||
occurrence_id="first",
|
||||
processing_version="p",
|
||||
metadata=FocusedWindowMetadata(None, None, None),
|
||||
)
|
||||
remaining = second if queued == first else first
|
||||
assert (intake.config.spool / f"{remaining['receipt_token']}.receipt.json").exists()
|
||||
intake.reconcile()
|
||||
assert intake.take_helper_receipt() == remaining
|
||||
|
||||
|
||||
def test_unacknowledged_receipt_survives_a_fresh_intake(tmp_path: Path) -> None:
|
||||
initial = ClipboardIntake(config(tmp_path))
|
||||
item = receipt(initial.config.spool, "retry-token")
|
||||
restarted = ClipboardIntake(config(tmp_path))
|
||||
restarted.armed = True
|
||||
restarted.reconcile()
|
||||
replay = restarted.take_helper_receipt()
|
||||
assert replay == item
|
||||
assert restarted.accept_helper_receipt(replay, occurrence_id="retry", processing_version="p")
|
||||
accepted = restarted.take()
|
||||
assert accepted is not None
|
||||
assert (restarted.config.spool / "retry-token.receipt.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["max_image_bytes", "max_queue_items", "max_restarts"])
|
||||
def test_bool_limits_are_rejected(tmp_path: Path, field: str) -> None:
|
||||
values: dict[str, object] = dict(
|
||||
spool=(tmp_path / "spool").resolve(),
|
||||
max_image_bytes=100,
|
||||
max_queue_items=1,
|
||||
policy_version="v1",
|
||||
max_restarts=1,
|
||||
)
|
||||
values[field] = True
|
||||
with pytest.raises(ValueError):
|
||||
WatchConfig(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_watcher_crash_uses_backoff_and_restart_cap(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
starts = 0
|
||||
|
||||
class Dead:
|
||||
pid = os.getpid()
|
||||
stdout = None
|
||||
|
||||
def poll(self) -> int:
|
||||
return 1
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
return 1
|
||||
|
||||
def popen(*_args: object, **_kwargs: object) -> Dead:
|
||||
nonlocal starts
|
||||
starts += 1
|
||||
return Dead()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", popen)
|
||||
intake = ClipboardIntake(config(tmp_path, retries=1))
|
||||
intake.arm()
|
||||
intake.tick(0)
|
||||
assert starts == 1
|
||||
intake.tick(0.02)
|
||||
assert starts == 2
|
||||
intake.tick(0.02)
|
||||
intake.tick(1)
|
||||
assert starts == 2
|
||||
|
||||
|
||||
def test_niri_timeout_and_malformed_json_are_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(clipboard, "_bounded_command_output", lambda *_args: None)
|
||||
assert focused_window() == FocusedWindowMetadata(None, None, None)
|
||||
monkeypatch.setattr(clipboard, "_bounded_command_output", lambda *_args: b"{broken")
|
||||
assert focused_window() == FocusedWindowMetadata(None, None, None)
|
||||
|
||||
|
||||
def test_receipt_mutation_matrix_and_file_integrity(tmp_path: Path) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path))
|
||||
intake.armed = True
|
||||
metadata = FocusedWindowMetadata(None, None, None)
|
||||
base = unique_receipt(intake.config.spool, "token", b"base")
|
||||
for key, value in (
|
||||
("receipt_version", 2),
|
||||
("receipt_token", ""),
|
||||
("sha256", "0" * 64),
|
||||
("byte_size", 9),
|
||||
("media_type", "text/plain"),
|
||||
("policy_version", "other"),
|
||||
("spool_name", "wrong.png"),
|
||||
):
|
||||
changed = dict(base)
|
||||
changed[key] = value
|
||||
assert not intake.accept_helper_receipt(
|
||||
changed, occurrence_id="ok", processing_version="p", metadata=metadata
|
||||
)
|
||||
for value in ("", "bad value"):
|
||||
assert not intake.accept_helper_receipt(
|
||||
base, occurrence_id=value, processing_version="p", metadata=metadata
|
||||
)
|
||||
assert not intake.accept_helper_receipt(
|
||||
base, occurrence_id="ok", processing_version=value, metadata=metadata
|
||||
)
|
||||
for index, (mode, content) in enumerate(
|
||||
((0o600, b"changed"), (0o600, PNG_SIGNATURE), (0o644, None))
|
||||
):
|
||||
item = unique_receipt(
|
||||
intake.config.spool, f"token{mode}{index}", bytes(f"{mode}{index}", "ascii")
|
||||
)
|
||||
path = intake.config.spool / str(item["spool_name"])
|
||||
if content is not None:
|
||||
path.write_bytes(content)
|
||||
path.chmod(mode)
|
||||
assert not intake.accept_helper_receipt(
|
||||
item, occurrence_id=f"o{mode}", processing_version="p", metadata=metadata
|
||||
)
|
||||
item = unique_receipt(intake.config.spool, "symlink", b"link")
|
||||
path = intake.config.spool / str(item["spool_name"])
|
||||
path.unlink()
|
||||
path.symlink_to(tmp_path / "outside")
|
||||
assert not intake.accept_helper_receipt(
|
||||
item, occurrence_id="symlink", processing_version="p", metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
def test_open_verified_and_acceptance_close_descriptors(tmp_path: Path) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path))
|
||||
intake.armed = True
|
||||
item = unique_receipt(intake.config.spool, "fd", b"reader")
|
||||
before = len(list(Path("/proc/self/fd").iterdir()))
|
||||
assert intake.accept_helper_receipt(
|
||||
item,
|
||||
occurrence_id="fd",
|
||||
processing_version="p",
|
||||
metadata=FocusedWindowMetadata(None, None, None),
|
||||
)
|
||||
accepted = intake.take()
|
||||
assert accepted is not None and len(list(Path("/proc/self/fd").iterdir())) == before
|
||||
with intake.open_verified(accepted) as reader:
|
||||
assert b"".join(reader) == PNG_SIGNATURE + b"reader"
|
||||
path = accepted.spool_path
|
||||
path.write_bytes(b"changed")
|
||||
with pytest.raises(ValueError), intake.open_verified(accepted):
|
||||
pass
|
||||
|
||||
|
||||
def test_duplicate_cache_eviction_shutdown_and_unreferenced_candidates(tmp_path: Path) -> None:
|
||||
intake = ClipboardIntake(config(tmp_path, queue=2))
|
||||
intake.armed = True
|
||||
metadata = FocusedWindowMetadata(None, None, None)
|
||||
items = [unique_receipt(intake.config.spool, f"t{i}", bytes([i])) for i in range(3)]
|
||||
for index, item in enumerate(items):
|
||||
assert intake.accept_helper_receipt(
|
||||
item, occurrence_id=f"o{index}", processing_version="p", metadata=metadata
|
||||
)
|
||||
intake.take()
|
||||
assert len(intake._seen_tokens) == 2 and not intake.accept_helper_receipt(
|
||||
items[2], occurrence_id="latest", processing_version="p", metadata=metadata
|
||||
)
|
||||
assert intake.accept_helper_receipt(
|
||||
items[0], occurrence_id="evicted", processing_version="p", metadata=metadata
|
||||
)
|
||||
intake = ClipboardIntake(config(tmp_path / "other", queue=1))
|
||||
intake.armed = True
|
||||
queued = unique_receipt(intake.config.spool, "queued", b"queued")
|
||||
extra = unique_receipt(intake.config.spool, "extra", b"extra")
|
||||
assert intake.accept_helper_receipt(
|
||||
queued, occurrence_id="queued", processing_version="p", metadata=metadata
|
||||
)
|
||||
assert [path.name for path in intake.unreferenced_spool_candidates()] == [
|
||||
str(extra["spool_name"])
|
||||
]
|
||||
intake._helper_payloads.append({"receipt": "real"})
|
||||
intake.shutdown()
|
||||
assert (
|
||||
not intake._queue
|
||||
and not intake._helper_payloads
|
||||
and not intake._seen_tokens
|
||||
and not intake._seen_order
|
||||
)
|
||||
|
||||
|
||||
def test_receipt_reader_bounds_spawn_failures_and_config_types(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class Output:
|
||||
def __init__(self) -> None:
|
||||
self.parts = [
|
||||
b"x" * 1025 + b"\n",
|
||||
json.dumps({"ok": 1}).encode() + b"\n",
|
||||
json.dumps({"two": 2}).encode() + b"\n",
|
||||
]
|
||||
|
||||
def readline(self, _size: int) -> bytes:
|
||||
return self.parts.pop(0) if self.parts else b""
|
||||
|
||||
class Process:
|
||||
stdout = Output()
|
||||
|
||||
intake = ClipboardIntake(config(tmp_path, queue=1))
|
||||
intake._read_receipts(Process()) # type: ignore[arg-type]
|
||||
assert intake.helper_queue_depth == 1 and intake.take_helper_receipt() == {"ok": 1}
|
||||
attempts = 0
|
||||
|
||||
def failing(*_args: object, **_kwargs: object) -> object:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise OSError("missing")
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", failing)
|
||||
failed = ClipboardIntake(config(tmp_path, retries=2))
|
||||
failed.arm()
|
||||
failed.tick(failed._restart_after + 0.01)
|
||||
failed.tick(failed._restart_after + 0.01)
|
||||
failed.tick(failed._restart_after + 1)
|
||||
assert attempts == 3
|
||||
for field, value in (
|
||||
("max_image_bytes", 1.5),
|
||||
("max_queue_items", "1"),
|
||||
("max_restarts", 1.5),
|
||||
("shutdown_timeout_seconds", 0),
|
||||
("restart_backoff_seconds", math.inf),
|
||||
):
|
||||
values: dict[str, object] = dict(
|
||||
spool=(tmp_path / "x").resolve(),
|
||||
max_image_bytes=100,
|
||||
max_queue_items=1,
|
||||
policy_version="v1",
|
||||
max_restarts=1,
|
||||
)
|
||||
values[field] = value
|
||||
with pytest.raises(ValueError):
|
||||
WatchConfig(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_bounded_niri_runner_and_valid_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert (
|
||||
clipboard._bounded_command_output([sys.executable, "-c", "print('x'*20000)"], {}, 1) is None
|
||||
)
|
||||
assert (
|
||||
clipboard._bounded_command_output(
|
||||
[sys.executable, "-c", "import time; time.sleep(2)"], {}, 0.01
|
||||
)
|
||||
is None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
clipboard,
|
||||
"_bounded_command_output",
|
||||
lambda *_args: b'{"app_id":"game","title":"title","id":1}',
|
||||
)
|
||||
assert focused_window() == FocusedWindowMetadata("game", "title", 1)
|
||||
|
||||
|
||||
def test_exited_watcher_is_waited_stdout_closed_and_reader_retired(tmp_path: Path) -> None:
|
||||
class Output:
|
||||
closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
class Child:
|
||||
pid = os.getpid()
|
||||
stdout = Output()
|
||||
waits = 0
|
||||
|
||||
def poll(self) -> int:
|
||||
return 1
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
self.waits += 1
|
||||
return 1
|
||||
|
||||
class Reader:
|
||||
joined = False
|
||||
|
||||
def join(self, timeout: float | None = None) -> None:
|
||||
self.joined = True
|
||||
|
||||
intake = ClipboardIntake(config(tmp_path))
|
||||
child = Child()
|
||||
reader = Reader()
|
||||
intake._watcher = child # type: ignore[assignment]
|
||||
intake._reader = reader # type: ignore[assignment]
|
||||
intake.pause()
|
||||
assert child.waits == 1 and child.stdout.closed and reader.joined and intake._reader is None
|
||||
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import game_data_capture.ingest.spool_helper as spool_helper
|
||||
from game_data_capture.ingest.spool_helper import (
|
||||
PNG_SIGNATURE,
|
||||
SpoolError,
|
||||
acknowledge_receipt,
|
||||
durable_receipts,
|
||||
receipt_name,
|
||||
reconcile_spool,
|
||||
spool_png,
|
||||
)
|
||||
|
||||
|
||||
def png(payload: bytes = b"data") -> bytes:
|
||||
return PNG_SIGNATURE + payload
|
||||
|
||||
|
||||
def fill_receipt_capacity(spool: Path) -> dict[str, object]:
|
||||
seed = spool_png(
|
||||
io.BytesIO(png(b"seed")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="seed",
|
||||
policy_version="v1",
|
||||
)
|
||||
for index in range(255):
|
||||
token = f"pre{index}"
|
||||
sidecar = spool / receipt_name(token)
|
||||
sidecar.write_text(
|
||||
json.dumps(dict(seed, receipt_token=token), sort_keys=True, separators=(",", ":"))
|
||||
)
|
||||
sidecar.chmod(0o600)
|
||||
return seed
|
||||
|
||||
|
||||
def test_png_spool_is_private_atomic_and_deduplicated(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
replaced: list[tuple[Path, Path]] = []
|
||||
real_replace = os.replace
|
||||
|
||||
def replace(source: Path, destination: Path) -> None:
|
||||
replaced.append((source, destination))
|
||||
real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(os, "replace", replace)
|
||||
first = spool_png(
|
||||
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
|
||||
)
|
||||
second = spool_png(
|
||||
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="two", policy_version="v1"
|
||||
)
|
||||
assert first["sha256"] == second["sha256"]
|
||||
assert (spool.stat().st_mode & 0o777) == 0o700
|
||||
installed = spool / str(first["spool_name"])
|
||||
assert installed.read_bytes() == png() and (installed.stat().st_mode & 0o777) == 0o600
|
||||
assert len(replaced) == 1 and replaced[0][1] == installed
|
||||
sidecars = sorted(spool.glob("*.receipt.json"))
|
||||
assert [sidecar.name for sidecar in sidecars] == ["one.receipt.json", "two.receipt.json"]
|
||||
assert all((sidecar.stat().st_mode & 0o777) == 0o600 for sidecar in sidecars)
|
||||
assert all(set(json.loads(sidecar.read_text())) == set(first) for sidecar in sidecars)
|
||||
assert not list(spool.glob("*.partial"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [b"", b"text", PNG_SIGNATURE[:4]])
|
||||
def test_empty_text_and_malformed_png_leave_no_partial(tmp_path: Path, value: bytes) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
with pytest.raises(SpoolError):
|
||||
spool_png(
|
||||
io.BytesIO(value), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
|
||||
)
|
||||
assert not list(spool.glob("*.partial"))
|
||||
|
||||
|
||||
def test_unsupported_mime_leaves_no_partial(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
with pytest.raises(SpoolError):
|
||||
spool_png(
|
||||
io.BytesIO(png()),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="one",
|
||||
policy_version="v1",
|
||||
media_type="text/plain",
|
||||
)
|
||||
assert not spool.exists()
|
||||
|
||||
|
||||
def test_short_reads_accumulate_the_png_signature(tmp_path: Path) -> None:
|
||||
class ShortReads:
|
||||
def __init__(self) -> None:
|
||||
self.parts = [PNG_SIGNATURE[:3], PNG_SIGNATURE[3:] + b"payload", b""]
|
||||
|
||||
def read(self, _size: int) -> bytes:
|
||||
return self.parts.pop(0)
|
||||
|
||||
receipt = spool_png(
|
||||
ShortReads(),
|
||||
spool=(tmp_path / "spool").resolve(),
|
||||
max_bytes=100,
|
||||
receipt_token="one",
|
||||
policy_version="v1",
|
||||
)
|
||||
assert receipt["byte_size"] == len(PNG_SIGNATURE + b"payload")
|
||||
|
||||
|
||||
def test_size_bound_and_crash_reconciliation_remove_partials(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
with pytest.raises(SpoolError):
|
||||
spool_png(
|
||||
io.BytesIO(png(b"x" * 100)),
|
||||
spool=spool,
|
||||
max_bytes=10,
|
||||
receipt_token="one",
|
||||
policy_version="v1",
|
||||
)
|
||||
leftover = spool / ".crash.partial"
|
||||
leftover.write_bytes(b"incomplete")
|
||||
assert reconcile_spool(spool) == [".crash.partial"]
|
||||
assert not leftover.exists()
|
||||
|
||||
|
||||
def test_pending_receipt_is_promoted_only_when_its_png_is_verified(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
receipt = spool_png(
|
||||
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
|
||||
)
|
||||
final = spool / receipt_name("one")
|
||||
pending = spool / ".one.receipt.pending"
|
||||
os.replace(final, pending)
|
||||
assert reconcile_spool(spool) == []
|
||||
assert final.exists() and not pending.exists()
|
||||
final.unlink()
|
||||
pending.write_text(json.dumps(receipt))
|
||||
assert reconcile_spool(spool) == [pending.name]
|
||||
assert not pending.exists()
|
||||
|
||||
|
||||
def test_receipt_token_replay_cannot_replace_the_original_sidecar(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
original = spool_png(
|
||||
io.BytesIO(png(b"original")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="same-token",
|
||||
policy_version="v1",
|
||||
)
|
||||
final = spool / receipt_name("same-token")
|
||||
original_bytes = final.read_bytes()
|
||||
assert (
|
||||
spool_png(
|
||||
io.BytesIO(png(b"original")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="same-token",
|
||||
policy_version="v1",
|
||||
)
|
||||
== original
|
||||
)
|
||||
with pytest.raises(SpoolError, match="receipt token conflict"):
|
||||
spool_png(
|
||||
io.BytesIO(png(b"different")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="same-token",
|
||||
policy_version="v1",
|
||||
)
|
||||
assert final.read_bytes() == original_bytes
|
||||
conflicting = dict(original, sha256="0" * 64, spool_name=f"{'0' * 64}.png")
|
||||
pending = spool / ".same-token.receipt.pending"
|
||||
pending.write_text(json.dumps(conflicting, sort_keys=True, separators=(",", ":")))
|
||||
pending.chmod(0o600)
|
||||
assert pending.name in reconcile_spool(spool)
|
||||
assert final.read_bytes() == original_bytes and not pending.exists()
|
||||
|
||||
|
||||
def test_sidecars_are_bounded_validated_and_acknowledged_exactly_once(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
first = spool_png(
|
||||
io.BytesIO(png(b"one")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="one",
|
||||
policy_version="v1",
|
||||
)
|
||||
second = spool_png(
|
||||
io.BytesIO(png(b"two")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="two",
|
||||
policy_version="v1",
|
||||
)
|
||||
malformed = spool / "bad.receipt.json"
|
||||
malformed.write_bytes(b"{" + b"x" * 1024)
|
||||
malformed.chmod(0o600)
|
||||
linked = spool / "linked.receipt.json"
|
||||
linked.symlink_to(malformed)
|
||||
assert {item["receipt_token"] for item in durable_receipts(spool, max_items=2)} == {
|
||||
"one",
|
||||
"two",
|
||||
}
|
||||
assert acknowledge_receipt(spool, first)
|
||||
assert not acknowledge_receipt(spool, first)
|
||||
assert (spool / receipt_name("two")).exists()
|
||||
assert not acknowledge_receipt(spool, dict(second, receipt_token="one"))
|
||||
|
||||
|
||||
def test_malformed_sidecars_do_not_starve_valid_receipts_or_partial_cleanup(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
spool.mkdir(mode=0o700)
|
||||
for index in range(256):
|
||||
malformed = spool / f"{index:03d}.receipt.json"
|
||||
malformed.write_bytes(b"{")
|
||||
malformed.chmod(0o600)
|
||||
valid = spool_png(
|
||||
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="valid", policy_version="v1"
|
||||
)
|
||||
assert list(durable_receipts(spool, max_items=1)) == [valid]
|
||||
assert list(spool.glob("*.receipt.json")) == [spool / "valid.receipt.json"]
|
||||
for index in range(256):
|
||||
pending = spool / f".pending{index}.receipt.pending"
|
||||
pending.write_text(
|
||||
json.dumps(
|
||||
dict(valid, receipt_token=f"pending{index}"), sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
)
|
||||
pending.chmod(0o600)
|
||||
partial = spool / ".after-pending.partial"
|
||||
partial.write_bytes(b"incomplete")
|
||||
assert partial.name in reconcile_spool(spool)
|
||||
assert not partial.exists()
|
||||
|
||||
|
||||
def test_receipt_capacity_rejects_new_tokens_but_accepts_exact_replay(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
seed = fill_receipt_capacity(spool)
|
||||
evidence = (spool / receipt_name("seed")).read_bytes()
|
||||
png_names = {path.name for path in spool.glob("*.png")}
|
||||
with pytest.raises(SpoolError, match="capacity"):
|
||||
spool_png(
|
||||
io.BytesIO(png(b"new")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="new",
|
||||
policy_version="v1",
|
||||
)
|
||||
assert (spool / receipt_name("seed")).read_bytes() == evidence
|
||||
assert {path.name for path in spool.glob("*.png")} == png_names
|
||||
assert (
|
||||
spool_png(
|
||||
io.BytesIO(png(b"seed")),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="seed",
|
||||
policy_version="v1",
|
||||
)
|
||||
== seed
|
||||
)
|
||||
|
||||
|
||||
def test_receipt_capacity_reconciles_malformed_files_before_counting(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
spool.mkdir(mode=0o700)
|
||||
for index in range(256):
|
||||
malformed = spool / f"bad{index}.receipt.json"
|
||||
malformed.write_bytes(b"{")
|
||||
malformed.chmod(0o600)
|
||||
accepted = spool_png(
|
||||
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="accepted", policy_version="v1"
|
||||
)
|
||||
assert accepted["receipt_token"] == "accepted"
|
||||
assert list(spool.glob("bad*.receipt.json")) == []
|
||||
|
||||
|
||||
def test_receipt_capacity_is_serialized_across_helper_processes(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
fill_receipt_capacity(spool)
|
||||
for index in range(1):
|
||||
(spool / receipt_name(f"pre{index}")).unlink()
|
||||
arguments = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"game_data_capture.ingest.spool_helper",
|
||||
"--spool",
|
||||
str(spool),
|
||||
"--max-bytes",
|
||||
"100",
|
||||
"--policy-version",
|
||||
"v1",
|
||||
]
|
||||
first = subprocess.Popen([*arguments, "--receipt-token", "race-one"], stdin=subprocess.PIPE)
|
||||
second = subprocess.Popen([*arguments, "--receipt-token", "race-two"], stdin=subprocess.PIPE)
|
||||
assert first.stdin is not None and second.stdin is not None
|
||||
first.stdin.write(png(b"one"))
|
||||
first.stdin.close()
|
||||
second.stdin.write(png(b"two"))
|
||||
second.stdin.close()
|
||||
assert sorted((first.wait(), second.wait())) == [0, 2]
|
||||
assert len(list(spool.glob("*.receipt.json"))) == 256
|
||||
|
||||
|
||||
def test_spool_lock_is_private_rejects_links_and_releases_on_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
spool.mkdir(mode=0o700)
|
||||
(spool / ".spool.lock").symlink_to(tmp_path / "outside")
|
||||
with pytest.raises(SpoolError, match="unsafe spool lock"):
|
||||
spool_png(
|
||||
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="link", policy_version="v1"
|
||||
)
|
||||
(spool / ".spool.lock").unlink()
|
||||
with monkeypatch.context() as context:
|
||||
context.setattr(
|
||||
spool_helper,
|
||||
"_install_pending_sidecar",
|
||||
lambda *_args: (_ for _ in ()).throw(OSError()),
|
||||
)
|
||||
with pytest.raises(OSError):
|
||||
spool_png(
|
||||
io.BytesIO(png()),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="failure",
|
||||
policy_version="v1",
|
||||
)
|
||||
accepted = spool_png(
|
||||
io.BytesIO(png()),
|
||||
spool=spool,
|
||||
max_bytes=100,
|
||||
receipt_token="recovered",
|
||||
policy_version="v1",
|
||||
)
|
||||
assert accepted["receipt_token"] == "recovered"
|
||||
assert (spool / ".spool.lock").stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_short_write_removes_partial(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
monkeypatch.setattr(os, "write", lambda _fd, _data: 0)
|
||||
with pytest.raises(OSError):
|
||||
spool_png(
|
||||
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
|
||||
)
|
||||
assert not list(spool.glob("*.partial"))
|
||||
|
||||
|
||||
def test_helper_source_has_no_service_or_storage_imports() -> None:
|
||||
source = Path(__file__).parents[3] / "src/game_data_capture/ingest/spool_helper.py"
|
||||
text = source.read_text(encoding="utf-8")
|
||||
for forbidden in ("fastapi", "sqlite", "openai", "game_data_capture.storage"):
|
||||
assert forbidden not in text.lower()
|
||||
|
||||
|
||||
def test_stdin_entrypoint_returns_only_bounded_json_receipt(tmp_path: Path) -> None:
|
||||
spool = (tmp_path / "spool").resolve()
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"game_data_capture.ingest.spool_helper",
|
||||
"--spool",
|
||||
str(spool),
|
||||
"--max-bytes",
|
||||
"100",
|
||||
"--receipt-token",
|
||||
"receipt",
|
||||
"--policy-version",
|
||||
"v1",
|
||||
],
|
||||
input=png(),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
assert completed.returncode == 0 and len(completed.stdout) <= 1024 and not completed.stderr
|
||||
receipt = json.loads(completed.stdout)
|
||||
assert receipt["media_type"] == "image/png" and receipt["receipt_token"] == "receipt"
|
||||
@@ -0,0 +1,466 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from contextlib import AbstractContextManager
|
||||
from pathlib import Path
|
||||
from threading import Event, Thread
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
import game_data_capture.runtime as runtime_module
|
||||
from game_data_capture.config import AppConfig, CaptureLimits, LifecycleConfig, ServerConfig
|
||||
from game_data_capture.ingest.clipboard import IntakeReceipt
|
||||
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE
|
||||
from game_data_capture.paths import RuntimePaths
|
||||
from game_data_capture.runtime import (
|
||||
CaptureRuntime,
|
||||
InstanceLock,
|
||||
IntakePort,
|
||||
RuntimeFailure,
|
||||
_CaptureControl,
|
||||
)
|
||||
from game_data_capture.storage import CaptureCorpus, CaptureRepository, StorageDatabase
|
||||
|
||||
_PNG = PNG_SIGNATURE + b"unit-png"
|
||||
|
||||
|
||||
def _paths(tmp_path: Path) -> RuntimePaths:
|
||||
root = tmp_path / "xdg"
|
||||
return RuntimePaths(
|
||||
config_dir=(root / "config").resolve(),
|
||||
data_dir=(root / "data").resolve(),
|
||||
cache_dir=(root / "cache").resolve(),
|
||||
runtime_dir=(root / "runtime").resolve(),
|
||||
)
|
||||
|
||||
|
||||
def _runtime(tmp_path: Path, *, limits: CaptureLimits | None = None) -> CaptureRuntime:
|
||||
return CaptureRuntime(
|
||||
AppConfig(
|
||||
lifecycle=LifecycleConfig(lock_timeout_seconds=0), limits=limits or CaptureLimits()
|
||||
),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
)
|
||||
|
||||
|
||||
def test_one_shot_persists_distinct_occurrences_and_one_blob(tmp_path: Path) -> None:
|
||||
runtime = _runtime(tmp_path)
|
||||
image = tmp_path / "image.png"
|
||||
image.write_bytes(_PNG)
|
||||
|
||||
first = runtime.ingest_image(image)
|
||||
second = runtime.ingest_image(image)
|
||||
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
|
||||
|
||||
with database.connect() as connection:
|
||||
assert first != second
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 2
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["malformed", "oversize", "symlink", "broad-mode"])
|
||||
def test_one_shot_rejects_unsafe_png_inputs(tmp_path: Path, kind: str) -> None:
|
||||
runtime = _runtime(tmp_path, limits=CaptureLimits(max_image_bytes=len(_PNG)))
|
||||
image = tmp_path / "image.png"
|
||||
image.write_bytes(_PNG)
|
||||
if kind == "malformed":
|
||||
image.write_bytes(b"not-a-png")
|
||||
elif kind == "oversize":
|
||||
image.write_bytes(_PNG + b"x")
|
||||
elif kind == "symlink":
|
||||
linked = tmp_path / "linked.png"
|
||||
linked.symlink_to(image)
|
||||
image = linked
|
||||
else:
|
||||
image.chmod(0o666)
|
||||
try:
|
||||
with pytest.raises(RuntimeFailure, match="safe PNG"):
|
||||
runtime.ingest_image(image)
|
||||
finally:
|
||||
if image.exists() and not image.is_symlink():
|
||||
image.chmod(0o600)
|
||||
|
||||
|
||||
def test_stale_lock_file_is_reused_but_an_active_lock_is_not(tmp_path: Path) -> None:
|
||||
paths = _paths(tmp_path)
|
||||
paths.runtime_dir.mkdir(parents=True)
|
||||
paths.instance_lock_file.write_text("stale", encoding="utf-8")
|
||||
paths.instance_lock_file.chmod(0o600)
|
||||
first = InstanceLock.acquire(paths.instance_lock_file, 0)
|
||||
try:
|
||||
with pytest.raises(RuntimeFailure, match="another capture instance is active"):
|
||||
InstanceLock.acquire(paths.instance_lock_file, 0)
|
||||
finally:
|
||||
first.release()
|
||||
second = InstanceLock.acquire(paths.instance_lock_file, 0)
|
||||
second.release()
|
||||
assert paths.instance_lock_file.exists()
|
||||
|
||||
|
||||
class InterruptingIntake:
|
||||
def __init__(self) -> None:
|
||||
self.shutdown_called = False
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
return []
|
||||
|
||||
def arm(self) -> None:
|
||||
return None
|
||||
|
||||
def tick(self) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None:
|
||||
return None
|
||||
|
||||
def accept_helper_receipt(
|
||||
self, payload: Mapping[str, object], *, occurrence_id: str, processing_version: str
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
def take(self) -> IntakeReceipt | None:
|
||||
return None
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
|
||||
raise AssertionError("no receipt should be opened")
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
raise AssertionError("no receipt should be acknowledged")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_called = True
|
||||
|
||||
|
||||
def test_keyboard_interrupt_reaps_intake_and_releases_runtime_lock(tmp_path: Path) -> None:
|
||||
intake = InterruptingIntake()
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
runtime.watch(poll_seconds=0.001)
|
||||
|
||||
assert intake.shutdown_called
|
||||
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
|
||||
reusable.release()
|
||||
|
||||
|
||||
def test_cancelled_watch_cleans_up_without_entering_the_tick_loop(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
cancelled.set()
|
||||
intake = InterruptingIntake()
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
assert intake.shutdown_called
|
||||
|
||||
|
||||
def test_one_shot_never_connects_outside_the_utility_data_root(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
runtime = _runtime(tmp_path)
|
||||
image = tmp_path / "image.png"
|
||||
image.write_bytes(_PNG)
|
||||
real_connect = cast(Callable[..., sqlite3.Connection], sqlite3.connect)
|
||||
connected: list[Path] = []
|
||||
|
||||
def checked_connect(database: str | Path, **kwargs: object) -> sqlite3.Connection:
|
||||
connected.append(Path(database))
|
||||
return real_connect(database, **kwargs)
|
||||
|
||||
monkeypatch.setattr(sqlite3, "connect", checked_connect)
|
||||
runtime.ingest_image(image)
|
||||
assert connected
|
||||
assert all(path.parent == runtime.paths.data_dir for path in connected)
|
||||
|
||||
|
||||
class ControlledIntake:
|
||||
def __init__(self, *, fail_tick: bool = False) -> None:
|
||||
self.armed = False
|
||||
self.reconciled = False
|
||||
self.shutdown_called = Event()
|
||||
self.queue_depth = 0
|
||||
self.helper_queue_depth = 0
|
||||
self.fail_tick = fail_tick
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
self.reconciled = True
|
||||
return []
|
||||
|
||||
def arm(self) -> None:
|
||||
self.armed = True
|
||||
|
||||
def tick(self) -> None:
|
||||
if self.fail_tick:
|
||||
raise OSError("helper fault")
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None:
|
||||
return None
|
||||
|
||||
def accept_helper_receipt(
|
||||
self, payload: Mapping[str, object], *, occurrence_id: str, processing_version: str
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
def take(self) -> IntakeReceipt | None:
|
||||
return None
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
|
||||
raise AssertionError("no receipt should be opened")
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
raise AssertionError("no receipt should be acknowledged")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_called.set()
|
||||
|
||||
|
||||
class FaultingCleanupIntake(ControlledIntake):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(fail_tick=True)
|
||||
self.cleanup_started = Event()
|
||||
self.cleanup_release = Event()
|
||||
self._shutdown_calls = 0
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._shutdown_calls += 1
|
||||
self.cleanup_started.set()
|
||||
if self._shutdown_calls == 1:
|
||||
self.cleanup_release.wait()
|
||||
self.shutdown_called.set()
|
||||
|
||||
|
||||
class MalformedQueueIntake:
|
||||
@property
|
||||
def queue_depth(self) -> object:
|
||||
raise OSError("private intake failure")
|
||||
|
||||
@property
|
||||
def helper_queue_depth(self) -> object:
|
||||
return True
|
||||
|
||||
|
||||
class EmptyRepository:
|
||||
def receive(
|
||||
self,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
raise AssertionError("no receipt should be stored")
|
||||
|
||||
|
||||
def test_control_serializes_repeated_start_stop_and_reports_bounded_queue(tmp_path: Path) -> None:
|
||||
intake = ControlledIntake()
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
control = _CaptureControl(runtime, cast(CaptureRepository, EmptyRepository()))
|
||||
outcomes: list[str] = []
|
||||
|
||||
def start() -> None:
|
||||
outcomes.append(control.start_session())
|
||||
|
||||
first, second = Thread(target=start), Thread(target=start)
|
||||
first.start()
|
||||
second.start()
|
||||
first.join()
|
||||
second.join()
|
||||
|
||||
assert sorted(outcomes) == ["already_armed", "started"]
|
||||
assert intake.reconciled and intake.armed
|
||||
assert control.status().queue_depth == 0
|
||||
assert control.status().queue_capacity == runtime.config.limits.max_queue_items
|
||||
assert control.stop_session() in {"stopped", "already_stopped"}
|
||||
assert control.stop_session() == "already_stopped"
|
||||
assert intake.shutdown_called.wait(0.1)
|
||||
|
||||
|
||||
def test_control_status_combines_and_bounds_both_intake_queues(tmp_path: Path) -> None:
|
||||
intake = ControlledIntake()
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
control = _CaptureControl(runtime, cast(CaptureRepository, EmptyRepository()))
|
||||
|
||||
intake.queue_depth, intake.helper_queue_depth = 2, 3
|
||||
control._intake = cast(IntakePort, intake)
|
||||
assert control.status().queue_depth == 5
|
||||
intake.queue_depth, intake.helper_queue_depth = 1000, -1
|
||||
assert control.status().queue_depth == runtime.config.limits.max_queue_items
|
||||
control._intake = cast(IntakePort, MalformedQueueIntake())
|
||||
assert control.status().queue_depth == 0
|
||||
|
||||
|
||||
def test_control_turns_background_intake_fault_into_bounded_disarmed_state(tmp_path: Path) -> None:
|
||||
intake = ControlledIntake(fail_tick=True)
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
control = _CaptureControl(runtime, cast(CaptureRepository, EmptyRepository()))
|
||||
|
||||
assert control.start_session() == "started"
|
||||
assert intake.shutdown_called.wait(0.2)
|
||||
assert control.stop_session() in {"stopped", "already_stopped"}
|
||||
assert control.status().armed is False
|
||||
assert control.status().queue_depth == 0
|
||||
|
||||
|
||||
def test_service_retains_its_lock_until_faulting_worker_cleanup_finishes(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
intake = FaultingCleanupIntake()
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0, shutdown_timeout_seconds=1)),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
captured: dict[str, _CaptureControl] = {}
|
||||
|
||||
def factory(**kwargs: object) -> object:
|
||||
captured["control"] = cast(_CaptureControl, kwargs["capture_control"])
|
||||
return object()
|
||||
|
||||
def runner(_: object, **__: object) -> None:
|
||||
assert captured["control"].start_session() == "started"
|
||||
assert intake.cleanup_started.wait(0.2)
|
||||
|
||||
monkeypatch.setattr(runtime_module, "create_app", factory)
|
||||
monkeypatch.setattr(uvicorn, "run", runner)
|
||||
failure: list[RuntimeFailure] = []
|
||||
|
||||
def serve() -> None:
|
||||
try:
|
||||
runtime.serve()
|
||||
except RuntimeFailure as error:
|
||||
failure.append(error)
|
||||
|
||||
service = Thread(target=serve)
|
||||
service.start()
|
||||
try:
|
||||
service.join(timeout=2)
|
||||
assert not service.is_alive()
|
||||
assert failure
|
||||
with pytest.raises(RuntimeFailure, match="another capture instance"):
|
||||
InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
|
||||
outcomes: list[str] = []
|
||||
start = Thread(target=lambda: outcomes.append(captured["control"].start_session()))
|
||||
stop = Thread(target=lambda: outcomes.append(captured["control"].stop_session()))
|
||||
start.start()
|
||||
stop.start()
|
||||
start.join()
|
||||
stop.join()
|
||||
assert sorted(outcomes) == ["conflict", "conflict"]
|
||||
finally:
|
||||
intake.cleanup_release.set()
|
||||
for _ in range(50):
|
||||
try:
|
||||
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
|
||||
except RuntimeFailure:
|
||||
Event().wait(0.01)
|
||||
else:
|
||||
reusable.release()
|
||||
break
|
||||
else:
|
||||
pytest.fail("service lock was not released after worker cleanup")
|
||||
|
||||
|
||||
def test_serve_constructs_durable_corpus_without_auto_arming_and_uses_one_fragment_token(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
created: list[ControlledIntake] = []
|
||||
token = "A" * 43
|
||||
|
||||
def factory(_: object) -> ControlledIntake:
|
||||
intake = ControlledIntake()
|
||||
created.append(intake)
|
||||
return intake
|
||||
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(
|
||||
server=ServerConfig(host="127.0.0.1", port=9876),
|
||||
lifecycle=LifecycleConfig(lock_timeout_seconds=0),
|
||||
),
|
||||
_paths(tmp_path),
|
||||
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
|
||||
intake_factory=factory,
|
||||
)
|
||||
monkeypatch.setattr(runtime_module, "new_bootstrap_token", lambda: token)
|
||||
received: dict[str, object] = {}
|
||||
|
||||
def runner(app: object, **kwargs: object) -> None:
|
||||
received.update(kwargs)
|
||||
with TestClient(cast(ASGIApp, app), base_url="http://127.0.0.1:9876") as client:
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
assert client.get("/").status_code == 200
|
||||
assert created == []
|
||||
bootstrapped = client.post(
|
||||
"/api/bootstrap",
|
||||
json={"token": token},
|
||||
headers={"Origin": "http://127.0.0.1:9876"},
|
||||
)
|
||||
csrf = bootstrapped.json()["csrf_token"]
|
||||
assert client.get("/api/session").json()["armed"] is False
|
||||
started = client.post(
|
||||
"/api/session/start",
|
||||
json={"confirmation": "START"},
|
||||
headers={"Origin": "http://127.0.0.1:9876", "X-CSRF-Token": csrf},
|
||||
)
|
||||
assert started.json()["result"] == "started"
|
||||
assert created[0].reconciled and created[0].armed
|
||||
|
||||
monkeypatch.setattr(uvicorn, "run", runner)
|
||||
runtime.serve()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert output.count(token) == 1
|
||||
assert f"http://127.0.0.1:9876/#bootstrap={token}" in output
|
||||
assert received == {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9876,
|
||||
"reload": False,
|
||||
"workers": 1,
|
||||
"proxy_headers": False,
|
||||
}
|
||||
assert created[0].shutdown_called.wait(0.1)
|
||||
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
|
||||
reusable.release()
|
||||
|
||||
|
||||
def test_service_startup_reconciliation_failure_releases_the_instance_lock(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
runtime = _runtime(tmp_path)
|
||||
|
||||
def failed_reconciliation(self: CaptureCorpus, *, limit: int = 64) -> int:
|
||||
raise OSError("private deletion recovery fault")
|
||||
|
||||
monkeypatch.setattr(CaptureCorpus, "reconcile_deletions", failed_reconciliation)
|
||||
with pytest.raises(RuntimeFailure, match="initialization failed"):
|
||||
runtime.serve()
|
||||
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
|
||||
reusable.release()
|
||||
@@ -0,0 +1,200 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.storage.blobs import (
|
||||
BlobCorruptionError,
|
||||
BlobLimitError,
|
||||
BlobLockError,
|
||||
ContentAddressedBlobStore,
|
||||
)
|
||||
|
||||
|
||||
def test_blob_is_verified_deduplicated_and_private(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
payload = b"capture-bytes"
|
||||
digest, size = store.put([payload], max_bytes=100)
|
||||
assert digest == hashlib.sha256(payload).hexdigest()
|
||||
assert store.put([payload], max_bytes=100) == (digest, size)
|
||||
assert b"".join(store.read(digest, max_bytes=100)) == payload
|
||||
assert (store._path(digest).stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_corruption_and_partial_files_are_detected_or_reconciled(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
digest, _ = store.put([b"good"], max_bytes=100)
|
||||
store._path(digest).write_bytes(b"bad")
|
||||
with pytest.raises(BlobCorruptionError):
|
||||
store.verify(digest)
|
||||
store.temporary.mkdir(parents=True, exist_ok=True)
|
||||
partial = store.temporary / ".crash.partial"
|
||||
partial.write_bytes(b"partial")
|
||||
assert store.reconcile() == [".crash.partial"]
|
||||
assert not partial.exists()
|
||||
|
||||
|
||||
def test_rejects_hash_and_generator_failures_without_partial_file(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
with pytest.raises(BlobCorruptionError):
|
||||
store.put([b"value"], max_bytes=100, expected_sha256="0" * 64)
|
||||
|
||||
def broken() -> Iterator[bytes]:
|
||||
yield b"prefix"
|
||||
raise RuntimeError("source failed")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
store.put(broken(), max_bytes=100)
|
||||
assert not list(store.temporary.glob("*.partial"))
|
||||
|
||||
|
||||
def test_root_symlinks_are_not_traversed(tmp_path: Path) -> None:
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
root = (tmp_path / "data").resolve()
|
||||
root.symlink_to(outside, target_is_directory=True)
|
||||
store = ContentAddressedBlobStore(root)
|
||||
with pytest.raises(BlobCorruptionError):
|
||||
store.put([b"x"], max_bytes=1)
|
||||
|
||||
|
||||
def test_limits_and_root_symlink_reconciliation_are_safe(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
for limit in (0, -1, True):
|
||||
with pytest.raises(BlobLimitError):
|
||||
store.put([b"x"], max_bytes=limit)
|
||||
digest, _ = store.put([b"x"], max_bytes=1)
|
||||
for limit in (0, -1, True):
|
||||
with pytest.raises(BlobLimitError):
|
||||
list(store.read(digest, max_bytes=limit))
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
partial = outside / ".outside.partial"
|
||||
partial.write_bytes(b"outside")
|
||||
store.temporary.rmdir()
|
||||
store.temporary.symlink_to(outside, target_is_directory=True)
|
||||
assert store.reconcile() == []
|
||||
assert partial.read_bytes() == b"outside"
|
||||
|
||||
|
||||
def test_corrupt_read_rejects_before_yielding(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
digest, _ = store.put([b"good"], max_bytes=10)
|
||||
store._path(digest).write_bytes(b"bad")
|
||||
with pytest.raises(BlobCorruptionError):
|
||||
list(store.read(digest, max_bytes=10))
|
||||
|
||||
|
||||
def test_oversize_and_valid_digest_mismatch_leave_no_partial(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
with pytest.raises(BlobLimitError):
|
||||
store.put([b"too-large"], max_bytes=1)
|
||||
assert not list(store.temporary.glob("*.partial"))
|
||||
with pytest.raises(BlobCorruptionError):
|
||||
store.put([b"value"], max_bytes=100, expected_sha256="0" * 64)
|
||||
assert not list(store.temporary.glob("*.partial"))
|
||||
|
||||
|
||||
def test_blob_root_symlink_purge_does_not_inspect_outside(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
store.put([b"inside"], max_bytes=100)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "outside-blob"
|
||||
outside_file.write_bytes(b"outside")
|
||||
for entry in store.blobs.iterdir():
|
||||
if entry.is_file():
|
||||
entry.unlink()
|
||||
else:
|
||||
for child in entry.iterdir():
|
||||
child.unlink()
|
||||
entry.rmdir()
|
||||
store.blobs.rmdir()
|
||||
store.blobs.symlink_to(outside, target_is_directory=True)
|
||||
assert store.purge_dry_run(set()) == []
|
||||
assert outside_file.read_bytes() == b"outside"
|
||||
|
||||
|
||||
def test_purge_dry_run_does_not_follow_symlink(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
digest, _ = store.put([b"inside"], max_bytes=100)
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_bytes(b"outside")
|
||||
unsafe = store.blobs / "ff"
|
||||
unsafe.symlink_to(tmp_path)
|
||||
assert store.purge_dry_run(set()) == [store._path(digest).relative_to(store.root)]
|
||||
assert outside.read_bytes() == b"outside"
|
||||
|
||||
|
||||
def test_operation_lock_rejects_symlink_and_unsafe_mode(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
store.root.mkdir(mode=0o700)
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_bytes(b"x")
|
||||
store.lock_path.symlink_to(outside)
|
||||
with pytest.raises(BlobLockError), store.operation_lock():
|
||||
pass
|
||||
store.lock_path.unlink()
|
||||
store.lock_path.write_bytes(b"")
|
||||
store.lock_path.chmod(0o644)
|
||||
with pytest.raises(BlobLockError), store.operation_lock():
|
||||
pass
|
||||
|
||||
|
||||
def test_operation_lock_timeout_and_release_after_exception(tmp_path: Path) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
entered = threading.Event()
|
||||
outcome: list[type[Exception]] = []
|
||||
|
||||
def contender() -> None:
|
||||
entered.set()
|
||||
try:
|
||||
with store.operation_lock(timeout_seconds=0.02):
|
||||
pass
|
||||
except BlobLockError as error:
|
||||
outcome.append(type(error))
|
||||
|
||||
with store.operation_lock():
|
||||
thread = threading.Thread(target=contender)
|
||||
thread.start()
|
||||
assert entered.wait(0.2)
|
||||
thread.join(0.5)
|
||||
assert not thread.is_alive()
|
||||
assert outcome == [BlobLockError]
|
||||
with pytest.raises(RuntimeError), store.operation_lock():
|
||||
raise RuntimeError("fault")
|
||||
with store.operation_lock(timeout_seconds=0.02):
|
||||
pass
|
||||
for invalid in (True, "1", 0, 6):
|
||||
with (
|
||||
pytest.raises(ValueError),
|
||||
store.operation_lock(
|
||||
timeout_seconds=invalid # type: ignore[arg-type]
|
||||
),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
def test_expected_size_mismatch_rejects_before_blob_read(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
|
||||
digest, _ = store.put([b"value"], max_bytes=100)
|
||||
store._path(digest).write_bytes(b"x" * 4096)
|
||||
reads = 0
|
||||
original = os.read
|
||||
|
||||
def forbidden_read(fd: int, size: int) -> bytes:
|
||||
nonlocal reads
|
||||
reads += 1
|
||||
return original(fd, size)
|
||||
|
||||
monkeypatch.setattr(os, "read", forbidden_read)
|
||||
with pytest.raises(BlobCorruptionError):
|
||||
store.verify(digest, expected_size=5)
|
||||
assert reads == 0
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.cli import EXIT_CONFIG_ERROR, EXIT_UNIMPLEMENTED, main
|
||||
from game_data_capture.logging import RedactingFormatter, configure_logging, redact_log_data
|
||||
|
||||
|
||||
def test_doctor_reports_required_diagnostics_without_openai_value(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-this-must-not-appear")
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
result = main(["doctor", "--json"])
|
||||
|
||||
report = output.getvalue()
|
||||
assert result == 0
|
||||
for name in ("niri", "wl-paste", "ffmpeg", "gpu_provider", "storage_permissions", "openai"):
|
||||
assert f'"name":"{name}"' in report
|
||||
assert "sk-this-must-not-appear" not in report
|
||||
|
||||
|
||||
def test_serve_rejects_public_binding() -> None:
|
||||
error = io.StringIO()
|
||||
|
||||
with redirect_stderr(error):
|
||||
result = main(["serve", "--host", "0.0.0.0"])
|
||||
|
||||
assert result == EXIT_CONFIG_ERROR
|
||||
assert "loopback IP literal" in error.getvalue()
|
||||
|
||||
|
||||
def test_serve_rejects_invalid_port_without_echoing_value() -> None:
|
||||
error = io.StringIO()
|
||||
|
||||
with redirect_stderr(error), pytest.raises(SystemExit) as raised:
|
||||
main(["serve", "--port", "99999-private-value"])
|
||||
|
||||
assert raised.value.code == EXIT_CONFIG_ERROR
|
||||
assert "port must be an integer from 1024 to 65535" in error.getvalue()
|
||||
assert "99999-private-value" not in error.getvalue()
|
||||
|
||||
|
||||
def test_subsystem_commands_are_explicit_stubs() -> None:
|
||||
error = io.StringIO()
|
||||
|
||||
with redirect_stderr(error):
|
||||
result = main(["watch"])
|
||||
|
||||
assert result == EXIT_UNIMPLEMENTED
|
||||
assert "requires gdc-runtime-integration" in error.getvalue()
|
||||
|
||||
|
||||
def test_unimplemented_command_validates_config_before_returning_stub_status(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_path = tmp_path / "invalid.toml"
|
||||
config_path.write_text("[server]\nport = 80\n", encoding="utf-8")
|
||||
error = io.StringIO()
|
||||
|
||||
with redirect_stderr(error):
|
||||
result = main(["watch", "--config", str(config_path)])
|
||||
|
||||
assert result == EXIT_CONFIG_ERROR
|
||||
assert "server.port" in error.getvalue()
|
||||
assert "subsystem is not implemented" not in error.getvalue()
|
||||
|
||||
|
||||
def test_logging_redacts_sensitive_fields_and_messages() -> None:
|
||||
redacted = redact_log_data(
|
||||
{
|
||||
"authorization": "Bearer secret-value",
|
||||
"image_payload": "data:image/png;base64,abc",
|
||||
"ocr_text": "unrestricted source text",
|
||||
"status": "ok",
|
||||
}
|
||||
)
|
||||
record = logging.LogRecord(
|
||||
"test", logging.INFO, __file__, 1, "Authorization: Bearer hidden sk-visible123", (), None
|
||||
)
|
||||
|
||||
assert redacted == {
|
||||
"authorization": "[REDACTED]",
|
||||
"image_payload": "[REDACTED]",
|
||||
"ocr_text": "[REDACTED]",
|
||||
"status": "ok",
|
||||
}
|
||||
assert "hidden" not in RedactingFormatter("%(message)s").format(record)
|
||||
assert "sk-visible123" not in RedactingFormatter("%(message)s").format(record)
|
||||
|
||||
|
||||
def test_configure_logging_replaces_unredacted_handlers_and_redacts_exceptions() -> None:
|
||||
package_logger = logging.getLogger("game_data_capture")
|
||||
package_handlers = package_logger.handlers[:]
|
||||
package_level = package_logger.level
|
||||
package_propagate = package_logger.propagate
|
||||
root_logger = logging.getLogger()
|
||||
root_handler = logging.NullHandler()
|
||||
root_logger.addHandler(root_handler)
|
||||
leaked_output = io.StringIO()
|
||||
existing_handler = logging.StreamHandler(leaked_output)
|
||||
package_logger.handlers = [existing_handler]
|
||||
captured_output = io.StringIO()
|
||||
|
||||
try:
|
||||
with redirect_stderr(captured_output):
|
||||
configured_logger = configure_logging()
|
||||
configure_logging()
|
||||
try:
|
||||
raise RuntimeError("Authorization: Bearer exception-secret; ocr_text=private text")
|
||||
except RuntimeError:
|
||||
configured_logger.exception("capture failed")
|
||||
|
||||
output = captured_output.getvalue()
|
||||
assert package_logger.handlers != [existing_handler]
|
||||
assert len(package_logger.handlers) == 1
|
||||
assert leaked_output.getvalue() == ""
|
||||
assert root_handler in root_logger.handlers
|
||||
assert "exception-secret" not in output
|
||||
assert "private text" not in output
|
||||
assert "[REDACTED EXCEPTION]" in output
|
||||
finally:
|
||||
for handler in package_logger.handlers[:]:
|
||||
package_logger.removeHandler(handler)
|
||||
package_logger.handlers = package_handlers
|
||||
package_logger.setLevel(package_level)
|
||||
package_logger.propagate = package_propagate
|
||||
root_logger.removeHandler(root_handler)
|
||||
|
||||
|
||||
def test_package_import_has_no_runtime_side_effects(tmp_path: Path) -> None:
|
||||
root = tmp_path / "fresh-xdg"
|
||||
source_root = Path(__file__).parents[2] / "src"
|
||||
environment = {
|
||||
"GDC_IMPORT_ROOT": str(root),
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONPATH": str(source_root),
|
||||
}
|
||||
script = """
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(os.environ["GDC_IMPORT_ROOT"])
|
||||
targets = [root / name for name in ("config", "data", "cache", "runtime")]
|
||||
events = []
|
||||
|
||||
def audit(event, arguments):
|
||||
if event in {"os.mkdir", "os.chmod", "subprocess.Popen", "socket.connect", "sqlite3.connect"}:
|
||||
events.append(event)
|
||||
if event == "open":
|
||||
mode = arguments[1] if len(arguments) > 1 else None
|
||||
flags = arguments[2] if len(arguments) > 2 else 0
|
||||
if isinstance(mode, str) and any(marker in mode for marker in ("w", "a", "x", "+")):
|
||||
events.append("open-write")
|
||||
if isinstance(flags, int) and flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC):
|
||||
events.append("open-write")
|
||||
|
||||
sys.addaudithook(audit)
|
||||
os.environ.update({
|
||||
"HOME": str(root / "home"),
|
||||
"XDG_CONFIG_HOME": str(targets[0]),
|
||||
"XDG_DATA_HOME": str(targets[1]),
|
||||
"XDG_CACHE_HOME": str(targets[2]),
|
||||
"XDG_RUNTIME_DIR": str(targets[3]),
|
||||
})
|
||||
import game_data_capture
|
||||
|
||||
if events or any(path.exists() for path in targets):
|
||||
raise SystemExit(1)
|
||||
"""
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-B", "-c", script],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from stat import S_IMODE
|
||||
|
||||
import pytest
|
||||
|
||||
from game_data_capture.config import ConfigError, ServerConfig, parse_config
|
||||
from game_data_capture.paths import (
|
||||
PathConfigurationError,
|
||||
prepare_private_directories,
|
||||
prepare_private_file,
|
||||
resolve_runtime_paths,
|
||||
)
|
||||
|
||||
|
||||
def test_config_uses_typed_defaults() -> None:
|
||||
config = parse_config({})
|
||||
|
||||
assert config.server == ServerConfig(host="127.0.0.1", port=8765)
|
||||
assert config.lifecycle.instance_name == "shd-capture"
|
||||
assert config.limits.max_queue_items == 64
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["0.0.0.0", "192.168.1.2", "localhost", "example.test"])
|
||||
def test_config_rejects_non_loopback_binding(host: str) -> None:
|
||||
with pytest.raises(ConfigError, match="loopback IP literal"):
|
||||
parse_config({"server": {"host": host}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("document", "message"),
|
||||
[
|
||||
({"server": {"port": 80}}, "server.port"),
|
||||
({"limits": {"max_queue_items": 257}}, "limits.max_queue_items"),
|
||||
({"lifecycle": {"instance_name": "SHD Capture"}}, "lifecycle.instance_name"),
|
||||
({"openai": {"api_key_env": "openai-key"}}, "openai.api_key_env"),
|
||||
],
|
||||
)
|
||||
def test_config_rejects_invalid_bounded_values(document: dict[str, object], message: str) -> None:
|
||||
with pytest.raises(ConfigError, match=message):
|
||||
parse_config(document)
|
||||
|
||||
|
||||
def test_xdg_paths_are_resolved_without_writing(tmp_path: Path) -> None:
|
||||
root = tmp_path / "xdg"
|
||||
paths = resolve_runtime_paths(
|
||||
{
|
||||
"HOME": str(tmp_path / "home"),
|
||||
"XDG_CONFIG_HOME": str(root / "config"),
|
||||
"XDG_DATA_HOME": str(root / "data"),
|
||||
"XDG_CACHE_HOME": str(root / "cache"),
|
||||
"XDG_RUNTIME_DIR": str(root / "runtime"),
|
||||
}
|
||||
)
|
||||
|
||||
assert paths.runtime_dir == root / "runtime" / "shd-capture"
|
||||
assert not root.exists()
|
||||
|
||||
|
||||
def test_xdg_paths_reject_relative_runtime_path() -> None:
|
||||
with pytest.raises(PathConfigurationError, match="XDG runtime"):
|
||||
resolve_runtime_paths({"HOME": "/tmp/home", "XDG_RUNTIME_DIR": "relative"})
|
||||
|
||||
|
||||
def test_runtime_directories_and_lock_file_are_owner_only(tmp_path: Path) -> None:
|
||||
paths = resolve_runtime_paths(
|
||||
{
|
||||
"HOME": str(tmp_path),
|
||||
"XDG_CONFIG_HOME": str(tmp_path / "config"),
|
||||
"XDG_DATA_HOME": str(tmp_path / "data"),
|
||||
"XDG_CACHE_HOME": str(tmp_path / "cache"),
|
||||
"XDG_RUNTIME_DIR": str(tmp_path / "runtime"),
|
||||
}
|
||||
)
|
||||
|
||||
prepare_private_directories(paths)
|
||||
prepare_private_file(paths.instance_lock_file)
|
||||
|
||||
assert S_IMODE(paths.runtime_dir.stat().st_mode) == 0o700
|
||||
assert S_IMODE(paths.instance_lock_file.stat().st_mode) == 0o600
|
||||
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.routing import Mount
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.web.app import (
|
||||
CaptureRecord,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
MediaDescriptor,
|
||||
MutationOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import new_bootstrap_token
|
||||
|
||||
_CAPTURE_ID = "capture-00000000000000000000000000000001"
|
||||
_LABEL: dict[str, object] = {
|
||||
"family": "weapon",
|
||||
"view": "detail",
|
||||
"gear_slot": None,
|
||||
"record_types": ["weapon", "weapon_talent"],
|
||||
"confidence": 1.0,
|
||||
"abstention_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeControl:
|
||||
armed: bool = False
|
||||
starts: int = 0
|
||||
stops: int = 0
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
return CaptureSessionStatus(self.armed, 0, 64)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
if self.armed:
|
||||
return "already_armed"
|
||||
self.starts += 1
|
||||
self.armed = True
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
if not self.armed:
|
||||
return "already_stopped"
|
||||
self.stops += 1
|
||||
self.armed = False
|
||||
return "stopped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCorpus:
|
||||
records: dict[str, CaptureRecord] = field(
|
||||
default_factory=lambda: {
|
||||
_CAPTURE_ID: CaptureRecord(_CAPTURE_ID, "stored", "2026-07-31T00:00:00Z", None)
|
||||
}
|
||||
)
|
||||
calls: list[str] = field(default_factory=list)
|
||||
media: MediaDescriptor | None = None
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
self.calls.append("list")
|
||||
return tuple(self.records.values())[:limit]
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
self.calls.append("get")
|
||||
return self.records.get(capture_id)
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
self.calls.append("media")
|
||||
if capture_id not in self.records:
|
||||
return None
|
||||
if self.media is not None:
|
||||
return self.media
|
||||
return MediaDescriptor("image/png", 4, [b"data"])
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
self.calls.append("label")
|
||||
if capture_id not in self.records:
|
||||
return "not_found"
|
||||
old = self.records[capture_id]
|
||||
self.records[capture_id] = CaptureRecord(
|
||||
old.capture_id, old.state, old.received_at, dict(classification)
|
||||
)
|
||||
return "updated"
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
self.calls.append("reject")
|
||||
return "updated" if capture_id in self.records else "not_found"
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
self.calls.append("delete")
|
||||
return "retained" if capture_id in self.records else "not_found"
|
||||
|
||||
|
||||
def _client(control: FakeControl, corpus: FakeCorpus) -> tuple[TestClient, str]:
|
||||
token = new_bootstrap_token()
|
||||
app = create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=control,
|
||||
corpus=corpus,
|
||||
bootstrap_token=token,
|
||||
)
|
||||
return TestClient(app, base_url="http://127.0.0.1:8765"), token
|
||||
|
||||
|
||||
def _bootstrap(client: TestClient, token: str) -> str:
|
||||
response = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return str(response.json()["csrf_token"])
|
||||
|
||||
|
||||
def _mutation_headers(csrf: str) -> dict[str, str]:
|
||||
return {"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf}
|
||||
|
||||
|
||||
def test_factory_and_authenticated_gets_never_arm_capture() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
|
||||
assert control.starts == 0
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
csrf = _bootstrap(client, token)
|
||||
assert client.get("/api/session").status_code == 200
|
||||
assert client.get("/api/captures").status_code == 200
|
||||
assert client.get(f"/api/captures/{_CAPTURE_ID}").status_code == 200
|
||||
assert control.starts == 0
|
||||
assert csrf
|
||||
|
||||
|
||||
def test_static_contract_is_package_local_and_does_not_accept_a_filesystem_root() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
app = create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=control,
|
||||
corpus=corpus,
|
||||
bootstrap_token=new_bootstrap_token(),
|
||||
)
|
||||
static_mount = next(
|
||||
route for route in app.routes if isinstance(route, Mount) and route.path == "/static"
|
||||
)
|
||||
|
||||
assert isinstance(static_mount.app, StaticFiles)
|
||||
assert static_mount.app.follow_symlink is False
|
||||
assert static_mount.app.directory is not None
|
||||
assert Path(static_mount.app.directory).name == "static"
|
||||
assert not Path(
|
||||
static_mount.app.get_path(
|
||||
cast(Scope, {"path": "/static/../../etc/passwd", "root_path": ""})
|
||||
)
|
||||
).is_absolute()
|
||||
|
||||
|
||||
def test_bootstrap_cookie_is_host_only_http_only_and_never_echoes_bootstrap_token() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
|
||||
response = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert token not in response.text
|
||||
cookie = response.headers["set-cookie"].lower()
|
||||
assert "httponly" in cookie
|
||||
assert "samesite=strict" in cookie
|
||||
assert "domain=" not in cookie
|
||||
assert token not in cookie
|
||||
assert response.json().keys() == {"csrf_token"}
|
||||
|
||||
|
||||
def test_start_is_explicit_csrf_protected_idempotent_and_rotates_session() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
csrf = _bootstrap(client, token)
|
||||
old_session = str(client.cookies["capture_session"])
|
||||
|
||||
response = client.post(
|
||||
"/api/session/start", headers=_mutation_headers(csrf), json={"confirmation": "START"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["result"] == "started"
|
||||
assert control.starts == 1
|
||||
next_csrf = str(response.json()["csrf_token"])
|
||||
stale = client.post(
|
||||
"/api/session/start", headers=_mutation_headers(csrf), json={"confirmation": "START"}
|
||||
)
|
||||
assert stale.status_code == 403
|
||||
assert control.starts == 1
|
||||
expired_session = client.post(
|
||||
"/api/session/start",
|
||||
headers={**_mutation_headers(next_csrf), "Cookie": f"capture_session={old_session}"},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
assert expired_session.status_code == 401
|
||||
assert control.starts == 1
|
||||
duplicate = client.post(
|
||||
"/api/session/start", headers=_mutation_headers(next_csrf), json={"confirmation": "START"}
|
||||
)
|
||||
assert duplicate.status_code == 200
|
||||
assert duplicate.json()["result"] == "already_armed"
|
||||
assert control.starts == 1
|
||||
|
||||
|
||||
def test_label_reject_and_retention_deletion_use_closed_confirmed_contracts() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
csrf = _bootstrap(client, token)
|
||||
|
||||
labeled = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/label", headers=_mutation_headers(csrf), json=_LABEL
|
||||
)
|
||||
assert labeled.status_code == 200
|
||||
csrf = str(labeled.json()["csrf_token"])
|
||||
detail = client.get(f"/api/captures/{_CAPTURE_ID}")
|
||||
assert detail.json()["capture"]["classification"] == _LABEL
|
||||
rejected = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/reject",
|
||||
headers=_mutation_headers(csrf),
|
||||
json={"confirmation": "REJECT"},
|
||||
)
|
||||
assert rejected.status_code == 200
|
||||
csrf = str(rejected.json()["csrf_token"])
|
||||
denied = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/delete",
|
||||
headers=_mutation_headers(csrf),
|
||||
json={"confirmation": "delete"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
assert "delete" not in corpus.calls
|
||||
retained = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/delete",
|
||||
headers=_mutation_headers(csrf),
|
||||
json={"confirmation": "DELETE"},
|
||||
)
|
||||
assert retained.status_code == 409
|
||||
assert retained.json() == {"error": "retained"}
|
||||
assert corpus.calls.count("label") == 1
|
||||
assert corpus.calls.count("reject") == 1
|
||||
assert corpus.calls.count("delete") == 1
|
||||
|
||||
|
||||
def test_opaque_media_route_has_private_nosniff_response_and_no_path_parameter() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
_bootstrap(client, token)
|
||||
|
||||
response = client.get(f"/api/captures/{_CAPTURE_ID}/media")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"data"
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
assert response.headers["cache-control"] == "private, no-store"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert client.get("/api/captures/../../etc/passwd/media").status_code == 404
|
||||
assert corpus.calls.count("media") == 1
|
||||
|
||||
|
||||
class RaisingChunks:
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
raise RuntimeError("raw-secret /private/capture/path")
|
||||
|
||||
|
||||
def test_malformed_media_streams_fail_before_success_headers_or_content() -> None:
|
||||
descriptors = (
|
||||
MediaDescriptor("image/png", 4, [b"abc"]),
|
||||
MediaDescriptor("image/png", 3, [b"abcd"]),
|
||||
MediaDescriptor("image/png", 3, [b""]),
|
||||
MediaDescriptor("image/png", 3, cast(Iterable[bytes], ["bad"])),
|
||||
MediaDescriptor("image/png", 3, RaisingChunks()),
|
||||
)
|
||||
for descriptor in descriptors:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus(media=descriptor)
|
||||
client, token = _client(control, corpus)
|
||||
_bootstrap(client, token)
|
||||
|
||||
response = client.get(f"/api/captures/{_CAPTURE_ID}/media")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "service_unavailable"}
|
||||
assert "raw-secret" not in response.text
|
||||
assert "/private/capture/path" not in response.text
|
||||
|
||||
|
||||
class FailingCorpus(FakeCorpus):
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
raise RuntimeError("raw-secret /private/capture/path")
|
||||
|
||||
|
||||
def test_port_exceptions_are_sanitized_without_an_uncaught_client_error() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FailingCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
_bootstrap(client, token)
|
||||
|
||||
response = client.get("/api/captures")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "service_unavailable"}
|
||||
assert "raw-secret" not in response.text
|
||||
assert "/private/capture/path" not in response.text
|
||||
|
||||
|
||||
def test_taxonomy_payload_rejects_unknown_and_ambiguous_values_before_corpus_mutation() -> None:
|
||||
control = FakeControl()
|
||||
corpus = FakeCorpus()
|
||||
client, token = _client(control, corpus)
|
||||
csrf = _bootstrap(client, token)
|
||||
unsupported = dict(_LABEL)
|
||||
unsupported["view"] = "not-a-view"
|
||||
unsupported["record_types"] = ["gear"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/captures/{_CAPTURE_ID}/label", headers=_mutation_headers(csrf), json=unsupported
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "label" not in corpus.calls
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from game_data_capture.config import ServerConfig
|
||||
from game_data_capture.web.app import (
|
||||
CaptureCorpusPort,
|
||||
CaptureRecord,
|
||||
CaptureSessionStatus,
|
||||
ControlOutcome,
|
||||
MediaDescriptor,
|
||||
MutationOutcome,
|
||||
WebConfig,
|
||||
create_app,
|
||||
)
|
||||
from game_data_capture.web.security import (
|
||||
LocalAuthority,
|
||||
LocalRequestLimitsMiddleware,
|
||||
RequestLimits,
|
||||
new_bootstrap_token,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Control:
|
||||
start_calls: int = 0
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
return CaptureSessionStatus(False, 0, 1)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
self.start_calls += 1
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
return "already_stopped"
|
||||
|
||||
|
||||
class Corpus(CaptureCorpusPort):
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
return ()
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
return None
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
return None
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
return "not_found"
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.value
|
||||
|
||||
|
||||
def _app(
|
||||
control: Control, *, now: Callable[[], float] | None = None, max_body: int = 16 * 1024
|
||||
) -> tuple[FastAPI, str]:
|
||||
token = new_bootstrap_token()
|
||||
config = WebConfig(ServerConfig(), request_limits=RequestLimits(max_body_bytes=max_body))
|
||||
if now is None:
|
||||
app = create_app(
|
||||
config=config,
|
||||
capture_control=control,
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=token,
|
||||
)
|
||||
else:
|
||||
app = create_app(
|
||||
config=config,
|
||||
capture_control=control,
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=token,
|
||||
now=now,
|
||||
)
|
||||
return app, token
|
||||
|
||||
|
||||
def _bootstrap(client: TestClient, token: str) -> str:
|
||||
result = client.post(
|
||||
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
|
||||
)
|
||||
assert result.status_code == 200
|
||||
return str(result.json()["csrf_token"])
|
||||
|
||||
|
||||
def test_host_origin_and_csrf_fail_before_start_side_effect() -> None:
|
||||
control = Control()
|
||||
app, token = _app(control)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
csrf = _bootstrap(client, token)
|
||||
|
||||
bad_host = client.post(
|
||||
"/api/session/start",
|
||||
headers={"Host": "evil.test:8765", "Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
missing_origin = client.post(
|
||||
"/api/session/start", headers={"X-CSRF-Token": csrf}, json={"confirmation": "START"}
|
||||
)
|
||||
cross_origin = client.post(
|
||||
"/api/session/start",
|
||||
headers={"Origin": "http://evil.test", "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
bad_csrf = client.post(
|
||||
"/api/session/start",
|
||||
headers={"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": "x" * 43},
|
||||
json={"confirmation": "START"},
|
||||
)
|
||||
|
||||
assert bad_host.status_code == 400
|
||||
assert missing_origin.status_code == 403
|
||||
assert cross_origin.status_code == 403
|
||||
assert bad_csrf.status_code == 403
|
||||
assert control.start_calls == 0
|
||||
|
||||
|
||||
def test_session_expiry_and_process_restart_invalidate_cookie() -> None:
|
||||
control = Control()
|
||||
clock = Clock()
|
||||
app, token = _app(control, now=clock)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
_bootstrap(client, token)
|
||||
assert client.get("/api/session").status_code == 200
|
||||
clock.value = 901
|
||||
assert client.get("/api/session").status_code == 401
|
||||
|
||||
new_app, _ = _app(control)
|
||||
restarted = TestClient(new_app, base_url="http://127.0.0.1:8765")
|
||||
restarted.cookies.update(client.cookies)
|
||||
assert restarted.get("/api/session").status_code == 401
|
||||
|
||||
|
||||
def test_duplicate_json_and_form_payloads_fail_before_start_side_effect() -> None:
|
||||
control = Control()
|
||||
app, token = _app(control)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
csrf = _bootstrap(client, token)
|
||||
headers = {"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf}
|
||||
|
||||
duplicate_json = client.post(
|
||||
"/api/session/start",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
content=b'{"confirmation":"START","confirmation":"START"}',
|
||||
)
|
||||
form = client.post(
|
||||
"/api/session/start",
|
||||
headers={**headers, "Content-Type": "application/x-www-form-urlencoded"},
|
||||
content=b"confirmation=START&confirmation=START",
|
||||
)
|
||||
|
||||
assert duplicate_json.status_code == 403
|
||||
assert form.status_code == 403
|
||||
assert control.start_calls == 0
|
||||
|
||||
|
||||
def test_ipv6_host_requires_bracketed_exact_authority() -> None:
|
||||
control = Control()
|
||||
token = new_bootstrap_token()
|
||||
app = create_app(
|
||||
config=WebConfig(ServerConfig(host="::1", port=8765)),
|
||||
capture_control=control,
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=token,
|
||||
)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
|
||||
accepted = client.get("/health", headers={"Host": "[::1]:8765"})
|
||||
rejected = client.get("/health", headers={"Host": "::1:8765"})
|
||||
|
||||
assert accepted.status_code == 200
|
||||
assert rejected.status_code == 400
|
||||
|
||||
|
||||
def test_all_success_and_error_responses_have_local_only_security_headers() -> None:
|
||||
control = Control()
|
||||
app, _ = _app(control)
|
||||
client = TestClient(app, base_url="http://127.0.0.1:8765")
|
||||
responses = (
|
||||
client.get("/health"),
|
||||
client.get("/health", headers={"Host": "evil.test:8765"}),
|
||||
client.get("/api/session"),
|
||||
)
|
||||
for response in responses:
|
||||
csp = response.headers["content-security-policy"]
|
||||
assert "default-src 'self'" in csp
|
||||
assert "script-src 'self'" in csp
|
||||
assert "style-src 'self'" in csp
|
||||
assert "font-src 'self'" in csp
|
||||
assert "img-src 'self'" in csp
|
||||
assert "media-src 'self'" in csp
|
||||
assert "connect-src 'self'" in csp
|
||||
assert "object-src 'none'" in csp
|
||||
assert "base-uri 'none'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
assert response.headers["referrer-policy"] == "no-referrer"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
|
||||
|
||||
async def _call_raw(app: ASGIApp, scope: Scope, messages: list[Message]) -> list[Message]:
|
||||
sent: list[Message] = []
|
||||
|
||||
async def receive() -> Message:
|
||||
return messages.pop(0)
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
|
||||
await app(scope, receive, send)
|
||||
return sent
|
||||
|
||||
|
||||
def _scope(path: str, headers: list[tuple[bytes, bytes]], *, method: str = "POST") -> Scope:
|
||||
return {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": method,
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": b"",
|
||||
"headers": headers,
|
||||
"client": ("127.0.0.1", 50000),
|
||||
"server": ("127.0.0.1", 8765),
|
||||
}
|
||||
|
||||
|
||||
def test_declared_streamed_duplicate_and_disconnect_requests_fail_before_start() -> None:
|
||||
control = Control()
|
||||
app, _ = _app(control, max_body=32)
|
||||
base_headers = [(b"host", b"127.0.0.1:8765"), (b"content-type", b"application/json")]
|
||||
|
||||
declared = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope("/api/session/start", [*base_headers, (b"content-length", b"33")]),
|
||||
[{"type": "http.request", "body": b"", "more_body": False}],
|
||||
)
|
||||
)
|
||||
duplicated = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope("/api/session/start", [*base_headers, (b"host", b"127.0.0.1:8765")]),
|
||||
[{"type": "http.request", "body": b"{}", "more_body": False}],
|
||||
)
|
||||
)
|
||||
streamed = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope(
|
||||
"/api/bootstrap",
|
||||
[*base_headers, (b"origin", b"http://127.0.0.1:8765")],
|
||||
),
|
||||
[
|
||||
{"type": "http.request", "body": b'{"token":"', "more_body": True},
|
||||
{"type": "http.request", "body": b"x" * 24, "more_body": False},
|
||||
],
|
||||
)
|
||||
)
|
||||
disconnected = asyncio.run(
|
||||
_call_raw(
|
||||
app,
|
||||
_scope(
|
||||
"/api/bootstrap",
|
||||
[*base_headers, (b"origin", b"http://127.0.0.1:8765")],
|
||||
),
|
||||
[{"type": "http.disconnect"}],
|
||||
)
|
||||
)
|
||||
|
||||
assert declared[0]["status"] == 413
|
||||
assert duplicated[0]["status"] == 400
|
||||
assert streamed[0]["status"] == 413
|
||||
assert disconnected[0]["status"] == 400
|
||||
assert control.start_calls == 0
|
||||
|
||||
|
||||
def test_post_completion_disconnect_is_normal_for_streaming_response_lifecycle() -> None:
|
||||
async def streaming_lifecycle(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await receive()
|
||||
assert (await receive())["type"] == "http.disconnect"
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
guarded = LocalRequestLimitsMiddleware(
|
||||
streaming_lifecycle,
|
||||
authority=LocalAuthority("127.0.0.1", 8765),
|
||||
limits=RequestLimits(),
|
||||
)
|
||||
sent = asyncio.run(
|
||||
_call_raw(
|
||||
guarded,
|
||||
_scope("/stream", [(b"host", b"127.0.0.1:8765")], method="GET"),
|
||||
[
|
||||
{"type": "http.request", "body": b"", "more_body": False},
|
||||
{"type": "http.disconnect"},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert sent[0]["status"] == 200
|
||||
Generated
+465
@@ -0,0 +1,465 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.12.*"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload-time = "2025-02-05T03:50:28.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload-time = "2025-02-05T03:50:13.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload-time = "2025-02-05T03:50:31.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload-time = "2025-02-05T03:48:48.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload-time = "2025-02-05T03:49:03.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/2b/7ca27e854d92df5e681e6527dc0f9254c9dc06c8408317893cf96c851cdd/ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2", size = 3799407, upload-time = "2025-03-14T13:52:36.539Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/40/3d0340a9e5edc77d37852c0cd98c5985a5a8081fc3befaeb2ae90aaafd2b/ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb", size = 10098158, upload-time = "2025-03-14T13:51:55.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a9/d8f5abb3b87b973b007649ac7bf63665a05b2ae2b2af39217b09f52abbbf/ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639", size = 10879071, upload-time = "2025-03-14T13:51:58.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/62/aaa198614c6211677913ec480415c5e6509586d7b796356cec73a2f8a3e6/ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88", size = 10247944, upload-time = "2025-03-14T13:52:02.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/52/59e0a9f2cf1ce5e6cbe336b6dd0144725c8ea3b97cac60688f4e7880bf13/ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2", size = 10421725, upload-time = "2025-03-14T13:52:04.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/c3/dcd71acc6dff72ce66d13f4be5bca1dbed4db678dff2f0f6f307b04e5c02/ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8", size = 9954435, upload-time = "2025-03-14T13:52:06.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9a/342d336c7c52dbd136dee97d4c7797e66c3f92df804f8f3b30da59b92e9c/ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905", size = 11492664, upload-time = "2025-03-14T13:52:08.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/35/6e7defd2d7ca95cc385ac1bd9f7f2e4a61b9cc35d60a263aebc8e590c462/ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329", size = 12207856, upload-time = "2025-03-14T13:52:11.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/78/da669c8731bacf40001c880ada6d31bcfb81f89cc996230c3b80d319993e/ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844", size = 11645156, upload-time = "2025-03-14T13:52:13.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/47/e27d17d83530a208f4a9ab2e94f758574a04c51e492aa58f91a3ed7cbbcb/ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e", size = 13884167, upload-time = "2025-03-14T13:52:15.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/5e/42ffbb0a5d4b07bbc642b7d58357b4e19a0f4774275ca6ca7d1f7b5452cd/ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db", size = 11348311, upload-time = "2025-03-14T13:52:18.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/51/dc3ce0c5ce1a586727a3444a32f98b83ba99599bb1ebca29d9302886e87f/ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445", size = 10305039, upload-time = "2025-03-14T13:52:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e0/475f0c2f26280f46f2d6d1df1ba96b3399e0234cf368cc4c88e6ad10dcd9/ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7", size = 9937939, upload-time = "2025-03-14T13:52:22.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/d3/3e61b7fd3e9cdd1e5b8c7ac188bec12975c824e51c5cd3d64caf81b0331e/ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7", size = 10923259, upload-time = "2025-03-14T13:52:24.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/32/cd74149ebb40b62ddd14bd2d1842149aeb7f74191fb0f49bd45c76909ff2/ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6", size = 11406212, upload-time = "2025-03-14T13:52:27.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ef/033022a6b104be32e899b00de704d7c6d1723a54d4c9e09d147368f14b62/ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2", size = 10310905, upload-time = "2025-03-14T13:52:30.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/8a/163f2e78c37757d035bd56cd60c8d96312904ca4a6deeab8442d7b3cbf89/ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21", size = 11411730, upload-time = "2025-03-14T13:52:32.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f7/096f6efabe69b49d7ca61052fc70289c05d8d35735c137ef5ba5ef423662/ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657", size = 10538956, upload-time = "2025-03-14T13:52:34.491Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shd-game-data-capture"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-jsonschema" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = "==0.115.12" },
|
||||
{ name = "jinja2", specifier = "==3.1.6" },
|
||||
{ name = "python-multipart", specifier = "==0.0.20" },
|
||||
{ name = "uvicorn", specifier = "==0.34.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "jsonschema", specifier = "==4.25.1" },
|
||||
{ name = "mypy", specifier = "==1.15.0" },
|
||||
{ name = "pytest", specifier = "==8.3.5" },
|
||||
{ name = "ruff", specifier = "==0.11.0" },
|
||||
{ name = "types-jsonschema", specifier = "==4.25.1.20251009" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-jsonschema"
|
||||
version = "4.25.1.20251009"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/da/5b901088da5f710690b422137e8ae74197fb1ca471e4aa84dd3ef0d6e295/types_jsonschema-4.25.1.20251009.tar.gz", hash = "sha256:75d0f5c5dd18dc23b664437a0c1a625743e8d2e665ceaf3aecb29841f3a5f97f", size = 15661, upload-time = "2025-10-09T02:54:36.963Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/6a/e5146754c0dfc272f176db9c245bc43cc19030262d891a5a85d472797e60/types_jsonschema-4.25.1.20251009-py3-none-any.whl", hash = "sha256:f30b329037b78e7a60146b1146feb0b6fb0b71628637584409bada83968dad3e", size = 15925, upload-time = "2025-10-09T02:54:35.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315, upload-time = "2024-12-15T13:33:27.467Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user