-- Stand-in for cgit's Lua filter host, dofiled by the test-*.lua files beside -- it and returning a table of helpers. It provides the html output sinks with -- the same escaping source/html.c performs, collects everything a filter -- emits so a check can look at the finished piece of page, and carries the -- check bookkeeping whose result the t05xx scripts read as the exit code. local harness = {} local pieces = {} -- cgit hands a sink's argument to lua_tostring, which turns a number into -- digits and anything else into no output at all, and the C side then -- measures the string with strlen, so everything from the first NUL byte on -- is dropped. Both behaviours are kept here because the extensions document -- the truncation and a test has to prove it rather than pass the bytes -- through. local function sink(escape) return function(value) if type(value) == "number" then value = tostring(value) elseif type(value) ~= "string" then return end local nul = value:find("\0", 1, true) if nul then value = value:sub(1, nul - 1) end if escape then value = escape(value) end pieces[#pieces + 1] = value end end local txt_map = { ["&"] = "&", ["<"] = "<", [">"] = ">" } local attr_map = { ["&"] = "&", ["<"] = "<", [">"] = ">", ["'"] = "'", ['"'] = """, } html = sink(nil) html_txt = sink(function(s) return (s:gsub("[&<>]", txt_map)) end) html_attr = sink(function(s) return (s:gsub("[&<>'\"]", attr_map)) end) -- The bytes url_escape_table encodes, which are those a url path cannot -- carry plainly and those an attribute or a query would misread. A query -- argument spells a space as a plus. local url_escapes = "[%c \"#%%&'+<=>?\\^`{|}\128-\255]" html_url_path = sink(function(s) return (s:gsub(url_escapes, function(c) return string.format("%%%02X", c:byte()) end)) end) html_url_arg = sink(function(s) return (s:gsub(url_escapes, function(c) return c == " " and "+" or string.format("%%%02X", c:byte()) end)) end) -- html_include copies a file into the page as it is, and a file it cannot -- open costs a line in the error log instead. html_include = sink(function(filename) local file, message, code = io.open(filename, "rb") if not file then io.stderr:write("[cgit] Unable to include ", filename, ": ", message:match("^.-: (.*)$") or message, " (", tostring(code), ")\n") return "" end local contents = file:read("*a") file:close() return contents end) -- cgit's print builds the line Lua's own would, each value through tostring -- with a tab between them, and writes it as html does, so a stray print -- lands on the page and not beside it. print = function(...) local parts = {} for i = 1, select("#", ...) do parts[i] = tostring((select(i, ...))) end html(table.concat(parts, "\t") .. "\n") end -- What a script writes to standard error goes to the web server's error log -- under cgit, so it is kept here for a check to read instead. local logged = {} io.stderr = { write = function(self, ...) logged[#logged + 1] = table.concat({ ... }) return self end, } function harness.reset() pieces = {} logged = {} end function harness.output() return table.concat(pieces) end function harness.error_log() return table.concat(logged) end function harness.load(script) dofile(script) end -- The mode a t05xx script names after the script path, which has to be one -- of those given, so a misspelt mode fails instead of quietly running -- another one. Lua 5.1 gives a function taking ... a local arg of its own, -- nil here, so the script's arguments are read from the global table. function harness.mode(...) local known = { ... } local wanted = _G.arg[2] for _, mode in ipairs(known) do if wanted == mode then return mode end end error("unknown mode " .. tostring(wanted) .. ", wanted one of " .. table.concat(known, ", ")) end -- Load a script with some of its own text replaced first, for checks that -- need the commented-out examples of its configuration switched on. Each -- replacement has to match exactly once, so a reworded example fails here -- rather than leaving the check to test the unedited configuration. function harness.load_with(script, replacements) local file = assert(io.open(script, "rb")) local source = file:read("*a") file:close() for _, pair in ipairs(replacements) do local start = source:find(pair[1], 1, true) assert(start and not source:find(pair[1], start + 1, true), "expected exactly one " .. pair[1]) source = source:sub(1, start - 1) .. pair[2] .. source:sub(start + #pair[1]) end assert((loadstring or load)(source, "@" .. script))() end local unpack_args = unpack or table.unpack -- One whole filter round trip, an open with the given arguments, a write per -- string and the close, returning the collected output and the close's -- answer. function harness.run(args, writes) harness.reset() filter_open(unpack_args(args or {})) for _, text in ipairs(writes or {}) do filter_write(text) end local ret = filter_close() return harness.output(), ret end -- Loaders registered here win over any real module on the package path, so a -- test can hand a script a deterministic stand-in, or with a failing loader -- prove the script's fallback for a module that is not installed. local function preload(name, module) package.preload[name] = function() return module end end function harness.preload_failure(name) package.preload[name] = function() error(name .. " deliberately unavailable in this test") end end -- Redirect chosen absolute paths to fixtures in the test directory, for the -- scripts that read configuration from fixed locations like /etc. local path_map = nil function harness.redirect_file(from, to) if path_map == nil then path_map = {} local real_open = io.open io.open = function(path, mode) return real_open(path_map[path] or path, mode) end end path_map[from] = to end -- Set or clear an environment variable as a script sees it, for a check -- that needs the environment to differ from the one the shell gave this -- run. A variable set to nil reads as unset. local env_map = nil function harness.setenv(name, value) if env_map == nil then env_map = {} local real_getenv = os.getenv os.getenv = function(key) if env_map[key] == nil then return real_getenv(key) end return env_map[key] or nil end end env_map[name] = value or false end -- Deterministic bytes standing in for a digest, built from djb2 style lanes -- in plain arithmetic so they compute the same on every Lua version. Not -- cryptographic, just stable and collision-shy enough for the checks. local function fake_digest_bytes(text) local lanes = { 5381, 52711, 1313, 7919 } for i = 1, #text do local byte = text:byte(i) for j = 1, 4 do lanes[j] = (lanes[j] * 33 + byte + j) % 4294967296 end end local bytes = {} for j = 1, 4 do local value = lanes[j] for _ = 1, 4 do bytes[#bytes + 1] = string.char(value % 256) value = math.floor(value / 256) end end return table.concat(bytes) end -- The slice of the luaossl digest interface the avatar filters use. Both -- services document SHA-256, so the stand-in refuses any other algorithm. function harness.stub_digest() preload("openssl.digest", { new = function(algorithm) assert(algorithm == "sha256", "the avatar filters hash with sha256, not " .. tostring(algorithm)) return { final = function(self, text) return fake_digest_bytes(algorithm .. "\0" .. text) end, } end, }) end -- crypt(3) reuses the salt fields of the setting it is handed and appends a -- hash of the password, so this stand-in keeps the fields and appends the -- password itself. A stored value of the salt fields plus the password then -- verifies exactly when the password matches, which is all the login checks -- need. local function fake_crypt(password, setting) local prefix = setting:match("^(%$[^$]+%$[^$]+%$[^$]*%$)") if prefix == nil then prefix = setting .. "$" end return prefix .. password end -- The slices of luaossl and luaposix the auth checks reach. link refuses a -- target that exists, as link(2) does, since that is how a script tells it -- lost a race to make a file, and umask hands back the mask it replaces, so -- a check can see the old one put back. function harness.stub_auth_modules() local rand_counter = 0 local mask = 18 preload("posix.unistd", { crypt = fake_crypt, link = function(from, to) local existing = io.open(to, "rb") if existing then existing:close() return nil, to .. ": File exists", 17 end local source = assert(io.open(from, "rb")) local text = source:read("*a") source:close() local target = assert(io.open(to, "wb")) target:write(text) target:close() return 0 end, unlink = function(path) local removed, err = os.remove(path) if not removed then return nil, err, 2 end return 0 end, }) preload("posix.sys.stat", { umask = function(new) local old = mask mask = new return old end, }) preload("openssl.rand", { bytes = function(count) local out = {} for i = 1, count do rand_counter = rand_counter + 1 out[i] = string.char((rand_counter * 37 + 11) % 256) end return table.concat(out) end, }) -- An HMAC-SHA256 is 32 bytes, twice what one fake digest gives, so two -- of different input are joined to make signatures the real length. preload("openssl.hmac", { new = function(key, algorithm) return { final = function(self, payload) local text = key .. "\1" .. algorithm .. "\1" .. payload return fake_digest_bytes(text) .. fake_digest_bytes("\2" .. text) end, } end, }) end local checks = 0 local failures = 0 local function fail(name, detail) failures = failures + 1 io.write("failed check ", name, "\n") if detail then io.write(detail, "\n") end end function harness.check(name, ok, detail) checks = checks + 1 if not ok then fail(name, detail) end end function harness.equals(name, got, want) checks = checks + 1 if got ~= want then fail(name, "wanted " .. tostring(want) .. "\n got " .. tostring(got)) end end function harness.contains(name, haystack, needle) checks = checks + 1 if type(haystack) ~= "string" or not haystack:find(needle, 1, true) then fail(name, "wanted " .. needle .. "\nwithin " .. tostring(haystack)) end end function harness.excludes(name, haystack, needle) checks = checks + 1 if type(haystack) ~= "string" or haystack:find(needle, 1, true) then fail(name, "did not want " .. needle .. "\nwithin " .. tostring(haystack)) end end -- Checks that a text four times as long costs about four times as much to -- run, as a linear pass does, where a quadratic one costs sixteen times as -- much. Timing one length against the other cancels out how fast the host -- is. Each timing repeats the run for at least 50 ms, and the two lengths -- take turns, keeping the fastest timing of each. A busy host can charge -- the process for time its CPU was taken away, which slows a timing but -- never speeds one up. A ratio under six is plainly linear and ends the -- turns, so only one near the bound takes all five before it can fail. -- Returns what the longer run gave back. function harness.stays_linear(name, run, make, length) local texts = { make(length), make(4 * length) } local fastest = { math.huge, math.huge } local out for _ = 1, 5 do for i = 1, 2 do local runs, started = 0, os.clock() repeat out = run(texts[i]) runs = runs + 1 until os.clock() - started >= 0.05 fastest[i] = math.min(fastest[i], (os.clock() - started) / runs) end if fastest[2] < 6 * fastest[1] then break end end local short, long = fastest[1], fastest[2] harness.check(name, long < 10 * short, string.format("%.4f s at length %d, then %.4f s at four times that", short, length, long)) return out end -- A run that made no check proves nothing, so it fails as well. function harness.finish() if failures > 0 then io.write(failures, " of ", checks, " checks failed\n") os.exit(1) end if checks == 0 then io.write("no checks ran\n") os.exit(1) end io.write("passed ", checks, " checks\n") os.exit(0) end return harness