feat(binary): check zuur/binary package.lua version against constraints
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
/// tofu.binary — Check zuur/binary package.lua versions against dependency constraints.
|
||||
///
|
||||
/// Fetches the package.lua manifest from the zuur binary repository via tofu.http,
|
||||
/// extracts the version field with a light-parse scan, and checks it against a
|
||||
/// dependency constraint using tofu.vercmp.satisfies.
|
||||
///
|
||||
/// Memoization: Results are cached per package name at module level for the
|
||||
/// lifetime of one command run. Tests must not rely on cross-test state;
|
||||
/// each test case uses a fresh or distinct package name.
|
||||
module tofu.binary;
|
||||
|
||||
import tofu.types; // BinaryCheckResult, DepConstraint, DepOp
|
||||
import tofu.http; // get, HttpException
|
||||
import tofu.config; // Config, binaryManifestUrl
|
||||
import tofu.vercmp; // satisfies
|
||||
import tofu.log; // logDetail, logInfo
|
||||
import std.algorithm.searching : canFind;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Exception
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thrown when the version field cannot be determined from a
|
||||
/// binary package's manifest.
|
||||
class BinaryException : Exception
|
||||
{
|
||||
@safe this(string msg)
|
||||
{
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Module-level memoization cache
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cache of manifest fetch results keyed by package name.
|
||||
/// Shared across all calls within a single command run.
|
||||
/// Tests must NOT rely on cross-test state.
|
||||
private BinaryCheckResult[string] _versionCache;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Light-parse the version field from a Lua package.lua manifest.
|
||||
/// Uses a simple scan for `version = "..."` — package.lua manifests
|
||||
/// are flat key-value tables; a full Lua parser is overkill for
|
||||
/// version extraction.
|
||||
private @safe string extractVersion(string content, string name)
|
||||
{
|
||||
size_t idx = 0;
|
||||
|
||||
// Find "version" keyword with word-boundary checks
|
||||
while (idx + 7 <= content.length)
|
||||
{
|
||||
if (content[idx .. idx + 7] == "version")
|
||||
{
|
||||
// Validate preceding char (word boundary)
|
||||
bool validBefore = (idx == 0);
|
||||
if (!validBefore)
|
||||
{
|
||||
char c = content[idx - 1];
|
||||
validBefore = (c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
|| c == '{' || c == ',' || c == ';');
|
||||
}
|
||||
|
||||
// Validate following char (word boundary)
|
||||
bool validAfter = (idx + 7 >= content.length);
|
||||
if (!validAfter)
|
||||
{
|
||||
char c = content[idx + 7];
|
||||
validAfter = (c == ' ' || c == '\t' || c == '=');
|
||||
}
|
||||
|
||||
if (validBefore && validAfter)
|
||||
break;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (idx + 7 > content.length)
|
||||
{
|
||||
throw new BinaryException(
|
||||
"cannot determine version for binary package '" ~ name ~ "'");
|
||||
}
|
||||
|
||||
idx += 7; // skip "version"
|
||||
|
||||
// Skip whitespace before '='
|
||||
while (idx < content.length && (content[idx] == ' ' || content[idx] == '\t'))
|
||||
idx++;
|
||||
|
||||
// Expect '='
|
||||
if (idx >= content.length || content[idx] != '=')
|
||||
{
|
||||
throw new BinaryException(
|
||||
"cannot determine version for binary package '" ~ name ~ "'");
|
||||
}
|
||||
idx++;
|
||||
|
||||
// Skip whitespace after '='
|
||||
while (idx < content.length && (content[idx] == ' ' || content[idx] == '\t'))
|
||||
idx++;
|
||||
|
||||
// Expect opening quote
|
||||
if (idx >= content.length || content[idx] != '"')
|
||||
{
|
||||
throw new BinaryException(
|
||||
"cannot determine version for binary package '" ~ name ~ "'");
|
||||
}
|
||||
idx++;
|
||||
|
||||
// Read version value until closing quote
|
||||
size_t verStart = idx;
|
||||
while (idx < content.length && content[idx] != '"')
|
||||
idx++;
|
||||
|
||||
if (idx >= content.length)
|
||||
{
|
||||
throw new BinaryException(
|
||||
"cannot determine version for binary package '" ~ name ~ "'");
|
||||
}
|
||||
|
||||
auto ver = content[verStart .. idx];
|
||||
if (ver.length == 0)
|
||||
{
|
||||
throw new BinaryException(
|
||||
"cannot determine version for binary package '" ~ name ~ "'");
|
||||
}
|
||||
|
||||
return ver;
|
||||
}
|
||||
|
||||
/// Format a dep constraint for human-readable log messages.
|
||||
private @safe string constraintToString(DepConstraint c)
|
||||
{
|
||||
final switch (c.op)
|
||||
{
|
||||
case DepOp.none:
|
||||
return c.name;
|
||||
case DepOp.ge:
|
||||
return c.name ~ ">=" ~ c.ver;
|
||||
case DepOp.le:
|
||||
return c.name ~ "<=" ~ c.ver;
|
||||
case DepOp.eq:
|
||||
return c.name ~ "==" ~ c.ver;
|
||||
case DepOp.ne:
|
||||
return c.name ~ "~=" ~ c.ver;
|
||||
case DepOp.gt:
|
||||
return c.name ~ ">" ~ c.ver;
|
||||
case DepOp.lt:
|
||||
return c.name ~ "<" ~ c.ver;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Check a binary package's version against a dependency constraint.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Check module-level cache for previously fetched manifest.
|
||||
/// 2. Fetch `cfg.binaryManifestUrl(name)` via `tofu.http.get`.
|
||||
/// 3. On HTTP 404 → return BinaryCheckResult(exists:false) —
|
||||
/// treat as recipe-only, no binary available.
|
||||
/// 4. On any other HttpException → rethrow (network/timeout errors
|
||||
/// must propagate, not be silently swallowed).
|
||||
/// 5. Parse the package.lua to extract `version` via light regex scan.
|
||||
/// If version not found → throw BinaryException.
|
||||
/// 6. Check `tofu.vercmp.satisfies(version, constraint)`.
|
||||
/// 7. Cache raw result (exists + ver) for subsequent calls; satisfaction
|
||||
/// is recomputed against each caller's constraint on cache hit.
|
||||
///
|
||||
/// Returns:
|
||||
/// BinaryCheckResult with exists, version string, and satisfaction flag.
|
||||
@safe BinaryCheckResult checkBinaryVersion(string name, DepConstraint constraint,
|
||||
Config cfg)
|
||||
{
|
||||
// 1. Check cache — recompute satisfaction against caller's constraint
|
||||
if (auto cached = name in _versionCache)
|
||||
{
|
||||
bool satisfied = cached.exists && satisfies(cached.ver, constraint);
|
||||
return BinaryCheckResult(cached.exists, cached.ver, satisfied);
|
||||
}
|
||||
|
||||
logDetail("checking binary %s ...", name);
|
||||
|
||||
// 2. Fetch the package.lua manifest
|
||||
string manifestContent;
|
||||
try
|
||||
{
|
||||
manifestContent = get(cfg.binaryManifestUrl(name));
|
||||
}
|
||||
catch (HttpException e)
|
||||
{
|
||||
// 3. HTTP 404 → binary doesn't exist (recipe-only)
|
||||
if (e.msg.canFind("404"))
|
||||
{
|
||||
auto result = BinaryCheckResult(false, "", false);
|
||||
_versionCache[name] = result;
|
||||
return result;
|
||||
}
|
||||
// 4. Any other HTTP error → rethrow (network problems must propagate)
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 5. Extract version from manifest (local name 'ver' — 'version' is a D keyword)
|
||||
auto ver = extractVersion(manifestContent, name);
|
||||
|
||||
// 6. Check satisfaction
|
||||
bool satisfied = satisfies(ver, constraint);
|
||||
|
||||
// 7. Log result
|
||||
if (satisfied)
|
||||
logInfo("binary %s-%s satisfies %s", name, ver, constraintToString(constraint));
|
||||
else
|
||||
logInfo("binary %s-%s does not satisfy %s", name, ver, constraintToString(constraint));
|
||||
|
||||
// 8. Cache and return
|
||||
auto result = BinaryCheckResult(true, ver, satisfied);
|
||||
_versionCache[name] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Unittests — local one-shot HTTP server (same pattern as http.d)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
version (unittest)
|
||||
{
|
||||
import std.socket;
|
||||
import std.concurrency;
|
||||
import std.string;
|
||||
import std.format;
|
||||
|
||||
/// Spawn a one-shot TCP server that sends `response` to the
|
||||
/// first connecting client then exits.
|
||||
private static void oneShotResponder(shared TcpSocket listener,
|
||||
string response) @trusted
|
||||
{
|
||||
try
|
||||
{
|
||||
auto sock = (cast() listener).accept();
|
||||
// drain the request
|
||||
ubyte[8192] buf = void;
|
||||
sock.receive(buf[]);
|
||||
sock.send(cast(immutable(ubyte)[]) response);
|
||||
sock.close();
|
||||
}
|
||||
catch (Throwable) {}
|
||||
}
|
||||
|
||||
/// Bind a listener on an ephemeral port, spawn a responder,
|
||||
/// and return the URL.
|
||||
private static auto bindAndSpawn(string response) @trusted
|
||||
{
|
||||
auto listener = new TcpSocket();
|
||||
listener.bind(new InternetAddress("127.0.0.1",
|
||||
InternetAddress.PORT_ANY));
|
||||
listener.listen(1);
|
||||
auto port = listener.localAddress().toPortString();
|
||||
auto url = "http://127.0.0.1:" ~ port ~ "/";
|
||||
spawn(&oneShotResponder, cast(shared) listener, response);
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Build a minimal HTTP response string.
|
||||
private static string httpResponse(int code, string reason, string body) @trusted
|
||||
{
|
||||
return format(
|
||||
"HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s",
|
||||
code, reason, body.length, body);
|
||||
}
|
||||
|
||||
/// Create a Config that points zuurUrl at the given base URL.
|
||||
private @safe Config testConfig(string baseUrl)
|
||||
{
|
||||
Config cfg;
|
||||
cfg.zuurUrl = baseUrl;
|
||||
cfg.cacheDir = "";
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (1): binary exists, version 2.1.0, constraint ge 2.0 → satisfies ──
|
||||
@safe unittest
|
||||
{
|
||||
auto manifest = `return { name = "firefox", version = "2.1.0", summary = "Browser" }`;
|
||||
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
|
||||
auto cfg = testConfig(baseUrl);
|
||||
|
||||
auto constraint = DepConstraint.parse("firefox>=2.0");
|
||||
auto result = checkBinaryVersion("firefox", constraint, cfg);
|
||||
|
||||
assert(result.exists, "binary should exist");
|
||||
assert(result.ver == "2.1.0", "version should be 2.1.0, got: " ~ result.ver);
|
||||
assert(result.satisfies, "2.1.0 should satisfy >=2.0");
|
||||
}
|
||||
|
||||
// ── Test (2): binary exists, version 1.9, constraint ge 2.0 → !satisfies ──
|
||||
@safe unittest
|
||||
{
|
||||
auto manifest = `return { name = "libfoo", version = "1.9", summary = "Library" }`;
|
||||
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
|
||||
auto cfg = testConfig(baseUrl);
|
||||
|
||||
auto constraint = DepConstraint.parse("libfoo>=2.0");
|
||||
auto result = checkBinaryVersion("libfoo", constraint, cfg);
|
||||
|
||||
assert(result.exists, "binary should exist");
|
||||
assert(result.ver == "1.9", "version should be 1.9, got: " ~ result.ver);
|
||||
assert(!result.satisfies, "1.9 should NOT satisfy >=2.0");
|
||||
}
|
||||
|
||||
// ── Test (3): 404 → exists:false, ver:"", satisfies:false ──
|
||||
@safe unittest
|
||||
{
|
||||
auto baseUrl = bindAndSpawn(httpResponse(404, "Not Found", "gone"));
|
||||
auto cfg = testConfig(baseUrl);
|
||||
|
||||
auto constraint = DepConstraint.parse("noexist>=1.0");
|
||||
auto result = checkBinaryVersion("noexist", constraint, cfg);
|
||||
|
||||
assert(!result.exists, "binary should not exist for 404");
|
||||
assert(result.ver == "", "version should be empty for 404");
|
||||
assert(!result.satisfies, "non-existent binary should not satisfy");
|
||||
}
|
||||
|
||||
// ── Test (4): 500 error → rethrows HttpException (not swallowed) ──
|
||||
@safe unittest
|
||||
{
|
||||
auto baseUrl = bindAndSpawn(httpResponse(500, "Internal Server Error", "boom"));
|
||||
auto cfg = testConfig(baseUrl);
|
||||
|
||||
bool caught = false;
|
||||
try
|
||||
{
|
||||
auto constraint = DepConstraint.parse("brokenpkg>=1.0");
|
||||
checkBinaryVersion("brokenpkg", constraint, cfg);
|
||||
assert(false, "Expected HttpException for 500");
|
||||
}
|
||||
catch (HttpException e)
|
||||
{
|
||||
caught = true;
|
||||
assert(e.msg.canFind("500"),
|
||||
"Message should contain '500', got: " ~ e.msg);
|
||||
}
|
||||
assert(caught, "Should have thrown HttpException");
|
||||
}
|
||||
|
||||
// ── Test (5): manifest without version field → BinaryException ──
|
||||
@safe unittest
|
||||
{
|
||||
auto manifest = `return { name = "badpkg", summary = "no version field" }`;
|
||||
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
|
||||
auto cfg = testConfig(baseUrl);
|
||||
|
||||
bool caught = false;
|
||||
try
|
||||
{
|
||||
auto constraint = DepConstraint.parse("badpkg>=1.0");
|
||||
checkBinaryVersion("badpkg", constraint, cfg);
|
||||
assert(false, "Expected BinaryException");
|
||||
}
|
||||
catch (BinaryException e)
|
||||
{
|
||||
caught = true;
|
||||
assert(e.msg.canFind("cannot determine version"),
|
||||
"Message should indicate missing version, got: " ~ e.msg);
|
||||
}
|
||||
assert(caught, "Should have thrown BinaryException");
|
||||
}
|
||||
|
||||
// ── Test (6): version "2.1.0" with unconstrained dep (op none) → satisfies ──
|
||||
@safe unittest
|
||||
{
|
||||
auto manifest = `return { name = "testpkg", version = "2.1.0", summary = "Test" }`;
|
||||
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
|
||||
auto cfg = testConfig(baseUrl);
|
||||
|
||||
auto constraint = DepConstraint.parse("testpkg");
|
||||
assert(constraint.op == DepOp.none, "constraint should be unconstrained");
|
||||
auto result = checkBinaryVersion("testpkg", constraint, cfg);
|
||||
|
||||
assert(result.exists, "binary should exist");
|
||||
assert(result.ver == "2.1.0", "version should be 2.1.0, got: " ~ result.ver);
|
||||
assert(result.satisfies, "unconstrained dep should always satisfy");
|
||||
}
|
||||
Reference in New Issue
Block a user