From 6da495dc5914587a79c40da13dd5df465a87cc35 Mon Sep 17 00:00:00 2001 From: atagen Date: Tue, 24 Mar 2026 22:18:12 +1100 Subject: [PATCH] fix old nix manpages, strip priv esc --- bin/main.ml | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ lib/manpage.ml | 45 +++++++++++++++++++++++++++++++-------------- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index 6577bb6..27fed59 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -762,6 +762,50 @@ let fuzzy_score needle haystack = ) (0, 0, 0, -1) haystack_lc in if ni = nlen then score else 0 +(* known privilege-escalation wrappers. when one of these is the first token, + * we strip it (and its own options/arguments) before completing the real command. + * options that consume the next token as an argument are listed per-command so + * we don't accidentally treat the argument value as the real command name. *) +let elevation_commands = + ["sudo"; "run0"; "doas"; "pkexec"; "su"; "calife"; "sux"; "sudoedit"; + "please"; "super"; "priv"] + +let elevation_opts_with_arg = function + | "sudo" -> ["-u"; "--user"; "-g"; "--group"; "-h"; "--host"; + "-p"; "--prompt"; "-r"; "--role"; "-t"; "--type"; + "-U"; "--other-user"; "-C"; "--close-from"; + "-T"; "--command-timeout"; "-D"; "--chdir"] + | "run0" -> ["--unit"; "--property"; "--description"; "--slice"; + "--user"; "--group"; "--nice"; "--chdir"; "--setenv"; + "--background"; "--machine"] + | "doas" -> ["-C"; "-u"] + | "pkexec" -> ["--user"] + | "su" -> ["-c"; "-s"; "-g"; "-G"] + | _ -> [] + +(* strip the elevation command itself plus all its own flags/options from + * the span list, returning the spans for the real command (or [] if none). + * handles: + * --flag=value (value is part of the token — no extra token consumed) + * --flag value (value is the next token — consumed if flag is in the list) + * -- (end-of-options sentinel — everything after is the command) *) +let strip_elevation cmd args = + let with_arg = elevation_opts_with_arg cmd in + let rec skip = function + | [] -> [] + | "--" :: rest -> rest + | arg :: rest when String.length arg > 0 && arg.[0] = '-' -> + if List.mem arg with_arg then + (* option takes the next token as its argument — drop both *) + skip (match rest with _ :: tl -> tl | [] -> []) + else begin + (* boolean flag or --flag=value form — drop only this token *) + skip rest + end + | real_cmd -> real_cmd + in + skip args + (* "inshellah complete CMD [ARGS...]" — the nushell custom completer. * this is the hot path — called every time the user presses tab in nushell. * @@ -785,6 +829,12 @@ let fuzzy_score needle haystack = * prevents showing sibling subcommands when the user has already committed * to a specific subcommand path. *) let cmd_complete spans user_dir system_dirs = + (* if the command line starts with a privilege-escalation wrapper, strip the + * wrapper and all its own options so we complete the real command instead. *) + let spans = match spans with + | cmd :: rest when List.mem cmd elevation_commands -> + strip_elevation cmd rest + | _ -> spans in match spans with | [] -> print_string "[]\n" | cmd_name :: rest -> diff --git a/lib/manpage.ml b/lib/manpage.ml index fdba6c6..156dff2 100644 --- a/lib/manpage.ml +++ b/lib/manpage.ml @@ -269,15 +269,23 @@ let classify_line line = (* --- section extraction --- * manpages are divided into sections by .SH macros. the options section * contains the flag definitions we want. if there's no OPTIONS section, - * we fall back to DESCRIPTION (some simple tools put flags there). *) + * we fall back to DESCRIPTION (some simple tools put flags there). + * + * old-style nix manpages (nix-build, nix-env-install, etc.) split flags + * across multiple .SH sections with option-like names: e.g. "Options" for + * command-specific flags and "Common Options" for flags shared by all nix + * commands. collecting only the first such section misses the majority of + * flags, so we collect and concatenate ALL option-like sections. *) let extract_options_section lines = let classified = List.map classify_line lines in - let rec collect_until_next_sh lines acc = + (* collect lines until the next .SH header, returning (content, rest) + * where rest starts at the .SH line (or is empty if at end of file). *) + let rec collect_section lines acc = match lines with - | [] -> List.rev acc - | Macro ("SH", _) :: _ -> List.rev acc - | line :: rest -> collect_until_next_sh rest (line :: acc) + | [] -> (List.rev acc, []) + | Macro ("SH", _) :: _ -> (List.rev acc, lines) + | line :: rest -> collect_section rest (line :: acc) in let is_options_section name = let s = String.uppercase_ascii (String.trim name) in @@ -286,24 +294,33 @@ let extract_options_section lines = try let _ = Str.search_forward (Str.regexp_string "OPTION") s 0 in true with Not_found -> false) in - (* First pass: look for OPTIONS section *) - let rec find_options = function - | [] -> None + (* Collect from ALL option-like .SH sections and concatenate them. + * handles the common nix pattern where "Options" and "Common Options" + * are separate .SH sections but both contain relevant flags. + * + * a synthetic Macro("SH","") separator is inserted between sections so + * that collect_desc_text (which stops on SH/SS) does not let a description + * from the last entry in one section bleed into the intro text of the next. *) + let rec find_all_options lines acc = + match lines with + | [] -> acc | Macro ("SH", args) :: rest when is_options_section args -> - Some (collect_until_next_sh rest []) - | _ :: rest -> find_options rest + let (section, remaining) = collect_section rest [] in + let sep = if acc = [] then [] else [Macro ("SH", "")] in + find_all_options remaining (acc @ sep @ section) + | _ :: rest -> find_all_options rest acc in (* Fallback: DESCRIPTION section *) let rec find_description = function | [] -> [] | Macro ("SH", args) :: rest when String.uppercase_ascii (String.trim args) = "DESCRIPTION" -> - collect_until_next_sh rest [] + fst (collect_section rest []) | _ :: rest -> find_description rest in - match find_options classified with - | Some section -> section - | None -> find_description classified + match find_all_options classified [] with + | [] -> find_description classified + | sections -> sections (* --- strategy-based entry extraction --- * rather than a single monolithic parser, we use multiple "strategies" that