From bdf2d3fe6e0b72e27b301ad30fa5d1ffe377c2d7 Mon Sep 17 00:00:00 2001 From: stacknil <219124580+stacknil@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:53:18 +0800 Subject: [PATCH 1/3] fix(process): validate saved inputs before writing evidence Signed-off-by: stacknil <219124580+stacknil@users.noreply.github.com> --- .../src/linux_process_observe/cli.py | 18 ++++++--- .../linux_process_observe/parsers/procfs.py | 29 ++++++++++---- .../parsers/saved_files.py | 39 +++++++++++++++++++ .../src/linux_process_observe/snapshot.py | 13 +++++-- 4 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 projects/linux-process-observe/src/linux_process_observe/parsers/saved_files.py diff --git a/projects/linux-process-observe/src/linux_process_observe/cli.py b/projects/linux-process-observe/src/linux_process_observe/cli.py index 05901e3..13b8455 100644 --- a/projects/linux-process-observe/src/linux_process_observe/cli.py +++ b/projects/linux-process-observe/src/linux_process_observe/cli.py @@ -61,8 +61,12 @@ def _handle_snapshot(args: Namespace) -> int: return 1 output_dir = Path(args.output_dir) - write_envelope(process_snapshot, output_dir / "process_snapshot.json") - write_envelope(socket_links, output_dir / "process_socket_links.json") + try: + write_envelope(process_snapshot, output_dir / "process_snapshot.json") + write_envelope(socket_links, output_dir / "process_socket_links.json") + except OSError as error: + _print_error("snapshot", EvidenceInputError.from_exception("output", output_dir, error)) + return 1 print( f"snapshot wrote {len(process_snapshot.records)} processes and {len(socket_links.records)} process/socket links", file=sys.stderr, @@ -90,9 +94,13 @@ def _handle_diff(args: Namespace) -> int: return 1 output_dir = Path(args.output_dir) - write_envelope(diff, output_dir / "process_diff.json") - output_dir.mkdir(parents=True, exist_ok=True) - (output_dir / "report.md").write_text(build_markdown_report(diff), encoding="utf-8", newline="\n") + try: + write_envelope(diff, output_dir / "process_diff.json") + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "report.md").write_text(build_markdown_report(diff), encoding="utf-8", newline="\n") + except OSError as error: + _print_error("diff", EvidenceInputError.from_exception("output", output_dir, error)) + return 1 return 0 diff --git a/projects/linux-process-observe/src/linux_process_observe/parsers/procfs.py b/projects/linux-process-observe/src/linux_process_observe/parsers/procfs.py index 4d620ff..19de9e1 100644 --- a/projects/linux-process-observe/src/linux_process_observe/parsers/procfs.py +++ b/projects/linux-process-observe/src/linux_process_observe/parsers/procfs.py @@ -5,6 +5,7 @@ import os from ..models import ProcessRecord +from .saved_files import read_saved_bytes, read_saved_text REQUIRED_STATUS_FIELDS = ("Name", "State", "PPid", "Uid", "Gid") @@ -17,13 +18,15 @@ def parse_procfs_root(root: str | Path, *, host_id: str) -> list[ProcessRecord]: if not root_path.is_dir(): raise NotADirectoryError(f"proc root is not a directory: {root_path}") - records = [ - _parse_pid_directory(path, host_id=host_id) - for path in sorted( + try: + pid_paths = sorted( (item for item in root_path.iterdir() if item.is_dir() and item.name.isdigit()), key=lambda item: int(item.name), ) - ] + except OSError as error: + error.add_note("enumerate proc root") + raise + records = [_parse_pid_directory(path, host_id=host_id) for path in pid_paths] processes_by_pid: dict[int, ProcessRecord] = {} for item in records: if item.pid in processes_by_pid: @@ -42,7 +45,12 @@ def _parse_pid_directory(pid_path: Path, *, host_id: str) -> ProcessRecord: pid = int(pid_path.name) status = _parse_status(_read_text(pid_path, "status"), pid=pid) stat = _parse_stat(_read_text(pid_path, "stat"), pid=pid) - argv = _parse_cmdline(_read_bytes(pid_path, "cmdline")) + raw_cmdline = _read_bytes(pid_path, "cmdline") + try: + argv = _parse_cmdline(raw_cmdline) + except ValueError as error: + error.add_note(f"pid {pid}") + raise executable = _read_executable(pid_path) if int(status["PPid"]) != stat["ppid"]: @@ -74,14 +82,14 @@ def _parse_pid_directory(pid_path: Path, *, host_id: str) -> ProcessRecord: def _read_text(pid_path: Path, name: str) -> str: try: - return (pid_path / name).read_text(encoding="utf-8") + return read_saved_text(pid_path / name, context=f"pid {pid_path.name}: {name}") except FileNotFoundError as exc: raise ValueError(f"pid {pid_path.name}: missing {name}") from exc def _read_bytes(pid_path: Path, name: str) -> bytes: try: - return (pid_path / name).read_bytes() + return read_saved_bytes(pid_path / name, context=f"pid {pid_path.name}: {name}") except FileNotFoundError as exc: raise ValueError(f"pid {pid_path.name}: missing {name}") from exc @@ -89,9 +97,14 @@ def _read_bytes(pid_path: Path, name: str) -> bytes: def _read_executable(pid_path: Path) -> str: path = pid_path / "exe" try: - value = os.readlink(path) if path.is_symlink() else path.read_text(encoding="utf-8").strip() + value = (os.readlink(os.fsencode(path)).decode("utf-8") if path.is_symlink() + else read_saved_text(path, context=f"pid {pid_path.name}: exe").strip()) except FileNotFoundError as exc: raise ValueError(f"pid {pid_path.name}: missing exe") from exc + except (OSError, UnicodeError) as error: + if not getattr(error, "__notes__", None): + error.add_note(f"pid {pid_path.name}: exe") + raise if not value: raise ValueError(f"pid {pid_path.name}: empty exe") return value diff --git a/projects/linux-process-observe/src/linux_process_observe/parsers/saved_files.py b/projects/linux-process-observe/src/linux_process_observe/parsers/saved_files.py new file mode 100644 index 0000000..d7ccc97 --- /dev/null +++ b/projects/linux-process-observe/src/linux_process_observe/parsers/saved_files.py @@ -0,0 +1,39 @@ +"""Read saved regular files, retaining native error types and field context.""" +import errno +import os +from pathlib import Path +import stat + + +def _require_regular(mode: int, context: str) -> None: + if stat.S_ISDIR(mode): + raise IsADirectoryError(errno.EISDIR, "Is a directory") + if not stat.S_ISREG(mode): + raise ValueError(f"{context}: expected a regular file") + + +def read_saved_bytes(path: Path, *, context: str) -> bytes: + descriptor = None + try: + _require_regular(path.stat().st_mode, context) + flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0) + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as stream: + descriptor = None # the stream owns the descriptor from here + _require_regular(os.fstat(stream.fileno()).st_mode, context) + return stream.read() + except OSError as error: + error.add_note(context) + raise + finally: + if descriptor is not None: + os.close(descriptor) + + +def read_saved_text(path: Path, *, context: str) -> str: + try: + text = read_saved_bytes(path, context=context).decode("utf-8") + except UnicodeDecodeError as error: + error.add_note(context) + raise + return text.replace("\r\n", "\n").replace("\r", "\n") diff --git a/projects/linux-process-observe/src/linux_process_observe/snapshot.py b/projects/linux-process-observe/src/linux_process_observe/snapshot.py index 8284fc3..86b208c 100644 --- a/projects/linux-process-observe/src/linux_process_observe/snapshot.py +++ b/projects/linux-process-observe/src/linux_process_observe/snapshot.py @@ -16,6 +16,7 @@ parse_observed_at, ) from .parsers.procfs import parse_procfs_root +from .parsers.saved_files import read_saved_text from .parsers.ss_text import parse_ss_text @@ -29,6 +30,12 @@ class EvidenceInputError(ValueError): def __str__(self) -> str: return self.message + @classmethod + def from_exception(cls, input_name: str, path: str | Path, error: Exception) -> EvidenceInputError: + reason = error.strerror if isinstance(error, OSError) and error.strerror is not None else str(error) + message = ": ".join([*getattr(error, "__notes__", []), reason]) + return cls(input_name, str(path), type(error).__name__, message) + def build_snapshot_artifacts( *, @@ -45,13 +52,13 @@ def build_snapshot_artifacts( try: processes = parse_procfs_root(proc_root, host_id=normalized_host) except (FileNotFoundError, NotADirectoryError, OSError, UnicodeError, ValueError) as exc: - raise EvidenceInputError("proc-root", str(proc_root), exc.__class__.__name__, str(exc)) from exc + raise EvidenceInputError.from_exception("proc-root", proc_root, exc) from exc ss_path_obj = Path(ss_path) try: - observations = parse_ss_text(ss_path_obj.read_text(encoding="utf-8")) + observations = parse_ss_text(read_saved_text(ss_path_obj, context="ss")) except (FileNotFoundError, OSError, UnicodeError, ValueError) as exc: - raise EvidenceInputError("ss", str(ss_path_obj), exc.__class__.__name__, str(exc)) from exc + raise EvidenceInputError.from_exception("ss", ss_path_obj, exc) from exc process_envelope = EvidenceEnvelope( schema=EVIDENCE_SCHEMA, From a9ecd15590974f5629413e649e637eb546ec80f6 Mon Sep 17 00:00:00 2001 From: stacknil <219124580+stacknil@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:53:23 +0800 Subject: [PATCH 2/3] test(process): cover native saved evidence failures Signed-off-by: stacknil <219124580+stacknil@users.noreply.github.com> --- .../tests/test_native_read_failures.py | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 projects/linux-process-observe/tests/test_native_read_failures.py diff --git a/projects/linux-process-observe/tests/test_native_read_failures.py b/projects/linux-process-observe/tests/test_native_read_failures.py new file mode 100644 index 0000000..88f2200 --- /dev/null +++ b/projects/linux-process-observe/tests/test_native_read_failures.py @@ -0,0 +1,257 @@ +"""Saved-input failure contracts; POSIX probes require real native filesystem behavior.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from linux_process_observe.cli import main +from linux_process_observe.snapshot import build_snapshot_artifacts + +PROJECT = Path(__file__).resolve().parents[1] +FIXTURES = PROJECT / "tests/fixtures" +POSIX = pytest.mark.skipif(os.name != "posix", reason="requires POSIX links or FIFO objects") +UNPRIVILEGED = pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 0)() == 0, + reason="requires an unprivileged POSIX user; root bypasses chmod denial", +) + + +@pytest.fixture +def saved(tmp_path): + proc = tmp_path / "proc" + shutil.copytree(FIXTURES / "baseline/proc", proc) + ss = tmp_path / "ss.txt" + shutil.copy2(FIXTURES / "baseline/ss.txt", ss) + return proc, ss, tmp_path / "output" + + +def arguments(proc, ss, output): + return ["snapshot", "--proc-root", str(proc), "--ss", str(ss), "--host-id", "lab-host", + "--observed-at", "2026-07-05T00:05:00Z", "--output-dir", str(output)] + + +def contents(output): + return {path.name: path.read_bytes() for path in output.iterdir()} if output.exists() else {} + + +def seed(saved, capsys): + assert main(arguments(*saved)) == 0 + capsys.readouterr() + return contents(saved[2]) + + +def rejected(saved, capsys, category, kind, detail, preserved): + assert main(arguments(*saved)) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert f"error command=snapshot input={category}" in captured.err + assert f"type={kind}" in captured.err and detail in captured.err + assert "Traceback" not in captured.err and "PRIVATE_FIELD_CONTENT" not in captured.err + assert contents(saved[2]) == preserved + if not preserved: + assert not saved[2].exists() + + +@UNPRIVILEGED +@pytest.mark.parametrize("which", ["status", "proc-root", "ss"]) +def test_native_permission_denial_is_not_empty_success(saved, capsys, which): + proc, ss, _ = saved + preserved = seed(saved, capsys) + path = {"status": proc / "120/status", "proc-root": proc, "ss": ss}[which] + mode = path.stat().st_mode + try: + path.chmod(0) + rejected(saved, capsys, "ss" if which == "ss" else "proc-root", + "PermissionError", "Permission denied", preserved) + finally: + path.chmod(mode) + + +@pytest.mark.parametrize("existing", [False, True]) +def test_required_file_disappearing_after_discovery_rejects_all(saved, capsys, monkeypatch, existing): + proc, _, _ = saved + preserved = seed(saved, capsys) if existing else {} + original = Path.iterdir + discovered = [] + def entries(path): + items = list(original(path)) + if path == proc: + discovered.extend(items) + (proc / "120/stat").unlink() + return iter(items) + monkeypatch.setattr(Path, "iterdir", entries) + rejected(saved, capsys, "proc-root", "ValueError", "pid 120: missing stat", preserved) + assert proc / "120" in discovered + + +@pytest.mark.parametrize("field", ["status", "stat", "cmdline", "exe", "ss"]) +def test_invalid_text_has_field_attribution_and_preserves_outputs(saved, capsys, field): + proc, ss, _ = saved + preserved = seed(saved, capsys) + target = ss if field == "ss" else proc / "120" / field + target.write_bytes(b"PRIVATE_FIELD_CONTENT\xff") + kind = "ValueError" if field == "cmdline" else "UnicodeDecodeError" + rejected(saved, capsys, "ss" if field == "ss" else "proc-root", kind, + "ss:" if field == "ss" else f"pid 120: {field}", preserved) + + +@POSIX +def test_invalid_executable_link_text_is_rejected_before_any_write(saved, capsys): + proc, _, _ = saved + preserved = seed(saved, capsys) + target = proc / "120/exe" + target.unlink() + os.symlink(b"\xff", os.fsencode(target)) + rejected(saved, capsys, "proc-root", "UnicodeDecodeError", "pid 120: exe", preserved) + + +@pytest.mark.parametrize("field", ["status", "ss"]) +def test_directory_in_place_of_required_file_is_rejected(saved, capsys, field): + proc, ss, _ = saved + preserved = seed(saved, capsys) + target = ss if field == "ss" else proc / "120/status" + target.unlink() + target.mkdir() + rejected(saved, capsys, "ss" if field == "ss" else "proc-root", "IsADirectoryError", + "ss:" if field == "ss" else "pid 120: status", preserved) + + +def child_env(): + return dict(os.environ, PYTHONPATH=str(PROJECT / "src"), PYTHONDONTWRITEBYTECODE="1", + PYTHONNOUSERSITE="1") + + +@POSIX +@pytest.mark.parametrize("field", ["status", "exe", "ss"]) +def test_fifo_input_is_bounded_failure_without_writer(saved, capsys, field, tmp_path): + proc, ss, output = saved + preserved = seed(saved, capsys) + target = ss if field == "ss" else proc / "120" / field + target.unlink() + os.mkfifo(target) + result = subprocess.run([sys.executable, "-m", "linux_process_observe", *arguments(*saved)], + cwd=tmp_path, env=child_env(), capture_output=True, text=True, timeout=5) + assert result.returncode == 1 and result.stdout == "" + assert f"input={'ss' if field == 'ss' else 'proc-root'}" in result.stderr + assert "regular file" in result.stderr and "Traceback" not in result.stderr + assert contents(output) == preserved + + +@POSIX +def test_replacement_with_fifo_after_stat_cannot_block_or_be_read(saved, tmp_path): + # A pre-open stat alone is insufficient. Replace the object deterministically + # after inspection; run in a bounded child so a blocking-open mutant fails. + code = """ +import os, sys +from pathlib import Path +from unittest.mock import patch +from linux_process_observe.parsers.saved_files import read_saved_bytes +path = Path(sys.argv[1]) +original = Path.stat +changed = [] +def inspect(self, *args, **kwargs): + result = original(self, *args, **kwargs) + if self == path and not changed: + changed.append(True) + path.unlink() + os.mkfifo(path) + return result +with patch.object(Path, 'stat', inspect): + try: + read_saved_bytes(path, context='pid 120: status') + except ValueError as error: + assert 'regular file' in str(error) and changed + else: + raise AssertionError('replacement FIFO accepted') +""" + result = subprocess.run([sys.executable, "-c", code, str(saved[0] / "120/status")], + cwd=tmp_path, env=child_env(), capture_output=True, text=True, timeout=5) + assert result.returncode == 0, result.stderr + + +@POSIX +@pytest.mark.parametrize("exists", [False, True]) +def test_executable_link_target_is_context_not_a_file_to_follow(saved, tmp_path, exists): + proc, ss, _ = saved + executable = tmp_path / "synthetic-executable" + if exists: + executable.write_text("context only", encoding="utf-8") + target = proc / "120/exe" + target.unlink() + target.symlink_to(executable) + processes, _ = build_snapshot_artifacts(proc_root=proc, ss_path=ss, host_id="lab-host", + observed_at="2026-07-05T00:05:00Z") + assert next(row for row in processes.records if row["pid"] == 120)["executable"] == str(executable) + + +@POSIX +def test_numeric_directory_links_keep_existing_trusted_export_profile(saved, tmp_path): + proc, ss, _ = saved + real = tmp_path / "saved-pid" + shutil.move(proc / "120", real) + (proc / "120").symlink_to(real, target_is_directory=True) + (proc / "999").symlink_to(tmp_path / "missing-pid", target_is_directory=True) + (proc / "1000").write_text("not a directory", encoding="utf-8") + (proc / "metadata").mkdir() + processes, _ = build_snapshot_artifacts(proc_root=proc, ss_path=ss, host_id="lab-host", + observed_at="2026-07-05T00:05:00Z") + assert [row["pid"] for row in processes.records] == [1, 120, 220, 230] + + +def test_empty_readable_export_is_distinct_from_failed_enumeration(saved): + proc, ss, output = saved + empty = proc.parent / "empty" + empty.mkdir() + ss.write_text("", encoding="utf-8") + assert main(arguments(empty, ss, output)) == 0 + for name in ("process_snapshot.json", "process_socket_links.json"): + assert json.loads((output / name).read_text(encoding="utf-8"))["records"] == [] + + +@pytest.mark.parametrize("field,detail", [("pid", "stat pid is 121"), + ("ppid", "PPid differs"), ("state", "state differs")]) +def test_inconsistent_saved_identity_rejects_existing_outputs(saved, capsys, field, detail): + proc, _, _ = saved + preserved = seed(saved, capsys) + path = proc / "120/stat" + text = path.read_text(encoding="utf-8") + closing = text.rfind(")") + prefix, columns = text[:closing + 1], text[closing + 1:].split() + if field == "pid": + prefix = prefix.replace("120 (", "121 (", 1) + else: + columns[1 if field == "ppid" else 0] = "999" if field == "ppid" else "R" + path.write_text(prefix + " " + " ".join(columns), encoding="utf-8") + rejected(saved, capsys, "proc-root", "ValueError", detail, preserved) + + +@POSIX +def test_broken_required_file_link_rejects_existing_outputs(saved, capsys): + proc, _, _ = saved + preserved = seed(saved, capsys) + path = proc / "120/status" + path.unlink() + path.symlink_to("missing-status") + rejected(saved, capsys, "proc-root", "ValueError", "pid 120: missing status", preserved) + + +@pytest.mark.parametrize("command", ["snapshot", "diff"]) +def test_output_failure_has_separate_attribution(saved, capsys, tmp_path, command): + target = tmp_path / "occupied-output" + target.write_bytes(b"preserve unrelated output") + args = arguments(saved[0], saved[1], target) + if command == "diff": + args = ["diff"] + for phase, name in (("before", "baseline"), ("after", "changed")): + for option, filename in (("processes", "process_snapshot.json"), ("links", "process_socket_links.json")): + args.extend([f"--{phase}-{option}", str(PROJECT / "tests/golden" / name / filename)]) + args.extend(["--output-dir", str(target)]) + assert main(args) == 1 + captured = capsys.readouterr() + assert captured.out == "" and f"error command={command} input=output" in captured.err + assert "type=FileExistsError" in captured.err and "Traceback" not in captured.err + assert target.read_bytes() == b"preserve unrelated output" From 0c0969c6f0c276ed1be95a66cd020a1be89efa00 Mon Sep 17 00:00:00 2001 From: stacknil <219124580+stacknil@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:53:24 +0800 Subject: [PATCH 3/3] docs(process): define saved filesystem failure contract Signed-off-by: stacknil <219124580+stacknil@users.noreply.github.com> --- projects/linux-process-observe/README.md | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/projects/linux-process-observe/README.md b/projects/linux-process-observe/README.md index a40985c..2c50ca5 100644 --- a/projects/linux-process-observe/README.md +++ b/projects/linux-process-observe/README.md @@ -134,3 +134,56 @@ The adapter adds golden JSONL coverage, an unlinked-socket source fallback test, ## Related Note - [Process evidence and envelope schema](../../notes/process-evidence-schema.md) + +## Saved Filesystem Failure Contract + +Use a trusted, stable saved export on a local filesystem. Discovery keeps its +existing profile: numeric entries resolving to directories are parsed, including +directory symlinks; other entries are ignored, including broken numeric directory +links. This is not a completeness check for every numbered entry or a path sandbox. + +Required `status`, `stat`, `cmdline`, text `exe`, and `ss` inputs must resolve to +regular files. Directory, FIFO, socket and device objects are rejected. Reads check +the opened descriptor as well as the path; POSIX nonblocking open prevents a FIFO +replacement between inspection and open from waiting for a writer. This does not +provide an arbitrary filesystem timeout or protection against all concurrent changes. + +A real `exe` symlink remains supported: its UTF-8 target text is context and is +read without following the executable target. A dangling executable target is +therefore valid evidence; a broken required-file link is incomplete input. Invalid +UTF-8 in text or executable-link targets rejects the snapshot before output writes. +Executable paths remain descriptive, not authenticated identity or integrity proof. + +Input failures return CLI exit 1 with operation, input category, native error type +and field/PID context where available. Missing PID files keep the existing +`ValueError` classification, as does invalid UTF-8 in `cmdline`; `PermissionError` +and other UTF-8 decoding error types are retained. The supplied input path remains +visible. Filesystem and UTF-8 diagnostics do not echo raw field contents; existing +parser diagnostics can include an offending malformed field value. +An unreadable export cannot become successful empty evidence. A readable empty +export with empty `ss` input remains valid. + +`snapshot` validates its inputs before creating or changing output artifacts; +input-validation failure preserves existing evidence. Output filesystem failures +in `snapshot` and `diff` report `input=output` and exit 1. Multi-artifact writes +remain sequential, **not transactional**: a later write failure can leave an +earlier artifact updated. No atomic acquisition or multi-file rollback is claimed. + +Run the failure suite from this project directory: + +```bash +python -m pip install -e ".[dev]" +python -m pytest tests/test_native_read_failures.py -v +python -m pytest -q +``` + +Permission controls require a non-root POSIX user on a filesystem enforcing mode +bits, such as native ext4 under WSL2. Root and non-POSIX runs explicitly skip those +controls; link/FIFO probes also declare their POSIX requirement. Tests use synthetic +files and restore denied permissions in `finally` blocks. Existing golden evidence, +schemas, adapter semantics and successful command names are unchanged. + +Decisions: skipping unreadable discovered PIDs would silently lose evidence; +rejecting every symlink would break the supported executable context profile. +Both alternatives are rejected. Transactional output publication needs a separate +storage/compatibility design and is not implied by these input-read guarantees.