"""Shell side-effect snapshots and output spill helpers.""" import glob import os import re import shlex from tai_pkg.consts import Consts from tai_pkg.text import Text __all__ = ["ShellSnap"] class ShellSnap: """ShellSnap.""" @staticmethod def shell_target_paths(command, workdir): spaced = re.sub(r"([;&|()<>])", r" \1 ", command) try: tokens = shlex.split(spaced, posix=True) except ValueError: return [] found = [] segment = [] def flush(): found.extend(ShellSnap.segment_targets(segment)) for token in tokens: if token in Consts.SHELL_SEGMENT: flush() segment = [] else: segment.append(token) flush() resolved = [] for item in found: if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES: break base = item if os.path.isabs(item) else os.path.join(workdir, item) if any(mark in item for mark in ("*", "?", "[")): try: hits = sorted(glob.glob(base, recursive=True)) except (OSError, ValueError): continue else: hits = [base] for hit in hits: if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES: break if os.path.isfile(hit): resolved.append(os.path.abspath(hit)) elif os.path.isdir(hit): for root, _dirs, files in os.walk(hit): for entry in sorted(files): if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES: break full = os.path.join(root, entry) if os.path.isfile(full): resolved.append(os.path.abspath(full)) if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES: break seen = [] for item in resolved: if item not in seen: seen.append(item) return seen @staticmethod def segment_targets(tokens): targets = [] pos = 0 while pos < len(tokens): token = tokens[pos] if "=" in token and not token.startswith((">", "<", "-", "/")): pos += 1 elif token in Consts.SHELL_WRAPPERS: pos += 1 elif token == "timeout" and pos + 1 < len(tokens) and re.fullmatch(r"[0-9.]+[smhd]?", tokens[pos + 1] or ""): pos += 2 else: break rest = tokens[pos:] for index, token in enumerate(rest): if token in (">", ">>") and index + 1 < len(rest): cand = rest[index + 1] if cand and not cand.startswith("&") and not cand.startswith("/dev/"): targets.append(cand) if not rest: return targets name = os.path.basename(rest[0]) operands = [item for item in rest[1:] if not item.startswith("-") or item in ("-", "--")] operands = [item for item in operands if item not in (">", ">>", "<", "--")] if name == "rm": targets.extend(operands) elif name in ("mv", "cp") and operands: targets.append(operands[-1]) elif name == "tee": targets.extend(operands) elif name == "truncate": for item in operands: if re.fullmatch(r"[0-9]+[KMGTPE]?[iB]?", item or ""): continue targets.append(item) elif name == "dd": for item in operands: if item.startswith("of="): targets.append(item[3:]) elif name == "shred": targets.extend(operands) return [item for item in targets if item and item != "-"] @staticmethod def spill_output(store, kind, title, text, tags=(), profile=None): profile = profile if profile is not None else store.profile record_id = store.add_record(kind, title, text, tags, profile) store.add_chunks("record", record_id, Text.chunk_lines(text), profile) return record_id @staticmethod def spilled_view(text, record_id, size, head=1500, tail=500): return text[:head] + "\n...[%d chars spilled to %s, record_read pages the rest]...\n" % (size, record_id) + text[-tail:] @staticmethod def spill_or_truncate(store, kind, title, text, tags=(), profile=None): if store is None or len(text) <= Consts.SPILL_LIMIT: return Text.truncate(text) record_id = ShellSnap.spill_output(store, kind, title, text, tags, profile) return ShellSnap.spilled_view(text, record_id, len(text))