From 4cb3464e86b21d781126ff892434f27987147c1a Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Sun, 14 Jun 2026 17:58:05 +0200 Subject: [PATCH] hel: pb multi-sink copy, enc command-consumer, proper help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pb: built-in fan-out to every clipboard on PATH (pbcopy/wl-copy/xclip -selection clipboard/xsel -ib/tmux in a session); no HEL_PB script needed. Override via 'set hel_pb' or $HEL_PB still honored; no sink -> data on stdout. enc: takes a sub-command like pb. 'enc ls|ld ' encodes the last match (newest for ld); literal name/id wins first. ls/ld emit bare names when captured (LKEval.capture), so 'pb ld ' copies names. Producers limited to ls/ld (enc never runs a mutating command). 'set hel_enc_strict 1' errors on >1 match. help: 'help [topic]' — grouped overview + per-topic detail (every command, the entry descriptor, all modes with examples, parent/master chaining, config keys, files/env, Notion store/load subcommands). Old inline help string removed. Tests added for all three; native + wasm32 builds green. --- hel/src/commands.rs | 360 +++++++++++++++++++++++++++++++++++++++++--- hel/src/parser.rs | 33 +++- hel/src/repl.rs | 146 +++++++++++++----- hel/src/structs.rs | 16 +- hel/src/utils.rs | 77 ++++++++-- 5 files changed, 565 insertions(+), 67 deletions(-) diff --git a/hel/src/commands.rs b/hel/src/commands.rs index 81de786..3eaecd3 100644 --- a/hel/src/commands.rs +++ b/hel/src/commands.rs @@ -7,12 +7,14 @@ use crate::parser::command_parser; use crate::password::fix_password_recursion; use crate::password::{Name, Password, PasswordRef}; use crate::repl::LKEval; -use crate::structs::{config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE}; +use crate::structs::{config_flag, config_get, config_set, Command, LKOut, Radix, CORRECT_FILE, DUMP_FILE}; use crate::utils::editor::password; -// call_cmd_with_input / get_cmd_args_from_command / get_copy_command_from_env are -// only used by the native (non-wasm) subprocess branches. +// call_cmd_with_input / get_cmd_args_from_command are only used by the native +// (non-wasm) subprocess branches. copy_to_clipboards is native-only, so it is +// referenced fully-qualified at its one call site (not imported here, which +// would break the wasm build). #[cfg_attr(target_arch = "wasm32", allow(unused_imports))] -use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, get_copy_command_from_env, rnd}; +use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, rnd}; // In the browser `pb` copies through the host page's clipboard (navigator.clipboard) // instead of shelling out to pbcopy/xclip. @@ -25,7 +27,245 @@ extern "C" { fn hel_clipboard_write(text: &str); } +const HELP_OVERVIEW: &str = "\ +hel — deterministic S/KEY (RFC 2289) passwords. Your master plus an entry's +name + sequence + date derive the password on the fly; nothing secret is +stored. Default form is six short, memorable words (xkcd 936). + + help detail for a command or concept, e.g. `help enc`, `help add`, + `help modes`, `help config`, `help files`. + +ENTRIES + add define an entry (help add / help name) + ls [regex] list entries by name + ld [regex] list entries by date (oldest first) + keep save list row (left column of ls/gen) to catalog + mv move entry under (folder `/` = top level) + comment [text] set or clear the comment + rm remove an entry + +PASSWORDS + enc show an entry's password + enc ls|ld show the matched entry's password (newest, for ld) + gen[N] N variants; name ends G.. (all) or X.. (random) [N=10] + pb run a command, copy its output to the clipboard + pass [pw] cache a master/override for an entry's subtree + unpass [name] forget cached master (unpass / = root, unpass = all) + correct trust this password's hash + uncorrect untrust it + +CATALOG + dump print the catalog as `add …` lines + save [target] write it: file / key / - / |command (help save) + source load it: file / key / command| + set runtime config (help config) + +OTHER + help [topic] this overview, or detail for one topic + # text a comment (ignored) + quit exit the REPL + +An entry id is the number in the left column of `ls`/`ld`/`gen`; use it anywhere +a is expected until the next listing. `ls`/`ld` match the name +case-insensitively as a regular expression. + +MODES (the [len][mode] in a descriptor — `help modes` for examples) + R six words (default) N hyphenated C CamelCase D decimal + H hex B base64 U.. = UPPERCASE variant (UR UN UH UB) + truncates the result to characters (e.g. 20R, 12UB)"; + +const HELP_NAME: &str = "\ +descriptor — used by `add`, `gen`, and every line of the dump/`save` format: + + [prefix] [] [] [] [comment] [^parent] + + prefix optional literal glued onto the output, e.g. #W9 (to satisfy a + \"must contain a symbol/digit\" rule). Part of the generated value. + name entry key, no spaces; feeds the S/KEY hash. + len optional: truncate the output to characters. + mode output form, default R (see `help modes`). + seq S/KEY sequence count, default 99. + date YYYY-MM-DD or `now`, default now. `ld` sorts by it; `enc ld ` + takes the newest. + comment free text (login, URL, notes). + ^parent place this entry under : the parent's generated password + becomes the master for this entry (chained). The root master is the + entry `/`, prompted once or set with `pass /`. + + examples + add github + add github 20UR 2024-01-01 me@example.com ^work + add #W9 ableton 99 2020-12-09 license note"; + +const HELP_MODES: &str = "\ +modes — output form; prefix with a length to truncate (e.g. 20R). For one fixed +entry + master: + + R six words, spaces ross beau week held yoga anti (default) + UR R, upper-cased ROSS BEAU WEEK HELD YOGA ANTI + N hyphenated ross-beau-week-held-yoga-anti + UN N, upper-cased ROSS-BEAU-WEEK-HELD-YOGA-ANTI + C CamelCase, no spaces RossBeauWeekHeldYogaAnti + H hex e5a38ad29afc3fcb (UH = upper) + B base64 0oqj5cs//Jo (UB = upper) + D decimal words 1684 680 1995 1203 2046 619 + truncate to characters, e.g. 20R, 12UB, 6D. + A prefix (e.g. #Q3a) is prepended to every form."; + +const HELP_LS: &str = "\ +ls [regex] list catalog entries sorted by name. +ld [regex] list catalog entries sorted by date, oldest first (newest last). + +The regex (default `.`) matches case-insensitively against the name, the full +descriptor, and the comment. Each row gets an id (left column) reusable as a + in enc/keep/mv/etc. until the next listing. Under `pb`/`enc` the listing +collapses to bare names (newest last for `ld`) — see `help pb`, `help enc`."; + +const HELP_ENC: &str = "\ +enc show an entry's generated password (to stdout). +enc ls encode the last entry of `ls ` (last by name). +enc ld encode the last entry of `ld ` (newest by date). + +A literal name or list id is tried first; otherwise the argument is run as an +`ls`/`ld` search and the last match is encoded. On more than one match a `note:` +reports the count and the chosen entry. To require a unique match instead: + + set hel_enc_strict 1 # multiple matches become an error + +Password goes to stdout, notes/warnings to stderr — so `pb enc …` copies only +the password. Only `ls`/`ld` are valid as the search form (enc never runs a +state-changing command). See `help pb`."; + +const HELP_GEN: &str = "\ +gen[N] show N variants of an entry, sorted by password length. + +If ends in one or more `G`, every numbered variant is generated +(testG -> test1..test9, testGG -> test1..test99). If it ends in `X`, one random +numbered variant is produced. Otherwise the single entry is shown. N defaults to +10. Results populate the id list (left column) for `keep`/`enc`."; + +const HELP_PB: &str = "\ +pb run and copy its stdout to the clipboard. + + pb enc github copy github's password + pb enc ld micro.*exa copy the newest matching entry's password + pb ld micro copy the matching names (newest last) + +If `hel_pb` (or $HEL_PB) is set, that one command receives the data on stdin. +Otherwise hel copies to every clipboard found on PATH — pbcopy, wl-copy, xclip, +xsel, and tmux inside a session — so a bare `pb` works on macOS, Wayland, X11 +and over SSH/tmux with no configuration. With none found, the data is left on +stdout."; + +const HELP_PASS: &str = "\ +pass [pw] cache a master for and its subtree for this session. + `pass ` prompts; `pass pw` sets it inline. Use + `pass /` for the ROOT master used by top-level entries. + Nothing is written to disk. +unpass [name] forget a cached master: `unpass ` one, `unpass /` the + root, `unpass` (no argument) all of them."; + +const HELP_CORRECT: &str = "\ +correct remember this password's hash as trusted, in ~/.hel_correct + (names + hashes only, never secrets). Later, hel warns if a + freshly derived password does not match a trusted hash — + catching a mistyped master before you use the result. +uncorrect drop that trust."; + +const HELP_KEEP: &str = "\ +keep copy list row (the left column of `ls`/`ld`/`gen`) into the + catalog as a permanent entry. Useful after `gen` to keep one of the + generated variants."; + +const HELP_MV: &str = "\ +mv move under so 's password becomes + its master (chained derivation). Use `/` as to + move the entry back to the top level. This re-parents; it + does not rename — to rename, `rm` and `add` again."; + +const HELP_RM: &str = "\ +rm remove an entry from the catalog. Other entries are unaffected; + `save` to persist the change."; + +const HELP_COMMENT: &str = "\ +comment [text] set the entry's comment to , or clear it when no + text is given. The comment is searched by `ls`/`ld` and + shown in listings."; + +const HELP_CATALOG: &str = "\ +The catalog is just a script of `add …` lines. + +dump print the whole catalog to the screen. +save [target] persist it. : + (omitted) hel_dump / $HEL_DUMP / ~/.hel_dump + a file + - print to screen (same as dump) + | pipe the dump into 's stdin + A diff (< removed, > added) vs the last load/save is shown. +source load a catalog. : + a file (a localStorage key in the wasm build) + | run , load its stdout as a script +Notion: `save |hel store notion:` and `source hel load notion:|` +store the catalog in a Notion code block (token via `set hel_notion_token …`)."; + +const HELP_CONFIG: &str = "\ +set set a runtime config value (typically from ~/.helrc). Keys + are case-insensitive and each also reads the UPPER-CASE env + var of the same name. + + hel_pb clipboard command for `pb` (else the built-in multi-sink copy) + hel_enc_strict 1/true/on -> `enc ls|ld ` errors when >1 entry matches + hel_dump default `save`/`dump` target + hel_notion_token token for the `hel store`/`hel load` Notion subcommands"; + +const HELP_FILES: &str = "\ +files and environment + ~/.helrc startup script, run once $HEL_INIT + ~/.hel_history REPL history $HEL_HISTORY + ~/.hel_dump default catalog file $HEL_DUMP + ~/.hel_correct trusted password hashes $HEL_CORRECT + prompt string $HEL_PROMPT +Non-interactive subcommands (run before the REPL, no ~/.helrc, no prompt): + hel store read a catalog on stdin and write it to Notion + hel load print the catalog stored in a Notion page"; + +const HELP_QUIT: &str = "\ +quit exit the REPL (also Ctrl-D / EOF). The catalog is NOT saved automatically + — `save` first if you have unsaved changes."; + impl<'a> LKEval<'a> { + /// `help [topic]`: the grouped command overview, or detail for one command + /// or concept. Output goes to stdout; an unknown topic errors on stderr. + pub fn cmd_help(&self, out: &LKOut, topic: &Option) { + let text: &str = match topic.as_deref().map(str::to_lowercase).as_deref() { + None => HELP_OVERVIEW, + Some("add" | "name" | "desc" | "descriptor" | "entry" | "parent") => HELP_NAME, + Some("modes" | "mode") => HELP_MODES, + Some("ls" | "ld" | "list") => HELP_LS, + Some("enc") => HELP_ENC, + Some("gen") => HELP_GEN, + Some("pb") => HELP_PB, + Some("pass" | "unpass") => HELP_PASS, + Some("correct" | "uncorrect") => HELP_CORRECT, + Some("keep") => HELP_KEEP, + Some("mv" | "move") => HELP_MV, + Some("rm" | "remove") => HELP_RM, + Some("comment") => HELP_COMMENT, + Some("dump" | "save" | "source" | "catalog") => HELP_CATALOG, + Some("set" | "config") => HELP_CONFIG, + Some("files" | "file" | "env" | "environment") => HELP_FILES, + Some("quit" | "exit") => HELP_QUIT, + Some(other) => { + out.e(format!( + "error: no help for {}; try `help` for the command list", + other + )); + return; + } + }; + out.o(text.to_string()); + } + pub fn get_password(&self, name: &String) -> Option { match self.state.lock().borrow().ls.get(name) { Some(pwd) => Some(pwd.clone()), @@ -218,10 +458,70 @@ impl<'a> LKEval<'a> { Some((name, pass)) } + /// `enc `: pick which entry to encode, then encode it. Precedence keeps + /// the historical `enc ` / `enc ` behavior working even when a name + /// collides with a command keyword: + /// 1. `arg` resolves to a catalog entry or `ls` id -> encode it. + /// 2. else `arg` parses as an `ls`/`ld` search -> evaluate it capturing + /// (so the listing yields bare names), take the LAST non-empty line as + /// the entry name (newest for `ld`), and encode that. With >1 match, + /// `set hel_enc_strict 1` errors instead of taking the newest. + /// 3. else -> error. + /// Only `ls`/`ld` are accepted as producers: enc must never execute a + /// mutating command (e.g. `rm`) as a side effect of resolving a name. + pub fn cmd_enc_arg(&self, out: &LKOut, arg: &String) { + if self.get_password(arg).is_some() { + self.cmd_enc(out, arg); + return; + } + let cmd = match command_parser::cmd(arg) { + Ok(c) if matches!(c, Command::Ls(_) | Command::Ld(_)) => c, + _ => { + out.e(format!("error: name {} not found", arg)); + return; + } + }; + let print = LKEval::new(self.rl.clone(), cmd, self.state.clone(), self.read_password) + .with_capture(true) + .eval(); + // Surface the producer's own diagnostics (e.g. a bad regex). + print.out.copy_err(out); + let names: Vec = print + .out + .data() + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + let name = match names.last() { + Some(n) => n.clone(), + None => { + out.e(format!("error: no entry matches {}", arg)); + return; + } + }; + if names.len() > 1 { + if config_flag("hel_enc_strict") { + out.e(format!( + "error: {} entries match {}; refusing under hel_enc_strict (narrow the pattern or use an id)", + names.len(), + arg + )); + return; + } + out.e(format!("note: {} names matched; encoding last: {}", names.len(), name)); + } + self.cmd_enc(out, &name); + } + pub fn cmd_pb(&self, out: &LKOut, command: &String) { match command_parser::cmd(command) { Ok(cmd) => { - let print = LKEval::new(self.rl.clone(), cmd, self.state.clone(), self.read_password).eval(); + // capture=true so a wrapped `ls`/`ld` yields bare names. + let print = LKEval::new(self.rl.clone(), cmd, self.state.clone(), self.read_password) + .with_capture(true) + .eval(); let data = print.out.data(); print.out.copy_err(&out); if data.len() > 0 { @@ -234,18 +534,37 @@ impl<'a> LKEval<'a> { } #[cfg(not(target_arch = "wasm32"))] { - let (copy_command, copy_cmd_args) = get_copy_command_from_env(); - match call_cmd_with_input(©_command, ©_cmd_args, &data) { - Ok(s) if s.len() > 0 => { - out.o(format!( - "Copied output with the command {}, and got following output:", - copy_command - )); - out.o(s.trim().to_string()); + // Explicit override (`set hel_pb` or $HEL_PB): one command, + // as before. Otherwise fan out to every present clipboard. + match config_get("hel_pb").and_then(|s| get_cmd_args_from_command(&s).ok()) { + Some((copy_command, copy_cmd_args)) => { + match call_cmd_with_input(©_command, ©_cmd_args, &data) { + Ok(s) if s.len() > 0 => { + out.o(format!( + "Copied output with the command {}, and got following output:", + copy_command + )); + out.o(s.trim().to_string()); + } + Ok(_) => out.o(format!("Copied output with command {}", copy_command)), + Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())), + }; } - Ok(_) => out.o(format!("Copied output with command {}", copy_command)), - Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())), - }; + None => { + let report = crate::utils::copy_to_clipboards(&data); + if !report.ok.is_empty() { + out.o(format!( + "Copied {} chars to clipboard ({})", + data.chars().count(), + report.ok.join(", ") + )); + } else { + // No sink and no override: never drop the payload. + out.o(data.clone()); + out.e("error: no clipboard available; data left on stdout".to_string()); + } + } + } } } } @@ -439,7 +758,14 @@ impl<'a> LKEval<'a> { let key = Radix::new(counter, 36).unwrap().to_string(); counter += 1; self.state.lock().borrow_mut().ls.insert(key.clone(), pwd.clone()); - out.o(format!("{:>3} {}", key, pwd.lock().borrow().to_string())); + // Captured (under `pb`/`enc`): emit just the entry name — a unique db + // key that re-resolves via get_password, so it can feed `enc` and + // makes `pb ls`/`pb ld` copy clean names. Interactive: rich rows. + if self.capture { + out.o(pwd.lock().borrow().name.to_string()); + } else { + out.o(format!("{:>3} {}", key, pwd.lock().borrow().to_string())); + } } } diff --git a/hel/src/parser.rs b/hel/src/parser.rs index 014c1a0..212f861 100644 --- a/hel/src/parser.rs +++ b/hel/src/parser.rs @@ -91,7 +91,7 @@ peg::parser! { rule mode() -> Mode = m:(umode() / rmode()) !['0'..='9' | 'a'..='z' | 'A'..='Z'] { m } rule noop_cmd() -> Command<'input> = ("#" [' '..='~']*)? { Command::Noop } - rule help_cmd() -> Command<'input> = "help" { Command::Help } + rule help_cmd() -> Command<'input> = "help" t:(_ w:word() { w })? { Command::Help(t) } rule quit_cmd() -> Command<'input> = "quit" { Command::Quit } rule pb_cmd() -> Command<'input> = "pb" _ e:$(([' '..='~'])+) { Command::PasteBuffer(e.to_string()) } rule save_cmd() -> Command<'input> = "save" _ s:$(([' '..='~'])+) { Command::Dump(Some(s.to_string())) } @@ -114,7 +114,9 @@ peg::parser! { rule correct_cmd() -> Command<'input> = "correct" _ name:word() { Command::Correct(name) } rule uncorrect_cmd() -> Command<'input> = "uncorrect" _ name:word() { Command::Uncorrect(name) } rule unpass_cmd() -> Command<'input> = "unpass" name:(_ w:word() { w })? { Command::UnPass(name) } - rule enc_cmd() -> Command<'input> = "enc" _ name:word() { Command::Enc(name) } + // `enc` takes the rest of the line (like `pb`): a bare name/id, or a + // sub-command whose output names the entry to encode (e.g. `enc ld re`). + rule enc_cmd() -> Command<'input> = "enc" _ e:$(([' '..='~'])+) { Command::Enc(e.to_string()) } rule rm_cmd() -> Command<'input> = "rm" _ name:word() { Command::Rm(name) } rule comment_cmd() -> Command<'input> = "comment" _ name:word() c:comment()? { Command::Comment(name, c) } } @@ -251,6 +253,33 @@ add t3 C 99 2022-12-14 ); } + #[test] + fn parse_enc_arg_test() { + // `enc` now captures the rest of the line (like `pb`): a bare name/id, + // or a sub-command (`ld `) resolved at eval time. + assert_eq!(command_parser::cmd("enc t3"), Ok(Command::Enc("t3".to_string()))); + assert_eq!(command_parser::cmd("enc 3"), Ok(Command::Enc("3".to_string()))); + assert_eq!( + command_parser::cmd("enc ld micro.*exa"), + Ok(Command::Enc("ld micro.*exa".to_string())) + ); + assert_eq!( + command_parser::cmd("enc ls foo"), + Ok(Command::Enc("ls foo".to_string())) + ); + // Display round-trips. + assert_eq!(Command::Enc("ld micro.*exa".to_string()).to_string(), "enc ld micro.*exa"); + } + + #[test] + fn parse_help_test() { + assert_eq!(command_parser::cmd("help"), Ok(Command::Help(None))); + assert_eq!(command_parser::cmd("help enc"), Ok(Command::Help(Some("enc".to_string())))); + assert_eq!(command_parser::cmd("help modes"), Ok(Command::Help(Some("modes".to_string())))); + assert_eq!(Command::Help(Some("enc".to_string())).to_string(), "help enc"); + assert_eq!(Command::Help(None).to_string(), "help"); + } + #[test] fn parse_short_parent_test() { // `name ^parent` (no mode/date): name is the name, ^parent stays in the comment diff --git a/hel/src/repl.rs b/hel/src/repl.rs index e1b0fc0..9387513 100644 --- a/hel/src/repl.rs +++ b/hel/src/repl.rs @@ -19,6 +19,12 @@ pub struct LKEval<'a> { pub cmd: Command<'a>, pub state: LKRef, pub read_password: fn(String) -> std::io::Result, + /// When true, listing commands (`ls`/`ld`) emit bare entry names instead of + /// the rich `key name mode seq date comment` rows. Set by `pb`/`enc` on the + /// sub-command they evaluate so the captured output is consumable (names + /// feed `enc`, and `pb ls`/`pb ld` copy clean names). Interactive evals keep + /// it false. + pub capture: bool, } #[derive(Debug)] @@ -80,9 +86,16 @@ impl<'a> LKEval<'a> { cmd, state, read_password, + capture: false, } } + /// Builder: mark this eval as capturing (see `LKEval::capture`). + pub fn with_capture(mut self, capture: bool) -> Self { + self.capture = capture; + self + } + pub fn news(cmd: Command<'a>, state: LKRef) -> Self { LKEval::new(Editor::new(), cmd, state, |_| { Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "could not read password")) }) } @@ -121,8 +134,8 @@ impl<'a> LKEval<'a> { } None => out.e(format!("error: password {} not found", name)), }, - Command::Enc(name) => { - self.cmd_enc(&out, name); + Command::Enc(arg) => { + self.cmd_enc_arg(&out, arg); } Command::Gen(num, name) => self.cmd_gen(&out, &num, &name), Command::PasteBuffer(command) => self.cmd_pb(&out, command), @@ -144,39 +157,7 @@ impl<'a> LKEval<'a> { Command::Correct(name) => self.cmd_correct(&out, name, true, None), Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None), Command::Noop => { to_history = false; }, - Command::Help => { - out.o(concat!( - "hel - S/KEY (RFC 2289) deterministic passwords from your master + the entry\n", - "name; nothing secret is stored. The default mode is six short, memorable words\n", - "(the \"correct horse battery staple\" idea from xkcd 936).\n", - "\n", - "entries\n", - " add [len][mode] [seq] [date] [text] [^parent] define an entry\n", - " keep keep list entry n (left column of ls/gen) in the catalog\n", - " ls [regex] list by name ld [regex] list by date\n", - " mv rename / move rm remove\n", - " comment [text] set or clear the comment\n", - "\n", - "passwords\n", - " enc show the generated password\n", - " gen[N] N numbered variants; name ends in G.. (all) or X.. (random)\n", - " pb run a command and copy its output to the clipboard\n", - " pass [pw] cache a master / override for an entry's subtree\n", - " unpass [name] forget a cached password (unpass / = root; unpass = all)\n", - " correct trust this password's hash uncorrect untrust it\n", - "\n", - "catalog\n", - " dump print the catalog ls list it\n", - " save [target] write it (file / localStorage key / |command)\n", - " source load it set config\n", - "\n", - "other\n", - " help this text # text a comment (ignored) quit exit (CLI)\n", - "\n", - "modes R words C camel N hyphenated H hex B base64 D decimal (U.. = UPPER)\n", - " R is the six-word S/KEY form: memorable, pronounceable, easy to type anywhere." - ).to_string()); - } + Command::Help(topic) => self.cmd_help(&out, topic), Command::Mv(name, folder) => self.cmd_mv(&out, &name, &folder), Command::Error(error) => { to_history = false; @@ -467,4 +448,99 @@ mod tests { LKEval::news(Command::Pass("t1".to_string(), Some("other pw".to_string())), lk.clone()).eval(); assert_eq!(lk.lock().borrow().secrets[&"t1".to_string()], "other pw"); } + + fn mk(name: &str, y: i32, m: u32, d: u32) -> crate::password::PasswordRef { + Password::from_password(Password { + name: name.to_string(), + prefix: None, + length: None, + mode: Mode::Regular, + seq: 99, + date: Date::new(y, m, d), + comment: None, + parent: None, + }) + } + + #[test] + fn capture_names_test() { + let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new()))); + LKEval::news(Command::Add(mk("btest", 2022, 1, 2)), lk.clone()).eval(); + LKEval::news(Command::Add(mk("atest", 2024, 5, 6)), lk.clone()).eval(); + + // Captured ls -> bare names, sorted by name. + let pr = LKEval::news(Command::Ls(".".to_string()), lk.clone()).with_capture(true).eval(); + assert_eq!(pr.out, LKOut::from_vecs(vec!["atest".to_string(), "btest".to_string()], vec![])); + + // Captured ld -> bare names, sorted by date ascending (newest last). + let pr = LKEval::news(Command::Ld(".".to_string()), lk.clone()).with_capture(true).eval(); + assert_eq!(pr.out, LKOut::from_vecs(vec!["btest".to_string(), "atest".to_string()], vec![])); + + // Interactive ls keeps the rich rows (key + mode + date), not bare names. + let pr = LKEval::news(Command::Ls(".".to_string()), lk.clone()).eval(); + let rows = pr.out.out.as_ref().unwrap().lock(); + assert!(rows.iter().any(|l| l.contains("atest R 99 2024-05-06"))); + assert!(rows.iter().all(|l| l.as_str() != "atest" && l.as_str() != "btest")); + } + + #[test] + fn help_test() { + let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new()))); + // overview + let pr = LKEval::news(Command::Help(None), lk.clone()).eval(); + assert!(pr.out.out.as_ref().unwrap().lock()[0].contains("ENTRIES")); + // per-topic detail + let pr = LKEval::news(Command::Help(Some("enc".to_string())), lk.clone()).eval(); + assert!(pr.out.out.as_ref().unwrap().lock()[0].contains("enc ld ")); + // alias resolves to the same topic + let pr = LKEval::news(Command::Help(Some("descriptor".to_string())), lk.clone()).eval(); + assert!(pr.out.out.as_ref().unwrap().lock()[0].contains("[prefix] ")); + // unknown topic -> stderr error, empty stdout + let pr = LKEval::news(Command::Help(Some("bogus".to_string())), lk.clone()).eval(); + assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 0); + assert!(pr.out.err.as_ref().unwrap().lock()[0].contains("no help for bogus")); + } + + #[test] + fn enc_consumer_test() { + let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new()))); + LKEval::news(Command::Add(mk("microexa1", 2024, 1, 10)), lk.clone()).eval(); + LKEval::news(Command::Add(mk("microexa2", 2025, 9, 1)), lk.clone()).eval(); + LKEval::news(Command::Add(mk("microexaadmin", 2026, 3, 4)), lk.clone()).eval(); + LKEval::news(Command::Add(mk("other", 2023, 1, 1)), lk.clone()).eval(); + let rp = |p: String| -> std::io::Result { + if p == "/" { Ok("a".to_string()) } else { Ok("".to_string()) } + }; + + // enc ld : 3 matches -> encode the newest (microexaadmin); note on stderr. + let pr = LKEval::newd(command_parser::cmd("enc ld micro.*exa").unwrap(), lk.clone(), rp).eval(); + assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 1); + let pass_newest = pr.out.out.as_ref().unwrap().lock()[0].clone(); + assert!(pr.out.err.as_ref().unwrap().lock().iter().any(|l| l == "note: 3 names matched; encoding last: microexaadmin")); + + // Same password as encoding the newest entry by name directly. + lk.lock().borrow_mut().secrets.clear(); + let pr2 = LKEval::newd(command_parser::cmd("enc microexaadmin").unwrap(), lk.clone(), rp).eval(); + assert_eq!(pr2.out.out.as_ref().unwrap().lock()[0].clone(), pass_newest); + + // 0 matches -> error, empty stdout (so a wrapping `pb` copies nothing). + let pr = LKEval::newd(command_parser::cmd("enc ld nomatch").unwrap(), lk.clone(), rp).eval(); + assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 0); + assert!(pr.out.err.as_ref().unwrap().lock().iter().any(|l| l.contains("no entry matches"))); + + // strict: >1 match errors, nothing encoded. + crate::structs::config_set("hel_enc_strict", "1"); + let pr = LKEval::newd(command_parser::cmd("enc ld micro.*exa").unwrap(), lk.clone(), rp).eval(); + assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 0); + assert!(pr.out.err.as_ref().unwrap().lock().iter().any(|l| l.contains("hel_enc_strict"))); + crate::structs::config_set("hel_enc_strict", "0"); + + // literal-first: an entry named like a command keyword still encodes that + // entry (not the listing) and emits no "names matched" note. + LKEval::news(Command::Add(mk("ld", 2020, 1, 1)), lk.clone()).eval(); + lk.lock().borrow_mut().secrets.clear(); + let pr = LKEval::newd(command_parser::cmd("enc ld").unwrap(), lk.clone(), rp).eval(); + assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 1); + assert!(!pr.out.err.as_ref().unwrap().lock().iter().any(|l| l.contains("names matched"))); + } } diff --git a/hel/src/structs.rs b/hel/src/structs.rs index 843e861..4bb517f 100644 --- a/hel/src/structs.rs +++ b/hel/src/structs.rs @@ -67,6 +67,15 @@ pub fn config_get(key: &str) -> Option { std::env::var(key.to_uppercase()).ok() } +/// A boolean config flag: true for `1/true/yes/on` (any case), false otherwise +/// (including unset). Used for toggles like `hel_enc_strict`. +pub fn config_flag(key: &str) -> bool { + match config_get(key) { + Some(v) => matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on"), + None => false, + } +} + /// The runtime config as `(UPPERCASE_KEY, value)` pairs, for injection into the /// environment of child processes (so `set hel_notion_token …` reaches a spawned /// `hel store`/`hel load`). @@ -107,7 +116,7 @@ pub enum Command<'a> { Comment(Name, Comment), Error(LKErr<'a>), Noop, - Help, + Help(Option), Quit, } @@ -133,7 +142,7 @@ impl<'a> PartialEq for Command<'a> { (Command::Comment(a, b), Command::Comment(x, y)) => a == x && b == y, (Command::Error(s), Command::Error(o)) => s == o, (Command::Noop, Command::Noop) => true, - (Command::Help, Command::Help) => true, + (Command::Help(s), Command::Help(o)) => s == o, (Command::Quit, Command::Quit) => true, _ => false, } @@ -168,7 +177,8 @@ impl<'a> std::fmt::Display for Command<'a> { Command::Comment(a, Some(b)) => write!(f, "comment {} {}", a, b), Command::Error(s) => write!(f, "error {}", s), Command::Noop => write!(f, "noop"), - Command::Help => write!(f, "help"), + Command::Help(None) => write!(f, "help"), + Command::Help(Some(t)) => write!(f, "help {}", t), Command::Quit => write!(f, "quit"), } } diff --git a/hel/src/utils.rs b/hel/src/utils.rs index c77c661..666cd56 100644 --- a/hel/src/utils.rs +++ b/hel/src/utils.rs @@ -1,6 +1,4 @@ use shlex::split; -use std::env; -use std::ffi::OsString; use std::io; use std::io::Write; use std::process::{Command, Stdio}; @@ -242,14 +240,60 @@ pub fn get_cmd_args_from_command(command: &str) -> io::Result<(String, Vec (String, Vec) { - let cmd_os_str = env::var_os("HEL_PB").unwrap_or_else(|| match env::consts::OS { - _ if env::var("TMUX").is_ok() => OsString::from("tmux load-buffer -"), - "macos" => OsString::from("pbcopy"), - "linux" => OsString::from("xclip"), - _ => OsString::from("cat"), - }); - get_cmd_args_from_command(&cmd_os_str.to_string_lossy()).unwrap_or_else(|_| ("cat".to_string(), vec![])) +/// Outcome of a built-in fan-out copy: which sinks accepted the data and which +/// failed (best-effort, like the reference shell script's `2>/dev/null`). +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Default, PartialEq)] +pub struct CopyReport { + pub ok: Vec, + pub err: Vec<(String, String)>, +} + +/// True if `bin` is an existing file on any `$PATH` directory. +#[cfg(not(target_arch = "wasm32"))] +pub fn bin_on_path(bin: &str) -> bool { + match std::env::var_os("PATH") { + Some(paths) => std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file()), + None => false, + } +} + +/// Built-in, zero-config clipboard copy: pipe `data` into **every** clipboard +/// sink present on `$PATH`, so a single `pb` works across macOS, Wayland, X11 +/// and (inside a session) tmux without any `HEL_PB` script. Used only when no +/// explicit override (`set hel_pb` / `HEL_PB`) is set. The sink set + flags +/// mirror the reference script, with two fixes: `xclip -selection clipboard` +/// (the script's bare `xclip` filled the X11 *primary*, so Ctrl/Cmd-V missed +/// it) and `xsel -ib` (input-to-clipboard; the script's `-ob` is the *output* +/// direction). `wl-copy` is added for Wayland. +#[cfg(not(target_arch = "wasm32"))] +pub fn copy_to_clipboards(data: &str) -> CopyReport { + let mut sinks: Vec<(&str, Vec<&str>)> = Vec::new(); + if bin_on_path("pbcopy") { + sinks.push(("pbcopy", vec![])); + } + if bin_on_path("wl-copy") { + sinks.push(("wl-copy", vec![])); + } + if bin_on_path("xclip") { + sinks.push(("xclip", vec!["-selection", "clipboard"])); + } + if bin_on_path("xsel") { + sinks.push(("xsel", vec!["-ib"])); + } + if std::env::var("TMUX").is_ok() && bin_on_path("tmux") { + sinks.push(("tmux", vec!["load-buffer", "-"])); + } + + let mut report = CopyReport::default(); + for (bin, args) in sinks { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + match call_cmd_with_input(bin, &args, data) { + Ok(_) => report.ok.push(bin.to_string()), + Err(e) => report.err.push((bin.to_string(), e.to_string())), + } + } + report } #[cfg(test)] @@ -279,6 +323,19 @@ line 4"### ); } + #[test] + fn bin_on_path_test() { + // `sh` is on PATH on every unix; a nonsense name is not. + assert!(bin_on_path("sh")); + assert!(!bin_on_path("definitely-not-a-real-binary-xyzzy-42")); + } + + #[test] + fn copy_report_default_test() { + let r = CopyReport::default(); + assert!(r.ok.is_empty() && r.err.is_empty()); + } + #[test] fn check_correct_stdin() { let cmd = "cat";