mirror of
https://github.com/th30d4y/ExecuTrace.git
synced 2026-05-26 11:35:51 +00:00
first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Recording modules for ExecuTrace."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
from exectrace.utils.sensitive_filter import redact_text
|
||||
|
||||
|
||||
def detect_history_file() -> Path | None:
|
||||
candidates = [Path.home() / ".bash_history", Path.home() / ".zsh_history"]
|
||||
for path in candidates:
|
||||
if path.exists() and path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def history_line_count(history_file: Path) -> int:
|
||||
with open(history_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return sum(1 for _ in f)
|
||||
|
||||
|
||||
def capture_commands_since(history_file: Path, start_line: int) -> Tuple[List[str], int]:
|
||||
with open(history_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
lines = [line.rstrip("\n") for line in f]
|
||||
|
||||
new_lines = lines[start_line:]
|
||||
commands = []
|
||||
for line in new_lines:
|
||||
cmd = line
|
||||
# zsh can store lines as ": <epoch>:<duration>;<command>"
|
||||
if line.startswith(": ") and ";" in line:
|
||||
cmd = line.split(";", 1)[1]
|
||||
commands.append(redact_text(cmd))
|
||||
|
||||
return commands, len(lines)
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from exectrace.utils.hash_utils import sha256_bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileSnapshotEntry:
|
||||
sha256: str
|
||||
size: int
|
||||
|
||||
|
||||
def snapshot_directory(root_dir: Path) -> Dict[str, FileSnapshotEntry]:
|
||||
"""Capture a file snapshot for deterministic change detection."""
|
||||
snapshot: Dict[str, FileSnapshotEntry] = {}
|
||||
for path in root_dir.rglob("*"):
|
||||
if path.is_file() and ".git" not in path.parts:
|
||||
rel = str(path.relative_to(root_dir))
|
||||
content = path.read_bytes()
|
||||
snapshot[rel] = FileSnapshotEntry(sha256=sha256_bytes(content), size=len(content))
|
||||
return snapshot
|
||||
|
||||
|
||||
def encode_file_content(path: Path) -> Tuple[str, bool]:
|
||||
content = path.read_bytes()
|
||||
try:
|
||||
content.decode("utf-8")
|
||||
is_binary = False
|
||||
except UnicodeDecodeError:
|
||||
is_binary = True
|
||||
return base64.b64encode(content).decode("ascii"), is_binary
|
||||
|
||||
|
||||
def diff_snapshots(
|
||||
root_dir: Path,
|
||||
before: Dict[str, FileSnapshotEntry],
|
||||
after: Dict[str, FileSnapshotEntry],
|
||||
) -> List[dict]:
|
||||
actions: List[dict] = []
|
||||
|
||||
before_paths = set(before.keys())
|
||||
after_paths = set(after.keys())
|
||||
|
||||
created = sorted(after_paths - before_paths)
|
||||
deleted = sorted(before_paths - after_paths)
|
||||
possibly_modified = sorted(before_paths & after_paths)
|
||||
|
||||
for rel_path in created:
|
||||
file_path = root_dir / rel_path
|
||||
encoded, is_binary = encode_file_content(file_path)
|
||||
actions.append(
|
||||
{
|
||||
"action_type": "file_create",
|
||||
"payload": {
|
||||
"path": rel_path,
|
||||
"content_b64": encoded,
|
||||
"is_binary": is_binary,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for rel_path in possibly_modified:
|
||||
if before[rel_path].sha256 != after[rel_path].sha256:
|
||||
file_path = root_dir / rel_path
|
||||
encoded, is_binary = encode_file_content(file_path)
|
||||
actions.append(
|
||||
{
|
||||
"action_type": "file_modify",
|
||||
"payload": {
|
||||
"path": rel_path,
|
||||
"content_b64": encoded,
|
||||
"is_binary": is_binary,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for rel_path in deleted:
|
||||
actions.append(
|
||||
{
|
||||
"action_type": "file_delete",
|
||||
"payload": {
|
||||
"path": rel_path,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return actions
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Union
|
||||
|
||||
from exectrace.core.models import Workflow
|
||||
from exectrace.recorder.command_capture import (
|
||||
capture_commands_since,
|
||||
detect_history_file,
|
||||
history_line_count,
|
||||
)
|
||||
from exectrace.recorder.fs_tracker import FileSnapshotEntry, diff_snapshots, snapshot_directory
|
||||
from exectrace.storage.factory import get_storage
|
||||
from exectrace.storage.json_storage import JsonStorage
|
||||
from exectrace.storage.xml_storage import XmlStorage
|
||||
from exectrace.utils.logger import get_logger
|
||||
from exectrace.utils.time_utils import utc_now_iso
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RecorderSession:
|
||||
def __init__(
|
||||
self,
|
||||
storage: Union[JsonStorage, XmlStorage] | None = None,
|
||||
storage_format: Literal["json", "xml"] = "json",
|
||||
storage_path: str | None = None,
|
||||
) -> None:
|
||||
if storage:
|
||||
self.storage = storage
|
||||
else:
|
||||
self.storage = get_storage(storage_format, storage_path)
|
||||
|
||||
def start(self, name: str, root_dir: str | None = None) -> Dict[str, Any]:
|
||||
root = Path(root_dir or ".").resolve()
|
||||
history_file = detect_history_file()
|
||||
history_start_line = history_line_count(history_file) if history_file else 0
|
||||
|
||||
snapshot = snapshot_directory(root)
|
||||
|
||||
state = {
|
||||
"name": name,
|
||||
"started_at": utc_now_iso(),
|
||||
"root_dir": str(root),
|
||||
"history_file": str(history_file) if history_file else None,
|
||||
"history_start_line": history_start_line,
|
||||
"snapshot": {k: {"sha256": v.sha256, "size": v.size} for k, v in snapshot.items()},
|
||||
}
|
||||
|
||||
self.storage.save_active_recording(state)
|
||||
logger.info("Recording started: %s", name)
|
||||
return state
|
||||
|
||||
def stop(self) -> Workflow:
|
||||
state = self.storage.load_active_recording()
|
||||
workflow = Workflow(name=str(state["name"]))
|
||||
workflow.created_at = str(state["started_at"])
|
||||
|
||||
root_dir = Path(str(state["root_dir"]))
|
||||
before_snapshot = {
|
||||
path: FileSnapshotEntry(sha256=entry["sha256"], size=entry["size"])
|
||||
for path, entry in dict(state["snapshot"]).items()
|
||||
}
|
||||
after_snapshot = snapshot_directory(root_dir)
|
||||
|
||||
history_file_value = state.get("history_file")
|
||||
if history_file_value:
|
||||
commands, _ = capture_commands_since(Path(str(history_file_value)), int(state["history_start_line"]))
|
||||
for command in commands:
|
||||
if command.strip():
|
||||
workflow.add_action("command", {"command": command, "cwd": str(root_dir)})
|
||||
|
||||
file_actions = diff_snapshots(root_dir, before_snapshot, after_snapshot)
|
||||
for item in file_actions:
|
||||
workflow.add_action(item["action_type"], item["payload"])
|
||||
|
||||
self.storage.save_workflow(workflow)
|
||||
self.storage.clear_active_recording()
|
||||
logger.info("Recording stopped: %s (%d actions)", workflow.name, len(workflow.actions))
|
||||
return workflow
|
||||
Reference in New Issue
Block a user