Merge pull request #1616 from HighDrexler/fix/large-required-imports-v2

Fix/large required imports v2
This commit is contained in:
bryanthaboi
2026-08-21 09:59:17 -04:00
committed by GitHub
12 changed files with 1119 additions and 10 deletions
+46 -5
View File
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
`description` gives players dump or region guidance in the import panel. `description` gives players dump or region guidance in the import panel.
`size` declares the exact canonical byte length; `max_size` declares a smaller `size` declares the exact canonical byte length; `max_size` declares a smaller
per-import ceiling when an exact size is not appropriate. Every import also per-import ceiling when an exact size is not appropriate. The engine hard limit
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
filesystem reports an invalid size. use the launcher's streaming large-file path rather than being materialized as
one Lua string.
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders, For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical strips a recognized 512-byte copier header, converts the bytes to canonical
big-endian `.z64` order, and then checks MD5. The canonical bytes are written big-endian `.z64` order, and then checks MD5. The canonical bytes are written
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
mod: the launcher never scans or copies another mod's imported files merely mod: the launcher never scans or copies another mod's imported files merely
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for because its manifest names the same digest. Small sources can still be read
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem with the existing scoped `mod:read` API, for example
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
`mod.imports` facade described below; no host path or new general filesystem
permission is exposed. Missing `required_imports` block the mod before its permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading. launcher panel but do not block loading.
#### Bounded access to validated imports
A loaded mod can address only ids declared by its own `required_imports` or
`optional_imports` arrays:
```lua
local info, err = mod.imports:info("stadium2")
local header, err = mod.imports:read("stadium2", 0, 4096)
```
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
rechecks the stored import before exposing it, seeks into the engine-owned
copy, and never gives the mod a host path or file handle. This is intended for
large source formats whose table/index can be parsed with small reads before
selectively reading the payloads a transform actually needs.
MD5 here identifies a known dump because ROM databases commonly publish it; MD5 here identifies a known dump because ROM databases commonly publish it;
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
@@ -441,6 +460,28 @@ default** (1x front, 2x back).
ball-to-pic grow multiplies your scale through each stage, so a rescaled ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way. mon still grows into place from the ball, grounded the whole way.
## Installation-scoped generated cache
Generated data derived from a validated user source often belongs to the mod
installation rather than to one Pokémon save. `mod.cache` is that namespace:
```lua
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
local bytes, err = mod.cache:read("extract/v1/arena.bin")
local info = mod.cache:info("extract/v1/arena.bin")
mod.cache:delete("extract/v1/arena.bin")
```
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
The cache does not rewind with checkpoints and is not scoped to game version,
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
policy, and completion marker; the engine treats the bytes as opaque data.
Use `mod.storage` instead when the data belongs to one playthrough. Use
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
from a declared user source.
## Durable tool storage and runtime checkpoints ## Durable tool storage and runtime checkpoints
`mod.save` remains the right place for state that should travel with the next `mod.save` remains the right place for state that should travel with the next
@@ -0,0 +1,141 @@
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
## Motivation
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
the existing launcher and public mod API still assume imported bytes are small:
* the Windows desktop picker stages a selected required import through a fixed
`%TEMP%/pokeport_required_import.bin` path before validation;
* the fallback import path materializes the selected file as one Lua string;
* after validation a mod can only use `mod:read("baseroms/...")`, which also
materializes the whole file;
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
not an appropriate home for a one-time generated asset cache shared by every
save using the same installed mod.
This makes optical-disc-sized user sources impractical even though the manifest
schema already accepts them. A failed temporary staging copy can also turn a
valid large source into a smaller temporary file and produce a misleading
"wrong file size" rejection.
A mod should be able to consume its own already-validated source incrementally
and compile derived runtime data once, without receiving a host path or general
filesystem access.
## Decision being extended
This extends the same legal/sandbox direction as **D11 asset transforms**
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
user-owned sources rather than shipping ROM-derived data. It also follows the
**D14 parity-gate** contract referenced by `tests/harness.lua` and
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
extension points ship public-API coverage, no-mod parity coverage, and docs in
the same change.
The historical D11 plan document is referenced by source comments but is not
present in the current repository tree; this RFC is the checked-in design
record for the new surface.
## Exact API delta
No manifest field changes. Existing `required_imports` and `optional_imports`
remain the declaration/validation authority.
Two additive facades are added to the `mod` object.
### `mod.imports`
```lua
local info, err = mod.imports:info("source_id")
local bytes, err = mod.imports:read("source_id", offset, length)
```
* `source_id` must name an import declared by the calling mod.
* the import is rechecked through `RequiredImports.validateStored` before it is
exposed, so missing, replaced, or invalid optional imports are not readable;
* `offset` and `length` are zero-based byte coordinates;
* one read is capped at 8 MiB;
* no host path or file handle is returned;
* production reads seek into the engine-owned stored copy instead of reading
the whole source.
`info()` returns declaration metadata plus stored size. It does not expose a
host path.
### `mod.cache`
```lua
mod.cache:write("extract/v1/model.bin", bytes)
local bytes = mod.cache:read("extract/v1/model.bin")
local info = mod.cache:info("extract/v1/model.bin")
mod.cache:delete("extract/v1/model.bin")
```
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
backend, and is independent of game version, launcher slot, and playthrough.
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
escapes remain unavailable. A single cache write is capped at 64 MiB so large
generated datasets are naturally split into independently replaceable files.
The engine does not interpret cache bytes. Mods own generated-format versioning,
fingerprints, transactional completion markers, and rebuild policy.
## Launcher/import transport delta
For large raw required imports:
1. desktop pickers return the original selected path instead of staging it
through a fixed temporary file;
2. the engine opens that source itself;
3. bytes are copied directly to the existing engine-owned
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
4. MD5 is updated incrementally during the copy;
5. the normal size/MD5 validation receipt is written only after the complete
destination passes validation;
6. partial destinations are removed on short reads, write failure, size
mismatch, or digest mismatch.
N64 imports stay on the existing canonicalization path because byte-order and
copier-header normalization require transformation rather than a raw copy.
If a validation receipt for an already-stored large raw import is missing, the
engine rebuilds it with streaming MD5 rather than a whole-file read.
## Backward compatibility / migration
**Existing mods do nothing.** This is additive.
* manifest v1/v2 fields are unchanged;
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
retain their existing behavior;
* small required imports retain the existing in-memory validation path;
* N64 imports retain canonicalization and existing accepted byte orders;
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
files and observes no new behavior.
The mod API integer is not bumped because no existing member changes meaning or
shape.
## Security and legal posture
The launcher remains the authority that validates user-supplied bytes. The new
facade narrows access rather than widening it: a mod can read only ids declared
in its own manifest, only after validation, and only in bounded ranges. It does
not receive host paths, `io`, or a raw filesystem handle.
`mod.cache` is writable only beneath the calling mod's generated-cache root.
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
continue to enforce the existing legal posture.
## Parity guarantee
The change ships with:
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
`mod:read` load do not create cache data or change the old surface;
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
real `Loader` load, including bounded reads, undeclared/missing imports,
cache isolation, and traversal rejection;
* incremental MD5 vectors and a large-import streaming regression test;
* the existing engine suite, required-import suite, and mod lint gates.
+37
View File
@@ -287,6 +287,43 @@ function CacheFs.write(rel, data)
return love.filesystem.write(rel, data) return love.filesystem.write(rel, data)
end end
-- Open a cache-relative file for streaming replacement. The returned handle
-- has write(bytes) and close() methods and follows the same portable/save-dir
-- routing as CacheFs.write without forcing the caller to hold the whole file
-- in one Lua string.
function CacheFs.openWrite(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
ensureParents(root, rel)
local f, err = io.open(realPath(root, rel), "wb")
if not f then return nil, err end
return {
write = function(_, data)
local ok, writeErr = f:write(data)
if not ok then return nil, writeErr end
return true
end,
close = function() f:close() end,
}
end
if not (love and love.filesystem and love.filesystem.newFile) then
return nil, "streaming cache writes are unavailable"
end
local parent = rel:match("^(.*)/[^/]+$")
if parent and not love.filesystem.createDirectory(parent) then
local info = love.filesystem.getInfo(parent)
local reason = info and ("a " .. info.type .. " already exists there")
or "unknown reason"
return nil, "could not create " .. parent .. ": " .. reason
end
local file, makeErr = love.filesystem.newFile(rel)
if not file then return nil, makeErr or "could not create cache file" end
local ok, openErr = file:open("w")
if not ok then return nil, openErr or "could not open cache file" end
return file
end
-- read cache-relative `rel`; returns the bytes or nil -- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel) function CacheFs.read(rel)
rel = withPrefix(rel) rel = withPrefix(rel)
+161 -4
View File
@@ -388,6 +388,142 @@ local function externalFileSize(path)
return size return size
end end
local function openImportSource(path)
-- Desktop picker paths live outside LÖVE's virtual filesystem. Prefer the
-- native file handle so a 1.46 GiB disc is never copied to a temp file or
-- read into one Lua string before validation.
local native = io.open(path, "rb")
if native then
local size, sizeErr = native:seek("end")
if size == nil or size == false then
native:close()
return nil, sizeErr or "could not determine source file size"
end
local reset, resetErr = native:seek("set", 0)
if reset == nil or reset == false then
native:close()
return nil, resetErr or "could not rewind source file"
end
return {
size = size,
read = function(_, n) return native:read(n) end,
close = function() native:close() end,
}
end
if love and love.filesystem and love.filesystem.newFile then
local file, makeErr = love.filesystem.newFile(path)
if not file then return nil, makeErr or "could not open source file" end
local ok, openErr = file:open("r")
if not ok then return nil, openErr or "could not open source file" end
local size = file.getSize and file:getSize() or nil
return {
size = size,
read = function(_, n) return file:read(n) end,
close = function() file:close() end,
}
end
return nil, "streaming source access is unavailable"
end
local function streamRequiredImport(manifest, importId, source)
local RequiredImports = require("src.mods.RequiredImports")
local spec = RequiredImports.spec(manifest, importId)
if not spec then return nil, "Import declaration was not found." end
if spec.format == "n64" then
return nil, "streaming canonicalization is unavailable for N64 imports"
end
local input, openErr = openImportSource(source)
if not input then return nil, openErr end
local sizeErr = RequiredImports.sizeError(spec, input.size, false)
if sizeErr then input:close(); return nil, sizeErr end
local CacheFs = require("src.import.CacheFs")
local destination = RequiredImports.path(manifest, spec)
local savedPrefix = CacheFs.prefix
local output
local inputClosed, outputClosed = false, false
local function closeInput()
if inputClosed then return end
inputClosed = true
pcall(function() input:close() end)
end
local function closeOutput()
if outputClosed or not output then return end
outputClosed = true
pcall(function() output:close() end)
end
local resultOk, resultDetail
CacheFs.prefix = ""
local ran, thrown = xpcall(function()
CacheFs.remove(RequiredImports.receiptPath(manifest, spec))
CacheFs.remove(destination)
local makeErr
output, makeErr = CacheFs.openWrite(destination)
if not output then
resultDetail = makeErr or "could not create imported file"
return
end
local MD5 = require("src.mods.StreamMD5")
local md5 = MD5.new()
local total, chunkBytes = 0, 4 * 1024 * 1024
while true do
local chunk = input:read(chunkBytes)
if not chunk or #chunk == 0 then break end
md5:update(chunk)
local wrote, writeErr = output:write(chunk)
if wrote == false or wrote == nil then
resultDetail = "could not copy import: "
.. tostring(writeErr or "write failed")
return
end
total = total + #chunk
if #chunk < chunkBytes then break end
end
closeInput()
closeOutput()
if input.size and total ~= input.size then
resultDetail = ("source read ended early (expected %d bytes, copied %d)")
:format(input.size, total)
return
end
local storedSizeErr = RequiredImports.sizeError(spec, total, true)
if storedSizeErr then resultDetail = storedSizeErr; return end
local digest = md5:final()
-- acceptStoredDigest uses the normal engine path/receipt rules, so
-- restore the caller's prefix before handing control to it.
CacheFs.prefix = savedPrefix
local accepted, detail = RequiredImports.acceptStoredDigest(
manifest, importId, digest, love.filesystem)
if not accepted then resultDetail = detail; return end
resultOk, resultDetail = true, detail
end, function(err)
if debug and debug.traceback then
return debug.traceback(tostring(err), 2)
end
return tostring(err)
end)
-- finally: resource handles and the process-global CacheFs prefix must
-- be restored even when a read/hash/write helper raises a Lua error.
closeInput()
closeOutput()
CacheFs.prefix = ""
if not ran or not resultOk then
pcall(function() CacheFs.remove(destination) end)
end
CacheFs.prefix = savedPrefix
if not ran then
return nil, "could not copy import: " .. tostring(thrown)
end
if not resultOk then return nil, resultDetail end
return true, resultDetail
end
local function readDroppedFile(file) local function readDroppedFile(file)
local ok, openError = file:open("r") local ok, openError = file:open("r")
if not ok then return nil, openError end if not ok then return nil, openError end
@@ -1234,11 +1370,13 @@ local function chooseRequiredFile()
"$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';", "$d.Title='" .. prompt .. "';",
"$d.Filter='All files (*.*)|*.*';", "$d.Filter='All files (*.*)|*.*';",
-- Required imports can be multi-gigabyte optical-disc images. Do NOT
-- stage them through %TEMP%: that doubles free-space requirements and a
-- failed Copy-Item can leave a plausible-looking truncated temp file.
-- Stream the selected source directly into the mod-owned destination.
"if($d.ShowDialog() -eq 'OK'){", "if($d.ShowDialog() -eq 'OK'){",
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}", "[Console]::Write($d.FileName)}",
}) })
return commandOutput( return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"') 'powershell -NoProfile -STA -Command "' .. script .. '"')
@@ -2078,8 +2216,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
return nil return nil
end end
local RequiredImports = require("src.mods.RequiredImports") local RequiredImports = require("src.mods.RequiredImports")
-- A desktop picker returns a host path. Ask the host file handle first;
-- love.filesystem.getInfo is only authoritative for virtual/save paths.
local size = externalFileSize(source)
if not size then
local info = love.filesystem.getInfo(source, "file") local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source) size = info and info.size or nil
end
local sizeErr = RequiredImports.sizeError(spec, size, false) local sizeErr = RequiredImports.sizeError(spec, size, false)
if sizeErr then if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr) requiredImportNotice(self, modId, importId, sizeErr)
@@ -2102,6 +2245,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
} }
return nil return nil
end end
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
and spec.format ~= "n64" then
local ok, result = streamRequiredImport(manifest, importId, source)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
requiredImportNotice(self, modId, importId, result)
self.modNotice = nil
return nil
end
local data = love.filesystem.read(source) local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end if not data then data = readExternalPath(source) end
if not data then if not data then
+174
View File
@@ -0,0 +1,174 @@
-- Scoped access to a mod's launcher-validated required/optional imports and
-- to installation-wide generated cache data.
--
-- This intentionally does not expose host paths or raw filesystem handles.
-- Import reads are bounded and can only address ids declared by the calling
-- mod's manifest. Cache paths are confined to mod_cache/<mod-id>/ and are not
-- tied to a Pokémon playthrough.
local RequiredImports = require("src.mods.RequiredImports")
local SafePath = require("src.mods.SafePath")
local SaveData = require("src.core.SaveData")
local ImportAccess = {}
ImportAccess.MAX_READ_BYTES = 8 * 1024 * 1024
ImportAccess.MAX_CACHE_WRITE_BYTES = 64 * 1024 * 1024
local function specMap(manifest)
local out = {}
for _, spec in ipairs(RequiredImports.specs(manifest)) do out[spec.id] = spec end
return out
end
local function parentOf(path)
return path:match("^(.*)/[^/]+$")
end
local function copyInfo(info)
if not info then return nil end
return { type = info.type, size = info.size, modtime = info.modtime }
end
local function fsReadRange(fs, path, offset, length)
if fs and type(fs.readRange) == "function" then
return fs.readRange(path, offset, length)
end
local newFile = fs and fs.newFile
if newFile then
local file, makeErr = newFile(path)
if not file then return nil, makeErr or "could not open import" end
local ok, openErr = file:open("r")
if not ok then return nil, openErr or "could not open import" end
local seekOk, seekErr = file:seek(offset)
if seekOk == nil or seekOk == false then
file:close()
return nil, seekErr or "could not seek import"
end
local data, readErr = file:read(length)
file:close()
return data, readErr
end
-- Injectable headless filesystems may expose only read(). Production
-- love.filesystem has newFile(), so large imports are never materialized
-- into one Lua string by this fallback.
if fs and fs.read then
local data = fs.read(path)
if type(data) ~= "string" then return nil, "could not read import" end
return data:sub(offset + 1, offset + length)
end
return nil, "random-access import reads are unavailable"
end
local function validatedInfo(manifest, spec, fs)
local ok, detail = RequiredImports.validateStored(manifest, spec, fs)
if not ok then return nil, detail or "import is not validated" end
local path = RequiredImports.path(manifest, spec)
local info = fs.getInfo and fs.getInfo(path, "file") or nil
if not info then return nil, "import is missing" end
return info, detail
end
local function makeCache(modId, fs)
local root = "mod_cache/" .. modId
local function pathFor(rel, what)
rel = SafePath.require(rel, what or "mod.cache path")
return root .. "/" .. rel
end
local cache = {}
function cache:write(rel, bytes)
if type(bytes) ~= "string" then
return nil, "mod.cache:write expects a byte string"
end
if #bytes > ImportAccess.MAX_CACHE_WRITE_BYTES then
return nil, "mod.cache:write payload exceeds 64 MiB; split generated data into smaller files"
end
local path = pathFor(rel, "mod.cache:write")
local parent = parentOf(path)
if parent and fs.createDirectory then
local ok = fs.createDirectory(parent)
if ok == false then return nil, "could not create cache directory" end
end
if not fs.write then return nil, "cache writes are unavailable" end
return fs.write(path, bytes)
end
function cache:read(rel)
local path = pathFor(rel, "mod.cache:read")
if not fs.read then return nil, "cache reads are unavailable" end
return fs.read(path)
end
function cache:info(rel)
local path = pathFor(rel, "mod.cache:info")
if not fs.getInfo then return nil end
return copyInfo(fs.getInfo(path))
end
function cache:exists(rel)
local info = self:info(rel)
return info ~= nil and info.type == "file"
end
function cache:delete(rel)
local path = pathFor(rel, "mod.cache:delete")
if not fs.remove then return nil, "cache deletion is unavailable" end
return fs.remove(path)
end
return cache
end
function ImportAccess.new(manifest, fs)
local specs = specMap(manifest)
local cacheFs = SaveData.persistenceFs(fs) or fs
local imports = {}
function imports:info(id)
local spec = specs[id]
if not spec then return nil, "undeclared import: " .. tostring(id) end
local info, digestOrErr = validatedInfo(manifest, spec, fs)
if not info then return nil, digestOrErr end
return {
id = spec.id,
name = spec.name,
file = spec.file,
size = info.size,
md5 = digestOrErr,
required = spec.required ~= false,
}
end
function imports:read(id, offset, length)
local spec = specs[id]
if not spec then return nil, "undeclared import: " .. tostring(id) end
offset, length = tonumber(offset), tonumber(length)
if not offset or offset < 0 or offset % 1 ~= 0 then
return nil, "offset must be a non-negative integer"
end
if not length or length < 0 or length % 1 ~= 0 then
return nil, "length must be a non-negative integer"
end
if length > ImportAccess.MAX_READ_BYTES then
return nil, "single import read exceeds 8 MiB"
end
local info, err = validatedInfo(manifest, spec, fs)
if not info then return nil, err end
local size = tonumber(info.size) or tonumber(spec.size)
if size and offset + length > size then return nil, "import read is out of bounds" end
if length == 0 then return "" end
local path = RequiredImports.path(manifest, spec)
local data, readErr = fsReadRange(fs, path, offset, length)
if not data then return nil, readErr end
if #data ~= length then return nil, "short import read" end
return data
end
return imports, makeCache(manifest.id, cacheFs)
end
return ImportAccess
+8
View File
@@ -962,6 +962,8 @@ function Loader:_api(mod)
local Storage = engineRequire("src.mods.Storage") local Storage = engineRequire("src.mods.Storage")
local storage = Storage and Storage.new(modId, loader.fs) local storage = Storage and Storage.new(modId, loader.fs)
local Checkpoint = engineRequire("src.core.Checkpoint") local Checkpoint = engineRequire("src.core.Checkpoint")
local ImportAccess = engineRequire("src.mods.ImportAccess")
local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs)
local api = { local api = {
id = modId, id = modId,
version = mod.manifest.version, version = mod.manifest.version,
@@ -1161,6 +1163,12 @@ function Loader:_api(mod)
-- checkpoint. The -- checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence; -- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle. -- callers never receive paths or a raw filesystem handle.
-- Read-only bounded access to this mod's manifest-declared, launcher-validated
-- imports. No host path is exposed; large sources are read in bounded ranges.
imports = importApi,
-- Installation-scoped generated data, independent from Pokémon save slots.
-- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped.
cache = installCache,
storage = { storage = {
context = function(_, game) return storage:context(game) end, context = function(_, game) return storage:context(game) end,
selected = function(_, game) return storage:selected(game) end, selected = function(_, game) return storage:selected(game) end,
+87
View File
@@ -120,6 +120,36 @@ local function accepts(spec, digest)
return false return false
end end
local function specById(manifest, importId)
for _, candidate in ipairs(allSpecs(manifest)) do
if candidate.id == importId then return candidate end
end
return nil
end
RequiredImports.spec = specById
local function streamDigest(fs, path, chunkBytes)
if not (fs and fs.newFile) then return nil, "streaming file access is unavailable" end
local file, makeErr = fs.newFile(path)
if not file then return nil, makeErr or "could not open stored import" end
local ok, openErr = file:open("r")
if not ok then return nil, openErr or "could not open stored import" end
local MD5 = require("src.mods.StreamMD5")
local ctx = MD5.new()
chunkBytes = chunkBytes or (4 * 1024 * 1024)
while true do
local data, readErr = file:read(chunkBytes)
if data and #data > 0 then ctx:update(data) end
if not data or #data < chunkBytes then
if readErr then file:close(); return nil, readErr end
break
end
end
file:close()
return ctx:final()
end
function RequiredImports.path(manifest, spec) function RequiredImports.path(manifest, spec)
return manifest.path .. "/baseroms/" .. spec.file return manifest.path .. "/baseroms/" .. spec.file
end end
@@ -180,6 +210,47 @@ local function removeReceipt(manifest, spec, fs)
end end
end end
-- Finalize a caller-streamed import after the destination bytes have already
-- been copied into the engine-owned baseroms path. This keeps large imports
-- out of a single Lua string while preserving the same size/MD5 receipt rules.
function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs)
fs = fs or (love and love.filesystem)
local spec = specById(manifest, importId)
if not spec then return nil, "unknown required import: " .. tostring(importId) end
digest = tostring(digest or ""):lower()
if not accepts(spec, digest) then
return nil, ("MD5 mismatch (got %s)"):format(digest ~= "" and digest or "unavailable")
end
local path = RequiredImports.path(manifest, spec)
local info = fs and fs.getInfo and fs.getInfo(path, "file") or nil
if not info then return nil, "copied import is missing" end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then return nil, sizeErr end
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
local ok, prefixErr = xpcall(function()
CacheFs.prefix = ""
CacheFs.remove(removedMarker(manifest, spec))
CacheFs.prefix = savedPrefix
-- writeReceipt has its own temporary CacheFs prefix switch. Keep it
-- inside this guard too so a write error cannot leak global state.
writeReceipt(manifest, spec, digest, info, fs)
end, function(err)
return tostring(err)
end)
CacheFs.prefix = savedPrefix
if not ok then
return nil, "could not finalize import receipt: " .. tostring(prefixErr)
end
return true, digest
elseif fs and fs.remove then
fs.remove(removedMarker(manifest, spec))
end
writeReceipt(manifest, spec, digest, info, fs)
return true, digest
end
-- Validate bytes against a declaration. The returned data is canonicalized -- Validate bytes against a declaration. The returned data is canonicalized
-- (notably for N64 byte order/header variants) and is what must be stored. -- (notably for N64 byte order/header variants) and is what must be stored.
function RequiredImports.validateData(spec, data, hashFn) function RequiredImports.validateData(spec, data, hashFn)
@@ -217,6 +288,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn)
local cached = cachedDigest(manifest, spec, fs, info) local cached = cachedDigest(manifest, spec, fs, info)
if cached then return true, cached, true end if cached then return true, cached, true end
removeReceipt(manifest, spec, fs) removeReceipt(manifest, spec, fs)
-- Large raw imports (GameCube discs, future optical images, etc.) must never
-- be materialized into one Lua string merely because their validation
-- receipt was lost. Stream the MD5 directly from the installed file. N64
-- sources stay on the canonicalization path because byte-order/header
-- normalization is part of their validation contract.
if info.size and info.size > RequiredImports.LARGE_WARN_BYTES
and spec.format ~= "n64" and fs.newFile then
local digest, hashErr = streamDigest(fs, path)
if not digest then return nil, hashErr end
if not accepts(spec, digest) then
return nil, ("MD5 mismatch (got %s)"):format(digest)
end
info = fs.getInfo(path, "file") or info
writeReceipt(manifest, spec, digest, info, fs)
return true, digest, false
end
if not fs.read then return nil, "file could not be read" end if not fs.read then return nil, "file could not be read" end
local data = fs.read(path) local data = fs.read(path)
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn) local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
+101
View File
@@ -0,0 +1,101 @@
local bitlib = rawget(_G, "bit") or rawget(_G, "bit32")
if not bitlib then error("StreamMD5 requires bit or bit32") end
local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot
local lshift, rshift = bitlib.lshift, bitlib.rshift
local rol = bitlib.rol or bitlib.lrotate
local K = {
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,
0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821,
0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,
0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,
0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391,
}
local S = {
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20,
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21,
}
local function add32(a,b,c,d)
local n = (a or 0) + (b or 0) + (c or 0) + (d or 0)
return band(n, 0xffffffff)
end
local function le32_from(s, i)
local b1,b2,b3,b4 = s:byte(i, i+3)
return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24))
end
local function le32_bytes(x)
return string.char(
band(x,0xff), band(rshift(x,8),0xff),
band(rshift(x,16),0xff), band(rshift(x,24),0xff))
end
local M = {}
local StreamMD5 = {}
StreamMD5.__index = StreamMD5
function StreamMD5.new()
return setmetatable({
a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476,
bytes=0, buffer="", done=false,
}, StreamMD5)
end
function StreamMD5:_block(block)
for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end
local a,b,c,d = self.a,self.b,self.c,self.d
for i=0,63 do
local f,g
if i < 16 then
f = bor(band(b,c), band(bnot(b),d)); g=i
elseif i < 32 then
f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16
elseif i < 48 then
f = bxor(b,c,d); g=(3*i+5)%16
else
f = bxor(c, bor(b,bnot(d))); g=(7*i)%16
end
local tmp=d
d=c
c=b
b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1]))
a=tmp
end
self.a=add32(self.a,a); self.b=add32(self.b,b)
self.c=add32(self.c,c); self.d=add32(self.d,d)
end
function StreamMD5:update(data)
assert(not self.done, "StreamMD5 context already finalized")
assert(type(data)=="string", "StreamMD5:update expects a string")
self.bytes = self.bytes + #data
local s = self.buffer .. data
local full = #s - (#s % 64)
for i=1,full,64 do self:_block(s:sub(i,i+63)) end
self.buffer = s:sub(full+1)
return self
end
function StreamMD5:final()
assert(not self.done, "StreamMD5 context already finalized")
local originalBytes = self.bytes
local padLen = (56 - ((originalBytes + 1) % 64)) % 64
local bits = originalBytes * 8
local lo = bits % 4294967296
local hi = math.floor(bits / 4294967296) % 4294967296
self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi))
assert(#self.buffer == 0, "MD5 finalization left a partial block")
self.done=true
local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d)
return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end))
end
return StreamMD5
@@ -0,0 +1,42 @@
-- No-mod and API-v1 parity coverage for additive mod.imports/mod.cache.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
-- No mod installed: no generated cache namespace is touched.
local emptyFiles = {}
local none = T.sdk.loadNone({ fs = T.sdk.memfs(emptyFiles) })
T.eq(#none.errors, 0, "zero-mod load remains clean")
for path in pairs(emptyFiles) do
T.check(path:sub(1, 10) ~= "mod_cache/",
"zero-mod load creates no installation cache data")
end
none.release()
-- Existing API-v1 style file access still behaves exactly as before. The new
-- facades are additive members; mod:read remains the same scoped read path.
local files = {
["mods/v1_read_probe/manifest.json"] = [[{
"id": "v1_read_probe",
"name": "V1 Read Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 1
}]],
["mods/v1_read_probe/main.lua"] = [[
local mod = ...
mod.exports.payload = mod:read("payload.txt")
mod.exports.hasImports = type(mod.imports) == "table"
mod.exports.hasCache = type(mod.cache) == "table"
]],
["mods/v1_read_probe/payload.txt"] = "unchanged-v1-read",
}
local run = T.sdk.loadMods({ "mods/v1_read_probe" }, { fs = T.sdk.memfs(files) })
T.eq(#run.errors, 0, "API-v1 probe still loads")
local out = run.loader.exports.v1_read_probe
T.eq(out.payload, "unchanged-v1-read", "mod:read keeps its v1 behavior")
T.eq(out.hasImports, true, "new import facade is additive")
T.eq(out.hasCache, true, "new cache facade is additive")
run.release()
T.finish("mod_import_access_parity")
+105
View File
@@ -0,0 +1,105 @@
-- Public mod-API coverage for bounded validated imports + installation cache.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local digest = "00000000000000000000000000000000"
local files = {
["mods/import_api_probe/manifest.json"] = [[{
"id": "import_api_probe",
"name": "Import API Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"required_imports": [{
"id": "disc",
"name": "Disc",
"file": "source.iso",
"md5": ["00000000000000000000000000000000"],
"size": 16
}],
"optional_imports": [{
"id": "optional",
"name": "Optional",
"file": "optional.bin",
"md5": ["11111111111111111111111111111111"],
"size": 4,
"required": false
}]
}]],
["mods/import_api_probe/main.lua"] = [[
local mod = ...
local info, infoErr = mod.imports:info("disc")
local slice, sliceErr = mod.imports:read("disc", 4, 6)
local oob, oobErr = mod.imports:read("disc", 15, 2)
local missing, missingErr = mod.imports:info("optional")
local undeclared, undeclaredErr = mod.imports:read("not_declared", 0, 1)
local wrote, writeErr = mod.cache:write("extract/v1/probe.bin", "abc")
local cached, readErr = mod.cache:read("extract/v1/probe.bin")
local cacheInfo = mod.cache:info("extract/v1/probe.bin")
local escaped = pcall(function() mod.cache:write("../escape.bin", "x") end)
mod.exports.result = {
info = info, infoErr = infoErr,
slice = slice, sliceErr = sliceErr,
oob = oob, oobErr = oobErr,
missing = missing, missingErr = missingErr,
undeclared = undeclared, undeclaredErr = undeclaredErr,
wrote = wrote, writeErr = writeErr,
cached = cached, readErr = readErr,
cacheInfo = cacheInfo,
escaped = escaped,
}
]],
["mods/import_api_probe/baseroms/source.iso"] = "0123456789abcdef",
["mods/import_api_probe/baseroms/.required-import-disc.validated"] =
"v1\n" .. digest .. "\n16\n1\n",
}
local baseFs = T.sdk.memfs(files)
local oldInfo = baseFs.getInfo
function baseFs.getInfo(path)
local info = oldInfo(path)
if info and info.type == "file" then
return { type = "file", size = #(files[path] or ""), modtime = 1 }
end
return info
end
function baseFs.readRange(path, offset, length)
local body = files[path]
if not body then return nil end
return body:sub(offset + 1, offset + length)
end
function baseFs.createDirectory() return true end
function baseFs.remove(path) files[path] = nil return true end
local run = T.sdk.loadMods({ "mods/import_api_probe" }, { fs = baseFs })
T.eq(#run.errors, 0,
"the import/cache probe loads through the production Loader")
local out = run.loader.exports.import_api_probe.result
T.check(type(out.info) == "table", "declared validated import has info")
T.eq(out.info.size, 16, "import info reports stored size")
T.eq(out.slice, "456789", "bounded read seeks into the validated import")
T.eq(out.sliceErr, nil, "bounded read has no error")
T.eq(out.oob, nil, "out-of-bounds read is refused")
T.check(type(out.oobErr) == "string" and out.oobErr:find("out of bounds", 1, true),
"out-of-bounds read explains the refusal")
T.eq(out.missing, nil, "missing optional import is not exposed")
T.check(type(out.missingErr) == "string", "missing optional import returns an error")
T.eq(out.undeclared, nil, "undeclared import id is refused")
T.check(type(out.undeclaredErr) == "string"
and out.undeclaredErr:find("undeclared", 1, true),
"undeclared import refusal is explicit")
T.check(out.wrote == true, "installation cache write succeeds")
T.eq(out.cached, "abc", "installation cache read returns exact bytes")
T.eq(out.cacheInfo and out.cacheInfo.size, 3, "installation cache info is scoped")
T.eq(out.escaped, false, "cache traversal is rejected by the public facade")
T.eq(files["mod_cache/import_api_probe/extract/v1/probe.bin"], "abc",
"cache bytes live under the calling mod id")
T.eq(files["escape.bin"], nil, "cache traversal created nothing outside its root")
run.release()
T.finish("mod_import_access")
@@ -0,0 +1,183 @@
-- Regression for large raw required imports: direct source -> engine-owned
-- baseroms destination, incremental MD5, no whole-file staging requirement.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local RomImporter = require("src.import.RomImporter")
local RequiredImports = require("src.mods.RequiredImports")
-- The stock headless filesystem intentionally omits love.filesystem.newFile.
-- Add the smallest streaming-file facade this transport path needs so the
-- regression drives the same seek/read/write API production LÖVE provides.
local oldNewFile = love.filesystem.newFile
local oldGetInfo = love.filesystem.getInfo
love.filesystem.newFile = function(path)
local body, pos, mode = love.filesystem.read(path) or "", 1, nil
local file = {}
function file:open(m)
mode = m
if m == "w" then body, pos = "", 1 else pos = 1 end
return true
end
function file:read(n)
local out = body:sub(pos, pos + n - 1)
pos = pos + #out
if out == "" then return nil end
return out
end
function file:write(bytes)
if mode ~= "w" then return nil, "not open for write" end
body = body:sub(1, pos - 1) .. bytes .. body:sub(pos + #bytes)
pos = pos + #bytes
love.filesystem.write(path, body)
return true
end
function file:seek(offset) pos = offset + 1 return offset end
function file:getSize() return #body end
function file:close()
if mode == "w" then love.filesystem.write(path, body) end
mode = nil
return true
end
return file
end
love.filesystem.getInfo = function(path, filter)
local info = oldGetInfo(path, filter)
if info and info.type == "file" then
local bytes = love.filesystem.read(path) or ""
return { type = "file", size = #bytes, modtime = 1 }
end
return info
end
local source = "required_import_streaming_source.tmp"
local f = assert(io.open(source, "wb"))
f:write("abc")
f:close()
local manifest = {
id = "stream_probe",
name = "Stream Probe",
path = "mods/stream_probe",
required_imports = {
{ id = "source", name = "Source", file = "source.bin", format = "raw",
size = 3, md5 = { "900150983cd24fb0d6963f7d28e17f72" } },
},
optional_imports = {},
}
local importer = setmetatable({
mods = { { id = "stream_probe", manifest = manifest } },
requiredImportNotice = nil,
modNotice = nil,
_refreshMods = function(self) self._refreshed = true end,
}, RomImporter)
-- Reproduce the Windows failure seen with a 1.46 GiB optical-disc image:
-- after the user accepts the "Large import" confirmation, the old path calls
-- love.filesystem.read(source) and attempts to materialize the entire source as
-- one Lua string. A large raw source must go straight to the streaming branch.
local ordinaryLoveRead = love.filesystem.read
local forbiddenWholeSourceReads = 0
love.filesystem.read = function(path, ...)
if path == source then
forbiddenWholeSourceReads = forbiddenWholeSourceReads + 1
error("large external required import used whole-file love.filesystem.read")
end
return ordinaryLoveRead(path, ...)
end
-- Exercise the exact large-file branch with tiny fixture bytes by lowering the
-- threshold for this test. Production keeps the 128 MiB confirmation/streaming
-- threshold; the copy/hash algorithm is identical.
local oldWarn = RequiredImports.LARGE_WARN_BYTES
RequiredImports.LARGE_WARN_BYTES = 2
local ok = importer:_importRequiredSource("stream_probe", "source", source, true)
RequiredImports.LARGE_WARN_BYTES = oldWarn
love.filesystem.read = ordinaryLoveRead
T.eq(ok, true, "large raw required import streams successfully")
T.eq(forbiddenWholeSourceReads, 0,
"post-confirm large import never materializes the external source as one Lua string")
T.eq(love.filesystem.read("mods/stream_probe/baseroms/source.bin"), "abc",
"streamed destination preserves exact source bytes")
T.eq(importer.requiredImportNotice, nil, "successful stream leaves no import error")
T.eq(importer._refreshed, true, "successful stream refreshes the mod list")
local CacheFs = require("src.import.CacheFs")
-- A thrown writer error must not leak the temporary empty CacheFs prefix.
local workingNewFile = love.filesystem.newFile
local workingPrefix = CacheFs.prefix
CacheFs.prefix = "sentinel/"
love.filesystem.newFile = function(path)
local file = workingNewFile(path)
if path == "mods/stream_probe/baseroms/source.bin" then
function file:write()
error("forced writer failure")
end
end
return file
end
importer.requiredImportNotice = nil
RequiredImports.LARGE_WARN_BYTES = 2
local failedWrite = importer:_importRequiredSource(
"stream_probe", "source", source, true)
RequiredImports.LARGE_WARN_BYTES = oldWarn
T.eq(failedWrite, nil, "thrown streaming writer error is contained")
T.eq(CacheFs.prefix, "sentinel/",
"streaming writer error restores CacheFs.prefix")
love.filesystem.newFile = workingNewFile
CacheFs.prefix = workingPrefix
-- acceptStoredDigest has its own prefix switch for marker/receipt I/O.
-- Even an unexpected CacheFs failure must restore the caller's prefix.
love.filesystem.write("mods/stream_probe/baseroms/source.bin", "abc")
local workingRemove = CacheFs.remove
CacheFs.prefix = "sentinel/"
CacheFs.remove = function()
error("forced marker removal failure")
end
local accepted = RequiredImports.acceptStoredDigest(
manifest, "source", "900150983cd24fb0d6963f7d28e17f72", love.filesystem)
T.eq(accepted, nil, "acceptStoredDigest contains CacheFs failure")
T.eq(CacheFs.prefix, "sentinel/",
"acceptStoredDigest failure restores CacheFs.prefix")
CacheFs.remove = workingRemove
CacheFs.prefix = workingPrefix
-- Native sources are rejected cleanly if seek-to-start fails after the
-- size probe; never return a handle left sitting at EOF.
local realIoOpen = io.open
local fakeCloses = 0
io.open = function(path, mode)
if path ~= source then return realIoOpen(path, mode) end
local calls = 0
return {
seek = function(_, whence)
calls = calls + 1
if whence == "end" then return 3 end
if whence == "set" then return nil, "forced rewind failure" end
return nil, "unexpected seek"
end,
close = function() fakeCloses = fakeCloses + 1 end,
}
end
importer.requiredImportNotice = nil
RequiredImports.LARGE_WARN_BYTES = 2
local failedSeek = importer:_importRequiredSource(
"stream_probe", "source", source, true)
RequiredImports.LARGE_WARN_BYTES = oldWarn
io.open = realIoOpen
T.eq(failedSeek, nil, "failed native rewind rejects import source")
T.eq(fakeCloses >= 2, true,
"failed native rewind closes probed source handles")
love.filesystem.remove("mods/stream_probe/baseroms/source.bin")
love.filesystem.remove("mods/stream_probe/baseroms/source.iso")
os.remove(source)
love.filesystem.newFile = oldNewFile
love.filesystem.getInfo = oldGetInfo
T.finish("required_import_streaming")
+33
View File
@@ -0,0 +1,33 @@
-- Incremental MD5 vectors used by large required-import streaming.
package.path = "./?.lua;./?/init.lua;" .. package.path
-- Plain Lua runners may expose bit32 while production LuaJIT exposes bit.
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.modkit")
local MD5 = require("src.mods.StreamMD5")
local vectors = {
{ "", "d41d8cd98f00b204e9800998ecf8427e" },
{ "a", "0cc175b9c0f1b6a831c399e269772661" },
{ "abc", "900150983cd24fb0d6963f7d28e17f72" },
{ "message digest", "f96b697d7cb7938d525a2f31aaf161d0" },
{ "abcdefghijklmnopqrstuvwxyz", "c3fcd3d76192e4007dfb496cca67e13b" },
}
for _, row in ipairs(vectors) do
local ctx = MD5.new()
for i = 1, #row[1], 3 do ctx:update(row[1]:sub(i, i + 2)) end
T.eq(ctx:final(), row[2], "incremental MD5 vector: " .. row[1])
end
-- Cross a large number of block boundaries without building one giant string.
local million = MD5.new()
for _ = 1, 1000 do million:update(string.rep("a", 1000)) end
T.eq(million:final(), "7707d6ae4e027c70eea2a935c2296f21",
"RFC 1321 million-a vector")
T.finish("stream_md5")