login

<     >

2022-02-26 15:44:55 (UTC-03:00)

Marcel Rodrigues <marcelgmr@gmail.com>

first commit

diff --git a/src/app.lua b/src/app.lua
new file mode 100644
index 0000000..352965b
--- /dev/null
+++ b/src/app.lua
@@ -0,0 +1,33 @@
+local http = require "http"
+
+local App = {}
+App.__index = App
+
+function App:add_route(method, pattern, callback)
+    table.insert(self.routes, {method, pattern, callback})
+end
+
+function App:run(port)
+    self.server:run(port)
+end
+
+local function new_app(routes)
+    local obj = setmetatable({}, App)
+    obj.routes = routes or {}
+    obj.server = http.new_http()
+    function obj.server:process(req)
+        for i, route in ipairs(obj.routes) do
+            local method, pattern, func = unpack(route)
+            if req.method == method then
+                local params = {req.path:match(pattern.."$")}
+                if #params > 0 then
+                    return func(req, unpack(params))
+                end
+            end
+        end
+        return "nothing here", 404, "Not found"
+    end
+    return obj
+end
+
+return {new_app=new_app}

diff --git a/src/crypt.lua b/src/crypt.lua
new file mode 100644
index 0000000..fa23583
--- /dev/null
+++ b/src/crypt.lua
@@ -0,0 +1,277 @@
+local bit = require "bit"
+local ffi = require "ffi"
+
+local bnot = bit.bnot
+local band, bor, bxor = bit.band, bit.bor, bit.bxor
+local lshift, rshift = bit.lshift, bit.rshift
+local rol, ror = bit.rol, bit.ror
+
+local char, byte = string.char, string.byte
+
+ffi.cdef[[
+ssize_t getrandom(void *buf, size_t buflen, unsigned int flags);
+]]
+local C = ffi.C
+
+local SHA256_H = {
+    0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
+    0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
+}
+local SHA256_K = {
+    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
+    0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
+    0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
+    0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
+    0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
+    0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
+    0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
+    0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+    0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
+}
+
+local function hex(h)
+    local s = ""
+    for i = 1, #h do
+        s = s .. ("%02x"):format(byte(h, i))
+    end
+    return s
+end
+
+local function urandom(len)
+    local buf = ffi.new("char ["..len.."]")
+    assert(C.getrandom(buf, len, 0) == len)
+    return ffi.string(buf, len)
+end
+
+local function uuid4()
+    local id = urandom(16)
+    local version = char(bor(0x40, band(byte(id, 7), 0x0F)))
+    local variant = char(bor(0x40, band(byte(id, 9), 0x1F)))
+    id = id:sub(1, 6) .. version .. id:sub(8, 8) .. variant .. id:sub(10, 16)
+    return id
+end
+
+-- returns b-bytes big endian integer encoded as string
+local function be_to_str(n, b)
+    local s = ""
+    for i = 1, b do
+        s = char(bit.band(n, 0xFF)) .. s
+        n = rshift(n, 8)
+    end
+    return s
+end
+
+local function sha256_str_to_be32(s)
+    assert(#s == 4)
+    local n = 0
+    for i = 1, 4 do
+        n = lshift(n, 8)
+        n = bor(n, byte(s, i))
+    end
+    return n
+end
+
+-- returns padded message
+local function sha256_pad(s)
+    -- all lengths in bytes
+    local L = #s                         -- input length
+    local n = math.ceil((L+9) / 64) * 64 -- padded length
+    local k = n - (L+9)                  -- zero-padding length
+    s = s .. char(128)
+    s = s .. char(0):rep(k)
+    s = s .. be_to_str(L*8, 8)
+    assert(#s % 64 == 0)
+    return s
+end
+
+local function sha256_mod(n)
+    return band(n, 0xFFFFFFFF)
+end
+
+-- compute hash of a 512-bit chunk
+local function sha256_chunk(s, H)
+    local s0, s1
+    local K = SHA256_K
+    local W = {}
+    for i = 0, 15 do
+        local w_str = s:sub(i*4+1, (i+1)*4)
+        W[i] = sha256_str_to_be32(w_str)
+    end
+    for i = 16, 63 do
+        s0 = bxor(ror(W[i-15],  7), ror(W[i-15], 18), rshift(W[i-15],  3))
+        s1 = bxor(ror(W[i- 2], 17), ror(W[i- 2], 19), rshift(W[i- 2], 10))
+        W[i] = sha256_mod(W[i-16] + s0 + W[i-7] + s1)
+    end
+    local a, b, c, d, e, f, g, h = unpack(H)
+    for i = 0, 63 do
+        local ch, maj, t1, t2
+        s1 = bxor(ror(e, 6), ror(e, 11), ror(e, 25))
+        ch = bxor(band(e, f), band(bnot(e), g))
+        t1 = sha256_mod(h + s1 + ch + K[i+1] + W[i])
+        s0 = bxor(ror(a, 2), ror(a, 13), ror(a, 22))
+        maj = bxor(band(a, b), band(a, c), band(b, c))
+        t2 = sha256_mod(s0 + maj)
+        a, b, c, d, e, f, g, h = sha256_mod(t1+t2), a, b, c, sha256_mod(d+t1), e, f, g
+    end
+    local S = {a, b, c, d, e, f, g, h}
+    for i = 1, 8 do
+        H[i] = sha256_mod(H[i] + S[i])
+    end
+end
+
+local function sha256(s)
+    s = sha256_pad(s)
+    local nchunks = #s / 64
+    local H = {}
+    for i = 1, 8 do
+        H[i] = SHA256_H[i]
+    end
+    for i = 1, nchunks do
+        local chunk = s:sub((i-1)*64+1, i*64)
+        sha256_chunk(chunk, H)
+    end
+    local h = ""
+    for i = 1, 8 do
+        h = h .. be_to_str(H[i], 4)
+    end
+    return h
+end
+
+local function hmac(key, msg)
+    if #key > 64 then
+        key = sha256(key)
+    end
+    key = key .. char(0):rep(64 - #key)
+    assert(#key == 64)
+    local o_key_pad = ""
+    local i_key_pad = ""
+    for i = 1, 64 do
+        local k = byte(key, i)
+        o_key_pad = o_key_pad .. char(bxor(k, 0x5C))
+        i_key_pad = i_key_pad .. char(bxor(k, 0x36))
+    end
+    return sha256(o_key_pad .. sha256(i_key_pad .. msg))
+end
+
+local function pbkdf2(password, salt, c, dklen)
+    assert(dklen % 32 == 0)
+    local n = dklen / 32
+    local dk = ""
+    for i = 1, n do
+        local f = {}
+        for j = 1, 32 do
+            f[j] = 0
+        end
+        local u = salt .. be_to_str(i, 4)
+        for j = 1, c do
+            u = hmac(password, u)
+            for k = 1, 32 do
+                f[k] = bxor(f[k], byte(u, k))
+            end
+        end
+        dk = dk .. char(unpack(f))
+    end
+    assert(#dk == dklen)
+    return dk
+end
+
+local B64_C = {}
+for i = byte("A"), byte("Z") do
+    table.insert(B64_C, char(i))
+end
+for i = byte("a"), byte("z") do
+    table.insert(B64_C, char(i))
+end
+for i = byte("0"), byte("9") do
+    table.insert(B64_C, char(i))
+end
+table.insert(B64_C, "+")
+table.insert(B64_C, "/")
+local B64_D = {}
+for i, c in ipairs(B64_C) do
+    B64_D[c] = i - 1
+end
+local B64_P = "="
+
+local function b64_tri_to_quad(bin)
+    if #bin == 0 then
+        return ""
+    end
+    local n = lshift(bin[1], 16)
+    if #bin > 1 then
+        n = bor(n, lshift(bin[2], 8))
+        if #bin > 2 then
+            n = bor(n, bin[3])
+        end
+    end
+    local i1 = rshift(n, 18) + 1
+    local i2 = band(rshift(n, 12), 0x3F) + 1
+    local i3 = band(rshift(n, 6), 0x3F) + 1
+    local i4 = band(n, 0x3F) + 1
+    local quad = B64_C[i1] .. B64_C[i2]
+    if #bin > 1 then
+        quad = quad .. B64_C[i3]
+        if #bin > 2 then
+            quad = quad .. B64_C[i4]
+        end
+    end
+    quad = quad .. B64_P:rep(4 - #quad)
+    return quad
+end
+
+local function b64_enc(data)
+    local b64 = ""
+    local bin = {}
+    for i = 1, #data do
+        table.insert(bin, byte(data, i))
+        if #bin == 3 then
+            b64 = b64 .. b64_tri_to_quad(bin)
+            bin = {}
+        end
+    end
+    b64 = b64 .. b64_tri_to_quad(bin)
+    return b64
+end
+
+local function base64_pad(b64)
+    local padlen = -#b64 % 4
+    return b64 .. B64_P:rep(padlen)
+end
+
+local function b64_dec(b64)
+    b64 = base64_pad(b64)
+    local data = ""
+    for i = 1, #b64/4 do
+        local n = 0
+        for j = (i-1)*4+1, i*4 do
+            local c = b64:sub(j, j)
+            n = lshift(n, 6)
+            if c ~= B64_P then
+                n = bor(n, B64_D[c])
+            end
+        end
+        local tri = be_to_str(n, 3)
+        local j = i*4
+        if b64:sub(j, j) == B64_P then
+            if b64:sub(j-1, j-1) == B64_P then
+                tri = tri:sub(1, 1)
+            else
+                tri = tri:sub(1, 2)
+            end
+        end
+        data = data .. tri
+    end
+    return data
+end
+
+return {
+    hex=hex, urandom=urandom, uuid4=uuid4, sha256=sha256, hmac=hmac,
+    pbkdf2=pbkdf2, b64_enc=b64_enc, b64_dec=b64_dec
+}

diff --git a/src/http.lua b/src/http.lua
new file mode 100644
index 0000000..b13a0f2
--- /dev/null
+++ b/src/http.lua
@@ -0,0 +1,90 @@
+local tcp = require "tcp"
+
+local function parse_uri(uri)
+    local path, query_str, fragment = uri:match("([^?#]*)%??([^#]*)#?(.*)")
+    local query = {}
+    if #query_str > 0 then
+        for pair in (query_str.."&"):gmatch("([^&]*)&") do
+            local key, val = pair:match("([^=]*)=(.*)")
+            query[key] = val
+        end
+    end
+    return path, query, fragment
+end
+
+local function parse_request(data)
+    local req = {payload="", headers={}}
+    local stage = "status"
+    for line in data:gmatch("(.-)\r?\n") do
+        if stage == "status" then
+            local target, protocol
+            req.method, target, protocol = line:match("(%S*) (%S*) (%S*)")
+            assert(protocol == "HTTP/1.1")
+            req.path, req.query, req.fragment = parse_uri(target)
+            stage = "header"
+        elseif stage == "header" then
+            if line == "" then
+                stage = "payload"
+            else
+                local key, value = line:match("([^:]*):%s*(.*)")
+                req.headers[key:lower()] = value
+            end
+        else -- payload
+            req.payload = req.payload..line.."\n"
+        end
+    end
+    return req
+end
+
+-- cookies is a sequence of tables with the following keys:
+--   key, val -> cookie entry (required)
+--   path -> cookie scope, e.g., /
+--   age -> cookie expiration time in seconds
+local function build_cookie_data(cookies)
+    local data = ""
+    for i, c in ipairs(cookies or {}) do
+        local line = ("Set-Cookie: %s=%s; HttpOnly"):format(c.key, c.val)
+        if c.path ~= nil then
+            line = line .. "; Path=" .. c.path
+        end
+        if c.age ~= nil then
+            line = line .. "; Max-Age=" .. c.age
+        end
+        data = data .. line .. "\r\n"
+    end
+    return data
+end
+
+local function build_response(data, status, reason, cookies)
+    if status == nil then
+        status = 200
+        reason = "OK"
+    end
+    local cookie_data = build_cookie_data(cookies)
+    local fmt = "HTTP/1.1 %03d %s\r\n%s\r\n%s"
+    return fmt:format(status, reason, cookie_data, data)
+end
+
+local HTTP = {}
+HTTP.__index = HTTP
+
+function HTTP:run(port)
+    self.tcp:init(port)
+    self.tcp:run()
+end
+
+local function new_http()
+    local obj = setmetatable({}, HTTP)
+    obj.tcp = tcp.new_tcp(1000, 200)
+    function obj.tcp:process(datain)
+        local req = parse_request(datain)
+        local dataout, status, reason, cookies = obj:process(req)
+        if dataout == nil then
+            return nil
+        end
+        return build_response(dataout, status, reason, cookies)
+    end
+    return obj
+end
+
+return {new_http=new_http}

diff --git a/src/ludweb.lua b/src/ludweb.lua
new file mode 100644
index 0000000..69cd5b4
--- /dev/null
+++ b/src/ludweb.lua
@@ -0,0 +1,6 @@
+local ludweb = {}
+ludweb.app      = require "app"
+ludweb.template = require "template"
+ludweb.crypt    = require "crypt"
+ludweb.sqlite   = require "sqlite"
+return ludweb

diff --git a/src/sqlite.lua b/src/sqlite.lua
new file mode 100644
index 0000000..6e7d488
--- /dev/null
+++ b/src/sqlite.lua
@@ -0,0 +1,42 @@
+local ffi = require "ffi"
+
+ffi.cdef[[
+typedef struct sqlite3 sqlite3;
+int sqlite3_open(
+  const char *filename,   /* Database filename (UTF-8) */
+  sqlite3 **ppDb          /* OUT: SQLite db handle */
+);
+int sqlite3_close(sqlite3*);
+int sqlite3_exec(
+  sqlite3*,                                  /* An open database */
+  const char *sql,                           /* SQL to be evaluated */
+  int (*callback)(void*,int,char**,char**),  /* Callback function */
+  void *,                                    /* 1st argument to callback */
+  char **errmsg                              /* Error msg written here */
+);
+]]
+local C = ffi.load("sqlite3")
+
+local DB = {}
+DB.__index = DB
+
+function DB:execute(sql, cb, arg)
+    if C.sqlite3_exec(self.db, sql, cb, arg, self.err) ~= 0 then
+        print(ffi.string(self.err[0]))
+    end
+end
+
+function DB:close()
+    C.sqlite3_close(self.db)
+end
+
+local function open(fname)
+    local self = setmetatable({}, DB)
+    local pdb = ffi.new("sqlite3 *[1]")
+    C.sqlite3_open(fname, pdb)
+    self.db = pdb[0]
+    self.err = ffi.new("char *[1]")
+    return self
+end
+
+return {open=open}

diff --git a/src/tcp.lua b/src/tcp.lua
new file mode 100644
index 0000000..5b60b57
--- /dev/null
+++ b/src/tcp.lua
@@ -0,0 +1,161 @@
+local ffi = require "ffi"
+local bit = require "bit"
+
+ffi.cdef[[
+int fcntl(int fd, int cmd, ... /* arg */ );
+typedef int socklen_t;
+struct sockaddr {
+    unsigned short   sa_family;
+    char             sa_data[14];
+};
+struct addrinfo {
+    int              ai_flags;
+    int              ai_family;
+    int              ai_socktype;
+    int              ai_protocol;
+    socklen_t        ai_addrlen;
+    struct sockaddr *ai_addr;
+    char            *ai_canonname;
+    struct addrinfo *ai_next;
+};
+typedef union epoll_data {
+    void    *ptr;
+    int      fd;
+    uint32_t u32;
+    uint64_t u64;
+} epoll_data_t;
+struct epoll_event {
+    uint32_t     events;    /* Epoll events */
+    epoll_data_t data;      /* User data variable */
+};
+int getaddrinfo(
+    const char *restrict node,
+    const char *restrict service,
+    const struct addrinfo *restrict hints,
+    struct addrinfo **restrict res
+);
+void freeaddrinfo(struct addrinfo *res);
+const char *gai_strerror(int errcode);
+int close(int fildes);
+int setsockopt(int socket, int level, int option_name, const void *option_value, socklen_t option_len);
+int socket(int domain, int type, int protocol);
+int bind(int socket, const struct sockaddr *address, socklen_t address_len);
+int listen(int socket, int backlog);
+int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
+ssize_t recv(int socket, void *buffer, size_t length, int flags);
+ssize_t send(int socket, const void *buffer, size_t length, int flags);
+int shutdown(int socket, int how);
+int epoll_create(int size);
+int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
+int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
+enum {F_GETFL = 3, F_SETFL = 4};
+enum {O_NONBLOCK = 2048};
+enum {AF_UNSPEC = 0};
+enum {SOCK_STREAM = 1};
+enum {AI_PASSIVE = 1};
+enum {EPOLLIN = 1, EPOLLET = -2147483648};
+enum {EPOLL_CTL_ADD = 1, EPOLL_CTL_DEL = 2};
+enum {SHUT_RDWR = 2};
+enum {SOL_SOCKET = 1};
+enum {SO_REUSEADDR = 2};
+]]
+local C = ffi.C
+
+local function set_non_blocking(sockfd)
+    local flags = C.fcntl(sockfd, C.F_GETFL, 0)
+    flags = bit.bor(flags, C.O_NONBLOCK)
+    C.fcntl(sockfd, C.F_SETFL, flags)
+end
+
+local TCP = {}
+TCP.__index = TCP
+
+function TCP:init(port)
+    local hints = ffi.new("struct addrinfo[1]")
+    hints[0].ai_family = C.AF_UNSPEC
+    hints[0].ai_socktype = C.SOCK_STREAM
+    hints[0].ai_flags = C.AI_PASSIVE
+
+    local servinfo = ffi.new("struct addrinfo[1][1]")
+    servinfo = ffi.cast("struct addrinfo **", servinfo)
+
+    local err = C.getaddrinfo(nil, tostring(port), hints, servinfo)
+    assert(err == 0, ("getaddrinfo error: %s"):format(ffi.string(C.gai_strerror(err))))
+    addr = servinfo[0][0]
+    local val = ffi.new("int [1]", 1) -- for setsockopt()
+    local sockfd = -1
+    while addr ~= nil do
+        sockfd = C.socket(addr.ai_family, addr.ai_socktype, addr.ai_protocol)
+        if sockfd >= 0 then
+            set_non_blocking(sockfd)
+            C.setsockopt(sockfd, C.SOL_SOCKET, C.SO_REUSEADDR, val, ffi.sizeof(val))
+            err = C.bind(sockfd, addr.ai_addr, addr.ai_addrlen)
+            if err >= 0 then
+                break
+            end
+            C.close(sockfd)
+        end
+        addr = addr.ai_next
+    end
+    assert(addr ~= nil, "bind error")
+    C.freeaddrinfo(servinfo[0])
+    assert(C.listen(sockfd, self.backlog) >= 0)
+    self.sockfd = sockfd
+end
+
+function TCP:run()
+    local efd = C.epoll_create(self.max_poll_size)
+    local ev = ffi.new("struct epoll_event[1]")
+    ev[0].events = bit.bor(C.EPOLLIN, C.EPOLLET)
+    ev[0].data.fd = self.sockfd
+    assert(C.epoll_ctl(efd, C.EPOLL_CTL_ADD, self.sockfd, ev) >= 0, "epoll_ctl error")
+
+    local evs = ffi.new("struct epoll_event["..self.max_poll_size.."]")
+    local curfds = 1
+
+    local client_addr = ffi.new("struct sockaddr[1]")
+    local addr_size = ffi.new("socklen_t[1]", ffi.sizeof(client_addr[0]))
+    local buflen = 4096
+    local buffer = ffi.new("char ["..buflen.."]")
+    local running = true
+    while running do
+        nfds = C.epoll_wait(efd, evs, curfds, -1)
+        assert(nfds >= 0, "epoll_wait error")
+        for n = 0, nfds-1 do
+            if evs[n].data.fd == self.sockfd then
+                local newfd = C.accept(self.sockfd, client_addr, addr_size)
+                if newfd < 0 then
+                    break
+                end
+                set_non_blocking(newfd)
+                ev[0].events = bit.bor(C.EPOLLIN, C.EPOLLET)
+                ev[0].data.fd = newfd
+                assert(C.epoll_ctl(efd, C.EPOLL_CTL_ADD, newfd, ev) >= 0, "epoll_ctl error")
+                curfds = curfds + 1
+            else
+                C.recv(evs[n].data.fd, buffer, buflen, 0)
+                local data = self:process(ffi.string(buffer))
+                if data == nil then
+                    running = false
+                else
+                    C.send(evs[n].data.fd, data, #data, 0)
+                end
+                C.epoll_ctl(efd, C.EPOLL_CTL_DEL, evs[n].data.fd, ev)
+                C.shutdown(evs[n].data.fd, C.SHUT_RDWR)
+                curfds = curfds - 1
+                C.close(evs[n].data.fd)
+            end
+        end
+    end
+    C.shutdown(self.sockfd, C.SHUT_RDWR)
+    C.close(self.sockfd)
+end
+
+local function new_tcp(max_poll_size, backlog)
+    local self = setmetatable({}, TCP)
+    self.max_poll_size = max_poll_size
+    self.backlog = backlog
+    return self
+end
+
+return {new_tcp=new_tcp}

diff --git a/src/template.lua b/src/template.lua
new file mode 100644
index 0000000..75c7f31
--- /dev/null
+++ b/src/template.lua
@@ -0,0 +1,94 @@
+local function eval(str, env)
+    local expr = str:gsub("%$(%w+)", "env.%1")
+    local code = "return function(env) return "..expr.." end"
+    return loadstring(code, "eval")()(env)
+end
+
+local function find_matching_end(lines, start, last)
+    local level = 1
+    local else_line
+    for line_num = start+1, last do
+        local line = lines[line_num]
+        if line:sub(1, 1) == "%" then
+            if line:match("%%%s+end") then
+                level = level - 1
+                if level == 0 then
+                    return line_num, else_line
+                end
+            elseif line:match("%%%s+else") then
+                if level == 1 then
+                    else_line = line_num
+                end
+            elseif line:match("%%%s+set") == nil then
+                level = level + 1
+            end
+        end
+    end
+    error(("line %d: no matching end"):format(start))
+end
+
+local function render_block(lines, first, last, env)
+    local result = ""
+    local function sub(s) return eval(s, env) end
+    local line_num = first
+    while line_num <= last do
+        local line = lines[line_num]
+        if line:match("%%%s*(%w+).*") == "set" then
+            local var, expr = line:match("%%%s*set%s+(%w+)%s*=%s*(.+)")
+            assert(var and expr)
+            env[var] = eval(expr, env)
+            line_num = line_num + 1
+        elseif line:sub(1, 1) == "%" then   -- block start
+            local block_type = line:match("%%%s*(%w+).*")
+            local block_end, else_line = find_matching_end(lines, line_num, last)
+            if block_type == "if" then
+                local expr = line:match("%%%s*if%s+(.+)%s+then")
+                assert(expr)
+                if eval(expr, env) then
+                    local true_end = else_line or block_end
+                    result = result .. render_block(lines, line_num+1, true_end-1, env)
+                elseif else_line ~= nil then
+                    result = result .. render_block(lines, else_line+1, block_end-1, env)
+                end
+            elseif block_type == "while" then
+                local expr = line:match("%%%s*while%s+(.+)%s+do")
+                assert(expr)
+                while eval(expr, env) do
+                    result = result .. render_block(lines, line_num+1, block_end-1, env)
+                end
+            elseif block_type == "for" then
+                local var, expr = line:match("%%%s*for%s+(%w+)%s+in%s+(.+)%s+do")
+                assert(var and expr)
+                for i, v in ipairs(eval(expr, env)) do
+                    env[var] = v
+                    result = result .. render_block(lines, line_num+1, block_end-1, env)
+                end
+            end
+            line_num = block_end + 1
+        else
+            local computed = line:gsub("{{(.-)}}", sub)
+            if #computed > 0 then
+                result = result .. computed .. "\n"
+            end
+            line_num = line_num + 1
+        end
+    end
+    return result
+end
+
+local function render_str(str, env)
+    assert(str:sub(#str) == "\n")
+    local lines = {}
+    for line in str:gmatch("(.-)\r?\n") do
+        line = line:gsub("^%s*%%", "%%")
+        table.insert(lines, line)
+    end
+    return render_block(lines, 1, #lines, env)
+end
+
+local function render_file(fname, env)
+    local str = io.input(fname):read("*a")
+    return render_str(str, env)
+end
+
+return {render_str=render_str, render_file=render_file}

diff --git a/test/run_tests.lua b/test/run_tests.lua
new file mode 100644
index 0000000..260a57b
--- /dev/null
+++ b/test/run_tests.lua
@@ -0,0 +1,61 @@
+local ffi = require "ffi"
+
+ffi.cdef[[
+typedef unsigned long ino_t;
+typedef unsigned long off_t;
+struct dirent {
+    ino_t          d_ino;       /* Inode number */
+    off_t          d_off;       /* Not an offset; see below */
+    unsigned short d_reclen;    /* Length of this record */
+    unsigned char  d_type;      /* Type of file; not supported
+                                  by all filesystem types */
+    char           d_name[256]; /* Null-terminated filename */
+};
+int scandir(const char *restrict dirp,
+    struct dirent ***restrict namelist,
+    int (*filter)(const struct dirent *),
+    int (*compar)(const struct dirent **,
+        const struct dirent **));
+int alphasort(const struct dirent **a, const struct dirent **b);
+]]
+local C = ffi.C
+
+local function listdir(path, filter)
+    local c_filter = filter and function (entry)
+        return filter(ffi.string(entry.d_name))
+    end
+    local pentries = ffi.new("struct dirent **[1]")
+    local n = C.scandir(path, pentries, c_filter, C.alphasort)
+    local entries = pentries[0]
+    local list = {}
+    for i = 1, n do
+        table.insert(list, ffi.string(entries[i-1].d_name))
+    end
+    return list
+end
+
+local function run_pattern(pattern)
+    local function filter(name)
+        return name:match(pattern) ~= nil
+    end
+    local list = listdir(".", filter)
+    local total_test_count = 0
+    local mod_count = 0
+    for _, name in ipairs(list) do
+        local modname = name:match(pattern)
+        print(modname)
+        local Suite = require(modname)
+        local test_count = 0
+        for funcname in pairs(Suite) do
+            print("  "..funcname)
+            Suite[funcname]()
+            test_count = test_count + 1
+        end
+        print("  "..test_count.." test(s) OK")
+        total_test_count = total_test_count + test_count
+        mod_count = mod_count + 1
+    end
+    print(total_test_count.." test(s) in "..mod_count.." suite(s) OK")
+end
+
+run_pattern("^(test_.*)%.lua")

diff --git a/test/test_crypt.lua b/test/test_crypt.lua
new file mode 100644
index 0000000..ac9f9d1
--- /dev/null
+++ b/test/test_crypt.lua
@@ -0,0 +1,101 @@
+local lud = require "ludweb"
+
+local hex = lud.crypt.hex
+
+local Suite = {}
+
+local b64_cases = {
+    -- generated by coreutils' `base64` command
+    {"Lorem Ipsum", "TG9yZW0gSXBzdW0="},
+    {"dolor sit", "ZG9sb3Igc2l0"},
+    {"amet", "YW1ldA=="},
+}
+
+function Suite.b64_enc()
+    for i, case in ipairs(b64_cases) do
+        local msg = "failed for '"..case[1].."'"
+        assert(lud.crypt.b64_enc(case[1]) == case[2], msg)
+    end
+end
+
+function Suite.b64_dec()
+    for i, case in ipairs(b64_cases) do
+        local msg = "failed for '"..case[1].."'"
+        assert(lud.crypt.b64_dec(case[2]) == case[1], msg)
+    end
+end
+
+function Suite.urandom()
+    for i = 1, 32 do
+        assert(#lud.crypt.urandom(i) == i)
+    end
+end
+
+function Suite.uuid4()
+    local ids = {}
+    local n = 32
+    for i = 1, n do
+        local id = lud.crypt.uuid4()
+        assert(#id == 16, "UUID has bad length: "..#id)
+        ids[id] = true
+    end
+    local j = 0
+    for id in pairs(ids) do
+        j = j + 1
+    end
+    assert(j == n, "UUIDs not unique")
+end
+
+-- $ printf "Lorem Ipsum" | awk '{for(i=1; i<=256; i++) print $0}'
+local big_str = ("Lorem Ipsum\n"):rep(256)
+
+function Suite.sha256()
+    local cases = {
+        -- generated by coreutils' `sha256sum` command
+        {"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", ""},
+        {"16aba5393ad72c0041f5600ad3c2c52ec437a2f0c7fc08fadfc3c0fe9641d7a3", "Lorem ipsum dolor sit amet"},
+        {"181eb9ac4d4d08b00a64d636ab7a6174c3781c32b3b3a32b8783e9dda7be3455", big_str},
+    }
+    for i, case in ipairs(cases) do
+        assert(hex(lud.crypt.sha256(case[2])) == case[1], "failed for case "..i)
+    end
+end
+
+function Suite.hmac()
+    local cases = {
+        -- generated by coreutils' `hmac256` command
+        {"bce68cb87da59c708eaff17571c501dee1c4aeed50d9552f0b8644286317bcc9", "test123", ""},
+        {"dce75555c455f477dd2f54d08c78277b3f3c12ba7f69d78b0222eb7b85438e89", "foo", "Lorem Ipsum"},
+        {"5dcbecdb10bb78da2ca11fe6849578e1d4a23bdcf23a71b8fd7a18f5d968a922", "kind-of-a-long-key-for-hmac", big_str},
+    }
+    for i, case in ipairs(cases) do
+        assert(hex(lud.crypt.hmac(case[2], case[3])) == case[1], "failed for case "..i)
+    end
+end
+
+function Suite.pbkdf2()
+    -- test vector from RFC 7914, section 11
+    local cases = {
+        {"passwd", "salt", 1, 64, [[
+            55 ac 04 6e 56 e3 08 9f ec 16 91 c2 25 44 b6 05
+            f9 41 85 21 6d de 04 65 e6 8b 9d 57 c2 0d ac bc
+            49 ca 9c cc f1 79 b6 45 99 16 64 b3 9d 77 ef 31
+            7c 71 b8 45 b1 e3 0b d5 09 11 20 41 d3 a1 97 83
+        ]]},
+        -- the following takes a few seconds; comment it out for quick test
+        {"Password", "NaCl", 80000, 64, [[
+            4d dc d8 f6 0b 98 be 21 83 0c ee 5e f2 27 01 f9
+            64 1a 44 18 d0 4c 04 14 ae ff 08 87 6b 34 ab 56
+            a1 d4 25 a1 22 58 33 54 9a db 84 1b 51 c9 b3 17
+            6a 27 2b de bb a1 d0 78 47 8f 62 b3 97 f3 3c 8d
+        ]]},
+    }
+    for i, case in ipairs(cases) do
+        local pw, salt, c, dklen, hash = unpack(case)
+        hash = hash:gsub("%s", "")
+        local my_hash = hex(lud.crypt.pbkdf2(pw, salt, c, dklen))
+        assert(my_hash == hash)
+    end
+end
+
+return Suite

diff --git a/test/test_template.lua b/test/test_template.lua
new file mode 100644
index 0000000..cf72382
--- /dev/null
+++ b/test/test_template.lua
@@ -0,0 +1,83 @@
+local lud = require "ludweb"
+
+local render = lud.template.render_str
+
+local Suite = {}
+
+function Suite.render_nop()
+    local str = "foo bar\n"
+    assert(render(str) == str)
+end
+
+function Suite.render_literal_expr()
+    local cases = {
+        {"foo {{13}} bar", "foo 13 bar"},
+        {"foo {{13+21}} bar", "foo 34 bar"},
+        {"{{(13+21)..' things'}}", "34 things"},
+    }
+    for i, case in ipairs(cases) do
+        assert(render(case[1].."\n") == case[2].."\n", "failed for case "..i)
+    end
+end
+
+function Suite.render_var_expr()
+    local cases = {
+        {"foo {{$n}} {{$foo}}", "foo 42 bar"},
+        {"foo {{$n+21}} {{$foo}}", "foo 63 bar"},
+        {"{{($n+21)..' things'}}", "63 things"},
+    }
+    local env = {n=42, foo="bar"}
+    for i, case in ipairs(cases) do
+        assert(render(case[1].."\n", env) == case[2].."\n", "failed for case "..i)
+    end
+end
+
+function Suite.render_assign()
+    local str = ([[
+        % set foo = "bar"
+        foo = {{$foo}}
+    ]]):gsub(" *$", "")
+    local r = render(str, {foo=true})
+    assert(r:gsub("^%s*", "") == "foo = bar\n")
+end
+
+function Suite.render_if()
+    local str = ([[
+        % if $foo then
+            foo is true
+        % else
+            foo is false
+        % end
+    ]]):gsub(" *$", "")
+    local r
+    r = render(str, {foo=true})
+    assert(r:gsub("^%s*", "") == "foo is true\n")
+    r = render(str, {foo=false})
+    assert(r:gsub("^%s*", "") == "foo is false\n")
+end
+
+function Suite.render_while()
+    local str = ([[
+        % set i = 1
+        % while $i <= 3 do
+            i = {{$i}}
+            % set i = $i + 1
+        % end
+    ]]):gsub(" *$", "")
+    local r = render(str, {foo=true})
+    r = r:gsub("^ *", ""):gsub("\n *", "\n")
+    assert(r == "i = 1\ni = 2\ni = 3\n")
+end
+
+function Suite.render_for()
+    local str = ([[
+        % for i in {2, 3, 5} do
+            i = {{$i}}
+        % end
+    ]]):gsub(" *$", "")
+    local r = render(str, {foo=true})
+    r = r:gsub("^ *", ""):gsub("\n *", "\n")
+    assert(r == "i = 2\ni = 3\ni = 5\n")
+end
+
+return Suite