mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 06:55:29 +02:00
ebf44f20d3
A custom cart pairs an identity (title, shell colour, label art) with a base game, a list of mods pinned to exact builds with their option values frozen, a load order, and a seal. It ships no code of its own: every mod it names is a separately published mod, which is what keeps a cart auditable before it runs and reproducible after an author's repo disappears. Format and storage: - src/carts/CartManifest.lua parses and validates cart.json, canonicalises it for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only serialised table read through SaveSerializer, so an imported cart can never execute code. Canonical strings are length-prefixed because option keys and values are author-controlled and could otherwise forge a record boundary and collide two different carts onto one hash. - Pins name a public source: a GitHub release with its sha256, a GameBanana file id with its md5, or "local" for a capture that only exists on this install. A local pin is unpublishable by construction, which is what makes "build it here, publish later" possible without inventing a hash. - Label art rides alongside the manifest rather than inside its identity, so re-arting a cart does not tell every player their run is out of date. src/core/Base64.lua decodes it; strict, with no whitespace tolerance. Saves: - Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/), so a cart's file never sits beside a vanilla one and uninstalling a cart never orphans a save. Every save records the cart build it was made under. The seal: - A sealed cart loads its pinned list, in its order, with its options, and nothing else. A pinned mod with no frozen options gets an empty bucket so unfrozen keys fall to schema defaults, identical for everyone; otherwise two players on one cart quietly run different games. - A sealed cart refuses to load when a pin is missing or installed at another version. Playing a subset of the cart is the exact dishonesty the seal exists to prevent, so the refusal loads nothing at all. - Breaking the seal is permanent, marked per save slot, and downgrades that playthrough to open behaviour. It cannot be cleared through any public API. Launcher: - A game's page carries a Custom Carts control and a picker; choosing a cart turns the page into that cart's page, with its own cartridge, title and save slots. The rail of five games never grows and a cart id never reaches imp.tab or imp.panelVersion. - Loader.planCart runs before boot so a refusal is visible on the page instead of being discovered as an error after launch. - Save as cart captures the enabled mods for a game and names, before the player confirms, every mod that could only be pinned to this install and whether the result can be shared at all. Authoring: - tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a release workflow. Its writer is byte-identical to the engine's serialiser.
414 lines
21 KiB
Lua
414 lines
21 KiB
Lua
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
|
|
local ok, bit32 = pcall(require, "bit32")
|
|
if ok then _G.bit32 = bit32 end
|
|
end
|
|
|
|
local T = require("tests.harness")
|
|
local Base64 = require("src.core.Base64")
|
|
local CartManifest = require("src.carts.CartManifest")
|
|
local SaveSerializer = require("src.core.SaveSerializer")
|
|
|
|
local SHA = ("a1b2c3d4"):rep(8)
|
|
local MD5 = ("0123456789abcdef"):rep(2)
|
|
|
|
local function baseCart()
|
|
return {
|
|
id = "kanto_plus",
|
|
title = " Kanto Plus ",
|
|
version = "1.2.0",
|
|
author = "Ren",
|
|
repo = "ren/kanto-plus",
|
|
summary = "A sealed set of five",
|
|
shell = "#3FA9F5",
|
|
label = "./art/label.png",
|
|
base = "red",
|
|
engine = ">=1.4.0",
|
|
mods = {
|
|
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
|
|
version = "0.4.1", sha256 = SHA,
|
|
options = { sweetness = 3, flavour = "grape", fizzy = true } },
|
|
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
|
|
md5 = MD5 },
|
|
},
|
|
}
|
|
end
|
|
|
|
local function rejects(mutate, fragment, what)
|
|
local tbl = baseCart()
|
|
mutate(tbl)
|
|
local cart, err = CartManifest.parse(tbl)
|
|
T.eq(cart, nil, what .. " is rejected")
|
|
T.check(type(err) == "string" and err:find(fragment, 1, true) ~= nil,
|
|
("%s says why (got %s)"):format(what, tostring(err)))
|
|
end
|
|
|
|
local raw = baseCart()
|
|
local cart, err = CartManifest.parse(raw)
|
|
T.check(cart ~= nil, "a good manifest parses: " .. tostring(err))
|
|
T.eq(cart.title, "Kanto Plus", "title is trimmed")
|
|
T.eq(cart.shell, "#3fa9f5", "shell normalises to lowercase")
|
|
T.eq(cart.label, "art/label.png", "label normalises through SafePath")
|
|
T.eq(cart.seal, "sealed", "seal defaults to sealed")
|
|
T.eq(cart.base, "red", "base survives")
|
|
T.eq(cart.engine, ">=1.4.0", "engine range is kept unevaluated")
|
|
T.eq(#cart.mods, 2, "both pins survive")
|
|
T.eq(cart.mods[1].sha256, SHA, "the github pin keeps its sha256")
|
|
T.eq(cart.mods[1].repo, "ren/rare-soda", "the github pin keeps its repo")
|
|
T.eq(cart.mods[1].version, "0.4.1", "the github pin keeps its version")
|
|
T.eq(cart.mods[1].options.flavour, "grape", "frozen option values survive")
|
|
T.eq(cart.mods[2].source, "gamebanana", "the gamebanana pin keeps its source")
|
|
T.eq(cart.mods[2].mod, 4821, "the gamebanana pin keeps its mod id")
|
|
T.eq(cart.mods[2].file, 99123, "the gamebanana pin keeps its file id")
|
|
T.eq(cart.mods[2].md5, MD5, "the gamebanana pin keeps its md5")
|
|
T.eq(cart.mods[2].sha256, nil, "a gamebanana pin carries no sha256")
|
|
T.eq(cart.load_order[1], "rare_soda", "load_order defaults to the mods order")
|
|
T.eq(cart.load_order[2], "hard_mode", "load_order defaults to the mods order")
|
|
|
|
T.eq(raw.title, " Kanto Plus ", "parse does not trim the input in place")
|
|
T.eq(raw.shell, "#3FA9F5", "parse does not recolour the input in place")
|
|
T.eq(raw.load_order, nil, "parse does not add load_order to the input")
|
|
T.neq(cart.mods, raw.mods, "the parsed mods array is a fresh table")
|
|
T.neq(cart.mods[1].options, raw.mods[1].options, "options are copied")
|
|
|
|
local again = CartManifest.parse(baseCart())
|
|
T.eq(CartManifest.canonical(again), CartManifest.canonical(cart),
|
|
"two independent parses encode identically")
|
|
T.eq(CartManifest.hash(again), CartManifest.hash(cart),
|
|
"two independent parses hash identically")
|
|
T.eq(#CartManifest.hash(cart), 32, "the cart hash is an MD5 hex digest")
|
|
|
|
local bumped = baseCart()
|
|
bumped.mods[1].version = "0.4.2"
|
|
T.neq(CartManifest.hash(CartManifest.parse(bumped)), CartManifest.hash(cart),
|
|
"a bumped mod version moves the cart hash")
|
|
|
|
local retuned = baseCart()
|
|
retuned.mods[1].options.sweetness = 4
|
|
T.neq(CartManifest.hash(CartManifest.parse(retuned)), CartManifest.hash(cart),
|
|
"a changed option value moves the cart hash")
|
|
|
|
local reordered = baseCart()
|
|
reordered.load_order = { "hard_mode", "rare_soda" }
|
|
local reorderedCart = CartManifest.parse(reordered)
|
|
T.eq(reorderedCart.load_order[1], "hard_mode", "an explicit load_order is kept")
|
|
T.neq(CartManifest.hash(reorderedCart), CartManifest.hash(cart),
|
|
"a different load order moves the cart hash")
|
|
|
|
local encoded = CartManifest.encode(cart)
|
|
local decoded, decodeErr = CartManifest.decode(encoded)
|
|
T.check(decoded ~= nil, "an encoded cart decodes: " .. tostring(decodeErr))
|
|
T.same(decoded, cart, "the round trip is lossless")
|
|
T.eq(CartManifest.hash(decoded), CartManifest.hash(cart),
|
|
"the round trip keeps the cart hash")
|
|
|
|
T.eq(CartManifest.decode(nil), nil, "decode refuses a non-string")
|
|
T.eq(CartManifest.decode(""), nil, "decode refuses an empty file")
|
|
T.eq(CartManifest.decode("return { }"), nil, "decode refuses an untagged file")
|
|
T.eq(CartManifest.decode('return { format = "g1rmodlist" }'), nil,
|
|
"decode refuses another format's file")
|
|
T.eq(CartManifest.decode(
|
|
('return { format = "%s", formatVersion = 99, cart = {} }')
|
|
:format(CartManifest.FORMAT)), nil, "decode refuses an unknown schema")
|
|
T.eq(CartManifest.decode('return os.exit(1)'), nil,
|
|
"decode refuses a file that tries to call out")
|
|
T.eq(CartManifest.decode(
|
|
('return { format = "%s", formatVersion = 1, cart = { id = "x" } }')
|
|
:format(CartManifest.FORMAT)), nil, "decode validates the cart it carries")
|
|
|
|
rejects(function(c) c.id = nil end, "cart id", "a missing id")
|
|
rejects(function(c) c.id = "kanto plus" end, "cart id", "an id with a space")
|
|
rejects(function(c) c.id = ("k"):rep(65) end, "cart id", "a 65 character id")
|
|
rejects(function(c) c.title = nil end, "cart title", "a missing title")
|
|
rejects(function(c) c.title = " " end, "cart title", "a blank title")
|
|
rejects(function(c) c.title = ("T"):rep(49) end, "cart title", "a 49 character title")
|
|
rejects(function(c) c.version = nil end, "cart version", "a missing version")
|
|
rejects(function(c) c.version = "one" end, "cart version", "a non-semver version")
|
|
rejects(function(c) c.author = nil end, "cart author", "a missing author")
|
|
rejects(function(c) c.author = "" end, "cart author", "an empty author")
|
|
rejects(function(c) c.author = ("A"):rep(65) end, "cart author", "a 65 character author")
|
|
rejects(function(c) c.repo = "ren" end, "cart repo", "a repo with no owner")
|
|
rejects(function(c) c.repo = "ren/kanto/plus" end, "cart repo", "a three part repo")
|
|
rejects(function(c) c.summary = ("s"):rep(121) end, "cart summary", "a 121 character summary")
|
|
rejects(function(c) c.shell = nil end, "cart shell", "a missing shell colour")
|
|
rejects(function(c) c.shell = "3FA9F5" end, "cart shell", "a shell colour with no hash")
|
|
rejects(function(c) c.shell = "#3FA9F" end, "cart shell", "a five digit shell colour")
|
|
rejects(function(c) c.shell = "#gggggg" end, "cart shell", "a non-hex shell colour")
|
|
rejects(function(c) c.label = "../../etc/passwd" end, "cart label", "a climbing label path")
|
|
rejects(function(c) c.label = "/etc/passwd" end, "cart label", "an absolute label path")
|
|
rejects(function(c) c.label = ("a"):rep(129) end, "cart label", "a 129 character label path")
|
|
rejects(function(c) c.base = nil end, "cart base", "a missing base game")
|
|
rejects(function(c) c.base = "crystal" end, "cart base", "an unknown base game")
|
|
rejects(function(c) c.engine = "" end, "cart engine", "an empty engine range")
|
|
rejects(function(c) c.engine = 3 end, "cart engine", "a numeric engine range")
|
|
rejects(function(c) c.seal = "welded" end, "cart seal", "an unknown seal")
|
|
rejects(function(c) c.mods = nil end, "cart mods", "a cart with no mods array")
|
|
rejects(function(c) c.mods = {} end, "cart must pin", "a cart that pins nothing")
|
|
rejects(function(c)
|
|
for i = 1, 65 do
|
|
c.mods[i] = { id = "mod" .. i, source = "gamebanana", mod = i, file = i, md5 = MD5 }
|
|
end
|
|
end, "cart must pin", "a cart that pins 65 mods")
|
|
|
|
rejects(function(c) c.mods[1] = "rare_soda" end, "must be a table", "a string mod entry")
|
|
rejects(function(c) c.mods[1].id = nil end, "id must be", "a pin with no id")
|
|
rejects(function(c) c.mods[1].id = "rare soda" end, "id must be", "a pin id with a space")
|
|
rejects(function(c) c.mods[2].id = "rare_soda" end, "pinned twice", "the same mod pinned twice")
|
|
rejects(function(c) c.mods[1].source = nil end, "source must be", "a pin with no source")
|
|
rejects(function(c) c.mods[1].source = "dropbox" end, "source must be", "a pin from an unknown source")
|
|
rejects(function(c) c.mods[1].repo = nil end, "repo must be", "a github pin with no repo")
|
|
rejects(function(c) c.mods[1].version = "latest" end, "version must be", "a github pin with no semver")
|
|
rejects(function(c) c.mods[1].sha256 = nil end, "sha256", "a github pin with no sha256")
|
|
rejects(function(c) c.mods[1].sha256 = SHA:upper() end, "sha256", "an uppercase sha256")
|
|
rejects(function(c) c.mods[1].sha256 = SHA:sub(1, 63) end, "sha256", "a short sha256")
|
|
rejects(function(c) c.mods[2].mod = nil end, "mod must be", "a gamebanana pin with no mod id")
|
|
rejects(function(c) c.mods[2].mod = 0 end, "mod must be", "a gamebanana mod id of zero")
|
|
rejects(function(c) c.mods[2].mod = 12.5 end, "mod must be", "a fractional gamebanana mod id")
|
|
rejects(function(c) c.mods[2].file = nil end, "file must be", "a gamebanana pin with no file id")
|
|
rejects(function(c) c.mods[2].file = -3 end, "file must be", "a negative gamebanana file id")
|
|
rejects(function(c) c.mods[2].md5 = nil end, "md5", "a gamebanana pin with no md5")
|
|
rejects(function(c) c.mods[2].md5 = MD5:upper() end, "md5", "an uppercase md5")
|
|
rejects(function(c) c.mods[2].md5 = MD5 .. "00" end, "md5", "an overlong md5")
|
|
|
|
rejects(function(c) c.mods[1].options = "grape" end, "options must be a table",
|
|
"a non-table options block")
|
|
rejects(function(c) c.mods[1].options = { [("k"):rep(65)] = 1 } end,
|
|
"option keys", "a 65 character option key")
|
|
rejects(function(c) c.mods[1].options = { [1] = "grape" } end,
|
|
"option keys", "a numeric option key")
|
|
rejects(function(c) c.mods[1].options.nested = { 1, 2 } end,
|
|
"must be a string, number or boolean", "a table option value")
|
|
rejects(function(c) c.mods[1].options.flavour = ("g"):rep(257) end,
|
|
"characters or fewer", "a 257 character option value")
|
|
rejects(function(c)
|
|
local options = {}
|
|
for i = 1, 65 do options["opt" .. i] = i end
|
|
c.mods[1].options = options
|
|
end, "more than 64 options", "a pin with 65 options")
|
|
|
|
rejects(function(c) c.load_order = "rare_soda" end, "load_order must be an array",
|
|
"a string load_order")
|
|
rejects(function(c) c.load_order = { "rare_soda" } end, "exactly once",
|
|
"a load_order that drops a mod")
|
|
rejects(function(c) c.load_order = { "rare_soda", "hard_mode", "hard_mode" } end,
|
|
"exactly once", "a load_order longer than the mods array")
|
|
rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
|
|
"a load_order that repeats a mod")
|
|
rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end,
|
|
"does not pin", "a load_order naming an unpinned mod")
|
|
|
|
T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table")
|
|
|
|
local open = baseCart()
|
|
open.seal = "open"
|
|
open.repo = nil
|
|
open.summary = nil
|
|
open.label = nil
|
|
open.engine = nil
|
|
local openCart = CartManifest.parse(open)
|
|
T.check(openCart ~= nil, "an open cart with no optional fields parses")
|
|
T.eq(openCart.seal, "open", "an open seal survives")
|
|
T.eq(openCart.label, nil, "an absent label stays absent")
|
|
T.same(CartManifest.decode(CartManifest.encode(openCart)), openCart,
|
|
"an open cart round trips")
|
|
T.neq(CartManifest.hash(openCart), CartManifest.hash(cart),
|
|
"the seal is part of the cart hash")
|
|
|
|
T.eq(CartManifest.publishable(cart), true,
|
|
"a cart pinned entirely to github and gamebanana is publishable")
|
|
T.eq(CartManifest.publishable(nil), false, "publishable refuses a non-cart")
|
|
T.eq(CartManifest.publishable({}), false, "publishable refuses an unparsed cart")
|
|
|
|
local localised = baseCart()
|
|
localised.mods[1] = { id = "rare_soda", source = "local", version = "0.4.1",
|
|
repo = "ren/rare-soda", sha256 = SHA,
|
|
options = { flavour = "grape" } }
|
|
local localCart, localErr = CartManifest.parse(localised)
|
|
T.check(localCart ~= nil, "a local pin parses: " .. tostring(localErr))
|
|
T.eq(localCart.mods[1].source, "local", "the local pin keeps its source")
|
|
T.eq(localCart.mods[1].version, "0.4.1", "the local pin keeps its version")
|
|
T.eq(localCart.mods[1].repo, nil, "a local pin carries no repo")
|
|
T.eq(localCart.mods[1].sha256, nil, "a local pin carries no sha256")
|
|
T.eq(localCart.mods[1].md5, nil, "a local pin carries no md5")
|
|
T.eq(localCart.mods[1].options.flavour, "grape", "a local pin still freezes options")
|
|
T.eq(localCart.mods[2].source, "gamebanana", "the sibling pin is untouched")
|
|
|
|
T.eq(CartManifest.canonical(CartManifest.parse(localised)),
|
|
CartManifest.canonical(localCart), "two parses of a local pin encode identically")
|
|
T.eq(CartManifest.hash(CartManifest.parse(localised)), CartManifest.hash(localCart),
|
|
"two parses of a local pin hash identically")
|
|
T.neq(CartManifest.hash(localCart), CartManifest.hash(cart),
|
|
"a local pin hashes differently from the github pin it replaced")
|
|
|
|
local localBump = baseCart()
|
|
localBump.mods[1] = { id = "rare_soda", source = "local", version = "0.4.2",
|
|
options = { flavour = "grape" } }
|
|
T.neq(CartManifest.hash(CartManifest.parse(localBump)), CartManifest.hash(localCart),
|
|
"a bumped local pin version moves the cart hash")
|
|
|
|
T.same(CartManifest.decode(CartManifest.encode(localCart)), localCart,
|
|
"a cart holding a local pin round trips")
|
|
|
|
local publishableLocal, localWhy = CartManifest.publishable(localCart)
|
|
T.eq(publishableLocal, false, "a cart holding a local pin is not publishable")
|
|
T.check(type(localWhy) == "string" and localWhy:find("rare_soda", 1, true) ~= nil,
|
|
"the reason names the local pin (got " .. tostring(localWhy) .. ")")
|
|
T.check(localWhy:find("hard_mode", 1, true) == nil,
|
|
"the reason leaves the publishable pins out")
|
|
|
|
local allLocal = baseCart()
|
|
allLocal.mods = {
|
|
{ id = "rare_soda", source = "local", version = "0.4.1" },
|
|
{ id = "hard_mode", source = "local", version = "2.0.0" },
|
|
}
|
|
local _, allWhy = CartManifest.publishable(CartManifest.parse(allLocal))
|
|
T.check(allWhy:find("hard_mode", 1, true) ~= nil and allWhy:find("rare_soda", 1, true) ~= nil,
|
|
"the reason names every local pin (got " .. tostring(allWhy) .. ")")
|
|
|
|
rejects(function(c) c.mods[1] = { id = "rare_soda", source = "local" } end,
|
|
"version must be", "a local pin with no version")
|
|
rejects(function(c)
|
|
c.mods[1] = { id = "rare_soda", source = "local", version = "latest" }
|
|
end, "version must be", "a local pin with a non-semver version")
|
|
rejects(function(c) c.mods[1].source = "localhost" end, "source must be",
|
|
"a source that merely starts like local")
|
|
|
|
local VECTORS = {
|
|
{ "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" },
|
|
{ "foob", "Zm9vYg==" }, { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" },
|
|
{ "\0\255\0", "AP8A" }, { "\255\255\255\255", "/////w==" },
|
|
}
|
|
for _, row in ipairs(VECTORS) do
|
|
T.eq(Base64.encode(row[1]), row[2],
|
|
("base64 encodes %q as %s"):format(row[1], row[2]))
|
|
T.eq(Base64.decode(row[2]), row[1],
|
|
("base64 decodes %s back"):format(row[2] == "" and "an empty string" or row[2]))
|
|
end
|
|
|
|
local seed = 7
|
|
local function nextByte()
|
|
seed = (seed * 75 + 74) % 65537
|
|
return seed % 256
|
|
end
|
|
for n = 0, 24 do
|
|
local chunk = {}
|
|
for i = 1, n do chunk[i] = string.char(nextByte()) end
|
|
local raw = table.concat(chunk)
|
|
local text = Base64.encode(raw)
|
|
T.eq(#text % 4, 0, ("base64 pads %d bytes to a multiple of four"):format(n))
|
|
T.eq(Base64.decode(text), raw, ("base64 round trips %d random bytes"):format(n))
|
|
end
|
|
|
|
T.eq(Base64.encode(nil), nil, "base64 encode refuses a non-string")
|
|
T.eq(Base64.decode(nil), nil, "base64 decode refuses a non-string")
|
|
T.eq(Base64.decode("TWF"), nil, "base64 refuses a length that is not a multiple of four")
|
|
T.eq(Base64.decode("TW*u"), nil, "base64 refuses a character outside the alphabet")
|
|
T.eq(Base64.decode("TWFu\n\n\n\n"), nil, "base64 refuses embedded whitespace")
|
|
T.eq(Base64.decode("TW=u"), nil, "base64 refuses padding inside a group")
|
|
T.eq(Base64.decode("=WFu"), nil, "base64 refuses a leading pad character")
|
|
T.eq(Base64.decode("TWFu===="), nil, "base64 refuses a group that is all padding")
|
|
T.eq(Base64.decode("TR=="), nil, "base64 refuses one-byte padding that carries data bits")
|
|
T.eq(Base64.decode("Zm9vYmF="), nil, "base64 refuses two-byte padding that carries data bits")
|
|
T.eq(Base64.decode("TWFu"), "Man", "base64 decodes a known vector")
|
|
|
|
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRtiny label art"
|
|
local ART_DATA = Base64.encode(PNG)
|
|
|
|
local NONE = {}
|
|
|
|
local function artTable(over)
|
|
local art = { name = "label.png", encoding = "base64", bytes = #PNG,
|
|
data = ART_DATA }
|
|
for key, value in pairs(over or {}) do
|
|
if value == NONE then art[key] = nil else art[key] = value end
|
|
end
|
|
return art
|
|
end
|
|
|
|
local function bundle(body, art)
|
|
return SaveSerializer.encode({ format = CartManifest.FORMAT,
|
|
formatVersion = CartManifest.SCHEMA, cart = body, labelArt = art })
|
|
end
|
|
|
|
local arted = CartManifest.parse(baseCart())
|
|
arted.labelArt = artTable()
|
|
|
|
local artedBytes = CartManifest.encode(arted)
|
|
local artedRound, artedErr = CartManifest.decode(artedBytes)
|
|
T.check(artedRound ~= nil, "a cart with label art decodes: " .. tostring(artedErr))
|
|
T.same(artedRound, arted, "the label art survives the encode and decode round trip")
|
|
T.eq(artedRound.labelArt.data, ART_DATA, "the base64 payload is preserved verbatim")
|
|
T.eq(artedRound.labelArt.bytes, #PNG, "the declared byte count is preserved")
|
|
T.eq(artedRound.labelArt.name, "label.png", "the art name is preserved")
|
|
T.eq(CartManifest.encode(artedRound), artedBytes,
|
|
"re-encoding a decoded cart writes the same bytes")
|
|
|
|
local artBytes, artName = CartManifest.labelArtBytes(artedRound)
|
|
T.eq(artBytes, PNG, "labelArtBytes hands back the PNG that was packed")
|
|
T.eq(artName, "label.png", "labelArtBytes hands back the art name")
|
|
T.eq(CartManifest.labelArtBytes(cart), nil, "a cart with no art has no art bytes")
|
|
|
|
T.eq(CartManifest.hash(arted), CartManifest.hash(cart),
|
|
"label art is not part of the cart hash")
|
|
T.check(CartManifest.canonical(arted):find(ART_DATA, 1, true) == nil,
|
|
"the canonical form leaves the art payload out")
|
|
|
|
local repainted = CartManifest.parse(baseCart())
|
|
local REPAINT = PNG .. "repainted"
|
|
repainted.labelArt = artTable({ data = Base64.encode(REPAINT), bytes = #REPAINT })
|
|
T.eq(CartManifest.hash(repainted), CartManifest.hash(arted),
|
|
"changing only the art leaves the cart hash alone")
|
|
T.eq(CartManifest.labelArtBytes(CartManifest.decode(CartManifest.encode(repainted))),
|
|
REPAINT, "the repainted art round trips")
|
|
|
|
local plain = CartManifest.decode(CartManifest.encode(cart))
|
|
T.eq(plain.labelArt, nil, "a cart with no art still decodes without art")
|
|
T.check(CartManifest.encode(cart):find("labelArt", 1, true) == nil,
|
|
"a cart with no art writes no labelArt key")
|
|
T.same(plain, cart, "a cart with no art round trips exactly as before")
|
|
|
|
local OVERSIZE = PNG .. string.rep("\0", CartManifest.MAX_LABEL_ART)
|
|
local OVERSIZE_DATA = Base64.encode(OVERSIZE)
|
|
|
|
local function drops(art, what)
|
|
local decodedArt, dropErr = CartManifest.decode(bundle(cart, art))
|
|
T.check(decodedArt ~= nil, what .. " still loads the cart: " .. tostring(dropErr))
|
|
T.eq(decodedArt.labelArt, nil, what .. " drops the art")
|
|
T.same(decodedArt, cart, what .. " leaves the rest of the cart untouched")
|
|
end
|
|
|
|
drops("label.png", "art that is not a table")
|
|
drops(artTable({ encoding = "hex" }), "art in an encoding we do not support")
|
|
drops(artTable({ encoding = NONE }), "art with no encoding")
|
|
drops(artTable({ data = "not base64!!" }), "art whose payload is not base64")
|
|
drops(artTable({ data = NONE }), "art with no payload")
|
|
drops(artTable({ data = "" }), "art with an empty payload")
|
|
drops(artTable({ bytes = #PNG + 1 }), "art whose byte count is too high")
|
|
drops(artTable({ bytes = #PNG - 1 }), "art whose byte count is too low")
|
|
drops(artTable({ bytes = NONE }), "art with no byte count")
|
|
drops(artTable({ bytes = tostring(#PNG) }), "art whose byte count is a string")
|
|
drops(artTable({ data = Base64.encode("GIF89a not a png at all"),
|
|
bytes = #"GIF89a not a png at all" }), "art that is not a PNG")
|
|
drops(artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }), "art past the size cap")
|
|
drops(artTable({ name = "../../etc/passwd" }), "art whose name climbs out")
|
|
drops(artTable({ name = ("a"):rep(129) }), "art with a 129 character name")
|
|
drops(artTable({ name = 7 }), "art with a numeric name")
|
|
|
|
local unnamed = CartManifest.decode(bundle(cart, artTable({ name = NONE })))
|
|
T.check(unnamed ~= nil and unnamed.labelArt ~= nil, "art with no name is still kept")
|
|
T.eq(unnamed.labelArt.name, nil, "the missing art name stays missing")
|
|
T.eq(CartManifest.labelArtBytes(unnamed), PNG, "unnamed art still decodes")
|
|
|
|
T.eq(CartManifest.parseLabelArt(nil), nil, "parseLabelArt refuses an absent payload")
|
|
local _, capErr = CartManifest.parseLabelArt(
|
|
artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }))
|
|
T.check(type(capErr) == "string" and capErr:find("bytes or fewer", 1, true) ~= nil,
|
|
"the size cap says why (got " .. tostring(capErr) .. ")")
|
|
local _, pngErr = CartManifest.parseLabelArt(
|
|
artTable({ data = Base64.encode("nope"), bytes = 4 }))
|
|
T.check(type(pngErr) == "string" and pngErr:find("PNG", 1, true) ~= nil,
|
|
"a non-PNG payload says why (got " .. tostring(pngErr) .. ")")
|
|
|
|
T.finish("cart_manifest")
|