From ab6d768e3e171f81f077b16c11ebbe94fe6b69cc Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Sun, 14 Jun 2026 18:16:43 +0200 Subject: [PATCH] hel: enc takes any command; gen names in capture; ls-name resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enc: drop the ls/ld-only guard — it now runs any parseable command and encodes the last output line, full parity with pb. ls/ld/gen all emit bare names in capture mode. gen: capture mode skips the password table and emits one bare variant name per row, so 'pb gen', 'enc gen' and 'pb enc gen' compose like ls/ld. get_password: third fallback resolves by ls-value entry name, so generated variants (which live only in the ls map under a numeric id, not in db) can be re-encoded by name. help (enc/gen/pb/overview) updated; test gen_capture_and_enc_test added. --- hel/src/commands.rs | 80 ++++++++++++++++++++++++++++----------------- hel/src/repl.rs | 26 ++++++++++++++- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/hel/src/commands.rs b/hel/src/commands.rs index 3eaecd3..ff612f3 100644 --- a/hel/src/commands.rs +++ b/hel/src/commands.rs @@ -7,7 +7,7 @@ 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_flag, config_get, config_set, Command, LKOut, Radix, CORRECT_FILE, DUMP_FILE}; +use crate::structs::{config_flag, config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE}; use crate::utils::editor::password; // 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 @@ -46,7 +46,7 @@ ENTRIES PASSWORDS enc show an entry's password - enc ls|ld show the matched entry's password (newest, for ld) + enc encode the last name a command prints (ls/ld/gen) 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 @@ -123,18 +123,21 @@ 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). +enc run and encode the LAST name in its output: + enc ld newest entry matching + enc ls last entry by name + enc gen the variant `gen ` would list last -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: +A literal name or list id is tried first; otherwise the argument is run as a +command (like `pb`) and its last output line is taken as the entry name — +`ls`/`ld`/`gen` emit bare names when consumed this way. On more than one +candidate a `note:` reports the count and the chosen entry; to require a unique +result instead: - set hel_enc_strict 1 # multiple matches become an error + set hel_enc_strict 1 # multiple candidates 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`."; +The password goes to stdout, notes/warnings to stderr, so `pb enc …` copies +only the password. See `help pb`, `help gen`."; const HELP_GEN: &str = "\ gen[N] show N variants of an entry, sorted by password length. @@ -142,7 +145,10 @@ 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`."; +10. Results populate the id list (left column) for `keep`/`enc`. + +Under `pb`/`enc` it lists just the variant names: `pb gen tX` copies a random +variant's name, `pb enc gen tX` copies that variant's password."; const HELP_PB: &str = "\ pb run and copy its stdout to the clipboard. @@ -150,6 +156,7 @@ 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) + pb gen tX copy a random variant's name 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, @@ -267,13 +274,16 @@ impl<'a> LKEval<'a> { } pub fn get_password(&self, name: &String) -> Option { - match self.state.lock().borrow().ls.get(name) { - Some(pwd) => Some(pwd.clone()), - None => match self.state.lock().borrow().db.get(name) { - Some(pwd) => Some(pwd.clone()), - None => None, - }, + if let Some(pwd) = self.state.lock().borrow().ls.get(name) { + return Some(pwd.clone()); } + if let Some(pwd) = self.state.lock().borrow().db.get(name) { + return Some(pwd.clone()); + } + // `gen` variants live only in the `ls` map under a numeric id (not in + // `db`); also resolve them by entry name, so `enc ` and + // `enc gen …` (which yields a variant name) can re-encode them. + self.state.lock().borrow().ls.values().find(|p| p.lock().borrow().name == *name).cloned() } pub fn read_master(&self, out: &LKOut, pwd: PasswordRef, read: bool) -> Option { @@ -458,25 +468,26 @@ 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 + /// `enc `: pick which entry to encode, then encode it. Abstract, like + /// `pb`: the argument is a name/id, or any command whose output names the + /// entry. Precedence keeps `enc `/`enc ` 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. + /// 2. else `arg` parses as a command -> evaluate it capturing (so + /// `ls`/`ld`/`gen` yield bare names), take the LAST non-empty output + /// line as the entry name (newest for `ld`), and encode that. With >1 + /// candidate, `set hel_enc_strict 1` errors instead of taking the last. /// 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. + /// Any command is accepted as a producer (parity with `pb`); a producer that + /// emits something that is not an entry name simply fails to resolve. 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, - _ => { + Ok(c) => c, + Err(_) => { out.e(format!("error: name {} not found", arg)); return; } @@ -911,13 +922,22 @@ impl<'a> LKEval<'a> { encpwds.sort_by(|a, b| b.1.len().cmp(&a.1.len())); self.state.lock().borrow_mut().ls.clear(); let mut counter = 1; - out.o(format!("{:>3} {:>36} {:>4} {}", "", "Password", "Len", "Name")); + // Captured (under `pb`/`enc`): emit just the variant names, like `ls`/`ld`, + // so `pb gen …` copies names and `enc gen …` resolves one. Interactive: + // the full key/password/len/name table. + if !self.capture { + out.o(format!("{:>3} {:>36} {:>4} {}", "", "Password", "Len", "Name")); + } for num in (encpwds.len() - min(genpwds.len(), num))..encpwds.len() { let (pwd, pass) = (encpwds[num].0.clone(), encpwds[num].1.to_string()); 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} {:>36} {:>4} {}", key, pass, pass.len(), pwd.lock().borrow().to_string())); + if self.capture { + out.o(pwd.lock().borrow().name.to_string()); + } else { + out.o(format!("{:>3} {:>36} {:>4} {}", key, pass, pass.len(), pwd.lock().borrow().to_string())); + } } } } diff --git a/hel/src/repl.rs b/hel/src/repl.rs index 9387513..d2f29de 100644 --- a/hel/src/repl.rs +++ b/hel/src/repl.rs @@ -491,7 +491,7 @@ mod tests { 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 ")); + 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] ")); @@ -543,4 +543,28 @@ mod tests { 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"))); } + + #[test] + fn gen_capture_and_enc_test() { + let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new()))); + let rp = |p: String| -> std::io::Result { + if p == "/" { Ok("a".to_string()) } else { Ok("".to_string()) } + }; + // Captured `gen` -> bare variant names (no header, no password columns), + // so it composes with pb/enc like ls/ld. + let pr = LKEval::newd(command_parser::cmd("gen testG").unwrap(), lk.clone(), rp) + .with_capture(true) + .eval(); + let names = pr.out.out.as_ref().unwrap().lock(); + assert!(!names.is_empty()); + assert!(names.iter().all(|l| l.starts_with("test") && !l.contains(' '))); + assert!(!names.iter().any(|l| l.contains("Password"))); + drop(names); + + // `enc gen` runs gen and encodes one variant — proves enc accepts any + // command, not only ls/ld. `testX` yields a single random variant. + let pr = LKEval::newd(command_parser::cmd("enc gen testX").unwrap(), lk.clone(), rp).eval(); + assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 1); + assert!(pr.out.out.as_ref().unwrap().lock()[0].contains(' ')); // six S/KEY words, not a name + } }