Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
df0730486e
|
|||
|
4cb3464e86
|
|||
| 11c0198e34 |
+21
@@ -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
@@ -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(©_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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-2
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user