okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnimages: refuse a PNG or JPEG cut short where it is attached, so it never reaches history79b55b3

main
5.8 KiB185 linesraw
1//// Kernel image wire decoding and old journal records must survive format
2//// changes; synthetic corrupted and legacy records cannot arise in E2E.
3
4import albedo/daemon/images
5import albedo/daemon/store
6import albedo/harness/extensions/python/cells
7import albedo/harness/extensions/python/kernel as python
8import albedo/harness/extensions/python/migrations/cell_images
9import albedo/openai_api/types
10import gleam/dynamic
11import gleam/dynamic/decode
12import gleam/erlang/atom
13import gleam/json
14import gleam/list
15import gleam/option.{None, Some}
16import gleeunit/should
17import sqlight
18
19/// A PNG signature and IHDR header for a 2x3 image: enough for the header
20/// check a stored record gets on load.
21const png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAAD"
22
23pub fn the_kernel_decoder_reads_images_at_the_boundary_test() -> Result(
24  python.Outcome,
25  json.DecodeError,
26) {
27  let frame = fn(images) {
28    json.object([
29      #("id", json.string("cell")),
30      #("status", json.string("ok")),
31      #("output", json.string("")),
32      #("value", json.string("")),
33      #("truncated", json.bool(False)),
34      ..images
35    ])
36    |> json.to_string
37  }
38  // A kernel from before images sends no field at all.
39  let assert Ok(plain) = json.parse(frame([]), python.outcome_decoder())
40  #(plain.images, plain.image_errors) |> should.equal(#([], []))
41  // Whole PNGs, IEND included: the daemon refuses an image cut short.
42  let small = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAAElFTkSuQmCC"
43  let wide = "iVBORw0KGgoAAAANSUhEUgAAAAUAAAADCAIAAADUVFKvAAAAAElFTkSuQmCC"
44  let images =
45    json.array([small, "bm90IGFuIGltYWdl", wide, "not base64!"], json.string)
46  let assert Ok(mixed) =
47    json.parse(frame([#("images", images)]), python.outcome_decoder())
48  // Both lists keep the order the cell showed its images in.
49  mixed.images
50  |> list.map(fn(image) {
51    let #(_, width, height, _) = types.image_meta(image)
52    #(width, height)
53  })
54  |> should.equal([#(2, 3), #(5, 3)])
55  mixed.image_errors
56  |> should.equal([
57    "image 2: not a PNG, JPEG, or WebP albedo can read",
58    "image 4: not a PNG, JPEG, or WebP albedo can read",
59  ])
60  // A wrong shape is a broken kernel, not an unreadable image.
61  let assert Error(_) =
62    json.parse(
63      frame([#("images", json.array([1], json.int))]),
64      python.outcome_decoder(),
65    )
66}
67
68/// The record shapes albedo stored before tool results carried images.
69type LegacyInput {
70  ToolOutput(String, String)
71}
72
73type LegacyOutcome {
74  Outcome(String, python.Status, String, String, Bool)
75}
76
77pub fn records_saved_before_images_still_load_test() -> Nil {
78  let assert Ok(input) =
79    unpack_input(term_to_binary(#(1, ToolOutput("call", "text"))))
80  input |> should.equal(types.ToolOutput("call", "text", []))
81  let saved = Outcome("cell", python.Succeeded, "out", "'v'", False)
82  let assert Ok(Ok(outcome)) = unpack_cell(term_to_binary(#(1, Ok(saved))))
83  outcome
84  |> should.equal(python.Outcome(
85    "cell",
86    python.Succeeded,
87    "out",
88    "'v'",
89    False,
90    [],
91    [],
92    None,
93  ))
94}
95
96pub fn a_journaled_image_is_checked_again_on_load_test() -> Nil {
97  // Journaled before cells were timed, so its duration is unknown.
98  let assert Ok(Ok(outcome)) = unpack_cell(journaled_png(2))
99  outcome.duration |> should.equal(None)
100  // The payload is 2 pixels wide; a record claiming 9 was altered.
101  unpack_cell(journaled_png(9)) |> should.equal(Error(Nil))
102}
103
104/// A journaled cell outcome carrying one PNG, built term by term.
105fn journaled_png(width: Int) -> BitArray {
106  let atom = fn(name) { atom.to_dynamic(atom.create(name)) }
107  let image =
108    dynamic.array([
109      atom("image"),
110      dynamic.string("image/png"),
111      dynamic.string(png),
112      dynamic.int(width),
113      dynamic.int(3),
114      dynamic.int(24),
115    ])
116  let outcome =
117    dynamic.array([
118      atom("outcome"),
119      dynamic.string("cell"),
120      atom("succeeded"),
121      dynamic.string(""),
122      dynamic.string(""),
123      dynamic.bool(False),
124      dynamic.list([image]),
125      dynamic.list([]),
126    ])
127  term_to_binary(#(1, Ok(outcome)))
128}
129
130/// Legacy cell records are not producible by the current daemon wire format.
131pub fn old_cell_images_migrate_without_losing_their_readers_test() -> Nil {
132  let path = temporary_database()
133  let assert Ok(ledger) = store.start(path, images.schema)
134  cell_images.run(ledger, path <> ".missing-backup") |> should.equal(Ok(0))
135  let assert Ok(_) = cells.initialise(ledger)
136  let assert Ok(_) =
137    store.write(
138      ledger,
139      "INSERT INTO cells(id,session,source,status,payload) VALUES('cell','s','show_image(...)','finished',?)",
140      [sqlight.blob(journaled_png(2))],
141    )
142  let backup = path <> ".backup"
143  cell_images.run(ledger, backup) |> should.equal(Ok(1))
144  cell_images.run(ledger, backup) |> should.equal(Ok(0))
145  let assert Ok(cell) = cells.get(ledger, "cell")
146  let assert Some(Ok(outcome)) = cell.outcome
147  let assert [stored] = outcome.images
148  let assert types.StoredData(read: read, ..) = types.image_data(stored)
149  read() |> should.equal(Ok(png))
150  let assert Ok([kind]) =
151    store.read(
152      ledger,
153      "SELECT typeof(data) FROM images",
154      [],
155      decode.field(0, decode.string, decode.success),
156    )
157  kind |> should.equal("blob")
158  store.close(ledger)
159  cleanup(path)
160  cleanup(backup)
161}
162
163@external(erlang, "albedo_runtime_test_support", "temporary_database")
164fn temporary_database() -> String
165
166@external(erlang, "albedo_runtime_test_support", "cleanup")
167fn cleanup(path: String) -> Nil
168
169@external(erlang, "erlang", "term_to_binary")
170fn term_to_binary(value: a) -> BitArray
171
172fn unpack_input(bytes: BitArray) -> Result(types.Input, Nil) {
173  unpack_with(bytes, fn(_) { Error(Nil) })
174}
175
176@external(erlang, "albedo_conversation", "unpack")
177fn unpack_with(
178  bytes: BitArray,
179  read: fn(String) -> Result(String, Nil),
180) -> Result(types.Input, Nil)
181
182@external(erlang, "albedo_native", "unpack_cell")
183fn unpack_cell(
184  bytes: BitArray,
185) -> Result(Result(python.Outcome, python.Error), Nil)