@@ 6,18 6,63 @@ fun text cs = String.implode (map (Char.chr o IntInf.toInt o Span.ordinal) cs);
fun read path =
let
- val stream = if path = "-" then TextIO.stdIn else TextIO.openIn path
- val text = TextIO.inputN (stream, 1048577)
- val _ = if path = "-" then () else TextIO.closeIn stream
+ val fd = if path = "-" then Posix.FileSys.stdin
+ else Posix.FileSys.openf (path, Posix.FileSys.O_RDONLY, Posix.FileSys.O.flags [])
+ fun close () = if path = "-" then () else Posix.IO.close fd
+ fun loop total chunks =
+ let
+ val bytes = Posix.IO.readVec (fd, Int.min (65536, 1048577 - total))
+ val count = Word8Vector.length bytes
+ in
+ if count = 0 then String.concat (List.rev chunks)
+ else if total + count > 1048576 then raise Fail "input exceeds 1 MiB"
+ else loop (total + count) (Byte.bytesToString bytes :: chunks)
+ end
+ val input = loop 0 [] handle exn => ((close () handle _ => ()); raise exn)
+ val _ = close ()
in
- if size text > 1048576 then raise Fail "input exceeds 1 MiB" else text
+ input
end;
fun write path text =
let
- val stream = BinIO.openOut path
- val _ = BinIO.output (stream, Byte.stringToBytes text)
- in BinIO.closeOut stream end;
+ val bytes = Byte.stringToBytes text
+ val pid = SysWord.toString (Posix.Process.pidToWord (Posix.ProcEnv.getpid ()))
+ val prefix = OS.Path.concat (OS.Path.dir path, "." ^ OS.Path.file path ^ "." ^ pid ^ ".")
+ val defaultMode = Posix.FileSys.S.flags [Posix.FileSys.S.irusr, Posix.FileSys.S.iwusr,
+ Posix.FileSys.S.irgrp, Posix.FileSys.S.iwgrp, Posix.FileSys.S.iroth, Posix.FileSys.S.iwoth]
+ val mode = Posix.FileSys.S.intersect [Posix.FileSys.ST.mode (Posix.FileSys.stat path),
+ Posix.FileSys.S.flags [Posix.FileSys.S.irwxu, Posix.FileSys.S.irwxg, Posix.FileSys.S.irwxo]]
+ handle exn as OS.SysErr (_, SOME error) =>
+ if error = Posix.Error.noent then defaultMode else raise exn
+ fun reserve n =
+ if n = 128 then raise Fail "cannot reserve output file"
+ else
+ let
+ val temporary = prefix ^ Int.toString n
+ in
+ (temporary, Posix.FileSys.createf
+ (temporary, Posix.FileSys.O_WRONLY, Posix.FileSys.O.excl, mode))
+ end handle exn as OS.SysErr (_, SOME error) =>
+ if error = Posix.Error.exist then reserve (n + 1) else raise exn
+ val (temporary, fd) = reserve 0
+ val closed = ref false
+ fun close () = (Posix.IO.close fd; closed := true)
+ fun discard () =
+ ((if !closed then () else close ()) handle _ => ();
+ OS.FileSys.remove temporary handle _ => ())
+ fun loop offset =
+ if offset = Word8Vector.length bytes then ()
+ else
+ let
+ val count = Posix.IO.writeVec (fd, Word8VectorSlice.slice (bytes, offset, NONE))
+ in
+ if count = 0 then raise Fail "output write made no progress" else loop (offset + count)
+ end
+ in
+ (loop 0; close (); OS.FileSys.rename {old = temporary, new = path})
+ handle exn => (discard (); raise exn)
+ end;
fun output (ok, message) =
(TextIO.output (if ok then TextIO.stdOut else TextIO.stdErr, text message);
@@ 35,13 80,12 @@ fun run args =
| ["disassemble", file] => output (Span.disassemble (chars (read file)))
| ["assemble", file, "-o", destination] =>
let
- val input = chars (read file)
- val (ok, bytes) = Span.binary input
+ val ((ok, bytes), report) = Span.artifact (chars (read file))
in
if ok then
(write destination (text bytes);
- output (Span.process [chars "assemble"] input))
- else output (false, bytes)
+ output (true, report))
+ else output (false, report)
end
| ["run", file] => output (Span.process (map chars ["run", "4096"]) (chars (read file)))
| ["run", file, fuel] => output (Span.process (map chars ["run", fuel]) (chars (read file)))
@@ 1,6 1,10 @@
import itertools
+import os
from pathlib import Path
import random
+import resource
+import signal
+import stat
import subprocess
import tempfile
@@ 16,7 20,7 @@ def check(condition, message):
CHECKS += 1
-def cli(*args, text=None, ok=True):
+def cli(*args, text=None, ok=True, preexec_fn=None):
result = subprocess.run(
[str(ROOT / "span"), *map(str, args)],
input=text,
@@ 24,11 28,17 @@ def cli(*args, text=None, ok=True):
capture_output=True,
timeout=90,
cwd=ROOT,
+ preexec_fn=preexec_fn,
)
check((result.returncode == 0) == ok, (args, result.stdout, result.stderr))
return result.stdout if ok else result.stderr
+def restrict_output():
+ signal.signal(signal.SIGXFSZ, signal.SIG_IGN)
+ resource.setrlimit(resource.RLIMIT_FSIZE, (1, 1))
+
+
def source(program):
lines = []
for i, (op, target) in enumerate(program):
@@ 122,6 132,9 @@ def main():
]:
check(message in cli("assemble", "-", text=text, ok=False), message)
check("16-bit range" in cli("assemble", "examples/overflow.span", ok=False), "overflow accepted")
+ check("65536 instructions, 65536 bytes" in cli("assemble", "-", text="nop\n" * 65536), "maximum line count")
+ check("invalid instruction at line 4097" in cli("assemble", "-", text="nop\n" * 4096 + "invalid\npad -1", ok=False), "first parse error changed")
+ check("undefined label first" in cli("assemble", "-", text="pad 3\njmp first\njmp second", ok=False), "first resolution error changed")
for padding, width in [(127, 2), (128, 3), (32767, 3)]:
report = cli("assemble", "-", text=f"jmp end\npad {padding}\nlabel end\nhalt")
check(f"{padding + width + 1} bytes" in report, "forward range boundary")
@@ 145,7 158,8 @@ def main():
expected = rows[pc][0] if pc < length else len(out.read_bytes())
report = cli("run", "-", "256", text=text)
check(f"pc={expected} acc={acc}\n" in report, f"execution mismatch in case {case}")
- cli("assemble", "examples/countdown.span", "-o", out)
+ report = cli("assemble", "examples/countdown.span", "-o", out)
+ check(report == cli("assemble", "examples/countdown.span"), "binary report changed")
check(out.read_bytes() == bytes([1, 1, 1, 2, 17, 130, 16, 123, 3]), "binary output")
check("jmp.8 -5" in cli("disassemble", out), "binary disassembly")
out.write_bytes(bytes([18, 0]))
@@ 153,8 167,56 @@ def main():
out.write_bytes(bytes([255]))
check("invalid encoding" in cli("disassemble", out, ok=False), "unknown opcode accepted")
out.write_bytes(b"preserve")
- cli("assemble", "-", "-o", out, text="jmp missing", ok=False)
+ error = cli("assemble", "-", "-o", out, text="jmp missing", ok=False)
+ check(error == cli("assemble", "-", text="jmp missing", ok=False), "binary error changed")
check(out.read_bytes() == b"preserve", "failed assembly clobbered output")
+ cli("assemble", "-", "-o", out, text="")
+ check(out.read_bytes() == b"", "empty binary output")
+ out.write_bytes(b"preserve")
+ before = set(Path(directory).iterdir())
+ cli("assemble", "examples/countdown.span", "-o", out, ok=False, preexec_fn=restrict_output)
+ check(out.read_bytes() == b"preserve", "failed write clobbered output")
+ check(set(Path(directory).iterdir()) == before, "failed write leaked temporary file")
+ destination = Path(directory) / "destination"
+ destination.mkdir()
+ before = set(Path(directory).iterdir())
+ cli("assemble", "examples/countdown.span", "-o", destination, ok=False)
+ check(destination.is_dir(), "failed rename replaced directory")
+ check(set(Path(directory).iterdir()) == before, "failed rename leaked temporary file")
+ same = Path(directory) / "source"
+ same.write_text("inc\nhalt\n")
+ cli("assemble", same, "-o", same)
+ check(same.read_bytes() == bytes([1, 3]), "assembly cannot replace its source")
+ link = Path(directory) / "link"
+ link.symlink_to(out)
+ cli("assemble", "-", "-o", link, text="halt")
+ check(out.read_bytes() == b"preserve", "output followed destination symlink")
+ check(not link.is_symlink() and link.read_bytes() == bytes([3]), "symlink not atomically replaced")
+ out.write_bytes(b"".join(bytes([18, n, 255 - n]) for n in range(256)) + bytes([3]))
+ listing = cli("disassemble", out)
+ raw = subprocess.run(
+ [str(ROOT / "span"), "disassemble", "-"],
+ input=out.read_bytes(), capture_output=True, timeout=90, cwd=ROOT,
+ )
+ check(raw.returncode == 0 and raw.stdout == listing.encode("ascii"), "binary stdin changed bytes")
+ check(len(listing.splitlines()) == 257, "binary file changed bytes")
+ out.write_bytes(bytes(1048577))
+ check("input exceeds 1 MiB" in cli("disassemble", out, ok=False), "binary size limit missing")
+ check("input exceeds 1 MiB" in cli("assemble", "-", text=" " * 1048577, ok=False), "stdin size limit missing")
+ out.chmod(0o600)
+ cli("assemble", "-", "-o", out, text="halt", preexec_fn=lambda: os.umask(0))
+ check(stat.S_IMODE(out.stat().st_mode) == 0o600, "replacement widened output permissions")
+ protected = Path(directory) / "protected"
+ protected.write_bytes(b"preserve")
+
+ def collide_output():
+ out.with_name(f".{out.name}.{os.getpid():X}.0").symlink_to(protected)
+
+ cli("assemble", "-", "-o", out, text="inc\nhalt", preexec_fn=collide_output)
+ check(out.read_bytes() == bytes([1, 3]), "temporary collision prevented output")
+ check(protected.read_bytes() == b"preserve", "temporary collision followed symlink")
+ collisions = list(Path(directory).glob(f".{out.name}.*"))
+ check(len(collisions) == 1 and collisions[0].is_symlink(), "temporary collision was removed or output leaked")
print(f"{CHECKS} checks passed, including 36 exhaustive layout comparisons")