from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from itertools import groupby from mirage.commands.builtin.utils.formatting import _ls_mode_string from mirage.commands.builtin.utils.output import format_records from mirage.commands.config import CommandOpts from mirage.commands.spec import SPECS from mirage.commands.spec.types import FlagValue, FlagView from mirage.core.timeutil import iso_to_epoch from mirage.io.types import ByteSource, IOResult from mirage.ops.types import LinkView from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec, StatFn from mirage.utils.errors import FS_ERRORS, fs_error_line _STR_DIRECTIVES = frozenset("nNF") _FORMAT_FLAGS = frozenset("#0 +-") _ASCII_DIGITS = frozenset("0123456789") _TYPE_LABELS = { FileType.DIRECTORY: "directory", FileType.SYMLINK: "symbolic link", FileType.TEXT: "regular file", FileType.BINARY: "regular file", FileType.JSON: "regular file", FileType.CSV: "regular file", } _DEFAULT_OWNER = "user" _SHELL_SPECIAL = frozenset("!\"#$&()*;<=>?[\\^`{|}~") _START_SAFE = frozenset("#~") _ESCAPE_NAMES = { "\a": "\\a", "\b": "\\b", "\t": "\\t", "\n": "\\n", "\v": "\\v", "\f": "\\f", "\r": "\\r", } @dataclass(frozen=True, slots=True) class _FormatDirective: """One parsed ``%[flags][width][.precision]conversion`` directive. Args: end (int): index just past the directive in the format string. flags (str): any of ``# 0 + -``. width (str): minimum field width (digits) or empty. precision (str | None): precision digits, or None when absent. spec (str): the conversion char, H/L-prefixed for device major/minor. """ end: int flags: str width: str precision: str | None spec: str def _type_label(s: FileStat) -> str: return _TYPE_LABELS.get(s.type, "regular file") if s.type else "regular file" def _effective_mode(s: FileStat) -> int: if s.mode is not None: return s.mode & 0o7777 if s.type == FileType.DIRECTORY: return 0o755 # A symlink carries no permission bits of its own; GNU reports 0777. if s.type == FileType.SYMLINK: return 0o777 return 0o644 def _type_bits(s: FileStat) -> int: if s.type == FileType.DIRECTORY: return 0o040000 if s.type == FileType.SYMLINK: return 0o120000 return 0o100000 def _owner(value: int | str | None) -> str: return str(value) if value is not None else _DEFAULT_OWNER def _epoch(iso: str | None) -> str: if not iso: return "0" try: return str(iso_to_epoch(iso)) except (ValueError, TypeError): return "0" def _needs_escape(char: str) -> bool: """Whether GNU spells a character as a ``$'..'`` escape. Args: char (str): the character to test. """ return char < " " or char == "\x7f" def _escape_char(char: str) -> str: """Spell one character the way bash's ``$'..'`` does. Args: char (str): the character to escape. """ named = _ESCAPE_NAMES.get(char) return named if named is not None else f"\\{ord(char):03o}" def _double_quotable(name: str) -> bool: """Whether a name holding an apostrophe still fits in double quotes. GNU only reaches for them when nothing else in the name would stay live inside them, so ``a'b`` renders as ``"a'b"`` but ``a'b$c`` does not. ``#`` and ``~`` count as special only away from the front. Args: name (str): the name to test. """ for index, char in enumerate(name): if _needs_escape(char): return False if char in _SHELL_SPECIAL and not (index == 0 and char in _START_SAFE): return False return True def _single_quoted(name: str) -> str: """Render single-quoted runs spliced with ``$'..'`` escape segments. Args: name (str): the name to quote. """ parts: list[str] = [] for index, (escaped, chars) in enumerate(groupby(name, _needs_escape)): run = "".join(chars) if not escaped: parts.append("'" + run.replace("'", "'\\''") + "'") continue # A leading escape keeps the empty quotes GNU emits; a trailing # one does not. if index == 0: parts.append("''") parts.append("$'" + "".join(_escape_char(c) for c in run) + "'") return "".join(parts) if parts else "''" def _quote_name(name: str) -> str: """Shell-safe quoting for %N, mirroring GNU's default. Single quotes are the rule, with each apostrophe escaped as ``'\\''`` and every unprintable character lifted into a ``$'..'`` segment. A name whose only awkward character is an apostrophe reads better in double quotes, and GNU renders that one case that way. Args: name (str): the file name to quote. """ if "'" in name and _double_quotable(name): return f'"{name}"' return _single_quoted(name) def _apply_flags(value: str, flags: str, width: str, precision: str | None, spec: str) -> str: """Apply GNU printf flags/width/precision to a rendered directive. Args: value (str): the raw directive value. flags (str): any of ``# 0 + -``. width (str): minimum field width (digits) or empty. precision (str | None): precision digits, or None when absent. spec (str): the conversion character. """ if "#" in flags and spec == "a" and not value.startswith("0"): value = "0" + value if precision is not None and spec in _STR_DIRECTIVES: value = value[:int(precision)] if precision else "" if width and len(value) < int(width): w = int(width) if "-" in flags: value = value.ljust(w) elif "0" in flags: value = value.rjust(w, "0") else: value = value.rjust(w) return value def _directive_value(spec: str, s: FileStat, name: str) -> str: if spec == "%": return "%" if spec == "n": return name if spec == "s": return str(s.size if s.size is not None else 0) if spec == "F": return _type_label(s) if spec == "a": return format(_effective_mode(s), "o") if spec == "A": return _ls_mode_string(s) if spec == "f": return format(_type_bits(s) | _effective_mode(s), "x") if spec in ("u", "U"): return _owner(s.uid) if spec in ("g", "G"): return _owner(s.gid) if spec == "x": return s.atime or s.modified or "" if spec == "X": return _epoch(s.atime or s.modified) if spec in ("y", "z"): return s.modified or "" if spec in ("Y", "Z"): return _epoch(s.modified) if spec == "w": return "-" if spec == "W": return "0" if spec == "B": return "512" if spec in ("r", "R", "t", "T"): return "0" if len(spec) == 2 and spec[0] in "HL": # %Hr/%Lr are rdev major/minor (0, like %r); %Hd/%Ld are device # major/minor, which a VFS has no truthful value for. return "0" if spec[1] in "rR" else "?" return "?" def _name_parts(s: FileStat, name: str, quoted: bool) -> list[str]: """The fields ``%N`` renders: the name, plus a symlink's target. Args: s (FileStat): the stat being rendered. name (str): the operand as it was typed. quoted (bool): shell-quote each field. GNU only does so for a bare ``%N``; any flag, width or precision drops the quotes. """ parts = [name] if s.type == FileType.SYMLINK: target = s.extra.get(LINK_TARGET_KEY) if target: parts.append(str(target)) return [_quote_name(p) for p in parts] if quoted else parts def _render_directive(d: _FormatDirective, s: FileStat, name: str) -> str: """Render one directive with its flags, width and precision applied. Args: d (_FormatDirective): the parsed directive. s (FileStat): the stat being rendered. name (str): the operand as it was typed. """ if d.spec == "N": # GNU formats the name and a symlink's target as two separate # fields, so a width pads each one rather than the joined line. bare = not d.flags and not d.width and d.precision is None return " -> ".join( _apply_flags(part, d.flags, d.width, d.precision, d.spec) for part in _name_parts(s, name, bare)) return _apply_flags(_directive_value(d.spec, s, name), d.flags, d.width, d.precision, d.spec) def _is_conversion(char: str) -> bool: return char == "%" or "A" <= char <= "Z" or "a" <= char <= "z" def _parse_format_directive(fmt: str, start: int) -> _FormatDirective | None: """Scan one GNU printf-style directive starting at a ``%``. Walks flags, width and precision with an explicit cursor so a long run of flag/width characters that never reaches a conversion char costs linear time instead of backtracking (CodeQL #247). Args: fmt (str): the whole format string. start (int): index of the leading ``%``. """ end = len(fmt) cursor = start + 1 flags_start = cursor while cursor < end and fmt[cursor] in _FORMAT_FLAGS: cursor += 1 flags = fmt[flags_start:cursor] width_start = cursor while cursor < end and fmt[cursor] in _ASCII_DIGITS: cursor += 1 width = fmt[width_start:cursor] precision: str | None = None if cursor < end and fmt[cursor] == ".": cursor += 1 precision_start = cursor while cursor < end and fmt[cursor] in _ASCII_DIGITS: cursor += 1 precision = fmt[precision_start:cursor] if cursor >= end or not _is_conversion(fmt[cursor]): return None spec = fmt[cursor] cursor += 1 if spec in ("H", "L") and cursor < end and _is_conversion(fmt[cursor]): spec += fmt[cursor] cursor += 1 return _FormatDirective(end=cursor, flags=flags, width=width, precision=precision, spec=spec) def _format_stat(fmt: str, s: FileStat, name: str) -> str: parts: list[str] = [] cursor = 0 while cursor < len(fmt): start = fmt.find("%", cursor) if start == -1: parts.append(fmt[cursor:]) break parts.append(fmt[cursor:start]) directive = _parse_format_directive(fmt, start) if directive is None: parts.append("%") cursor = start + 1 continue parts.append(_render_directive(directive, s, name)) cursor = directive.end return "".join(parts) def _render_stat(s: FileStat) -> str: """Render the default (no -c) stat line. Args: s (FileStat): the stat to render. """ return (f"name={s.name} size={s.size} modified={s.modified}" f" type={s.type.value if s.type else None}") async def stat( paths: list[PathSpec], *, stat_fn: Callable[..., Awaitable[FileStat]], c: str | None = None, f: str | None = None, L: bool = False, links: LinkView | None = None, ) -> tuple[ByteSource | None, IOResult]: """Report file status, GNU stat semantics. Args: paths (list[PathSpec]): operands to stat. stat_fn (Callable): backend stat for a resolved path. c (str | None): output format string. f (str | None): output format string (alias of -c here). L (bool): dereference symlinks instead of reporting the link. links (LinkView | None): the namespace's symlink facts; absent when the workspace holds no links. """ if not paths: raise ValueError("stat: missing operand") fmt = c if c is not None else f lines: list[str] = [] err = b"" for p in paths: # GNU stat lstats: a symlink operand reports the link itself, # not its target, unless -L asks to dereference. A link has no # backend inode, so the namespace is the only authority for it. linked = None if L or links is None else links.stat_at(p.virtual) if linked is not None: if fmt is not None: lines.append(_format_stat(fmt, linked, p.raw_path)) else: lines.append(_render_stat(linked)) continue try: s = await stat_fn(p) except FS_ERRORS as exc: # GNU stat keeps reporting the remaining operands, exit 1. err += fs_error_line("stat", p, exc).encode() continue if fmt is not None: lines.append(_format_stat(fmt, s, p.raw_path)) else: lines.append(_render_stat(s)) io = IOResult(exit_code=1 if err else 0, stderr=err or None) if not lines: return None, io return format_records(lines), io __all__ = ["stat"] @dataclass(frozen=True, slots=True) class StatFlags: format: str | None = None file_system: str | None = None deref: bool = False def parse_flags(flags: Mapping[str, FlagValue]) -> StatFlags: fl = FlagView(flags, spec=SPECS["stat"]) return StatFlags( format=fl.as_str("c"), file_system=fl.as_str("f"), deref=fl.as_bool("L"), ) async def stat_generic( paths: list[PathSpec], texts: list[str], opts: CommandOpts, stat_fn: StatFn, ) -> tuple[ByteSource | None, IOResult]: parsed = parse_flags(opts.flags) return await stat(paths, stat_fn=stat_fn, c=parsed.format, f=parsed.file_system, L=parsed.deref, links=opts.ns.links if opts.ns is not None else None)