Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions projects/linux-process-observe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 13 additions & 5 deletions projects/linux-process-observe/src/linux_process_observe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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:
Expand All @@ -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"]:
Expand Down Expand Up @@ -74,24 +82,29 @@ 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


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
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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(
*,
Expand All @@ -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,
Expand Down
Loading