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
13 changes: 13 additions & 0 deletions projects/linux-auth-observe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@
- `_PID` is preserved as contextual process metadata when present; it should not be read as a guaranteed identity anchor on its own
- Unsupported or malformed records fail clearly per line without stopping the full batch

## Normalization Failures

If the input is missing or cannot be opened for reading, `normalize` exits `1`,
leaves stdout empty, and writes an `error input=... type=... message=...` diagnostic
to stderr without a Python traceback. This applies to automatic source detection
and explicitly selected sources. Existing output files are not overwritten.
The diagnostic message text depends on the operating system.

Per-line parse failures still report the source, line number, and error type to
stderr while processing continues; a completed batch exits `0`, even if every
record is malformed. `--error-output` optionally records those per-line failures
as JSONL. It does not record input-open failures, which have no record or line number.

## End-to-End Workflow

```bash
Expand Down
19 changes: 17 additions & 2 deletions projects/linux-auth-observe/src/linux_auth_observe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ def main(argv: list[str] | None = None) -> int:
def _handle_normalize(args: Namespace) -> int:
input_path = Path(args.input)
output_path = Path(args.output)
source = _detect_source(input_path, args.source)
try:
source = _detect_source(input_path, args.source)
except OSError as exc:
return _report_input_error(input_path, exc)

parser_fn, parser_kwargs = _resolve_parser(source, args.year, args.timezone)

Expand All @@ -83,7 +86,10 @@ def _handle_normalize(args: Namespace) -> int:
error_output_path.parent.mkdir(parents=True, exist_ok=True)

with ExitStack() as stack:
source_handle = stack.enter_context(input_path.open("r", encoding="utf-8"))
try:
source_handle = stack.enter_context(input_path.open("r", encoding="utf-8"))
except OSError as exc:
return _report_input_error(input_path, exc)
output_handle = stack.enter_context(
output_path.open(
"w",
Expand Down Expand Up @@ -142,6 +148,15 @@ def _handle_normalize(args: Namespace) -> int:
return 0


def _report_input_error(input_path: Path, exc: OSError) -> int:
message = exc.strerror if exc.strerror is not None else str(exc)
print(
f"error input={input_path} type={exc.__class__.__name__} message={message}",
file=sys.stderr,
)
return 1


def _handle_filter(args: Namespace) -> int:
events = load_events(args.input)
filtered = iter_filtered_events(
Expand Down
99 changes: 99 additions & 0 deletions projects/linux-auth-observe/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from __future__ import annotations

from pathlib import Path
import errno
import json
import os
import subprocess
import sys

import pytest

PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))

Expand Down Expand Up @@ -154,3 +159,97 @@ def test_normalize_cli_can_write_structured_parse_errors(tmp_path: Path, capsys)
assert error_rows[0]["line"] == 2
assert error_rows[0]["error_type"] == "MalformedSyslogLineError"
assert error_rows[0]["raw"] == "THIS IS NOT SYSLOG"


@pytest.mark.parametrize("source", ["auto", "journal_json"])
@pytest.mark.parametrize("error_output", [False, True])
def test_normalize_cli_reports_missing_input_without_traceback(tmp_path: Path, source: str, error_output: bool) -> None:
output_path = tmp_path / "events.jsonl"
error_path = tmp_path / "errors.jsonl"
output_path.write_text("existing events\n", encoding="utf-8")
error_path.write_text("existing errors\n", encoding="utf-8")
args = ["normalize", "--input", "missing.log", "--source", source, "--output", "events.jsonl"]
if error_output:
args += ["--error-output", "errors.jsonl"]
env = os.environ.copy()
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "linux_auth_observe", *args],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=10,
)

assert result.returncode == 1
assert result.stdout == ""
assert result.stderr.startswith("error input=missing.log type=FileNotFoundError message=")
assert len(result.stderr.splitlines()) == 1
assert "Traceback" not in result.stderr
assert output_path.read_text(encoding="utf-8") == "existing events\n"
assert error_path.read_text(encoding="utf-8") == "existing errors\n"


@pytest.mark.parametrize("source", ["auto", "journal_json"])
@pytest.mark.parametrize("error_output", [False, True])
def test_normalize_cli_reports_input_permission_error(tmp_path: Path, capsys, monkeypatch, source: str, error_output: bool) -> None:
input_path = tmp_path / "unreadable.log"
output_path = tmp_path / "events.jsonl"
error_path = tmp_path / "errors.jsonl"
input_path.write_text("synthetic input\n", encoding="utf-8")
output_path.write_text("existing events\n", encoding="utf-8")
error_path.write_text("existing errors\n", encoding="utf-8")
original_open = Path.open

def deny_input_open(path: Path, *args, **kwargs):
if path == input_path:
raise PermissionError(errno.EACCES, "Permission denied", str(path))
return original_open(path, *args, **kwargs)

monkeypatch.setattr(Path, "open", deny_input_open)
args = ["normalize", "--input", str(input_path), "--source", source, "--output", str(output_path)]
if error_output:
args += ["--error-output", str(error_path)]
exit_code = main(args)
captured = capsys.readouterr()

assert exit_code == 1
assert captured.out == ""
assert captured.err.startswith(f"error input={input_path} type=PermissionError message=")
assert len(captured.err.splitlines()) == 1
assert "Traceback" not in captured.err
assert output_path.read_text(encoding="utf-8") == "existing events\n"
assert error_path.read_text(encoding="utf-8") == "existing errors\n"


def test_normalize_cli_does_not_relabel_output_open_failures(tmp_path: Path) -> None:
input_path = tmp_path / "empty.jsonl"
input_path.write_text("", encoding="utf-8")

with pytest.raises(OSError):
main(["normalize", "--input", str(input_path), "--source", "journal_json", "--output", str(tmp_path)])


def test_normalize_cli_reports_oserror_without_strerror(tmp_path: Path, capsys, monkeypatch) -> None:
input_path = tmp_path / "input.jsonl"
output_path = tmp_path / "events.jsonl"
error_path = tmp_path / "errors.jsonl"
input_path.write_text("synthetic input\n", encoding="utf-8")
failure = OSError("synthetic input open failure")
assert failure.strerror is None
original_open = Path.open

def fail_input_open(path: Path, *args, **kwargs):
if path == input_path:
raise failure
return original_open(path, *args, **kwargs)

monkeypatch.setattr(Path, "open", fail_input_open)
exit_code = main([
"normalize", "--input", str(input_path), "--source", "journal_json",
"--output", str(output_path), "--error-output", str(error_path),
])
captured = capsys.readouterr()

assert exit_code == 1
assert captured.out == ""
assert captured.err == f"error input={input_path} type=OSError message={failure}\n"
assert not output_path.exists()
assert not error_path.exists()