From f16879036e5dddfbf1c2a538856777f8030f4e0c Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 5 Oct 2026 12:52:33 -0400 Subject: [PATCH] Batch remote drift checks into agent file manifests --- agent/ec_agent.py | 159 ++++++++++++++++- agent/tests/test_agent.py | 217 +++++++++++++++++++++++ src/lib/server/backends/files.ts | 32 +++- src/lib/server/backends/native.ts | 2 +- src/lib/server/fleet/drift.ts | 51 ++++-- src/lib/server/hosts/index.ts | 2 +- static/agent/ec-agent.py | 159 ++++++++++++++++- static/agent/manifest.json | 4 +- tests/unit/native-drift-manifest.test.ts | 183 +++++++++++++++++++ tests/unit/native-hosts.test.ts | 2 +- tests/unit/native-observations.test.ts | 2 +- 11 files changed, 779 insertions(+), 34 deletions(-) create mode 100644 tests/unit/native-drift-manifest.test.ts diff --git a/agent/ec_agent.py b/agent/ec_agent.py index 7506378..8b0373c 100644 --- a/agent/ec_agent.py +++ b/agent/ec_agent.py @@ -32,9 +32,13 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.4.7" +VERSION = "1.4.8" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 +MANIFEST_MAX_ENTRIES = 10000 +MANIFEST_MAX_BYTES = 16 * 1024 * 1024 +MANIFEST_MAX_SECONDS = 15 +MANIFEST_MAX_DEPTH = 64 LIBRARY_DOWNLOAD_WORKERS = 4 ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") @@ -296,6 +300,151 @@ def managed_read(root, value): return None +def managed_manifest(root, payload, stopping): + """Hash a bounded managed tree without following runtime-controlled symlinks.""" + deadline = time.monotonic() + MANIFEST_MAX_SECONDS + + def check_budget(): + if stopping.is_set() or time.monotonic() >= deadline: + raise AgentError("Manifest scan exceeded its time limit or was interrupted") + + roots = payload.get("roots") + ignored = payload.get("ignoreNames") + paths = payload.get("paths", []) + if not isinstance(roots, list) or not roots or len(roots) > 64: + raise AgentError("Manifest roots must contain between 1 and 64 managed directories") + if not isinstance(ignored, list) or len(ignored) > 256 or any( + not isinstance(name, str) or not name or name in (".", "..") or any(char in name for char in ("/", "\\", "\x00")) + for name in ignored + ): + raise AgentError("Manifest ignored names must be plain file or directory names") + if not isinstance(paths, list) or len(paths) > 10000: + raise AgentError("Manifest managed paths must contain at most 10000 files") + for value in roots: + check_budget() + scoped(root, value) + roots = sorted(set(roots)) + roots = [value for index, value in enumerate(roots) if not any(value.startswith(parent + "/") for parent in roots[:index])] + ignored = set(ignored) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + entries = 0 + result_bytes = len(json.dumps({"files": []}).encode()) + files = [] + + def validate_paths(): + for value in paths: + check_budget() + try: + with managed_open(root, value): + pass + except FileNotFoundError: + pass + + def identity(info): + return info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns + + def open_directory(app, relative): + directory = os.dup(app) + try: + for part in PurePosixPath(relative).parts: + nested = os.open(part, flags, dir_fd=directory) + os.close(directory) + directory = nested + return directory + except BaseException: + os.close(directory) + raise + + def scan(directory, relative, depth): + nonlocal entries, result_bytes + check_budget() + if depth > MANIFEST_MAX_DEPTH: + raise AgentError("Manifest scan exceeded its directory depth limit") + before = os.fstat(directory) + with os.scandir(directory) as children: + for child in children: + check_budget() + entries += 1 + if entries > MANIFEST_MAX_ENTRIES: + raise AgentError("Manifest scan exceeded its entry limit") + if child.name in ignored: + continue + info = os.stat(child.name, dir_fd=directory, follow_symlinks=False) + if stat_module.S_ISLNK(info.st_mode): + continue + path = relative + "/" + child.name + if stat_module.S_ISDIR(info.st_mode): + nested = os.open(child.name, flags, dir_fd=directory) + try: + if identity(info) != identity(os.fstat(nested)): + raise AgentError("Manifest directory changed during the scan") + scan(nested, path, depth + 1) + if identity(os.fstat(nested)) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)): + raise AgentError("Manifest directory changed during the scan") + finally: + os.close(nested) + elif stat_module.S_ISREG(info.st_mode): + descriptor = os.open(child.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + try: + if identity(info) != identity(os.fstat(descriptor)): + raise AgentError("Manifest file changed during the scan") + sha = hashlib.sha256() + size = 0 + with os.fdopen(descriptor, "rb", closefd=False) as stream: + while True: + check_budget() + block = stream.read(1024 * 1024) + if not block: + break + sha.update(block) + size += len(block) + if size != info.st_size or identity(info) != identity(os.fstat(descriptor)) or identity(info) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)): + raise AgentError("Manifest file changed during the scan") + finally: + os.close(descriptor) + item = {"path": path, "size": size, "sha256": sha.hexdigest()} + result_bytes += len(json.dumps(item).encode()) + (2 if files else 0) + if result_bytes > MANIFEST_MAX_BYTES: + raise AgentError("Manifest scan exceeded its result size limit") + files.append(item) + else: + raise AgentError("Manifest scan encountered a non-regular managed file") + if identity(before) != identity(os.fstat(directory)): + raise AgentError("Manifest directory changed during the scan") + + try: + app = os.open(root, flags) + try: + app_identity = os.fstat(app) + validate_paths() + for relative in roots: + check_budget() + try: + directory = open_directory(app, relative) + except FileNotFoundError: + continue # A destination directory need not exist yet. + try: + scan(directory, relative, len(PurePosixPath(relative).parts)) + current_directory = open_directory(app, relative) + try: + if identity(os.fstat(directory)) != identity(os.fstat(current_directory)): + raise AgentError("Manifest directory changed during the scan") + finally: + os.close(current_directory) + finally: + os.close(directory) + validate_paths() + current = os.stat(root, follow_symlinks=False) + if (current.st_dev, current.st_ino, current.st_mode) != (app_identity.st_dev, app_identity.st_ino, app_identity.st_mode): + raise AgentError("Manifest managed root changed during the scan") + check_budget() + finally: + os.close(app) + except OSError: + raise AgentError("Manifest scan could not read the complete managed tree") from None + return {"files": sorted(files, key=lambda item: item["path"])} + + def managed_write(root, value, content, uid=1000, gid=1000): if content is None: try: @@ -1094,7 +1243,7 @@ class Agent: if line.startswith("PRETTY_NAME="): os_name = line.split("=", 1)[1].strip('"') update = self.update_inventory() - capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) + capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} def safe_to_disrupt(self, instance_id, generation): @@ -1446,6 +1595,8 @@ class Agent: return result if kind == "logs.read": return self.read_log(instance_id, generation, payload) + if kind == "files.manifest": + return managed_manifest(app, payload, self.stopping) path = scoped(app, payload.get("path", ""), allow_root=kind in ("files.list", "files.stat")) if kind == "files.read": content = managed_read(app, payload["path"]) @@ -1967,7 +2118,7 @@ class Agent: return self.journal.update(operation["id"], "failed", error="Interrupted library download could not be resumed") if kind == "files.stage": return self.journal.update(operation["id"], "completed", self.stage(instance_id, generation, payload, operation["id"])) - if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"): + if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"): return self.journal.update(operation["id"], "completed", self.dispatch(operation)) if kind in ("files.activate", "files.rollback") and checkpoint: stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json" @@ -2054,7 +2205,7 @@ class Agent: @staticmethod def read_operation(operation): - return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version")) + return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version")) def instance_lock(self, operation): key = operation.get("instanceId") diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index 3ab56cd..0271dd6 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -57,6 +57,209 @@ class Response(io.BytesIO): self.headers = headers or {} +class ManifestTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "app" + self.root.mkdir() + self.stopping = threading.Event() + + def tearDown(self): + self.temp.cleanup() + + def write(self, path, content=b"content"): + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return target + + def manifest(self, roots=None, ignore_names=None, paths=None): + return module.managed_manifest(self.root, { + "roots": ["scripts", "configs", "plugins"] if roots is None else roots, + "ignoreNames": [] if ignore_names is None else ignore_names, + "paths": [] if paths is None else paths, + }, self.stopping) + + def test_hashes_sorted_unique_files_across_overlapping_roots_and_skips_ignored_names(self): + self.write("scripts/nested/z.gsc", b"script") + self.write("configs/server.cfg", b"config") + self.write("scripts/cache/ignored.gsc") + self.write("scripts/nested/ignore.gsc") + expected = [{"path": path, "size": len(content), "sha256": module.digest(content)} for path, content in ( + ("configs/server.cfg", b"config"), ("scripts/nested/z.gsc", b"script"), + )] + self.assertEqual(self.manifest(["scripts/nested", "configs", "scripts", "scripts", "plugins"], ["cache", "ignore.gsc"]), {"files": expected}) + + def test_nested_symlinks_are_skipped_but_requested_symlink_roots_fail(self): + self.write("scripts/good.gsc") + outside = Path(self.temp.name) / "outside" + outside.mkdir() + (outside / "secret").write_bytes(b"secret") + (self.root / "scripts/file-link").symlink_to(outside / "secret") + (self.root / "scripts/dir-link").symlink_to(outside, target_is_directory=True) + (self.root / "scripts/dangling").symlink_to(outside / "missing") + self.assertEqual([item["path"] for item in self.manifest()["files"]], ["scripts/good.gsc"]) + for roots in (["scripts/dir-link"], ["scripts", "scripts/dir-link"]): + with self.subTest(roots=roots), self.assertRaisesRegex(AgentError, "symlink|Symlink"): + self.manifest(roots) + + def test_validates_roots_and_ignored_names(self): + for roots in ([], "scripts", [""], ["."], [".."], ["../scripts"], ["/scripts"], ["scripts//nested"], ["scripts/../configs"], [None], ["scripts"] * 65): + with self.subTest(roots=roots), self.assertRaises(AgentError): + self.manifest(roots) + for ignored in ("cache", [None], [""], ["."], [".."], ["scripts/cache"], ["a\\b"], ["a\x00b"], ["cache"] * 257): + with self.subTest(ignored=ignored), self.assertRaises(AgentError): + self.manifest(ignore_names=ignored) + for paths in ("scripts/a", [None], ["../outside"], ["."], ["scripts/a"] * 10001): + with self.subTest(paths=paths), self.assertRaises(AgentError): + self.manifest(paths=paths) + + def test_explicit_managed_paths_cannot_be_symlinks_or_directories(self): + self.write("scripts/good.gsc") + (self.root / "scripts/link.gsc").symlink_to(self.root / "scripts/good.gsc") + (self.root / "scripts/dangling.gsc").symlink_to(self.root / "missing") + for path in ("scripts", "scripts/link.gsc", "scripts/dangling.gsc"): + with self.subTest(path=path), self.assertRaises(AgentError): + self.manifest(paths=[path]) + self.assertEqual([item["path"] for item in self.manifest(paths=["scripts/good.gsc", "scripts/missing.gsc", "missing/future.gsc"])["files"]], ["scripts/good.gsc"]) + + def test_explicit_managed_paths_are_validated_again_after_the_scan(self): + self.write("scripts/good.gsc") + original = module.managed_open + opened = 0 + + @module.contextlib.contextmanager + def swap(root, value): + nonlocal opened + opened += 1 + if opened == 2: + (self.root / "scripts/good.gsc").unlink() + (self.root / "scripts/good.gsc").symlink_to(self.root / "missing") + with original(root, value) as stream: + yield stream + + with patch.object(module, "managed_open", side_effect=swap), self.assertRaisesRegex(AgentError, "Symlinks"): + self.manifest(paths=["scripts/good.gsc"]) + + def test_missing_destinations_are_empty_but_unavailable_app_and_regular_roots_fail(self): + self.assertEqual(self.manifest(), {"files": []}) + self.write("scripts", b"not a directory") + with self.assertRaisesRegex(AgentError, "complete managed tree"): + self.manifest() + (self.root / "scripts").unlink() + self.root.rmdir() + with self.assertRaisesRegex(AgentError, "complete managed tree"): + self.manifest() + replacement = Path(self.temp.name) / "replacement" + replacement.mkdir() + self.root.symlink_to(replacement, target_is_directory=True) + with self.assertRaisesRegex(AgentError, "symlink"): + self.manifest() + + def test_errors_and_special_files_never_return_a_partial_manifest(self): + self.write("scripts/good.gsc") + self.write("scripts/unreadable.gsc") + original = module.os.stat + + def denied(path, *args, **kwargs): + if path == "unreadable.gsc": + raise PermissionError("unreadable") + return original(path, *args, **kwargs) + + with patch.object(module.os, "stat", side_effect=denied), self.assertRaisesRegex(AgentError, "complete managed tree"): + self.manifest() + module.os.mkfifo(self.root / "scripts/fifo") + with self.assertRaisesRegex(AgentError, "non-regular"): + self.manifest() + + def test_scan_limits_fail_instead_of_truncating_results(self): + self.write("scripts/a.gsc") + self.write("scripts/b.gsc") + for setting, limit, message in ( + ("MANIFEST_MAX_ENTRIES", 1, "entry limit"), + ("MANIFEST_MAX_BYTES", 1, "result size limit"), + ("MANIFEST_MAX_SECONDS", 0, "time limit"), + ("MANIFEST_MAX_DEPTH", 0, "depth limit"), + ): + with self.subTest(setting=setting), patch.object(module, setting, limit), self.assertRaisesRegex(AgentError, message): + self.manifest() + self.stopping.set() + with self.assertRaisesRegex(AgentError, "interrupted"): + self.manifest() + + def test_files_are_streamed_and_not_limited_by_the_content_read_api(self): + content = b"x" * (2 * 1024 * 1024 + 13) + self.write("plugins/large.dll", content) + original = module.os.fdopen + reads = [] + + @module.contextlib.contextmanager + def tracked(*args, **kwargs): + with original(*args, **kwargs) as stream: + proxy = MagicMock(wraps=stream) + def read(size): + reads.append(size) + return stream.read(size) + proxy.read.side_effect = read + yield proxy + + with patch.object(module, "MAX_FILE", 1), patch.object(module.os, "fdopen", side_effect=tracked): + result = self.manifest() + self.assertEqual(result, {"files": [{"path": "plugins/large.dll", "size": len(content), "sha256": module.digest(content)}]}) + self.assertGreater(len(reads), 2) + self.assertEqual(set(reads), {1024 * 1024}) + + def test_file_changes_while_hashing_invalidate_the_scan(self): + target = self.write("scripts/changing.gsc", b"before") + original = module.hashlib.sha256 + + def changing_hash(): + digest = original() + proxy = MagicMock(wraps=digest) + def update(block): + digest.update(block) + prior = target.stat() + target.write_bytes(b"after!") + module.os.utime(target, ns=(prior.st_atime_ns, prior.st_mtime_ns + 1)) + proxy.update.side_effect = update + return proxy + + with patch.object(module.hashlib, "sha256", side_effect=changing_hash), self.assertRaisesRegex(AgentError, "file changed"): + self.manifest() + + def test_symlink_swap_after_validation_cannot_hash_outside_the_app(self): + self.write("scripts/good.gsc") + outside = Path(self.temp.name) / "outside" + outside.mkdir() + (outside / "secret").write_bytes(b"secret") + original = module.scoped + + def swap(root, relative, **kwargs): + result = original(root, relative, **kwargs) + (self.root / "scripts").rename(self.root / "saved") + (self.root / "scripts").symlink_to(outside, target_is_directory=True) + return result + + with patch.object(module, "scoped", side_effect=swap), self.assertRaisesRegex(AgentError, "complete managed tree"): + self.manifest(["scripts"]) + + def test_disappearing_app_after_hashing_is_not_successful_evidence(self): + self.write("scripts/good.gsc") + original = module.hashlib.sha256 + + def moving_hash(): + digest = original() + proxy = MagicMock(wraps=digest) + def update(block): + digest.update(block) + self.root.rename(self.root.with_name("moved-app")) + proxy.update.side_effect = update + return proxy + + with patch.object(module.hashlib, "sha256", side_effect=moving_hash), self.assertRaisesRegex(AgentError, "complete managed tree"): + self.manifest() + + class AgentTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() @@ -227,6 +430,20 @@ class AgentTests(unittest.TestCase): self.assertEqual(second["result"], first["result"]) self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1) + def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self): + self.create() + operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]}) + self.assertIn("files.manifest", self.agent.inventory()["capabilities"]) + self.assertTrue(self.agent.read_operation(operation)) + self.assertFalse(self.agent.health_operation(operation)) + self.agent.journal.accept(operation) + self.agent.journal.update(operation["id"], "unknown") + calls = list(self.docker.calls) + result = self.agent.execute(operation) + self.assertEqual(result["state"], "completed") + self.assertEqual(json.loads(result["result"]), {"files": [{"path": "configs/server.cfg", "size": 8, "sha256": module.digest(b"original")}]}) + self.assertEqual(self.docker.calls, calls) + def test_reusing_id_with_different_payload_is_rejected(self): self.agent.execute(self.operation("host.inspect")) with self.assertRaisesRegex(AgentError, "different content"): diff --git a/src/lib/server/backends/files.ts b/src/lib/server/backends/files.ts index 99006eb..23298e1 100644 --- a/src/lib/server/backends/files.ts +++ b/src/lib/server/backends/files.ts @@ -1,7 +1,8 @@ import * as fs from 'node:fs/promises'; -import { relative, resolve, sep } from 'node:path'; +import { join, posix, relative, resolve, sep } from 'node:path'; import { config } from '../config.js'; import { nativeInstance, nativeRequest } from './native.js'; +import { getHost } from '../hosts/index.js'; function remote(path: string) { for (const root of [config.serversRoot, config.t6ServersRoot]) { @@ -16,6 +17,35 @@ function remote(path: string) { } function missing(path: string): never { throw Object.assign(new Error(`File does not exist: ${path}`), { code: 'ENOENT' }); } +export interface FileFingerprint { size: number; sha256: string } + +/** One scoped agent scan; null preserves compatibility with older agents and local files. */ +export async function nativeFileManifest(root: string, directories: string[], ignoreNames: string[], managedPaths: string[]): Promise | null> { + const target = remote(root); + if (!target || target.path !== '.' || !getHost(target.instance.host_id)?.inventory.capabilities?.includes('files.manifest')) return null; + const roots = [...new Set(directories.map(directory => relative(root, directory).split(sep).join('/')))]; + // Preserve legacy configurations that scan the entire instance root. + if (roots.some(directory => !directory)) return null; + const paths = [...new Set(managedPaths.map(path => relative(root, path).split(sep).join('/')))]; + const result = await nativeRequest<{ files: Array }>(target.instance, 'files.manifest', { + roots, paths, ignoreNames: ignoreNames.filter(name => !paths.some(path => path.split('/').includes(name))) + }); + if (!result || !Array.isArray(result.files) || result.files.length > 10_000) throw new Error('Agent returned an invalid file manifest.'); + const manifest = new Map(); + for (const entry of result.files) { + if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.includes('\\') || entry.path.includes('\0') + || posix.normalize(entry.path) !== entry.path || entry.path.split('/').some(part => part === '..' || part === '.') + || !roots.some(directory => entry.path.startsWith(`${directory}/`)) + || !Number.isSafeInteger(entry.size) || entry.size < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256)) { + throw new Error('Agent returned an invalid file manifest entry.'); + } + const path = join(root, entry.path); + if (manifest.has(path)) throw new Error('Agent returned duplicate file manifest entries.'); + manifest.set(path, { size: entry.size, sha256: entry.sha256 }); + } + return manifest; +} + export async function readFile(path: string): Promise { const target = remote(path); if (!target) return fs.readFile(path); diff --git a/src/lib/server/backends/native.ts b/src/lib/server/backends/native.ts index bae922c..b424b24 100644 --- a/src/lib/server/backends/native.ts +++ b/src/lib/server/backends/native.ts @@ -31,7 +31,7 @@ export function unplacedNativeCreation(server: string): string | null { } const observations = new Map>(); -const readKinds = new Set(['instance.inspect', 'files.read', 'files.stat', 'files.list', 'logs.read']); +const readKinds = new Set(['instance.inspect', 'files.read', 'files.stat', 'files.list', 'files.manifest', 'logs.read']); /** Share overlapping observations only, never completed results or mutations. */ export async function nativeRequest>(instance: NativeInstance, kind: string, payload: Record = {}, key?: string): Promise { diff --git a/src/lib/server/fleet/drift.ts b/src/lib/server/fleet/drift.ts index 409fb19..fc57f4e 100644 --- a/src/lib/server/fleet/drift.ts +++ b/src/lib/server/fleet/drift.ts @@ -1,4 +1,4 @@ -import { readdir, readFile, stat } from '../backends/files.js'; +import { readdir, readFile, stat, nativeFileManifest, type FileFingerprint } from '../backends/files.js'; import { randomUUID } from 'node:crypto'; import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js'; import { join, posix, relative, sep } from 'node:path'; @@ -11,7 +11,7 @@ import { getBaselineForScope, getVersion } from './scripts.js'; import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; import { selectedZombieMap } from './map-aliases.js'; import { getServer, listServers } from './servers.js'; -import { promotedArtifacts } from './promoted-artifacts.js'; +import { promotedArtifactMetadata } from './promoted-artifacts.js'; import { queueFileChange } from './staged-files.js'; import { trackActivity } from '../pipeline/activity.js'; @@ -88,6 +88,12 @@ async function readExistingFile(path: string): Promise { }); } +async function fingerprint(path: string, manifest: Map | null): Promise { + if (manifest) return manifest.get(path) ?? null; + const content = await readExistingFile(path); + return content === null ? null : { size: content.byteLength, sha256: sha256Buffer(content) }; +} + async function walkFiles(root: string): Promise { const out: string[] = []; let directories = [root]; @@ -128,11 +134,15 @@ async function scanServerFiles(server: string): Promise { const srv = getServer(server); if (!srv || srv.destroyed_at || hasActiveBeta(server)) return []; // A missing mount is unavailable evidence, not an empty managed tree. - await requireServerDirectory(server); - const drift = await scanManagedForServer(server); + const expectedPaths = promotedArtifactMetadata(srv.game, srv.map).map(({ kind, script }) => join(destOfKind(server, kind), script.target_rel_path)); + if (srv.game && getBaselineForScope('game', srv.game)?.current_version_id) expectedPaths.push(join(destOfKind(server, 'config'), configFileOf(server))); + if (srv.created_by && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) expectedPaths.push(join(destOfKind(server, 'config'), MAP_CONFIG_FILE)); + const manifest = await nativeFileManifest(serverDir(server), DRIFT_DESTS.map(kind => destOfKind(server, kind)), [...DRIFT_IGNORE_FILENAMES], expectedPaths); + if (!manifest) await requireServerDirectory(server); + const drift = await scanManagedForServer(server, manifest); // Previous inventory is an audit of installed bytes, not permission to // retain artifacts whose scope was moved or removed from the library. - const managed = new Set(promotedArtifacts(srv.game, srv.map) + const managed = new Set(promotedArtifactMetadata(srv.game, srv.map) .map(({ kind, script }) => `${kind}:${script.target_rel_path}`)); const baseline = srv.game ? getBaselineForScope('game', srv.game) : undefined; if (baseline?.current_version_id) managed.add(`config:${configFileOf(server)}`); @@ -145,7 +155,10 @@ async function scanServerFiles(server: string): Promise { // Scan for untracked files only after resolving managed targets. for (const kind of DRIFT_DESTS) { const dest = destOfKind(server, kind); - const onDisk = await walkFiles(dest); + const onDisk = manifest + ? [...manifest.keys()].filter(path => path.startsWith(`${dest}${sep}`)).map(path => toPosix(relative(dest, path))) + .filter(path => !path.split('/').some(name => DRIFT_IGNORE_FILENAMES.has(name))).sort() + : await walkFiles(dest); if (onDisk.length === 0) continue; const untracked = await scanBatch(onDisk, async (relPath): Promise => { @@ -155,11 +168,11 @@ async function scanServerFiles(server: string): Promise { && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) return null; const abs = join(dest, relPath); try { - const buf = await readFile(abs); + const info = await fingerprint(abs, manifest); + if (!info) return null; return { server, relPath, - size: buf.byteLength, - sha256: sha256Buffer(buf), + ...info, kind, driftKind: 'untracked' }; @@ -172,14 +185,14 @@ async function scanServerFiles(server: string): Promise { drift.push(...untracked.filter((entry): entry is DriftEntry => entry !== null)); } - await requireServerDirectory(server); + if (!manifest) await requireServerDirectory(server); return hasActiveBeta(server) ? [] : drift; } // Every promoted artifact whose scope matches this server's game/map should // have its target file present on disk. Anything absent is 'missing' drift. // Configs are handled as a composed layer (baseline + overlays → one cfg). -async function scanManagedForServer(server: string): Promise { +async function scanManagedForServer(server: string, manifest: Map | null): Promise { const srv = getServer(server); if (!srv) return []; const out: DriftEntry[] = []; @@ -190,8 +203,8 @@ async function scanManagedForServer(server: string): Promise { ? getServerMapConfig(srv.game, srv.map, srv.map_slug) : null; if (mapConfig) { const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE); - const content = await readExistingFile(abs); - if (!content?.equals(mapConfig)) { + const content = await fingerprint(abs, manifest); + if (content?.sha256 !== sha256Buffer(mapConfig)) { out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: content ? 'replace' : 'missing', size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) }); } @@ -199,10 +212,10 @@ async function scanManagedForServer(server: string): Promise { // Inventory records the last managed write, not the current disk bytes. // Check both so edits and outdated versions are detected on tracked files. - const managed = await scanBatch(promotedArtifacts(srv.game, srv.map), async ({ kind, script, version }): Promise => { - const content = await readExistingFile(join(destOfKind(server, kind), script.target_rel_path)); + const managed = await scanBatch(promotedArtifactMetadata(srv.game, srv.map), async ({ kind, script, version }): Promise => { + const content = await fingerprint(join(destOfKind(server, kind), script.target_rel_path), manifest); const inventory = getServerScript(server, script.target_rel_path); - if (content?.equals(version.content) && inventory?.sha256 === version.sha256 + if (content?.sha256 === version.sha256 && inventory?.sha256 === version.sha256 && inventory.script_version_id === version.id) return null; return { server, relPath: script.target_rel_path, kind, @@ -226,8 +239,8 @@ async function scanManagedForServer(server: string): Promise { const targetRel = configFileOf(server); const abs = join(dest, targetRel); const inv = getServerScript(server, targetRel); - const content = await readExistingFile(abs); - const onDiskMatches = content?.equals(composed.content) && inv?.sha256 === composed.sha256; + const content = await fingerprint(abs, manifest); + const onDiskMatches = content?.sha256 === composed.sha256 && inv?.sha256 === composed.sha256; if (!onDiskMatches) { out.push({ server, @@ -366,7 +379,7 @@ export async function prepareDriftCampaign(server: string): Promise file)), files }; } diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index b5b4a8c..6cee07e 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -435,7 +435,7 @@ export function pendingHostOperationRows(hostId: string, lane: OperationLane = ' WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return []; // Accepted/running/unknown commands keep the same immutable ID. The agent // consults its durable journal before acknowledging or executing a replay. - const readOnly = `(kind IN ('host.inspect','instance.inspect','files.read','files.list','files.stat','logs.read') + const readOnly = `(kind IN ('host.inspect','instance.inspect','files.read','files.list','files.stat','files.manifest','logs.read') OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`; const healthOnly = `(kind='instance.inspect' OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`; const healthLane = host.inventory.capabilities?.includes('operations.health-lane'); diff --git a/static/agent/ec-agent.py b/static/agent/ec-agent.py index 7506378..8b0373c 100644 --- a/static/agent/ec-agent.py +++ b/static/agent/ec-agent.py @@ -32,9 +32,13 @@ import urllib.parse import urllib.request import uuid -VERSION = "1.4.7" +VERSION = "1.4.8" LABEL = "io.electric-cherry" MAX_FILE = 32 * 1024 * 1024 +MANIFEST_MAX_ENTRIES = 10000 +MANIFEST_MAX_BYTES = 16 * 1024 * 1024 +MANIFEST_MAX_SECONDS = 15 +MANIFEST_MAX_DEPTH = 64 LIBRARY_DOWNLOAD_WORKERS = 4 ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") @@ -296,6 +300,151 @@ def managed_read(root, value): return None +def managed_manifest(root, payload, stopping): + """Hash a bounded managed tree without following runtime-controlled symlinks.""" + deadline = time.monotonic() + MANIFEST_MAX_SECONDS + + def check_budget(): + if stopping.is_set() or time.monotonic() >= deadline: + raise AgentError("Manifest scan exceeded its time limit or was interrupted") + + roots = payload.get("roots") + ignored = payload.get("ignoreNames") + paths = payload.get("paths", []) + if not isinstance(roots, list) or not roots or len(roots) > 64: + raise AgentError("Manifest roots must contain between 1 and 64 managed directories") + if not isinstance(ignored, list) or len(ignored) > 256 or any( + not isinstance(name, str) or not name or name in (".", "..") or any(char in name for char in ("/", "\\", "\x00")) + for name in ignored + ): + raise AgentError("Manifest ignored names must be plain file or directory names") + if not isinstance(paths, list) or len(paths) > 10000: + raise AgentError("Manifest managed paths must contain at most 10000 files") + for value in roots: + check_budget() + scoped(root, value) + roots = sorted(set(roots)) + roots = [value for index, value in enumerate(roots) if not any(value.startswith(parent + "/") for parent in roots[:index])] + ignored = set(ignored) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + entries = 0 + result_bytes = len(json.dumps({"files": []}).encode()) + files = [] + + def validate_paths(): + for value in paths: + check_budget() + try: + with managed_open(root, value): + pass + except FileNotFoundError: + pass + + def identity(info): + return info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns + + def open_directory(app, relative): + directory = os.dup(app) + try: + for part in PurePosixPath(relative).parts: + nested = os.open(part, flags, dir_fd=directory) + os.close(directory) + directory = nested + return directory + except BaseException: + os.close(directory) + raise + + def scan(directory, relative, depth): + nonlocal entries, result_bytes + check_budget() + if depth > MANIFEST_MAX_DEPTH: + raise AgentError("Manifest scan exceeded its directory depth limit") + before = os.fstat(directory) + with os.scandir(directory) as children: + for child in children: + check_budget() + entries += 1 + if entries > MANIFEST_MAX_ENTRIES: + raise AgentError("Manifest scan exceeded its entry limit") + if child.name in ignored: + continue + info = os.stat(child.name, dir_fd=directory, follow_symlinks=False) + if stat_module.S_ISLNK(info.st_mode): + continue + path = relative + "/" + child.name + if stat_module.S_ISDIR(info.st_mode): + nested = os.open(child.name, flags, dir_fd=directory) + try: + if identity(info) != identity(os.fstat(nested)): + raise AgentError("Manifest directory changed during the scan") + scan(nested, path, depth + 1) + if identity(os.fstat(nested)) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)): + raise AgentError("Manifest directory changed during the scan") + finally: + os.close(nested) + elif stat_module.S_ISREG(info.st_mode): + descriptor = os.open(child.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + try: + if identity(info) != identity(os.fstat(descriptor)): + raise AgentError("Manifest file changed during the scan") + sha = hashlib.sha256() + size = 0 + with os.fdopen(descriptor, "rb", closefd=False) as stream: + while True: + check_budget() + block = stream.read(1024 * 1024) + if not block: + break + sha.update(block) + size += len(block) + if size != info.st_size or identity(info) != identity(os.fstat(descriptor)) or identity(info) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)): + raise AgentError("Manifest file changed during the scan") + finally: + os.close(descriptor) + item = {"path": path, "size": size, "sha256": sha.hexdigest()} + result_bytes += len(json.dumps(item).encode()) + (2 if files else 0) + if result_bytes > MANIFEST_MAX_BYTES: + raise AgentError("Manifest scan exceeded its result size limit") + files.append(item) + else: + raise AgentError("Manifest scan encountered a non-regular managed file") + if identity(before) != identity(os.fstat(directory)): + raise AgentError("Manifest directory changed during the scan") + + try: + app = os.open(root, flags) + try: + app_identity = os.fstat(app) + validate_paths() + for relative in roots: + check_budget() + try: + directory = open_directory(app, relative) + except FileNotFoundError: + continue # A destination directory need not exist yet. + try: + scan(directory, relative, len(PurePosixPath(relative).parts)) + current_directory = open_directory(app, relative) + try: + if identity(os.fstat(directory)) != identity(os.fstat(current_directory)): + raise AgentError("Manifest directory changed during the scan") + finally: + os.close(current_directory) + finally: + os.close(directory) + validate_paths() + current = os.stat(root, follow_symlinks=False) + if (current.st_dev, current.st_ino, current.st_mode) != (app_identity.st_dev, app_identity.st_ino, app_identity.st_mode): + raise AgentError("Manifest managed root changed during the scan") + check_budget() + finally: + os.close(app) + except OSError: + raise AgentError("Manifest scan could not read the complete managed tree") from None + return {"files": sorted(files, key=lambda item: item["path"])} + + def managed_write(root, value, content, uid=1000, gid=1000): if content is None: try: @@ -1094,7 +1243,7 @@ class Agent: if line.startswith("PRETTY_NAME="): os_name = line.split("=", 1)[1].strip('"') update = self.update_inventory() - capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) + capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} def safe_to_disrupt(self, instance_id, generation): @@ -1446,6 +1595,8 @@ class Agent: return result if kind == "logs.read": return self.read_log(instance_id, generation, payload) + if kind == "files.manifest": + return managed_manifest(app, payload, self.stopping) path = scoped(app, payload.get("path", ""), allow_root=kind in ("files.list", "files.stat")) if kind == "files.read": content = managed_read(app, payload["path"]) @@ -1967,7 +2118,7 @@ class Agent: return self.journal.update(operation["id"], "failed", error="Interrupted library download could not be resumed") if kind == "files.stage": return self.journal.update(operation["id"], "completed", self.stage(instance_id, generation, payload, operation["id"])) - if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"): + if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"): return self.journal.update(operation["id"], "completed", self.dispatch(operation)) if kind in ("files.activate", "files.rollback") and checkpoint: stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json" @@ -2054,7 +2205,7 @@ class Agent: @staticmethod def read_operation(operation): - return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version")) + return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version")) def instance_lock(self, operation): key = operation.get("instanceId") diff --git a/static/agent/manifest.json b/static/agent/manifest.json index c6fa0ff..a2031f1 100644 --- a/static/agent/manifest.json +++ b/static/agent/manifest.json @@ -1,8 +1,8 @@ { - "version": "1.4.7", + "version": "1.4.8", "protocolVersion": 1, "platform": "debian-amd64", - "sha256": "e9e1b3869d66eca351dadb045d99451b97c91d15c5b7199a3de3241b550de998", + "sha256": "45eb0ef619fb0ceef2ac843657fd7fdaf8cfa610951f0ddfabdbf5942abc6e8c", "updaterProtocolVersion": 1, "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" } diff --git a/tests/unit/native-drift-manifest.test.ts b/tests/unit/native-drift-manifest.test.ts new file mode 100644 index 0000000..b50ffe6 --- /dev/null +++ b/tests/unit/native-drift-manifest.test.ts @@ -0,0 +1,183 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +const server = 't5_zombie_theater_1'; +const scriptRoot = 'runtime/plutonium/storage/t5/raw/scripts/sp'; +const pluginRoot = 'runtime/plutonium/plugins'; +const hash = (content: Buffer) => createHash('sha256').update(content).digest('hex'); +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture([]); +}); +afterEach(async () => { await fixture.cleanup(); vi.restoreAllMocks(); }); + +async function setup(capable = true) { + const hosts = await import('../../src/lib/server/hosts/index.js'); + const host = hosts.createHost({ name: 'Drift manifest host' }); + hosts.enrollHost(hosts.createEnrollment(host.id).token, { + protocolVersion: 1, capabilities: capable ? ['files.manifest'] : [] + }); + const { db } = await import('../../src/lib/server/db/index.js'); + const database = db(); + database.prepare(`INSERT INTO servers(name,komodo_name,game,map,game_mode,map_slug,created_by) + VALUES(?,'','t5','zombie_theater','zombie','theater','ui')`).run(server); + database.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) + VALUES(?,?,?,'library','image',28960,?)`).run(randomUUID(), server, host.id, new Date().toISOString()); + + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { recordScript } = await import('../../src/lib/server/fleet/inventory.js'); + const contents = new Map(); + function promoted(kind: 'script' | 'plugin' | 'baseline', name: string, content: Buffer, targetRelPath?: string) { + const artifact = createScript({ name, kind, targetRelPath, scopeType: 'game', scopeValue: 't5', content }); + database.prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(artifact.version.id, artifact.script.id); + return artifact; + } + const script = promoted('script', 'Managed script', Buffer.from('main() { wait 1; }'), 'managed.gsc'); + const plugin = promoted('plugin', 'Managed plugin', Buffer.from('promoted plugin'), 'managed.dll'); + const baseline = promoted('baseline', 'Baseline', Buffer.from('set sv_voice "1"\n')); + for (const [root, artifact] of [[scriptRoot, script], [pluginRoot, plugin]] as const) { + const { version } = artifact; + contents.set(`${root}/${artifact.script.target_rel_path}`, version.content); + recordScript(server, artifact.script.target_rel_path, version.id, version.sha256, version.size, 'test'); + } + const { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } = await import('../../src/lib/server/fleet/server-config.js'); + const config = getPromotedServerConfig('t5', 'zombie_theater', 'theater')!; + contents.set('configs/dedicated_sp.cfg', config); + recordScript(server, 'dedicated_sp.cfg', null, hash(config), config.length, 'test'); + contents.set(`configs/${MAP_CONFIG_FILE}`, getServerMapConfig('t5', 'zombie_theater', 'theater')!); + // The runtime owns this generated file; neither scanner should classify it as drift. + contents.set('configs/dedicated.cfg', Buffer.from('runtime generated')); + + const backend = await import('../../src/lib/server/backends/native.js'); + const request = vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind, payload = {}) => { + if (kind === 'files.manifest') { + const roots = payload.roots as string[]; + const ignored = payload.ignoreNames as string[]; + return { files: [...contents].filter(([path]) => roots.some(root => path.startsWith(`${root}/`)) + && !path.split('/').some(part => ignored.includes(part))) + .map(([path, content]) => ({ path, size: content.length, sha256: hash(content) })) } as never; + } + if (capable) throw new Error(`Manifest scan requested an extra remote operation: ${kind}`); + const path = String(payload.path); + const content = contents.get(path); + if (kind === 'files.read') return { contentBase64: content?.toString('base64') ?? null } as never; + if (kind === 'files.stat') { + const isDirectory = path === '.' || [...contents.keys()].some(file => file.startsWith(`${path}/`)); + return { exists: content !== undefined || isDirectory, size: content?.length ?? 0, + mtime: new Date().toISOString(), isDirectory, isFile: content !== undefined } as never; + } + if (kind === 'files.list') { + const entries = new Map(); + for (const file of contents.keys()) { + if (!file.startsWith(`${path}/`)) continue; + const [name, ...nested] = file.slice(path.length + 1).split('/'); + entries.set(name, { name, isDirectory: nested.length > 0, isFile: nested.length === 0 }); + } + return { entries: [...entries.values()] } as never; + } + throw new Error(`Unexpected remote operation: ${kind}`); + }); + const drift = await import('../../src/lib/server/fleet/drift.js'); + return { ...drift, database, request, contents, script, plugin, baseline, promoted, mapConfigFile: MAP_CONFIG_FILE }; +} + +function expectOneManifest(request: Awaited>['request']) { + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0][1]).toBe('files.manifest'); + expect(request.mock.calls[0][2]).toEqual({ + roots: [scriptRoot, 'configs', pluginRoot], ignoreNames: ['dedicated.cfg'], + paths: [`${scriptRoot}/managed.gsc`, `${pluginRoot}/managed.dll`, 'configs/dedicated_sp.cfg', 'configs/electric_cherry_map.cfg'] + }); +} + +describe('native drift manifests', () => { + it('checks managed files, composed config, generated config, and untracked trees in one request', async () => { + const api = await setup(); + expect(await api.scanServerDrift(server)).toEqual([]); + expectOneManifest(api.request); + + api.request.mockClear(); + expect(await api.prepareDriftCampaign(server)).toMatchObject({ plan: { files: [] }, files: [] }); + expectOneManifest(api.request); + }); + + it('detects changed, missing, and nested untracked files from the same manifest', async () => { + const api = await setup(); + api.contents.set(`${scriptRoot}/managed.gsc`, Buffer.alloc(0)); + api.contents.delete(`${pluginRoot}/managed.dll`); + api.contents.set('configs/dedicated_sp.cfg', Buffer.from('changed config')); + const stray = Buffer.from('untracked bytes'); + api.contents.set(`${scriptRoot}/custom/stray.gsc`, stray); + + const changes = await api.scanServerDrift(server); + expect(changes).toHaveLength(4); + expect(changes).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'script', relPath: 'managed.gsc', driftKind: 'replace', sha256: api.script.version.sha256 }), + expect.objectContaining({ kind: 'plugin', relPath: 'managed.dll', driftKind: 'missing', sha256: api.plugin.version.sha256 }), + expect.objectContaining({ kind: 'config', relPath: 'dedicated_sp.cfg', driftKind: 'replace' }), + expect.objectContaining({ kind: 'script', relPath: 'custom/stray.gsc', driftKind: 'untracked', size: stray.length, sha256: hash(stray) }) + ])); + expectOneManifest(api.request); + }); + + it('still repairs missing inventory and generated map config', async () => { + const api = await setup(); + api.database.prepare('DELETE FROM server_scripts WHERE server=? AND rel_path=?').run(server, 'managed.gsc'); + api.contents.delete(`configs/${api.mapConfigFile}`); + expect(await api.scanServerDrift(server)).toEqual(expect.arrayContaining([ + expect.objectContaining({ relPath: 'managed.gsc', driftKind: 'replace' }), + expect.objectContaining({ relPath: api.mapConfigFile, driftKind: 'missing' }) + ])); + expectOneManifest(api.request); + }); + + it('compares explicitly managed ignored filenames without treating runtime-owned files as untracked', async () => { + const api = await setup(); + api.promoted('plugin', 'Managed ignored name', Buffer.from('expected'), 'dedicated.cfg'); + api.contents.set(`${pluginRoot}/dedicated.cfg`, Buffer.from('changed')); + expect(await api.scanServerDrift(server)).toEqual([ + expect.objectContaining({ kind: 'plugin', relPath: 'dedicated.cfg', driftKind: 'replace' }) + ]); + expect(api.request).toHaveBeenCalledTimes(1); + expect(api.request.mock.calls[0][2]).toMatchObject({ ignoreNames: [], paths: expect.arrayContaining([`${pluginRoot}/dedicated.cfg`]) }); + }); + + it.each(['running', 'ready-promote'])('skips remote reads while a %s beta owns the server', async state => { + const api = await setup(); + const beta = api.database.prepare("INSERT INTO betas(script_id,script_version_id,trigger_kind,state) VALUES(?,?,'manual',?)") + .run(api.script.script.id, api.script.version.id, state).lastInsertRowid; + api.database.prepare("INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(?,?,0,'awaiting-empty')").run(beta, server); + expect(await api.scanServerDrift(server)).toEqual([]); + expect(api.request).not.toHaveBeenCalled(); + }); + + it('preserves unavailable evidence as a failed scan without falling back to per-file reads', async () => { + const api = await setup(); + api.request.mockRejectedValueOnce(new Error('Agent manifest unavailable')); + await expect(api.scanServerDrift(server)).rejects.toThrow('Agent manifest unavailable'); + expectOneManifest(api.request); + }); + + it('rejects a malformed manifest instead of treating it as an empty tree', async () => { + const api = await setup(); + api.request.mockResolvedValueOnce({} as never); + await expect(api.scanServerDrift(server)).rejects.toThrow(); + expectOneManifest(api.request); + }); + + it('keeps legacy agents on the existing per-file scan and detects their drift', async () => { + const api = await setup(false); + api.contents.set(`${scriptRoot}/managed.gsc`, Buffer.from('old script')); + api.contents.set('configs/stray.cfg', Buffer.from('untracked config')); + expect(await api.scanServerDrift(server)).toEqual(expect.arrayContaining([ + expect.objectContaining({ relPath: 'managed.gsc', driftKind: 'replace' }), + expect.objectContaining({ relPath: 'stray.cfg', driftKind: 'untracked' }) + ])); + const operations = api.request.mock.calls.map(([, kind]) => kind); + expect(operations).not.toContain('files.manifest'); + expect(operations).toEqual(expect.arrayContaining(['files.stat', 'files.list', 'files.read'])); + }); +}); diff --git a/tests/unit/native-hosts.test.ts b/tests/unit/native-hosts.test.ts index a273e9c..3e99d98 100644 --- a/tests/unit/native-hosts.test.ts +++ b/tests/unit/native-hosts.test.ts @@ -119,7 +119,7 @@ describe('native host enrollment and scoped authentication', () => { it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => { const api = await enrolled(); - const inspect = api.queueHostOperation(api.host.id, 'host.inspect', {}); + const inspect = api.queueHostOperation(api.host.id, 'files.manifest', { roots: ['configs'] }); const restart = api.queueHostOperation(api.host.id, 'instance.restart', {}); const status = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'status' }); const command = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'map_restart' }); diff --git a/tests/unit/native-observations.test.ts b/tests/unit/native-observations.test.ts index 05c4f30..ff986a3 100644 --- a/tests/unit/native-observations.test.ts +++ b/tests/unit/native-observations.test.ts @@ -19,7 +19,7 @@ async function setup() { } describe('overlapping native observations', () => { - it.each(['files.read', 'files.list', 'files.stat', 'logs.read', 'instance.inspect'])('shares %s only until completion', async kind => { + it.each(['files.read', 'files.list', 'files.stat', 'files.manifest', 'logs.read', 'instance.inspect'])('shares %s only until completion', async kind => { const api = await setup(); const requests = Array.from({ length: 4 }, () => api.nativeRequest(api.instance, kind, { path: 'same' })); const operations = api.listHostOperations(api.host.id);