Compare commits

..

3 Commits

Author SHA1 Message Date
ok df0730486e Merge github main (gitignore scratch) into hel CLI work 2026-06-14 18:02:00 +02:00
ok 4cb3464e86 hel: pb multi-sink copy, enc command-consumer, proper help
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 <regex>' encodes the last match
(newest for ld); literal name/id wins first. ls/ld emit bare names when
captured (LKEval.capture), so 'pb ld <re>' 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.
2026-06-14 17:58:05 +02:00
Oleksandr Kozachuk 11c0198e34 gitignore local scratch: login exports, test artifacts, dead my-app scaffold, editor config 2026-06-11 15:42:41 +02:00
6 changed files with 586 additions and 67 deletions
+21
View File
@@ -12,3 +12,24 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Editor config
.vscode/
# Build artifacts
helwasm/helwasm.wasm
# Untouched rust-webpack-template scaffold (superseded by helwasm/ proper)
helwasm/my-app/
# Local test scratch + PERSONAL DATA (real login-name exports — never commit)
hel_dump
logins.txt
logins2.txt
test.dat
test.out
test_dump
test_init
test_pb
testrc
x1
+343 -17
View File
@@ -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 <topic> detail for a command or concept, e.g. `help enc`, `help add`,
`help modes`, `help config`, `help files`.
ENTRIES
add <descriptor> define an entry (help add / help name)
ls [regex] list entries by name
ld [regex] list entries by date (oldest first)
keep <id> save list row <id> (left column of ls/gen) to catalog
mv <name> <folder> move entry under <folder> (folder `/` = top level)
comment <name> [text] set or clear the comment
rm <name> remove an entry
PASSWORDS
enc <name|id> show an entry's password
enc ls|ld <regex> show the matched entry's password (newest, for ld)
gen[N] <name> N variants; name ends G.. (all) or X.. (random) [N=10]
pb <command> run a command, copy its output to the clipboard
pass <name> [pw] cache a master/override for an entry's subtree
unpass [name] forget cached master (unpass / = root, unpass = all)
correct <name> trust this password's hash
uncorrect <name> untrust it
CATALOG
dump print the catalog as `add …` lines
save [target] write it: file / key / - / |command (help save)
source <target> load it: file / key / command|
set <key> <value> 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 <name> 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)
<len> truncates the result to <len> characters (e.g. 20R, 12UB)";
const HELP_NAME: &str = "\
descriptor — used by `add`, `gen`, and every line of the dump/`save` format:
[prefix] <name> [<len>]<mode> [<seq>] [<date>] [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 <len> 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 <re>`
takes the newest.
comment free text (login, URL, notes).
^parent place this entry under <parent>: 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
<len><mode> truncate to <len> 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
<name> 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 <name|id> show an entry's generated password (to stdout).
enc ls <regex> encode the last entry of `ls <regex>` (last by name).
enc ld <regex> encode the last entry of `ld <regex>` (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] <name> show N variants of an entry, sorted by password length.
If <name> 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 <command> run <command> 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 <name> [pw] cache a master for <name> and its subtree for this session.
`pass <name>` prompts; `pass <name> 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 <name>` one, `unpass /` the
root, `unpass` (no argument) all of them.";
const HELP_CORRECT: &str = "\
correct <name> 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 <name> drop that trust.";
const HELP_KEEP: &str = "\
keep <id> copy list row <id> (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 <name> <folder> move <name> under <folder> so <folder>'s password becomes
its master (chained derivation). Use `/` as <folder> 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 <name> remove an entry from the catalog. Other entries are unaffected;
`save` to persist the change.";
const HELP_COMMENT: &str = "\
comment <name> [text] set the entry's comment to <text>, 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. <target>:
(omitted) hel_dump / $HEL_DUMP / ~/.hel_dump
<path> a file
- print to screen (same as dump)
|<command> pipe the dump into <command>'s stdin
A diff (< removed, > added) vs the last load/save is shown.
source <target> load a catalog. <target>:
<path> a file (a localStorage key in the wasm build)
<command>| run <command>, load its stdout as a script
Notion: `save |hel store notion:<page>` and `source hel load notion:<page>|`
store the catalog in a Notion code block (token via `set hel_notion_token …`).";
const HELP_CONFIG: &str = "\
set <key> <value> 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 <re>` 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 <notion:ref> read a catalog on stdin and write it to Notion
hel load <notion:ref> 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<String>) {
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<PasswordRef> {
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 <arg>`: pick which entry to encode, then encode it. Precedence keeps
/// the historical `enc <name>` / `enc <id>` 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<String> = 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(&copy_command, &copy_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(&copy_command, &copy_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()));
}
}
}
+31 -2
View File
@@ -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 <regex>`) 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
+111 -35
View File
@@ -19,6 +19,12 @@ pub struct LKEval<'a> {
pub cmd: Command<'a>,
pub state: LKRef,
pub read_password: fn(String) -> std::io::Result<String>,
/// 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 <name> [len][mode] [seq] [date] [text] [^parent] define an entry\n",
" keep <n> 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 <name> <new> rename / move rm <name> remove\n",
" comment <name> [text] set or clear the comment\n",
"\n",
"passwords\n",
" enc <name> show the generated password\n",
" gen[N] <name> N numbered variants; name ends in G.. (all) or X.. (random)\n",
" pb <command> run a command and copy its output to the clipboard\n",
" pass <name> [pw] cache a master / override for an entry's subtree\n",
" unpass [name] forget a cached password (unpass / = root; unpass = all)\n",
" correct <name> trust this password's hash uncorrect <name> untrust it\n",
"\n",
"catalog\n",
" dump print the catalog ls list it\n",
" save [target] write it (file / localStorage key / |command)\n",
" source <target> load it set <key> <val> 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 <regex>"));
// 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] <name>"));
// 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<String> {
if p == "/" { Ok("a".to_string()) } else { Ok("".to_string()) }
};
// enc ld <re>: 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")));
}
}
+13 -3
View File
@@ -67,6 +67,15 @@ pub fn config_get(key: &str) -> Option<String> {
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<Name>),
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"),
}
}
+67 -10
View File
@@ -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<Strin
Ok((shellexpand::full(&args[0]).unwrap().into_owned(), args[1..].to_vec()))
}
pub fn get_copy_command_from_env() -> (String, Vec<String>) {
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<String>,
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<String> = 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";