clamp down on casual watch requests
This commit is contained in:
parent
3b014c2609
commit
2e233fe3b6
2 changed files with 186 additions and 22 deletions
20
README.md
20
README.md
|
|
@ -30,6 +30,7 @@ curl http://localhost:8080/AAPL/returns?fmt=xml
|
||||||
| `GET /symbols` | `application/json` | List of tracked symbols |
|
| `GET /symbols` | `application/json` | List of tracked symbols |
|
||||||
| `GET /:symbol/returns` | `application/json` | Trailing 1/3/5/10yr returns + volatility |
|
| `GET /:symbol/returns` | `application/json` | Trailing 1/3/5/10yr returns + volatility |
|
||||||
| `GET /:symbol/returns?fmt=xml` | `application/xml` | Same, XML for LibreCalc |
|
| `GET /:symbol/returns?fmt=xml` | `application/xml` | Same, XML for LibreCalc |
|
||||||
|
| `GET /:symbol/watch` | `application/json` | Add the symbol to the watchlist (authenticated) |
|
||||||
| `GET /:symbol/quote` | `application/json` | Latest quote |
|
| `GET /:symbol/quote` | `application/json` | Latest quote |
|
||||||
| `GET /:symbol/candles` | `application/x-srf` | Raw SRF cache file |
|
| `GET /:symbol/candles` | `application/x-srf` | Raw SRF cache file |
|
||||||
| `GET /:symbol/dividends` | `application/x-srf` | Raw SRF cache file |
|
| `GET /:symbol/dividends` | `application/x-srf` | Raw SRF cache file |
|
||||||
|
|
@ -68,6 +69,25 @@ If `ZFIN_SERVER_API_KEY` is unset or empty, the server is fully open --
|
||||||
a soft cutover so the key can roll out to clients before enforcement is
|
a soft cutover so the key can roll out to clients before enforcement is
|
||||||
switched on.
|
switched on.
|
||||||
|
|
||||||
|
## Watchlist
|
||||||
|
|
||||||
|
Adding a symbol to the watchlist enrolls it in the `refresh` cron, so it
|
||||||
|
gets fetched on every run from then on -- the one operation that creates
|
||||||
|
recurring provider load. There are two ways in, gated to fit each
|
||||||
|
consumer:
|
||||||
|
|
||||||
|
- **`GET /:symbol/watch`** -- authenticated (API key), for your own
|
||||||
|
tooling deliberately growing the tracked set.
|
||||||
|
- **`GET /:symbol/returns?watch=true`** -- public, but the add only
|
||||||
|
happens when the request carries LibreOffice's `WEBSERVICE`
|
||||||
|
User-Agent. This lets the non-technical user add symbols from a
|
||||||
|
spreadsheet while keeping random callers from growing the cron set.
|
||||||
|
(Obscurity-grade: a User-Agent is trivially spoofable, which is
|
||||||
|
acceptable under the casual-traffic threat model.)
|
||||||
|
|
||||||
|
Adds are serialized and written atomically, and implausible symbols are
|
||||||
|
rejected, so a stray request can't corrupt or balloon `portfolio.srf`.
|
||||||
|
|
||||||
## LibreCalc usage
|
## LibreCalc usage
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
188
src/main.zig
188
src/main.zig
|
|
@ -35,6 +35,11 @@ const App = struct {
|
||||||
/// soft cutover lets the key roll out to clients before enforcement
|
/// soft cutover lets the key roll out to clients before enforcement
|
||||||
/// is switched on. Captured once at init.
|
/// is switched on. Captured once at init.
|
||||||
api_key: ?[]const u8,
|
api_key: ?[]const u8,
|
||||||
|
/// Serializes the portfolio read-modify-write across concurrent
|
||||||
|
/// watchlist adds (httpz dispatches requests on multiple threads).
|
||||||
|
/// Without it two simultaneous adds could both read the old file and
|
||||||
|
/// the second writer would clobber the first's new symbol.
|
||||||
|
watch_mutex: std.Io.Mutex = .init,
|
||||||
|
|
||||||
fn init(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) App {
|
fn init(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) App {
|
||||||
const config = zfin.Config.fromEnv(io, allocator, environ);
|
const config = zfin.Config.fromEnv(io, allocator, environ);
|
||||||
|
|
@ -165,6 +170,47 @@ fn providedKeyMatches(req: *httpz.Request, expected: []const u8) bool {
|
||||||
return keyMatches(q.get("api_key"), expected);
|
return keyMatches(q.get("api_key"), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive User-Agent substrings permitted to add to the
|
||||||
|
/// watchlist via the public `/:symbol/returns?watch=true` path. This is
|
||||||
|
/// obscurity-grade (a UA is trivially spoofable) and matches the
|
||||||
|
/// casual-traffic threat model: it keeps crawlers and stray browsers
|
||||||
|
/// from growing the tracked set - and thus the recurring cron-refresh
|
||||||
|
/// load - while letting the non-technical user's LibreOffice WEBSERVICE
|
||||||
|
/// calls through. The authenticated `/:symbol/watch` route bypasses this
|
||||||
|
/// (a valid API key is a stronger signal than any UA).
|
||||||
|
///
|
||||||
|
/// Confirmed empirically - LibreOffice's WEBSERVICE sends e.g.
|
||||||
|
/// "LibreOffice 24.2.7.2 denylistedbackend/8.5.0 OpenSSL/3.0.13"
|
||||||
|
/// (it also fires a WebDAV OPTIONS preflight that 404s harmlessly; the
|
||||||
|
/// real GET carries the same User-Agent). Matching the version-agnostic
|
||||||
|
/// "LibreOffice" token keeps this robust across releases.
|
||||||
|
const watch_user_agents = [_][]const u8{"LibreOffice"};
|
||||||
|
|
||||||
|
/// True if `ua` matches one of `watch_user_agents` (case-insensitive
|
||||||
|
/// substring). A null/absent User-Agent never matches.
|
||||||
|
fn userAgentMayWatch(ua: ?[]const u8) bool {
|
||||||
|
const agent = ua orelse return false;
|
||||||
|
for (watch_user_agents) |needle| {
|
||||||
|
if (std.ascii.indexOfIgnoreCase(agent, needle) != null) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanity gate for symbols entering the tracked set (and thus recurring
|
||||||
|
/// cron load): non-empty, <=16 chars, and only the characters real
|
||||||
|
/// tickers use - uppercase letters, digits, and `.`/`-` for class
|
||||||
|
/// shares. Not a real ticker validator; just enough to keep junk like an
|
||||||
|
/// over-long or path-shaped segment out of the portfolio file. Symbols
|
||||||
|
/// reach here already upper-cased by `upperDupe`.
|
||||||
|
fn isPlausibleSymbol(sym: []const u8) bool {
|
||||||
|
if (sym.len == 0 or sym.len > 16) return false;
|
||||||
|
for (sym) |c| {
|
||||||
|
const ok = (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '.' or c == '-';
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Route handlers ───────────────────────────────────────────
|
// ── Route handlers ───────────────────────────────────────────
|
||||||
|
|
||||||
fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
|
|
@ -188,6 +234,7 @@ fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
\\Endpoints:
|
\\Endpoints:
|
||||||
\\ GET /{SYMBOL}/returns Trailing 1/3/5/10yr returns (JSON)
|
\\ GET /{SYMBOL}/returns Trailing 1/3/5/10yr returns (JSON)
|
||||||
\\ GET /{SYMBOL}/returns?fmt=xml Trailing returns (XML, for LibreCalc)
|
\\ GET /{SYMBOL}/returns?fmt=xml Trailing returns (XML, for LibreCalc)
|
||||||
|
\\ GET /{SYMBOL}/watch Add SYMBOL to the watchlist (authenticated)
|
||||||
\\ GET /{SYMBOL}/quote Latest quote (JSON)
|
\\ GET /{SYMBOL}/quote Latest quote (JSON)
|
||||||
\\ GET /{SYMBOL}/candles Raw SRF cache file
|
\\ GET /{SYMBOL}/candles Raw SRF cache file
|
||||||
\\ GET /{SYMBOL}/candles_meta Candle freshness metadata (SRF)
|
\\ GET /{SYMBOL}/candles_meta Candle freshness metadata (SRF)
|
||||||
|
|
@ -212,6 +259,11 @@ fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
\\ fetches once from the provider, fills the cache, then serves
|
\\ fetches once from the provider, fills the cache, then serves
|
||||||
\\ (404 only if that fetch also fails).
|
\\ (404 only if that fetch also fails).
|
||||||
\\
|
\\
|
||||||
|
\\Watchlist (add a symbol to the cron refresh set):
|
||||||
|
\\ GET /{SYMBOL}/watch authenticated; for your own tooling
|
||||||
|
\\ GET /{SYMBOL}/returns?watch=true public, but only LibreOffice's
|
||||||
|
\\ WEBSERVICE User-Agent is honored
|
||||||
|
\\
|
||||||
\\Returns fields:
|
\\Returns fields:
|
||||||
\\ lastClose Last closing price
|
\\ lastClose Last closing price
|
||||||
\\ trailing{1,3,5,10}YearReturn Total return with dividend reinvestment
|
\\ trailing{1,3,5,10}YearReturn Total return with dividend reinvestment
|
||||||
|
|
@ -275,13 +327,20 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||||
const arena = res.arena;
|
const arena = res.arena;
|
||||||
const symbol = try upperDupe(arena, raw_symbol);
|
const symbol = try upperDupe(arena, raw_symbol);
|
||||||
|
|
||||||
// Auto-add to watchlist if requested
|
// Auto-add to watchlist if requested. UA-gated (obscurity) so only
|
||||||
|
// LibreOffice WEBSERVICE calls - not random browsers/crawlers - can
|
||||||
|
// grow the tracked set via this public endpoint. Best-effort: the
|
||||||
|
// returns response below is served regardless of whether the add ran.
|
||||||
const q = try req.query();
|
const q = try req.query();
|
||||||
if (q.get("watch")) |w| {
|
if (q.get("watch")) |w| {
|
||||||
if (std.ascii.eqlIgnoreCase(w, "true")) {
|
if (std.ascii.eqlIgnoreCase(w, "true")) {
|
||||||
appendWatchSymbol(app, symbol) catch |err| {
|
if (userAgentMayWatch(req.header("user-agent"))) {
|
||||||
log.warn("failed to append watch symbol {s}: {}", .{ symbol, err });
|
appendWatchSymbol(app, symbol) catch |err| {
|
||||||
};
|
log.warn("failed to append watch symbol {s}: {t}", .{ symbol, err });
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
log.debug("watch add for {s} skipped: User-Agent not allowlisted", .{symbol});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,6 +468,36 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authenticated explicit watchlist add. Not on the public allowlist, so
|
||||||
|
/// `dispatch` requires the API key - unlike the UA-gated `?watch=true`
|
||||||
|
/// path, this is for the operator's own tooling deliberately growing the
|
||||||
|
/// tracked set (and accepting the recurring cron-refresh cost).
|
||||||
|
fn handleWatch(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||||
|
const raw_symbol = req.param("symbol") orelse {
|
||||||
|
res.status = 400;
|
||||||
|
res.body = "Missing symbol";
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
const arena = res.arena;
|
||||||
|
const symbol = try upperDupe(arena, raw_symbol);
|
||||||
|
|
||||||
|
appendWatchSymbol(app, symbol) catch |err| switch (err) {
|
||||||
|
error.InvalidSymbol => {
|
||||||
|
res.status = 400;
|
||||||
|
res.body = "Invalid symbol";
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
res.status = 500;
|
||||||
|
res.body = try std.fmt.allocPrint(arena, "Failed to add watch symbol: {t}", .{err});
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.content_type = httpz.ContentType.JSON;
|
||||||
|
res.body = try std.fmt.allocPrint(arena, "{{\"symbol\":\"{s}\",\"watched\":true}}", .{symbol});
|
||||||
|
}
|
||||||
|
|
||||||
fn handleQuote(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
fn handleQuote(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
|
||||||
const raw_symbol = req.param("symbol") orelse {
|
const raw_symbol = req.param("symbol") orelse {
|
||||||
res.status = 400;
|
res.status = 400;
|
||||||
|
|
@ -762,13 +851,24 @@ fn fmtInt(arena: std.mem.Allocator, value: ?u8) []const u8 {
|
||||||
return "null";
|
return "null";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a watch lot for the given symbol to the portfolio SRF file,
|
/// Append a watch lot for `symbol` to the portfolio SRF file, unless it
|
||||||
/// unless it already exists. Best-effort — errors are logged, not fatal.
|
/// is already tracked. Serialized across requests via `app.watch_mutex`
|
||||||
|
/// and written atomically, so a concurrent add or a mid-write crash can't
|
||||||
|
/// clobber or truncate the portfolio file. Returns `error.InvalidSymbol`
|
||||||
|
/// for implausible symbols; callers decide how loud to be.
|
||||||
fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
|
fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
|
||||||
|
if (!isPlausibleSymbol(symbol)) return error.InvalidSymbol;
|
||||||
|
|
||||||
const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf";
|
const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf";
|
||||||
const allocator = app.allocator;
|
const allocator = app.allocator;
|
||||||
const io = app.io;
|
const io = app.io;
|
||||||
|
|
||||||
|
// Serialize the whole read-modify-write so concurrent adds don't lose
|
||||||
|
// updates (a last-writer-wins race would otherwise drop a symbol).
|
||||||
|
// Uncancelable so a canceled request can't abandon a half-done write.
|
||||||
|
app.watch_mutex.lockUncancelable(io);
|
||||||
|
defer app.watch_mutex.unlock(io);
|
||||||
|
|
||||||
// Read and deserialize existing portfolio (or start empty)
|
// Read and deserialize existing portfolio (or start empty)
|
||||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
const file_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||||
if (err == error.FileNotFound) return writeNewPortfolio(io, allocator, portfolio_path, symbol);
|
if (err == error.FileNotFound) return writeNewPortfolio(io, allocator, portfolio_path, symbol);
|
||||||
|
|
@ -785,7 +885,7 @@ fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new lot list with the watch entry appended
|
// Build new lot list with the watch entry appended
|
||||||
var new_lots = try allocator.alloc(zfin.Lot, portfolio.lots.len + 1);
|
const new_lots = try allocator.alloc(zfin.Lot, portfolio.lots.len + 1);
|
||||||
defer allocator.free(new_lots);
|
defer allocator.free(new_lots);
|
||||||
@memcpy(new_lots[0..portfolio.lots.len], portfolio.lots);
|
@memcpy(new_lots[0..portfolio.lots.len], portfolio.lots);
|
||||||
new_lots[portfolio.lots.len] = .{
|
new_lots[portfolio.lots.len] = .{
|
||||||
|
|
@ -796,16 +896,10 @@ fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
|
||||||
.security_type = .watch,
|
.security_type = .watch,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Serialize and write
|
// Serialize and write atomically.
|
||||||
const output = try zfin.cache.serializePortfolio(allocator, new_lots);
|
const output = try zfin.cache.serializePortfolio(allocator, new_lots);
|
||||||
defer allocator.free(output);
|
defer allocator.free(output);
|
||||||
|
try writeFileAtomic(io, allocator, portfolio_path, output);
|
||||||
const file = try std.Io.Dir.cwd().createFile(io, portfolio_path, .{});
|
|
||||||
defer file.close(io);
|
|
||||||
var write_buf: [4096]u8 = undefined;
|
|
||||||
var fw = file.writer(io, &write_buf);
|
|
||||||
try fw.interface.writeAll(output);
|
|
||||||
try fw.interface.flush();
|
|
||||||
|
|
||||||
log.info("added watch symbol {s} to {s}", .{ symbol, portfolio_path });
|
log.info("added watch symbol {s} to {s}", .{ symbol, portfolio_path });
|
||||||
}
|
}
|
||||||
|
|
@ -820,17 +914,41 @@ fn writeNewPortfolio(io: std.Io, allocator: std.mem.Allocator, path: []const u8,
|
||||||
}};
|
}};
|
||||||
const output = try zfin.cache.serializePortfolio(allocator, &lot);
|
const output = try zfin.cache.serializePortfolio(allocator, &lot);
|
||||||
defer allocator.free(output);
|
defer allocator.free(output);
|
||||||
|
try writeFileAtomic(io, allocator, path, output);
|
||||||
const file = try std.Io.Dir.cwd().createFile(io, path, .{});
|
|
||||||
defer file.close(io);
|
|
||||||
var write_buf: [4096]u8 = undefined;
|
|
||||||
var fw = file.writer(io, &write_buf);
|
|
||||||
try fw.interface.writeAll(output);
|
|
||||||
try fw.interface.flush();
|
|
||||||
|
|
||||||
log.info("created {s} with watch symbol {s}", .{ path, symbol });
|
log.info("created {s} with watch symbol {s}", .{ path, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Crash-safe file write: write to `<path>.tmp`, fsync, then rename over
|
||||||
|
/// `path`. A mid-write crash leaves the prior file intact rather than a
|
||||||
|
/// truncated portfolio. (zfin's internal `atomic.writeFileAtomic` isn't
|
||||||
|
/// part of its public module, so we keep a small local copy.)
|
||||||
|
fn writeFileAtomic(io: std.Io, allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void {
|
||||||
|
const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path});
|
||||||
|
defer allocator.free(tmp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
var tmp_file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{ .truncate = true, .exclusive = false });
|
||||||
|
errdefer {
|
||||||
|
tmp_file.close(io);
|
||||||
|
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err| {
|
||||||
|
log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, err });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try tmp_file.writeStreamingAll(io, bytes);
|
||||||
|
// fsync so the data is durable before the rename appears.
|
||||||
|
try tmp_file.sync(io);
|
||||||
|
tmp_file.close(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
|
||||||
|
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |del_err| {
|
||||||
|
log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, del_err });
|
||||||
|
};
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Refresh command ──────────────────────────────────────────
|
// ── Refresh command ──────────────────────────────────────────
|
||||||
|
|
||||||
fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) !u8 {
|
fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) !u8 {
|
||||||
|
|
@ -1204,6 +1322,10 @@ pub fn main(init: std.process.Init) !u8 {
|
||||||
|
|
||||||
// Symbol routes
|
// Symbol routes
|
||||||
router.get("/:symbol/returns", handleReturns, .{});
|
router.get("/:symbol/returns", handleReturns, .{});
|
||||||
|
// Authenticated explicit watchlist add (API key required - not on
|
||||||
|
// the public allowlist). Distinct from the UA-gated
|
||||||
|
// /:symbol/returns?watch=true path used by LibreOffice.
|
||||||
|
router.get("/:symbol/watch", handleWatch, .{});
|
||||||
router.get("/:symbol/quote", handleQuote, .{});
|
router.get("/:symbol/quote", handleQuote, .{});
|
||||||
router.get("/:symbol/candles", handleCandles, .{});
|
router.get("/:symbol/candles", handleCandles, .{});
|
||||||
router.get("/:symbol/candles_meta", handleCandlesMeta, .{});
|
router.get("/:symbol/candles_meta", handleCandlesMeta, .{});
|
||||||
|
|
@ -1348,6 +1470,28 @@ test "keyMatches" {
|
||||||
try std.testing.expect(!keyMatches("s3cretX", "s3cret"));
|
try std.testing.expect(!keyMatches("s3cretX", "s3cret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "userAgentMayWatch" {
|
||||||
|
try std.testing.expect(userAgentMayWatch("LibreOffice 24.8"));
|
||||||
|
try std.testing.expect(userAgentMayWatch("libreoffice")); // case-insensitive
|
||||||
|
try std.testing.expect(userAgentMayWatch("Mozilla/5.0 LibreOffice/7.6"));
|
||||||
|
try std.testing.expect(!userAgentMayWatch("Mozilla/5.0 (X11; Linux x86_64)"));
|
||||||
|
try std.testing.expect(!userAgentMayWatch("curl/8.14.1"));
|
||||||
|
try std.testing.expect(!userAgentMayWatch(null));
|
||||||
|
try std.testing.expect(!userAgentMayWatch(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "isPlausibleSymbol" {
|
||||||
|
try std.testing.expect(isPlausibleSymbol("AAPL"));
|
||||||
|
try std.testing.expect(isPlausibleSymbol("BRK.B"));
|
||||||
|
try std.testing.expect(isPlausibleSymbol("BRK-B"));
|
||||||
|
try std.testing.expect(isPlausibleSymbol("X"));
|
||||||
|
try std.testing.expect(!isPlausibleSymbol("")); // empty
|
||||||
|
try std.testing.expect(!isPlausibleSymbol("aapl")); // lowercase (upper-cased before this)
|
||||||
|
try std.testing.expect(!isPlausibleSymbol("AB CD")); // space
|
||||||
|
try std.testing.expect(!isPlausibleSymbol("../etc/passwd")); // path-shaped junk
|
||||||
|
try std.testing.expect(!isPlausibleSymbol("ABCDEFGHIJKLMNOPQ")); // 17 chars, too long
|
||||||
|
}
|
||||||
|
|
||||||
test "refreshExit: hard failure dominates, then lag, else clean" {
|
test "refreshExit: hard failure dominates, then lag, else clean" {
|
||||||
try std.testing.expectEqual(@as(u8, 0), refreshExit(0, 0));
|
try std.testing.expectEqual(@as(u8, 0), refreshExit(0, 0));
|
||||||
try std.testing.expectEqual(@as(u8, 75), refreshExit(0, 3));
|
try std.testing.expectEqual(@as(u8, 75), refreshExit(0, 3));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue