helwasm: hierarchical quick-gen, pure sugar over the engine

Quick form now drives the one shared engine through commands only, so it and the
console give identical results (form == console).

- password hook answers only the "/" prompt, so read_master climbs and computes
  every ^parent from a single root master (Req 4)
- new master in the field is authoritative via `unpass`; empty field reuses the
  stored session master, so later names generate without re-typing it (Req 1/2)
- `unpass` with no name now clears ALL cached masters (real command, not a private
  hook) so the form stays reproducible in the console
- result colour = root master marked correct (correct / / uncorrect /), derived
  live from the engine; black otherwise (Req 3)
- Mark-correct toggle button; click the master to reveal it; honest memory-only hint
- master stays in memory only, never written to storage; only the one-way correct
  hash is saved on the device

parser: bare-name forms now keep a trailing comment, and a ^-led token is never a
name, so `name ^parent` / `name MODE ^parent` link the parent instead of qpname
reading it as prefix+name (was silently mis-stored).
This commit is contained in:
Oleksandr Kozachuk
2026-06-09 22:45:44 +02:00
parent e25746d5f0
commit 10a21d1f9d
6 changed files with 130 additions and 41 deletions
+34 -10
View File
@@ -38,17 +38,20 @@ peg::parser! {
rule mname() -> Password = &(word() _ word() _ num()? mode() _ date()) pr:word() _ pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()?
{ Password::new(Some(pr), pn, pl, pm, 99, pd, pc) }
// prefix + name + [len]mode (no seq/date) -> defaults seq 99, date now
rule npname() -> Password = &(word() _ word() _ num()? mode()) pr:word() _ pn:word() _ pl:num()? pm:mode()
{ Password::new(Some(pr), pn, pl, pm, 99, Date::now(), None) }
rule npname() -> Password = &(word() _ word() _ num()? mode()) pr:word() _ pn:word() _ pl:num()? pm:mode() pc:comment()?
{ Password::new(Some(pr), pn, pl, pm, 99, Date::now(), pc) }
rule sname() -> Password = &(word() _ num()? mode() _ date()) pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()?
{ Password::new(None, pn, pl, pm, 99, pd, pc) }
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode()
{ Password::new(None, pn, pl, pm, 99, Date::now(), None) }
// prefix + name only -> defaults mode R, seq 99, date now
rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:word()
{ Password::new(Some(pr), pn, None, Mode::Regular, 99, Date::now(), None) }
rule qname() -> Password = &(word()) pn:word()
{ Password::new(None, pn, None, Mode::Regular, 99, Date::now(), None) }
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() pc:comment()?
{ Password::new(None, pn, pl, pm, 99, Date::now(), pc) }
// prefix + name (+ optional comment) -> defaults mode R, seq 99, date now.
// A `^parent` token is never a name, so a `^`-leading second word fails here and
// falls through to qname, which keeps it as the comment (the parent marker).
rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:$(!"^" ['!'..='~']+) pc:comment()?
{ Password::new(Some(pr), pn.to_string(), None, Mode::Regular, 99, Date::now(), pc) }
// name (+ optional comment) -> e.g. `github` or `github ^work`
rule qname() -> Password = pn:word() pc:comment()?
{ Password::new(None, pn, None, Mode::Regular, 99, Date::now(), pc) }
pub rule name() -> Password = name:(jname() / pname() / mname() / npname() / sname() / nname() / qpname() / qname())? {?
match name { Some(n) => Ok(n), None => Err("failed to parse password description") }
}
@@ -107,7 +110,7 @@ peg::parser! {
rule pass_cmd() -> Command<'input> = p:(pass_long_cmd() / pass_short_cmd()) { p }
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:word() { Command::UnPass(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) }
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) }
@@ -245,6 +248,27 @@ add t3 C 99 2022-12-14
);
}
#[test]
fn parse_short_parent_test() {
// `name ^parent` (no mode/date): name is the name, ^parent stays in the comment
// for fix_hierarchy — it must NOT be read as prefix+name.
let p = command_parser::name("x ^acc").unwrap();
assert_eq!(p.name, "x");
assert_eq!(p.prefix, None);
assert_eq!(p.comment, Some("^acc".to_string()));
// `name [len]mode ^parent` keeps the parent in the comment too
let p2 = command_parser::name("x 20R ^acc").unwrap();
assert_eq!(p2.name, "x");
assert_eq!(p2.length, Some(20));
assert_eq!(p2.mode, Mode::Regular);
assert_eq!(p2.comment, Some("^acc".to_string()));
// a real prefix + name (second word not ^-led) still parses as prefix+name
let p3 = command_parser::name("#W9 github").unwrap();
assert_eq!(p3.prefix, Some("#W9".to_string()));
assert_eq!(p3.name, "github");
assert_eq!(p3.comment, None);
}
#[test]
fn parse_password_test() {
assert_eq!(
+6 -2
View File
@@ -133,10 +133,14 @@ impl<'a> LKEval<'a> {
Command::Set(key, value) => { to_history = false; self.cmd_set(&out, key, value); }
Command::Pass(name, None) => self.cmd_pass(&out, &name, &None),
Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); },
Command::UnPass(name) => match self.state.lock().borrow_mut().secrets.remove(name) {
Command::UnPass(Some(name)) => match self.state.lock().borrow_mut().secrets.remove(name) {
Some(_) => out.o(format!("Removed saved password for {}", name)),
None => out.e(format!("error: saved password for {} not found", name)),
},
Command::UnPass(None) => {
self.state.lock().borrow_mut().secrets.clear();
out.o("forgot all cached masters".to_string());
},
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; },
@@ -156,7 +160,7 @@ impl<'a> LKEval<'a> {
" enc <name> show the generated password\n",
" gen[N] <name> N numbered variants; name ends in G.. (all) or X.. (random)\n",
" pass <name> [pw] cache a master / override for an entry's subtree\n",
" unpass <name> forget a cached password (unpass / = the root master)\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",
+3 -2
View File
@@ -97,7 +97,7 @@ pub enum Command<'a> {
Enc(Name),
Gen(u32, PasswordRef),
Pass(Name, Option<String>),
UnPass(Name),
UnPass(Option<Name>),
Correct(Name),
Uncorrect(Name),
PasteBuffer(String),
@@ -153,7 +153,8 @@ impl<'a> std::fmt::Display for Command<'a> {
Command::Gen(a, b) => write!(f, "gen{} {}", a, b.lock().borrow().to_string().trim()),
Command::Pass(a, None) => write!(f, "pass {}", a),
Command::Pass(a, Some(b)) => write!(f, "pass {} {}", a, b),
Command::UnPass(s) => write!(f, "unpass {}", s),
Command::UnPass(None) => write!(f, "unpass"),
Command::UnPass(Some(s)) => write!(f, "unpass {}", s),
Command::Correct(s) => write!(f, "correct {}", s),
Command::Uncorrect(s) => write!(f, "uncorrect {}", s),
Command::PasteBuffer(s) => write!(f, "pb {}", s),