566 lines
30 KiB
Python
566 lines
30 KiB
Python
"""Execute the exact Computer helper against disposable filesystem fixtures."""
|
|
import base64
|
|
import concurrent.futures
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
HELPER = Path(__file__).resolve().parents[1] / "crates/supervisor/src/computer_files.py"
|
|
|
|
|
|
class ComputerFiles(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name) / "home"
|
|
self.root.mkdir()
|
|
|
|
def call(self, action, name, content=b"", expected=None):
|
|
return subprocess.run(
|
|
["python3", str(HELPER), action, str(self.root), name, json.dumps(expected)],
|
|
input=base64.b64encode(content), capture_output=True, timeout=10,
|
|
)
|
|
|
|
def list_page(self, path="", limit=200, cursor=None):
|
|
return subprocess.run(["python3", str(HELPER), "list-page", str(self.root), path, "null"],
|
|
input=json.dumps({"limit": limit, "cursor": cursor}).encode(),
|
|
capture_output=True, timeout=10)
|
|
|
|
def test_verified_read_returns_only_matching_binary_bytes(self):
|
|
data = bytes(range(256)) * 4096
|
|
(self.root / 'artifact').write_bytes(data)
|
|
expected = {'sha256': hashlib.sha256(data).hexdigest(), 'size': len(data)}
|
|
def verified(request, path='artifact'):
|
|
return subprocess.run(['python3', '-I', str(HELPER), 'read-verified', str(self.root), path],
|
|
input=json.dumps(request).encode(), capture_output=True, timeout=5)
|
|
result = verified(expected)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout, data)
|
|
for request in [{**expected, 'sha256': '0' * 64}, {**expected, 'size': len(data) - 1},
|
|
{**expected, 'size': True}, {**expected, 'sha256': 'bad'}]:
|
|
result = verified(request)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(result.stdout, b'')
|
|
(self.root / 'link').symlink_to('artifact')
|
|
self.assertNotEqual(verified(expected, 'link').returncode, 0)
|
|
(self.root / 'artifact').write_bytes(b'later edit')
|
|
result = verified(expected)
|
|
self.assertIn(b'CONFLICT:', result.stderr)
|
|
self.assertEqual(result.stdout, b'')
|
|
(self.root / 'empty').write_bytes(b'')
|
|
self.assertEqual(verified({'sha256': hashlib.sha256(b'').hexdigest(), 'size': 0}, 'empty').returncode, 0)
|
|
|
|
def test_paired_verification_is_readonly_and_locks_both_directories(self):
|
|
(self.root / 'other').mkdir()
|
|
(self.root / 'other/target').write_bytes(b'target\x00\xff')
|
|
def verify():
|
|
return subprocess.run(['python3', str(HELPER), 'fingerprint-pair-verify', str(self.root), 'source'],
|
|
input=json.dumps({'destination': 'other/target'}).encode(), capture_output=True, timeout=5)
|
|
result = verify()
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
pair = json.loads(result.stdout)
|
|
self.assertEqual(pair['source']['kind'], 'missing')
|
|
self.assertEqual(pair['destination']['sha256'], hashlib.sha256(b'target\x00\xff').hexdigest())
|
|
self.assertTrue(pair['paired'])
|
|
self.assertFalse((self.root / 'source').exists())
|
|
self.assertEqual((self.root / 'other/target').read_bytes(), b'target\x00\xff')
|
|
# A cooperating writer owns the destination lock: even an absent source
|
|
# must not let paired verification bypass that lock.
|
|
import fcntl
|
|
import os
|
|
directory = os.open(self.root / 'other', os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
fcntl.flock(directory, fcntl.LOCK_EX)
|
|
with self.assertRaises(subprocess.TimeoutExpired):
|
|
subprocess.run(['python3', str(HELPER), 'fingerprint-pair-verify', str(self.root), 'source'],
|
|
input=b'{"destination":"other/target"}', capture_output=True, timeout=0.2)
|
|
finally:
|
|
os.close(directory)
|
|
|
|
def test_fingerprint_is_bounded_binary_safe_and_never_follows_symlinks(self):
|
|
name = "觀察\n'file"
|
|
content = b"secret-fixture\x00\xff"
|
|
(self.root / name).write_bytes(content)
|
|
def observe(path):
|
|
return subprocess.run(["python3", "-I", str(HELPER), "fingerprint", str(self.root), path], capture_output=True, timeout=5)
|
|
result = observe(name)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(json.loads(result.stdout), {"path": name, "kind": "file", "size": len(content), "sha256": hashlib.sha256(content).hexdigest()})
|
|
self.assertNotIn(b"secret-fixture", result.stdout)
|
|
self.assertEqual((self.root / name).read_bytes(), content)
|
|
self.assertEqual(json.loads(observe("missing").stdout)["kind"], "missing")
|
|
(self.root / "link").symlink_to(self.root / name)
|
|
self.assertNotEqual(observe("link").returncode, 0)
|
|
with (self.root / "large").open("wb") as stream:
|
|
stream.truncate(1024 * 1024 * 1024)
|
|
result = observe("large")
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertTrue(result.stderr.startswith(b"SIZE_LIMIT:"))
|
|
|
|
def test_directory_pages_cover_large_directory_without_duplicates(self):
|
|
names = {str(number) for number in range(4300)} | {"中文\n'檔案"}
|
|
for name in names:
|
|
(self.root / name).touch()
|
|
seen, cursor, version = [], None, None
|
|
while True:
|
|
response = self.list_page(cursor=cursor)
|
|
self.assertEqual(response.returncode, 0, response.stderr)
|
|
page = json.loads(response.stdout)
|
|
self.assertLessEqual(len(page["entries"]), 200)
|
|
seen.extend(entry["path"] for entry in page["entries"])
|
|
if version is not None:
|
|
self.assertEqual(page["version"], version)
|
|
version = page["version"]
|
|
cursor = page["nextCursor"]
|
|
if page["eof"]:
|
|
self.assertIsNone(cursor)
|
|
break
|
|
self.assertTrue(cursor)
|
|
self.assertEqual(len(seen), len(names))
|
|
self.assertEqual(set(seen), names)
|
|
|
|
def test_directory_cursor_replay_change_and_cross_path_guards(self):
|
|
(self.root / "other").mkdir()
|
|
for name in ["a", "b", "c"]:
|
|
(self.root / name).touch()
|
|
first = json.loads(self.list_page(limit=1).stdout)
|
|
cursor = first["nextCursor"]
|
|
second = self.list_page(limit=1, cursor=cursor)
|
|
self.assertEqual(second.returncode, 0, second.stderr)
|
|
self.assertEqual(json.loads(second.stdout), json.loads(self.list_page(limit=1, cursor=cursor).stdout))
|
|
self.assertIn(b"CONFLICT:", self.list_page("other", cursor=cursor).stderr)
|
|
(self.root / "new").touch()
|
|
stale = self.list_page(cursor=cursor)
|
|
self.assertNotEqual(stale.returncode, 0)
|
|
self.assertIn(b"CONFLICT:", stale.stderr)
|
|
self.assertEqual(stale.stdout, b"")
|
|
for invalid in ["bad-base64", base64.urlsafe_b64encode(b'{"version":"x","cookie":-1}').decode()]:
|
|
self.assertNotEqual(self.list_page(cursor=invalid).returncode, 0)
|
|
self.assertNotEqual(self.list_page(limit=True).returncode, 0)
|
|
self.assertNotEqual(self.list_page("../escape").returncode, 0)
|
|
empty = json.loads(self.list_page("other").stdout)
|
|
self.assertEqual(empty["entries"], [])
|
|
self.assertTrue(empty["eof"])
|
|
|
|
def test_directory_pages_bound_long_names_and_continue_at_output_limit(self):
|
|
directory = self.root
|
|
for number in range(4):
|
|
directory = directory / (str(number) + "\x01" * 200)
|
|
directory.mkdir()
|
|
names = {str(number) + "\x01" * 200 for number in range(220)}
|
|
for name in names:
|
|
(directory / name).touch()
|
|
path = str(directory.relative_to(self.root))
|
|
cursor, seen = None, []
|
|
while True:
|
|
response = self.list_page(path, cursor=cursor)
|
|
self.assertEqual(response.returncode, 0, response.stderr)
|
|
self.assertLess(len(response.stdout), 600 * 1024)
|
|
page = json.loads(response.stdout)
|
|
self.assertLess(len(page["entries"]), 200)
|
|
seen.extend(Path(entry["path"]).name for entry in page["entries"])
|
|
cursor = page["nextCursor"]
|
|
if page["eof"]:
|
|
break
|
|
self.assertEqual(len(seen), len(names))
|
|
self.assertEqual(set(seen), names)
|
|
|
|
def range_read(self, path, offset=0, length=65536, version=None):
|
|
request = {"offset": offset, "length": length, "expectedVersion": version}
|
|
return subprocess.run(["python3", str(HELPER), "read-range", str(self.root), path, "null"],
|
|
input=json.dumps(request).encode(), capture_output=True, timeout=10)
|
|
|
|
def test_byte_pages_round_trip_and_refuse_mixed_versions(self):
|
|
data = bytes(range(256)) * 300
|
|
self.call("write", "binary", data)
|
|
first = json.loads(self.range_read("binary").stdout)
|
|
self.assertFalse(first["eof"])
|
|
self.assertEqual(first["bytesRead"], 65536)
|
|
self.assertEqual(first["nextOffset"], 65536)
|
|
second = json.loads(self.range_read("binary", first["nextOffset"], version=first["version"]).stdout)
|
|
self.assertTrue(second["eof"])
|
|
self.assertEqual(base64.b64decode(first["contentBase64"]) + base64.b64decode(second["contentBase64"]), data)
|
|
self.assertEqual(first["chunkSha256"], hashlib.sha256(data[:65536]).hexdigest())
|
|
self.assertEqual(first["version"], second["version"])
|
|
final = json.loads(self.range_read("binary", len(data), version=first["version"]).stdout)
|
|
self.assertEqual(final["bytesRead"], 0)
|
|
self.assertTrue(final["eof"])
|
|
self.call("write", "binary", data)
|
|
stale = self.range_read("binary", 65536, version=first["version"])
|
|
self.assertNotEqual(stale.returncode, 0)
|
|
self.assertIn(b"CONFLICT:", stale.stderr)
|
|
self.assertEqual(stale.stdout, b"")
|
|
for offset, length, version in [(1, 4, None), (-1, 4, None), (True, 4, None), (0, 0, None), (0, 65537, None), (0, 4, "wrong")]:
|
|
invalid = self.range_read("binary", offset, length, version)
|
|
self.assertNotEqual(invalid.returncode, 0)
|
|
self.assertIn(b"INVALID_ARGUMENT:", invalid.stderr)
|
|
self.assertIn(b"FILE_NOT_FOUND:", self.range_read("missing").stderr)
|
|
|
|
def test_byte_pages_seek_large_sparse_files_without_loading_them(self):
|
|
size = 1024 ** 4
|
|
with (self.root / "large").open("wb") as stream:
|
|
stream.truncate(size)
|
|
stream.seek(size - 4)
|
|
stream.write(b"tail")
|
|
first = json.loads(self.range_read("large", length=16).stdout)
|
|
self.assertEqual(first["size"], size)
|
|
self.assertEqual(base64.b64decode(first["contentBase64"]), b"\x00" * 16)
|
|
tail = json.loads(self.range_read("large", size - 4, version=first["version"]).stdout)
|
|
self.assertEqual(base64.b64decode(tail["contentBase64"]), b"tail")
|
|
self.assertTrue(tail["eof"])
|
|
self.assertEqual(tail["version"], first["version"])
|
|
self.assertIn(b"INVALID_ARGUMENT:", self.range_read("large", size + 1, version=first["version"]).stderr)
|
|
(self.root / "link").symlink_to("/etc/passwd")
|
|
self.assertNotEqual(self.range_read("link").returncode, 0)
|
|
|
|
def search(self, path, query, maximum=20):
|
|
return subprocess.run(["python3", str(HELPER), "search", str(self.root), path, "null"],
|
|
input=json.dumps({"query": query, "maxResults": maximum}).encode(),
|
|
capture_output=True, timeout=10)
|
|
|
|
def test_search_unicode_literal_offsets_and_visible_omissions(self):
|
|
import os
|
|
content = "前綴 literal[.*] 中文 end 中文".encode()
|
|
self.call("write", "nested/text", content)
|
|
result = json.loads(self.search("nested", "中文").stdout)
|
|
self.assertTrue(result["complete"])
|
|
self.assertEqual([item["byteOffset"] for item in result["matches"]],
|
|
[content.index("中文".encode()), content.rindex("中文".encode())])
|
|
self.assertIn("中文".encode(), base64.b64decode(result["matches"][0]["previewBase64"]))
|
|
self.assertEqual(len(json.loads(self.search("nested/text", "[.*]").stdout)["matches"]), 1)
|
|
self.call("write", "binary", b"\x00match")
|
|
(self.root / "outside").symlink_to("/etc")
|
|
os.mkfifo(self.root / "fifo")
|
|
with (self.root / "large").open("wb") as stream:
|
|
stream.truncate(1024 * 1024 + 1)
|
|
result = json.loads(self.search("", "absent").stdout)
|
|
self.assertFalse(result["complete"])
|
|
self.assertEqual(result["matches"], [])
|
|
self.assertEqual(result["skipped"], {"binary": 1, "symlink": 1, "special": 1, "oversize": 1})
|
|
limited = json.loads(self.search("nested", "中文", 1).stdout)
|
|
self.assertTrue(limited["truncated"])
|
|
self.assertEqual(limited["limitReason"], "results")
|
|
self.assertEqual(len(limited["matches"]), 1)
|
|
self.assertNotEqual(self.search("../escape", "x").returncode, 0)
|
|
self.assertNotEqual(self.search("nested", "").returncode, 0)
|
|
self.assertNotEqual(self.search("nested", "x", True).returncode, 0)
|
|
|
|
def test_search_serialized_output_is_bounded(self):
|
|
directory = self.root
|
|
for number in range(4):
|
|
directory = directory / (str(number) + "\x01" * 200)
|
|
directory.mkdir()
|
|
(directory / ("file" + "\x01" * 200)).write_text("x" * 101)
|
|
response = self.search("", "x", 100)
|
|
self.assertEqual(response.returncode, 0, response.stderr)
|
|
result = json.loads(response.stdout)
|
|
self.assertEqual(result["limitReason"], "outputBytes")
|
|
self.assertLessEqual(result["matchBytes"], 512 * 1024)
|
|
self.assertLess(len(response.stdout), 513 * 1024)
|
|
self.assertFalse(result["complete"])
|
|
|
|
def test_search_entry_and_byte_limits_are_explicit(self):
|
|
directory = self.root / "many"
|
|
directory.mkdir()
|
|
for number in range(4100):
|
|
(directory / str(number)).touch()
|
|
result = json.loads(self.search("many", "absent").stdout)
|
|
self.assertEqual(result["limitReason"], "entries")
|
|
self.assertEqual(result["entriesScanned"], 4096)
|
|
self.assertFalse(result["complete"])
|
|
directory = self.root / "bytes"
|
|
directory.mkdir()
|
|
for number in range(17):
|
|
(directory / str(number)).write_bytes(b"a" * (1024 * 1024))
|
|
result = json.loads(self.search("bytes", "absent").stdout)
|
|
self.assertEqual(result["limitReason"], "bytes")
|
|
self.assertEqual(result["bytesRead"], 16 * 1024 * 1024)
|
|
self.assertFalse(result["complete"])
|
|
|
|
def move(self, source, destination, original, expected_target=""):
|
|
request = {"destination": destination, "expectedHash": hashlib.sha256(original).hexdigest(),
|
|
"expectedDestinationHash": expected_target}
|
|
return subprocess.run(["python3", str(HELPER), "move", str(self.root), source, "null"],
|
|
input=json.dumps(request).encode(), capture_output=True, timeout=10)
|
|
|
|
def test_move_preconditions_preserve_files_and_metadata(self):
|
|
data = b"\x00\xfforiginal"
|
|
self.call("write", "a/source", data)
|
|
(self.root / "b").mkdir()
|
|
(self.root / "a/source").chmod(0o640)
|
|
result = self.move("a/source", "b/target", data)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertFalse((self.root / "a/source").exists())
|
|
self.assertEqual((self.root / "b/target").read_bytes(), data)
|
|
self.assertEqual((self.root / "b/target").stat().st_mode & 0o777, 0o640)
|
|
self.assertTrue(json.loads(result.stdout)["verified"])
|
|
self.call("write", "a/source", b"new")
|
|
for digest in ["", hashlib.sha256(b"wrong").hexdigest()]:
|
|
result = self.move("a/source", "b/target", b"new", digest)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(b"CONFLICT", result.stderr)
|
|
self.assertEqual((self.root / "a/source").read_bytes(), b"new")
|
|
self.assertEqual((self.root / "b/target").read_bytes(), data)
|
|
result = self.move("a/source", "b/target", b"new", hashlib.sha256(data).hexdigest())
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual((self.root / "b/target").read_bytes(), b"new")
|
|
self.assertNotEqual(self.move("b/target", "b/target", b"new").returncode, 0)
|
|
(self.root / "escape").symlink_to("/tmp")
|
|
self.assertNotEqual(self.move("b/target", "escape/should-not-exist", b"new").returncode, 0)
|
|
(self.root / "link").symlink_to("b/target")
|
|
self.assertNotEqual(self.move("link", "other", b"new").returncode, 0)
|
|
self.assertNotEqual(self.move("b/target", "../escape", b"new").returncode, 0)
|
|
|
|
def test_moves_lock_directories_in_stable_order(self):
|
|
self.call("write", "a/source", b"a")
|
|
self.call("write", "b/source", b"b")
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
first = pool.submit(self.move, "a/source", "b/target", b"a")
|
|
second = pool.submit(self.move, "b/source", "a/target", b"b")
|
|
for result in [first.result(), second.result()]:
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.call("write", "a/one", b"one")
|
|
self.call("write", "b/two", b"two")
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
first = pool.submit(self.move, "a/one", "winner", b"one")
|
|
second = pool.submit(self.move, "b/two", "winner", b"two")
|
|
results = [first.result(), second.result()]
|
|
self.assertEqual(sum(result.returncode == 0 for result in results), 1)
|
|
self.assertEqual(sum(b"CONFLICT" in result.stderr for result in results), 1)
|
|
|
|
def patch(self, name, original, offset, delete, replacement, **extra):
|
|
request = {"offset": offset, "deleteBytes": delete,
|
|
"contentBase64": base64.b64encode(replacement).decode(),
|
|
"expectedHash": hashlib.sha256(original).hexdigest(), **extra}
|
|
return subprocess.run(["python3", str(HELPER), "patch", str(self.root), name, "null"],
|
|
input=json.dumps(request).encode(), capture_output=True, timeout=10)
|
|
|
|
def test_journal_handoff_rejects_missing_or_truncated_payload_before_directories(self):
|
|
payload = base64.b64encode(b"original")
|
|
argv = ["python3", str(HELPER), "write", str(self.root), "nested/file", "null", "journal-v1", str(len(payload)), hashlib.sha256(payload).hexdigest()]
|
|
for body in [b"", payload, b"LAZYBOY_FILE_COMMIT_V1\n" + payload[:4], b"LAZYBOY_FILE_COMMIT_V1\n" + payload + b"extra"]:
|
|
result = subprocess.run(argv, input=body, capture_output=True, timeout=5)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertFalse((self.root / "nested").exists())
|
|
result = subprocess.run(argv, input=b"LAZYBOY_FILE_COMMIT_V1\n" + payload, capture_output=True, timeout=5)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual((self.root / "nested/file").read_bytes(), b"original")
|
|
|
|
def test_patch_plan_is_readonly_and_mutation_rechecks_base(self):
|
|
original = b"before\x00\xffafter"
|
|
self.call("write", "file", original)
|
|
request = {"offset": 6, "deleteBytes": 2, "contentBase64": base64.b64encode(b"new").decode(), "expectedHash": hashlib.sha256(original).hexdigest()}
|
|
plan = subprocess.run(["python3", str(HELPER), "patch-plan", str(self.root), "file", "null"], input=json.dumps(request).encode(), capture_output=True, timeout=10)
|
|
self.assertEqual(plan.returncode, 0, plan.stderr)
|
|
desired = b"beforenewafter"
|
|
result = json.loads(plan.stdout)
|
|
self.assertTrue(result["planned"])
|
|
self.assertEqual(result["sha256"], hashlib.sha256(desired).hexdigest())
|
|
self.assertEqual(result["size"], len(desired))
|
|
self.assertEqual((self.root / "file").read_bytes(), original)
|
|
self.assertFalse(list(self.root.glob(".lazyboy-write-*")))
|
|
(self.root / "file").write_bytes(b"human edit")
|
|
self.assertEqual(self.patch("file", original, 6, 2, b"new").returncode, 73)
|
|
self.assertEqual((self.root / "file").read_bytes(), b"human edit")
|
|
|
|
def test_patch_binary_cas_range_and_atomic_rejection(self):
|
|
original = b"before\x00\xffafter"
|
|
self.call("write", "file", original)
|
|
(self.root / "file").chmod(0o640)
|
|
result = self.patch("file", original, 6, 2, b"\xfe\x00\x01")
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
updated = b"before\xfe\x00\x01after"
|
|
self.assertEqual((self.root / "file").read_bytes(), updated)
|
|
self.assertEqual(json.loads(result.stdout)["sha256"], hashlib.sha256(updated).hexdigest())
|
|
self.assertTrue(json.loads(result.stdout)["verified"])
|
|
self.assertEqual((self.root / "file").stat().st_mode & 0o777, 0o640)
|
|
self.assertEqual(self.patch("file", original, 0, 1, b"x").returncode, 73)
|
|
for offset, delete in [(100, 0), (0, 100), (-1, 0), (True, 0)]:
|
|
self.assertNotEqual(self.patch("file", updated, offset, delete, b"x").returncode, 0)
|
|
self.assertEqual((self.root / "file").read_bytes(), updated)
|
|
self.assertNotEqual(self.patch("missing", b"", 0, 0, b"x").returncode, 0)
|
|
(self.root / "link").symlink_to("file")
|
|
self.assertNotEqual(self.patch("link", updated, 0, 0, b"x").returncode, 0)
|
|
self.assertNotEqual(self.patch("file", updated, 0, 0, b"x", extra=True).returncode, 0)
|
|
self.assertFalse(list(self.root.glob(".lazyboy-write-*")))
|
|
|
|
def test_concurrent_patches_have_one_winner(self):
|
|
original = b"original"
|
|
self.call("write", "file", original)
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
|
results = list(pool.map(lambda i: self.patch("file", original, 0, 1, str(i).encode()), range(8)))
|
|
self.assertEqual(sum(r.returncode == 0 for r in results), 1)
|
|
self.assertEqual(sum(r.returncode == 73 for r in results), 7)
|
|
|
|
def test_stat_is_bounded_and_does_not_follow_links(self):
|
|
import os
|
|
large = self.root / "large"
|
|
with large.open("wb") as stream:
|
|
stream.truncate(1024 ** 4)
|
|
large.chmod(0o640)
|
|
metadata = json.loads(self.call("stat", "large").stdout)
|
|
self.assertEqual(metadata["size"], 1024 ** 4)
|
|
self.assertEqual(metadata["kind"], "file")
|
|
self.assertEqual(metadata["mode"], 0o640)
|
|
self.assertEqual(metadata["modifiedNs"], str(large.stat().st_mtime_ns))
|
|
(self.root / "link").symlink_to("/etc/passwd")
|
|
self.assertEqual(json.loads(self.call("stat", "link").stdout)["kind"], "symlink")
|
|
(self.root / "outside").symlink_to("/etc")
|
|
self.assertNotEqual(self.call("stat", "outside/passwd").returncode, 0)
|
|
self.assertNotEqual(self.call("stat", "../escape").returncode, 0)
|
|
self.assertNotEqual(self.call("stat", "missing").returncode, 0)
|
|
os.mkfifo(self.root / "pipe")
|
|
self.assertEqual(json.loads(self.call("stat", "pipe").stdout)["kind"], "special")
|
|
self.assertEqual(json.loads(self.call("stat", "").stdout)["kind"], "dir")
|
|
|
|
def test_binary_and_unusual_names_round_trip(self):
|
|
data = bytes(range(256)) * 4096
|
|
for name in ["shared/中文\n檔案", "$(touch INJECTED)", "`touch INJECTED`", "quote'\"\\name"]:
|
|
result = self.call("write", name, data)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(self.call("read", name).stdout, data)
|
|
self.assertFalse((self.root / "INJECTED").exists())
|
|
self.assertEqual(len(json.loads(self.call("list", "").stdout)), 4)
|
|
|
|
def test_concurrent_compare_and_swap_has_one_winner(self):
|
|
self.assertEqual(self.call("write", "shared/result", b"before").returncode, 0)
|
|
digest = hashlib.sha256(b"before").hexdigest()
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
|
results = list(pool.map(lambda i: self.call("write", "shared/result", str(i).encode(), digest), range(8)))
|
|
self.assertEqual(sum(r.returncode == 0 for r in results), 1)
|
|
self.assertEqual(sum(r.returncode == 73 for r in results), 7)
|
|
self.assertEqual(self.call("write", "shared/result", b"overwrite", "").returncode, 73)
|
|
self.assertEqual(self.call("write", "new", b"create", "").returncode, 0)
|
|
self.assertFalse(list(self.root.rglob(".lazyboy-write-*")))
|
|
|
|
def test_symlinks_and_special_files_cannot_escape_or_block(self):
|
|
outside = Path(self.temp.name) / "sentinel"
|
|
outside.write_bytes(b"unchanged")
|
|
(self.root / "link").symlink_to(outside)
|
|
(self.root / "escape").symlink_to(outside.parent, target_is_directory=True)
|
|
for name in ["link", "escape/sentinel", "../sentinel", str(outside)]:
|
|
for action in ["read", "write"]:
|
|
self.assertNotEqual(self.call(action, name, b"bad").returncode, 0)
|
|
self.assertEqual(outside.read_bytes(), b"unchanged")
|
|
import os
|
|
os.mkfifo(self.root / "fifo")
|
|
self.assertNotEqual(self.call("read", "fifo").returncode, 0)
|
|
self.assertNotEqual(self.call("write", "fifo", b"bad").returncode, 0)
|
|
|
|
def test_listing_refuses_oversize_without_partial_success(self):
|
|
for number in range(4097):
|
|
(self.root / str(number)).touch()
|
|
result = self.call("list", "")
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(b"DIRECTORY_LIMIT:", result.stderr)
|
|
self.assertEqual(result.stdout, b"")
|
|
|
|
def test_listing_bounds_serialized_names_not_only_entry_count(self):
|
|
for number in range(600):
|
|
(self.root / (str(number) + "\x01" * 200)).touch()
|
|
result = self.call("list", "")
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(b"DIRECTORY_LIMIT:", result.stderr)
|
|
self.assertEqual(result.stdout, b"")
|
|
|
|
def test_listing_distinguishes_links_and_special_files(self):
|
|
import os
|
|
(self.root / "file").write_text("data")
|
|
(self.root / "link").symlink_to("/etc/passwd")
|
|
os.mkfifo(self.root / "fifo")
|
|
entries = json.loads(self.call("list", "").stdout)
|
|
self.assertEqual({entry["path"]: entry["kind"] for entry in entries},
|
|
{"file": "file", "link": "symlink", "fifo": "special"})
|
|
|
|
def test_read_only_errors_preserve_categories(self):
|
|
for action in ["read", "stat", "list"]:
|
|
result = self.call(action, "missing")
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(b"FILE_NOT_FOUND:", result.stderr)
|
|
self.assertEqual(result.stdout, b"")
|
|
with (self.root / "large").open("wb") as stream:
|
|
stream.truncate(8 * 1024 * 1024 + 1)
|
|
self.assertIn(b"SIZE_LIMIT:", self.call("read", "large").stderr)
|
|
self.assertIn(b"INVALID_ARGUMENT:", self.call("read", "../escape").stderr)
|
|
|
|
def test_read_permission_denial_is_typed(self):
|
|
import os
|
|
Path(self.temp.name).chmod(0o755)
|
|
self.root.chmod(0o755)
|
|
private = self.root / "private"
|
|
private.write_bytes(b"must-not-return")
|
|
private.chmod(0)
|
|
result = subprocess.run(["python3", "-I", "-c", HELPER.read_text(), "read", str(self.root), "private", "null"],
|
|
capture_output=True, timeout=10,
|
|
preexec_fn=(lambda: os.setuid(65534)) if os.geteuid() == 0 else None)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(b"PERMISSION_DENIED:", result.stderr)
|
|
self.assertEqual(result.stdout, b"")
|
|
|
|
def test_write_sync_failure_before_and_after_replace(self):
|
|
# Execute the exact helper while injecting ENOSPC at each fsync boundary.
|
|
import errno
|
|
for failure in (1, 2):
|
|
self.call("write", "file", b"original")
|
|
bootstrap = """
|
|
import errno, os, sys
|
|
source = sys.stdin.read()
|
|
original_sync = os.fsync
|
|
failure_point = int(sys.argv[1])
|
|
calls = 0
|
|
def injected_sync(fd):
|
|
global calls
|
|
calls += 1
|
|
if calls == failure_point:
|
|
raise OSError(errno.ENOSPC, 'fixture disk full')
|
|
return original_sync(fd)
|
|
os.fsync = injected_sync
|
|
sys.argv = ['helper', 'write', sys.argv[2], 'file', 'null']
|
|
import io
|
|
sys.stdin = io.TextIOWrapper(io.BytesIO(b'bmV3'))
|
|
exec(compile(source, 'computer_files.py', 'exec'), {'__name__': '__main__'})
|
|
"""
|
|
result = subprocess.run(["python3", "-I", "-c", bootstrap, str(failure), str(self.root)],
|
|
input=HELPER.read_bytes(), capture_output=True, timeout=10)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(b"fixture disk full", result.stderr)
|
|
self.assertEqual((self.root / "file").read_bytes(), b"original" if failure == 1 else b"new")
|
|
self.assertFalse(list(self.root.glob(".lazyboy-write-*")))
|
|
|
|
def test_postcondition_verification_refuses_failed_durability(self):
|
|
(self.root / 'file').write_bytes(b'unchanged')
|
|
bootstrap = """
|
|
import errno, os, sys
|
|
source = sys.stdin.read()
|
|
def fail_sync(fd):
|
|
raise OSError(errno.ENOSPC, 'verification sync failed')
|
|
os.fsync = fail_sync
|
|
sys.argv = ['helper', 'fingerprint-verify', sys.argv[1], 'file']
|
|
exec(compile(source, 'computer_files.py', 'exec'), {'__name__': '__main__'})
|
|
"""
|
|
result = subprocess.run(['python3', '-I', '-c', bootstrap, str(self.root)],
|
|
input=HELPER.read_bytes(), capture_output=True, timeout=5)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(result.stdout, b'')
|
|
self.assertIn(b'verification sync failed', result.stderr)
|
|
self.assertEqual((self.root / 'file').read_bytes(), b'unchanged')
|
|
|
|
def test_missing_list_and_oversize_are_errors(self):
|
|
self.assertNotEqual(self.call("list", "absent").returncode, 0)
|
|
self.assertNotEqual(self.call("write", "large", b"x" * (8 * 1024 * 1024 + 1)).returncode, 0)
|
|
self.assertFalse((self.root / "large").exists())
|
|
|
|
def test_atomic_replace_preserves_existing_executable_permissions(self):
|
|
target = self.root / "script"
|
|
target.write_bytes(b"before")
|
|
target.chmod(0o755)
|
|
self.assertEqual(self.call("write", "script", b"after").returncode, 0)
|
|
self.assertEqual(target.stat().st_mode & 0o777, 0o755)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|