- Module tofu.index: download index via tofu.http.get, sandbox parse via Lua subprocess - Whitelist sandbox (ported from ZETA lib/sandbox.lua): index code has zero I/O/exec access - Lua 5.1/5.2+ compatible: setfenv detection with fallback to load() env param - JSON escaping: handles quotes, backslashes, control characters in string values - Defensive parsing: skips entries with empty names or invalid pool values - 10 unittest blocks: happy path, malicious os.execute/io.open blocked, lua not found, bad JSON, empty index, missing name skip, invalid pool skip, syntax error, string escaping - Evidence: 7 modules pass unittests, dub build passes with warnings-as-errors
17 KiB
Learnings — tofu-core
Conventions, patterns, and successful approaches discovered during work on this plan.
Auto-scaffolded by /start-work. Append new entries below - never overwrite.
Task 5 — tofu.vercmp (RPM-style version comparison)
Module naming
versionis a D reserved keyword (conditional compilation). Module is namedtofu.vercmp, filesrc/tofu/vercmp.d. TheDepConstraintstruct intypes.duses fieldver(notversion) for the same reason.
Algorithm (exact port from ZETA/lib/vercmp.lua)
nextSegment: skips any non-digit/non-letter chars (separators); then reads a contiguous run of same-type chars. Returns(segment, nextIndex).cmpNumeric: strips leading zeros from both segments ("007"→"7","000"→"0"). Longer stripped string wins; then lexicographic.compare: strips all whitespace, then loops over alternating segments. If both segments are digit-typed →cmpNumeric, else lexical. When one version exhausts segments: the exhausted one is older (shorter = older). This matches RPM semantics:compare("1.0.0", "1.0") > 0.satisfies: usesfinal switchonDepOpenum. Maps tocompareresult as expected (ge→c>=0, le→c<=0, eq→c==0, ne→c!=0, gt→c>0, lt→c<0).parseDep: delegates toDepConstraint.parse(already ported in types.d by task 4). Both implementations are kept consistent.
whitespace stripping
- Could not use
filter+arraybecause Phobos auto-decodesstringtodcharrange elements, returningdchar[]notstring. Implemented a manualremoveWhitespacehelper using raw code-unit indexing, with a@trustedcast since the freshly-allocatedchar[]has no aliasing.
Test porting
- All 10 ZETA test categories ported as separate
@safe unittestblocks. - 4 modules pass unittests (types, vercmp, plus pre-existing ones).
dub buildpasses with warnings-as-errors enabled.
Future considerations
- When task 12 (constraint resolution) imports
tofu.vercmp, thecompareandsatisfiesfunctions are ready. TheparseDepintegration withDepConstraint.parseensures consistency between ZETA and tofu's dep constraint semantics.
Task 2: Config Loading (src/tofu/config.d)
toml package API (v1.0.0, Kripth/toml)
- Import:
import toml;→ providesTOMLDocument,TOMLValue,parseTOML,TOMLException,TOMLParserException TOMLDocumenthasalias table this— acts likeTOMLValue[string]AA- Access sections:
doc["core"]returnsTOMLValuewithtype == TOML_TYPE.TABLE - Access string values:
doc["core"]["key"].str(throws if type mismatch) - Check existence:
"key" in docreturnsTOMLValue*(null if absent) parseTOML()is@system— must wrap in@trustedblock when calling from@safecode- Keys without
.str/.integeraccess will throwTOMLException
D language gotchas
- Nested function declarations: D does NOT allow
void foo() { ... }inside a function body. Use lambdas:auto foo = () { ... }; foo();or anonymous() @trusted { ... }(); versionis a keyword: Cannot usestring version;as field name — usestring version_;or initialize with= ""parseTOMLis@system: The toml package's parse function isn't marked@safe— must wrap in@trustedlambdastd.file.readTextis@system: Same — wrap in@trustedstd.process.environment.get(): Returns empty string when env var is unset;"VAR" in environmentchecks existence butenvironment.get("VAR", "default")is cleanerstderr.writeflnis@safein recent DMD (2.106+) — usable directly in@safecode- AA
inoperator: Returns pointer (V*) ornull— must check for null before dereferencing
Pattern: env-overrideable config loading
- Priority: env vars > TOML file > hardcoded defaults
- TOML read happens BEFORE env override — so TOML values serve as base, env vars overwrite
- Missing config file → no error, just skip to defaults
- Malformed TOML →
TOMLParserExceptioncaught, warning to stderr, fall back to defaults - Testability:
load()accepts optionalenvOverridesmap and explicitconfigFilepath to avoid real env/filesystem
Config design decisions
TOFU_CONFIGenv var overrides default config file path (~/.config/tofu/config.toml)~in default paths expanded withstd.path.expandTilde- All path fields default to empty string when "find on PATH" — no hardcoded binary paths
defaultJobstyped asint(notsize_t/ulong) since it's a user-facing count
buildOptions warningsAsErrors deprecation
- DUB warns about
warningsAsErrorsinbuildOptions— recommendsbuildRequirementsinstead - Not blocking, informational only
Task 3 — tofu.log (colored, NO_COLOR-aware logging)
- dmd 2.112 gotcha: the
std.stdioglobalsstdout/stderrare@systemto access —makeGlobaluses__gshared File result, which fails@safeinference. An@safefunction cannot callstdout.writeln(...)directly. Fix: route every write through a tiny@trustedhelper (writeStdout(string)/writeStderr(string)), keep the public log functions@safe. Compiler hint: "using__gsharedinstead ofsharedmakes it fail to infer@safe". std.process.environment(dmd 2.112): it is anabstract final classof static methods —environment.get(name)is@safeand returnsnullwhen the var is unset (so "NO_COLOR set" ==get !is null, even for an empty string).environment["K"] = vandenvironment.remove(name)are@trusted. NOT deprecated in 2.112.- Testable color decision: make
colorEnabled()re-read the environment on every call instead of caching instatic this(). Enables NO_COLOR/TERM tests without restarting the process. Costs onegetenvper log line — negligible. - Env save/restore in unittests: a
private struct ColorEnv(inversion(unittest)) that snapshots NO_COLOR+TERM in its ctor and restores them in~this()(destructor) —auto env = ColorEnv("1", "xterm")scopes the restore.nullvalue = remove the var. Double-destruction from a copied temporary is harmless (restore is idempotent). - Capturing stdout/stderr in unittests: swap the global
stdout/stderrFile to aFile(name, "w+"), write,flush(), swap back, thenrewind()+readln(). Restore the global before the temp File's destructor runs. - zeta log format details:
warnpaints the whole prefix yellow (paint("yellow", "warn ") ~ msg), buterrorpaints only the word then" " ~ msg(paint("red","error") ~ " " ~ msg). Assertion must expect\x1b[31merror\x1b[0m failed deploy— the reset code sits between "error" and the message. versionis a D keyword — cannot be used as a struct field / identifier (trap for the paralleltypes.dwork).std.stdio.writelnflushes per call — already satisfies "no buffering"; no explicitflush()needed in production paths.- Verifying a single module without the full build:
dmd -main -unittest -i src/tofu/log.druns just that module's unittests when sibling modules (config.d,types.d) are mid-edit by parallel agents.-ipulls imports in automatically.
Task 4 — tofu.types (core data structures)
versionis a D keyword — all struct fields namedversionmust be renamed tover. The D compiler treatsversionas a conditional-compilation directive and rejects it as a field name (see task 3 learnings above).- Struct field defaults in D:
stringfields initialise tonullby default. To enforce empty-string semantics (""), every string field must be explicitly= "". final switchfor exhaustive enum handling: usingfinal switchon enum types forces the compiler to verify all cases are covered — catches missing branches at compile time. Used forpoolToString().- Manual character scanning preferred over
std.regex: the dep-spec parser uses simple char-by-char scanning withisNameChar()/isWhite()helpers instead of regex. This avoids thestd.regexdependency, keeps the parser@safe, and matches the Lua reference semantics exactly. - Dep spec parsing order matters: 2-character operators (
>=,<=,==,~=) MUST be checked before 1-character operators (>,<,=) to avoid false matches. The Lua reference uses an ordered tableOPS = { ">=", "<=", "==", "~=", ">", "<", "=" }— we replicate this with if/else chains. =normalises to==: the Lua code hasif op == "=" then op = "==" end; we map both toDepOp.eq.- Trailing garbage detection: after extracting version chars, any non-whitespace remaining in the spec string causes a
TypesException. The Lua pattern uses$anchor for this. dub testincludes all.dfiles in source paths for executable targets, not just imported modules. Pre-existing compile errors in sibling modules (config.d) block test runs. Fixed config.d by removingprivatefrom a nested function (access specifiers not allowed on local functions in D).BuildResult.failedasBuildFailure[]: the plan describes it asstruct {string name; string reason}[]— in D this is a named structBuildFailurewith array fieldBuildFailure[] failed. ThefailedNames()helper extracts just the names for caller convenience.DepConstraint.parseis a static factory method: returns a newDepConstraintby value, never allocates on the heap.@safethroughout.- All structs are
@safe: no methods do I/O, no@systemcalls. The module stands alone — no imports from other tofu modules.
Task 6 — tofu.http (sync HTTP client via std.net.curl)
std.net.curl API (key members for HTTP struct)
- Construction:
HTTP(url)viaopCall, orHTTP()+.url = urlsetter. The.urlsetter auto-prependshttp://if no scheme is present. - Timeouts (all are
@propertysetters takingcore.time.Duration):connectTimeout— connect phase only (CurlOption.connecttimeout_ms).operationTimeout— DNS + connect + transfer total (CurlOption.timeout_ms).dataTimeout— low-speed activity timeout (NOT total read timeout; setslow_speed_limit+low_speed_time).- For a "read 120s" requirement use
operationTimeout = 120.seconds— it covers the entire operation including data transfer.
- Redirects:
maxRedirects(property setter, takesuint). Defaults to 10. Set to 0 to disable,uint.maxfor infinite. Internally setsCurlOption.followlocation+CurlOption.maxredirs. - User-Agent:
setUserAgent(string)— instance method (not property). - onReceive:
void delegate(ubyte[])returningsize_t. Must accept all bytes or the request aborts. Usereturn data.length;. - onReceiveStatusLine: Not a direct property on HTTP struct — must
be set via the
onReceiveHeaderproperty setter which wraps the delegate and internally firesonReceiveStatusLinewhen it detects an HTTP status line. Afterperform()the final status is available viahttp.statusLine. - statusLine:
HTTP.StatusLinestruct with.code(ushort),.majorVersion,.minorVersion,.reason. Reset to zero before eachperform(). - perform():
CurlCode perform(ThrowOnError = Yes.throwOnError). Default throws on curl errors, but the thrown exception classes are:CurlTimeoutException : CurlException— operation timed out (code 28).CurlException— all other curl errors.HTTPStatusException : CurlException— only thrown by high-level wrappers (_basicHTTP); low-levelperform()does NOT throw on HTTP status codes — you must checkstatusLine.codeyourself.
- Exception isolation pattern: catch
CurlTimeoutExceptionandCurlExceptionin@trustedhelpers, re-throw asHttpException(which extendsException, notCurlException). Callers never see curl types. - verifyPeer / verifyHost: On by default in HTTP struct (no change needed).
@safe / @trusted architecture
- All public functions (
get,downloadFile) are@safe. - The
configure(HTTP),getImpl, anddownloadFileImplhelpers are@trusted— they are the ONLY placesstd.net.curl(which is@system-heavy) is used.
Download pattern (.part temp file + rename)
- Ported from ZETA
lib/fetch.lua: write todest.part, rename on success, remove partial on error.scope(failure)at function level handles all error paths automatically. File(tmpPath, "wb")— binary write mode. File destructor auto-closes; explicitf.close()needed before rename.
Unittest local server
- Used
std.concurrency.spawn+std.socket.TcpSocketfor a self-contained one-shot HTTP server (no external dependency on Python3). - Pattern: bind ephemeral port (
InternetAddress.PORT_ANY), spawn thread thataccept()s one connection, sends canned HTTP response, exits.cast(shared)the listener to pass tospawn. Mark test helpers@trusted. - HTTP response must include
Content-Lengthheader or libcurl hangs.
Connection refused test
- Bind socket, record port, close it, then
get()to that port. curl returns error 7 (CURLE_COULDNT_CONNECT) →CurlException→ caught and re-thrown asHttpException.
Build verified
dub buildpasses with warnings-as-errors.dub testpasses — all 5 modules (config, log, types, vercmp, http).
Task 7 — tofu.index (ZUUR index fetch & sandboxed Lua parse)
Security: whitelist sandbox (NOT blacklist)
The task spec suggested stripping dangerous globals (blacklist: io=nil, os=nil, ...). We chose the ZETA lib/sandbox.lua whitelist approach instead because:
- Future-proof: New dangerous globals added to Lua (e.g.
rawlenin 5.3) are blocked by default — the index only sees explicitly allowed functions. - Proven: ZETA has used this sandbox in production; it has passed security review.
- Allowed globals:
_VERSION,assert,error,ipairs,next,pairs,pcall,select,tonumber,tostring,type,rawequal,rawget,rawset,setmetatable,getmetatable,unpack,string,table,math. NOTHING else.
Lua 5.1 vs 5.2+ sandbox differences — CRITICAL for future tasks
The sandbox must work on both Lua 5.1/5.2+/5.5.x. The key difference:
| Version | Compile API | Set environment |
|---|---|---|
| 5.1 / LuaJIT | loadstring(src, name) |
setfenv(chunk, env) |
| 5.2+ (incl. 5.5.1) | load(src, name, "t", env) |
env is 4th arg to load |
Detection: if setfenv then ... else ... end. In Lua 5.2+, setfenv was removed entirely, so if setfenv is nil (falsy) on 5.2+ — this is the canonical cross-version detection pattern.
What NOT to mix up: loadfile is the FILE loader (takes a path). load/loadstring are STRING loaders. The sandbox loader script uses io.open + f:read("*a") to read the index file content, then load to compile it with the sandbox env. This is deliberately explicit — it's exactly what ZETA's sandbox.loadfile does.
System: The system has Lua 5.5.1 (lua -v → Lua 5.5.1). Our sandbox script uses the 5.2+ branch (load(src, name, "t", env)). The 5.1 branch is retained for portability.
std.process import conflicts
import std.process; (unqualified) pulls in std.process.Config, which conflicts with tofu.config.Config. Fix: use selective imports — import std.process : execute, ProcessException, thisProcessID;. This affected both index.d and fetch.d.
std.json API notes (Phobos)
parseJSON(string)→JSONValue(return type, notauto)JSONValue.type→JSONTypeenum:JSONType.array,JSONType.object(NOTobject_).arrayproperty →@system(must wrap in@trusted).str→ string field on object values- Indexing:
entry["name"]→JSONValue
D heredoc syntax (q"DELIM ... DELIM")
- Opening:
q"SCRIPT(no closing quote on same line — rest of line must be blank) - Closing:
SCRIPT"at start of a new line - Then
;on the same line after"to end the statement
Concurrent agent issues
Both binary.d and fetch.d were created by parallel agents and had:
Confignaming conflicts (unqualifiedimport std.process;)versionkeyword used as variable name (binary.d— already fixed by agent)- Missing
canFindimport (fetch.d) - Different test server patterns (
spawnProcessvsspawn— fetch.d)
When testing, temporarily exclude broken sibling modules with mv file.d file.d.skip.
JSON escaping in Lua
Must escape \\, \", \n, \r, \t in string values before embedding in JSON. Order matters: escape backslash BEFORE quote, or doubled backslashes leak:
s = s:gsub("\\", "\\\\"):gsub('"', '\\"')
Test patterns ported from http.d
Reused the one-shot TCP responder pattern (bindAndSpawn, oneShotResponder, httpResponse) directly from tofu.http test harness. The same spawn+accept+send pattern works for index serving.
Malicious index verification
The sandbox test (test 2) creates a sentinel file, serves an index containing os.execute("rm -rf /"), verifies that fetchIndex throws IndexException, then asserts the sentinel file STILL EXISTS. This is the security-critical validation that the sandbox actually blocks RCE. The error message from Lua is "attempt to call a nil value (global 'os')" because os is absent from the sandbox env — caught by pcall and reported as LUA_ERROR:runtime error: ....