1use anyhow::{bail, Context, Result};
19use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
20use serde_json::Value;
21use std::collections::BTreeMap;
22use std::path::PathBuf;
23
24pub const SIG_NOTE: &str = "ballot-sig: ";
26pub const PIN_FILE: &str = ".ljos-seat-keys";
28
29#[must_use]
33pub fn message(issue: &str, voter: &str, choice: &str) -> Vec<u8> {
34 format!(
35 "ljos-ballot/v1\n{}\n{}\n{}\n",
36 issue.trim(),
37 voter.trim(),
38 choice.trim()
39 )
40 .into_bytes()
41}
42
43#[must_use]
45pub fn message_v2(issue: &str, voter: &str, choice: &str, harness: &str, nonce: &str) -> Vec<u8> {
46 format!(
47 "ljos-ballot/v2\n{}\n{}\n{}\n{}\n{}\n",
48 issue.trim(),
49 voter.trim(),
50 choice.trim(),
51 harness.trim(),
52 nonce.trim()
53 )
54 .into_bytes()
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct SigNote {
61 pub voter: String,
62 pub choice: String,
63 pub key: String,
64 pub sig: String,
65 pub harness: Option<String>,
66 pub nonce: Option<String>,
67}
68
69#[must_use]
76pub fn parse_note(note: &str) -> Option<SigNote> {
77 let rest = note.trim().strip_prefix(SIG_NOTE)?;
78 let at = spans(rest);
79 let n = at.len();
80 if n < 4 {
81 return None;
82 }
83 let tok = |i: usize| &rest[at[i].0..at[i].1];
84 let key_at = |i: usize| is_hex_len(tok(i), 64) && is_hex_len(tok(i + 1), 128);
85 let (k, bound) = if n >= 6 && key_at(n - 4) {
87 (n - 4, true)
88 } else if key_at(n - 2) {
89 (n - 2, false)
90 } else if n == 6 {
91 (2, true)
92 } else if n == 4 {
93 (2, false)
94 } else {
95 return None;
96 };
97 let choice = rest[at[1].0..at[k - 1].1].trim();
98 Some(SigNote {
99 voter: tok(0).to_string(),
100 choice: choice.to_string(),
101 key: tok(k).to_string(),
102 sig: tok(k + 1).to_string(),
103 harness: bound.then(|| tok(k + 2).to_string()),
104 nonce: bound.then(|| tok(k + 3).to_string()),
105 })
106}
107
108#[must_use]
113pub fn note_session(note: &str) -> Option<(String, String)> {
114 if let Some(sig) = parse_note(note) {
115 return Some((
116 sig.harness.unwrap_or_default(),
117 sig.nonce.unwrap_or_default(),
118 ));
119 }
120 let rest = note.trim().strip_prefix(SIG_NOTE)?;
121 let at = spans(rest);
122 let n = at.len();
123 if n < 6 {
124 return None;
125 }
126 let tok = |i: usize| rest[at[i].0..at[i].1].to_string();
127 Some((tok(n - 2), tok(n - 1)))
128}
129
130fn spans(text: &str) -> Vec<(usize, usize)> {
132 let mut out = Vec::new();
133 let mut start = None;
134 for (i, c) in text.char_indices() {
135 if c.is_whitespace() {
136 if let Some(b) = start.take() {
137 out.push((b, i));
138 }
139 } else if start.is_none() {
140 start = Some(i);
141 }
142 }
143 if let Some(b) = start {
144 out.push((b, text.len()));
145 }
146 out
147}
148
149fn is_hex_len(text: &str, len: usize) -> bool {
150 text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
151}
152
153fn key_from_file(path: &std::path::Path) -> Result<SigningKey> {
154 let raw = std::fs::read(path).with_context(|| format!("seat key {}", path.display()))?;
155 let seed: [u8; 32] = raw
156 .get(..32)
157 .and_then(|s| s.try_into().ok())
158 .ok_or_else(|| anyhow::anyhow!("seat key {} holds fewer than 32 bytes", path.display()))?;
159 Ok(SigningKey::from_bytes(&seed))
160}
161
162pub const SIGNING_SINCE: i64 = 1_791_984_778;
166
167#[must_use]
171pub fn seat_key_path(seat: &str) -> Option<PathBuf> {
172 if let Some(p) = std::env::var_os("LJOS_SEAT_KEY").filter(|p| !p.is_empty()) {
173 return Some(PathBuf::from(p));
174 }
175 let base = std::env::var_os("XDG_DATA_HOME")
176 .filter(|p| !p.is_empty())
177 .map(PathBuf::from)
178 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
179 let name: String = seat
180 .trim()
181 .chars()
182 .map(|c| {
183 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
184 c
185 } else {
186 '_'
187 }
188 })
189 .collect();
190 Some(
191 base.join("ljos")
192 .join("seats")
193 .join(name)
194 .join("ballot.key"),
195 )
196}
197
198pub fn load_or_create(path: &std::path::Path) -> Result<SigningKey> {
204 if !path.exists() {
205 use std::io::Read;
206 use std::os::unix::fs::OpenOptionsExt;
207 let mut seed = [0u8; 32];
208 std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
209 if let Some(dir) = path.parent() {
210 std::fs::create_dir_all(dir)?;
211 }
212 let mut f = std::fs::OpenOptions::new()
213 .write(true)
214 .create_new(true)
215 .mode(0o600)
216 .open(path)
217 .with_context(|| format!("seat key {}", path.display()))?;
218 std::io::Write::write_all(&mut f, &seed)?;
219 }
220 key_from_file(path)
221}
222
223#[must_use]
228pub fn recorded_at(issue: &str, voter: &str, choice: &str) -> Option<i64> {
229 let root = std::env::var_os("VISSUE_ROOT").filter(|r| !r.is_empty())?;
230 let git = |args: &[&str]| -> Option<String> {
231 let out = std::process::Command::new("git")
232 .arg("-C")
233 .arg(&root)
234 .args(args)
235 .output()
236 .ok()?;
237 out.status
238 .success()
239 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
240 };
241 let pattern = format!(":ID:[[:space:]]+{issue}[[:space:]]*$");
242 let files = git(&["grep", "-l", "-E", &pattern])?;
243 let file = files.lines().next()?.to_string();
244 let needle = format!("] {}: {}", voter.trim(), choice.trim());
245 git(&["log", "-1", "--format=%ct", "-S", &needle, "--", &file])?
246 .parse()
247 .ok()
248}
249
250#[must_use]
252pub fn sign_with(key: &SigningKey, issue: &str, voter: &str, choice: &str) -> String {
253 let sig = key.sign(&message(issue, voter, choice));
254 format!(
255 "{SIG_NOTE}{} {} {} {}",
256 voter.trim(),
257 choice.trim(),
258 hex::encode(key.verifying_key().to_bytes()),
259 hex::encode(sig.to_bytes())
260 )
261}
262
263#[must_use]
265pub fn sign_bound(
266 key: &SigningKey,
267 issue: &str,
268 voter: &str,
269 choice: &str,
270 harness: &str,
271 nonce: &str,
272) -> String {
273 let harness = field(harness);
274 let nonce = field(nonce);
275 let sig = key.sign(&message_v2(issue, voter, choice, &harness, &nonce));
276 format!(
277 "{SIG_NOTE}{} {} {} {} {} {}",
278 voter.trim(),
279 choice.trim(),
280 hex::encode(key.verifying_key().to_bytes()),
281 hex::encode(sig.to_bytes()),
282 harness,
283 nonce
284 )
285}
286
287fn covered(issue: &str, note: &SigNote) -> Vec<u8> {
288 match (¬e.harness, ¬e.nonce) {
289 (Some(h), Some(n)) => message_v2(issue, ¬e.voter, ¬e.choice, h, n),
290 _ => message(issue, ¬e.voter, ¬e.choice),
291 }
292}
293
294#[must_use]
296pub fn verifies(issue: &str, note: &SigNote) -> bool {
297 let (Ok(k), Ok(s)) = (hex::decode(¬e.key), hex::decode(¬e.sig)) else {
298 return false;
299 };
300 let (Ok(k), Ok(s)) = (<[u8; 32]>::try_from(k), <[u8; 64]>::try_from(s)) else {
301 return false;
302 };
303 let Ok(vk) = VerifyingKey::from_bytes(&k) else {
304 return false;
305 };
306 vk.verify(&covered(issue, note), &Signature::from_bytes(&s))
307 .is_ok()
308}
309
310fn pin_path() -> Option<PathBuf> {
312 let root = std::env::var_os("VISSUE_ROOT").filter(|r| !r.is_empty())?;
313 Some(PathBuf::from(root).join(PIN_FILE))
314}
315
316#[must_use]
318pub fn parse_pins(text: &str) -> BTreeMap<String, String> {
319 text.lines()
320 .filter_map(|l| {
321 let mut it = l.split_whitespace();
322 Some((it.next()?.to_string(), it.next()?.to_string()))
323 })
324 .collect()
325}
326
327#[must_use]
329pub fn pins() -> BTreeMap<String, String> {
330 pin_path()
331 .and_then(|p| std::fs::read_to_string(p).ok())
332 .map(|t| parse_pins(&t))
333 .unwrap_or_default()
334}
335
336#[must_use]
338pub fn field(raw: &str) -> String {
339 let t: String = raw
340 .trim()
341 .chars()
342 .map(|c| {
343 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@') {
344 c
345 } else {
346 '_'
347 }
348 })
349 .collect();
350 if t.is_empty() {
351 "-".into()
352 } else {
353 t
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct SessionIdentity {
363 pub harness: String,
364 pub nonce: String,
365}
366
367tokio::task_local! {
368 static CALL_SESSION: String;
369}
370
371std::thread_local! {
372 static HOOK_SESSION: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
373}
374
375#[must_use]
377pub fn call_session() -> Option<String> {
378 CALL_SESSION
379 .try_with(|s| s.clone())
380 .ok()
381 .filter(|s| !s.is_empty())
382}
383
384pub async fn scope_session<F: std::future::Future>(session: Option<String>, f: F) -> F::Output {
386 match session.map(|s| field(&s)).filter(|s| s != "-") {
387 Some(s) => CALL_SESSION.scope(s, f).await,
388 None => f.await,
389 }
390}
391
392pub fn export_hook_session(id: Option<&str>) {
395 crate::with_env_mutation(|| {
396 let id = id.map(str::trim).filter(|s| !s.is_empty()).map(field);
397 HOOK_SESSION.with(|c| *c.borrow_mut() = id.clone());
398 if let Some(id) = id {
399 if std::env::var("LJOS_HARNESS_SESSION")
400 .ok()
401 .filter(|s| !s.trim().is_empty())
402 .is_none()
403 {
404 std::env::set_var("LJOS_HARNESS_SESSION", id);
405 }
406 }
407 });
408}
409
410fn env_token(key: &str) -> Option<String> {
411 let v = std::env::var(key).ok()?;
412 let v = v.trim();
413 if v.is_empty() {
414 return None;
415 }
416 Some(field(v))
417}
418
419static CONNECTION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
420
421pub fn pin_connection_session(hint: Option<&str>) {
426 let Some(chosen) = hint
427 .map(str::trim)
428 .filter(|s| !s.is_empty())
429 .map(field)
430 .filter(|s| s != "-")
431 .or_else(|| env_token("LJOS_HARNESS_SESSION"))
432 else {
433 return;
434 };
435 let _ = CONNECTION.set(chosen);
436}
437
438#[must_use]
441pub fn connection_session() -> Option<String> {
442 CONNECTION.get().cloned()
443}
444
445#[must_use]
449pub fn harness_session() -> String {
450 if let Some(s) = connection_session() {
451 return s;
452 }
453 if let Some(s) = call_session() {
454 return s;
455 }
456 if let Some(s) = HOOK_SESSION.with(|c| c.borrow().clone()) {
457 return s;
458 }
459 if let Some(s) = env_token("LJOS_HARNESS_SESSION") {
460 return s;
461 }
462 const PREFER: &[&str] = &[
463 "CURSOR_SESSION_ID",
464 "CLAUDE_SESSION_ID",
465 "CODEX_THREAD_ID",
466 "CODEX_SESSION_ID",
467 "GROK_SESSION_ID",
468 "SEAT_SESSION_ID",
469 "LJOS_SESSION_ID",
470 ];
471 for key in PREFER {
472 if let Some(s) = env_token(key) {
473 if s.len() >= 8 {
474 return s;
475 }
476 }
477 }
478 crate::runner_session_id()
479 .map(|s| field(&s))
480 .unwrap_or_default()
481}
482
483fn random_hex(n: usize) -> String {
484 use std::io::Read;
485 let mut buf = vec![0u8; n];
486 if std::fs::File::open("/dev/urandom")
487 .and_then(|mut f| f.read_exact(&mut buf))
488 .is_ok()
489 {
490 return hex::encode(buf);
491 }
492 format!("{:x}{:x}", std::process::id(), std::process::id())
493}
494
495static PROCESS_NONCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
496
497#[must_use]
499pub fn process_nonce() -> String {
500 if let Some(s) = env_token("LJOS_PROCESS_NONCE") {
501 if s != "-" {
502 return s;
503 }
504 }
505 PROCESS_NONCE.get_or_init(|| random_hex(16)).clone()
506}
507
508fn named_session_actor(key: &str) -> Option<String> {
510 let name = env_token(key)?;
511 let lower = name.to_ascii_lowercase();
512 if matches!(lower.as_str(), "seat" | "you" | "agent" | "runner" | "-") {
513 return None;
514 }
515 Some(name)
516}
517
518#[must_use]
520pub fn identity() -> SessionIdentity {
521 let harness = harness_session();
522 let harness = if !harness.is_empty() {
523 harness
524 } else {
525 named_session_actor("VISSUE_AGENT")
526 .or_else(|| named_session_actor("LJOS_SEAT"))
527 .unwrap_or_else(|| "-".into())
528 };
529 SessionIdentity {
530 harness,
531 nonce: "-".into(),
532 }
533}
534
535fn signing_key(voter: &str) -> Result<(SigningKey, String)> {
537 let path = seat_key_path(voter)
538 .ok_or_else(|| anyhow::anyhow!("no place for a seat key: HOME and XDG_DATA_HOME are unset; the ballot is unsigned and no gate counts it"))?;
539 let key = load_or_create(&path)?;
540 let mine = hex::encode(key.verifying_key().to_bytes());
541 let mut pinned = pins();
542 match pinned.get(voter.trim()) {
543 Some(k) if *k != mine => bail!(
544 "seat {voter} is pinned to key {}…, and this key is {}…; the ballot is unsigned and no gate counts it",
545 &k[..12.min(k.len())],
546 &mine[..12]
547 ),
548 Some(_) => {}
549 None => {
550 if let Some(p) = pin_path() {
551 pinned.insert(voter.trim().to_string(), mine.clone());
552 let text: String = pinned.iter().map(|(v, k)| format!("{v} {k}\n")).collect();
553 std::fs::write(&p, text)?;
554 if let Some(root) = p.parent() {
555 let _ = std::process::Command::new("git")
556 .arg("-C")
557 .arg(root)
558 .args(["add", PIN_FILE])
559 .output();
560 }
561 }
562 }
563 }
564 Ok((key, mine))
565}
566
567pub fn sign_ballot(issue: &str, voter: &str, choice: &str) -> Result<String> {
577 let (key, mine) = signing_key(voter)?;
578 let id = identity();
579 let note = sign_bound(&key, issue, voter, choice, &id.harness, &id.nonce);
580 crate::run_captured("vissue", &["note", issue, ¬e])?;
581 let session = if id.harness == "-" {
582 format!("nonce {}", &id.nonce[..12.min(id.nonce.len())])
583 } else {
584 id.harness.clone()
585 };
586 Ok(format!(
587 "ballot signed by {voter} with key {}… session {session}\n",
588 &mine[..12.min(mine.len())]
589 ))
590}
591
592pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
594
595#[must_use]
598pub fn candidate_message(
599 issue: &str,
600 seat: &str,
601 harness: &str,
602 nonce: &str,
603 saw: &str,
604) -> Vec<u8> {
605 format!(
606 "ljos-candidate/v1\n{}\n{}\n{}\n{}\n{}\n",
607 issue.trim(),
608 seat.trim(),
609 harness.trim(),
610 nonce.trim(),
611 saw.trim()
612 )
613 .into_bytes()
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct CandidateNote {
619 pub issue: String,
620 pub seat: String,
621 pub harness: String,
622 pub nonce: String,
623 pub saw: Vec<String>,
624 pub key: String,
625 pub sig: String,
626}
627
628fn saw_token(saw: &[String]) -> String {
629 if saw.is_empty() {
630 "-".into()
631 } else {
632 saw.iter().map(|s| field(s)).collect::<Vec<_>>().join(",")
633 }
634}
635
636fn saw_list(token: &str) -> Vec<String> {
637 if token.is_empty() || token == "-" {
638 Vec::new()
639 } else {
640 token
641 .split(',')
642 .map(str::trim)
643 .filter(|s| !s.is_empty() && *s != "-")
644 .map(str::to_string)
645 .collect()
646 }
647}
648
649#[must_use]
651pub fn parse_candidate(note: &str) -> Option<CandidateNote> {
652 let rest = note.trim().strip_prefix(CANDIDATE_NOTE)?;
653 let parts: Vec<&str> = rest.split_whitespace().collect();
654 let [issue, seat, harness, nonce, saw, key, sig] = parts.as_slice() else {
655 return None;
656 };
657 Some(CandidateNote {
658 issue: (*issue).to_string(),
659 seat: (*seat).to_string(),
660 harness: (*harness).to_string(),
661 nonce: (*nonce).to_string(),
662 saw: saw_list(saw),
663 key: (*key).to_string(),
664 sig: (*sig).to_string(),
665 })
666}
667
668#[must_use]
670pub fn candidate_verifies(note: &CandidateNote) -> bool {
671 let (Ok(k), Ok(s)) = (hex::decode(¬e.key), hex::decode(¬e.sig)) else {
672 return false;
673 };
674 let (Ok(k), Ok(s)) = (<[u8; 32]>::try_from(k), <[u8; 64]>::try_from(s)) else {
675 return false;
676 };
677 let Ok(vk) = VerifyingKey::from_bytes(&k) else {
678 return false;
679 };
680 vk.verify(
681 &candidate_message(
682 ¬e.issue,
683 ¬e.seat,
684 ¬e.harness,
685 ¬e.nonce,
686 &saw_token(¬e.saw),
687 ),
688 &Signature::from_bytes(&s),
689 )
690 .is_ok()
691}
692
693pub fn sign_candidate(
699 issue: &str,
700 seat: &str,
701 id: &SessionIdentity,
702 saw: &[String],
703) -> Result<String> {
704 let (key, _) = signing_key(seat)?;
705 let harness = field(&id.harness);
706 let nonce = field(&id.nonce);
707 let saw_tok = saw_token(saw);
708 let sig = key.sign(&candidate_message(issue, seat, &harness, &nonce, &saw_tok));
709 Ok(format!(
710 "{CANDIDATE_NOTE}{} {} {} {} {} {} {}",
711 field(issue),
712 field(seat),
713 harness,
714 nonce,
715 saw_tok,
716 hex::encode(key.verifying_key().to_bytes()),
717 hex::encode(sig.to_bytes())
718 ))
719}
720
721#[must_use]
725pub fn seat_problems(
726 issue: &str,
727 seats: &[(&str, &str)],
728 notes: &[String],
729 pins: &BTreeMap<String, String>,
730) -> Vec<String> {
731 seat_check(issue, seats, notes, pins, &|_, _| false).0
732}
733
734#[must_use]
738pub fn seat_check(
739 issue: &str,
740 seats: &[(&str, &str)],
741 notes: &[String],
742 pins: &BTreeMap<String, String>,
743 legacy: &dyn Fn(&str, &str) -> bool,
744) -> (Vec<String>, Vec<String>) {
745 let mut out = Vec::new();
746 let mut old = Vec::new();
747 for (voter, choice) in seats {
748 let newest = notes
749 .iter()
750 .filter_map(|n| parse_note(n))
751 .find(|n| n.voter == *voter);
752 match newest {
753 None if legacy(voter, choice) => old.push((*voter).to_string()),
754 None => out.push(format!("{voter}'s ballot is unsigned")),
755 Some(n) if n.choice != *choice => out.push(format!(
756 "{voter}'s signature is for {} and the ballot says {choice}",
757 n.choice
758 )),
759 Some(n) if !verifies(issue, &n) => {
760 out.push(format!("{voter}'s signature does not verify"));
761 }
762 Some(n) => match pins.get(*voter) {
763 Some(k) if *k == n.key => {}
764 Some(_) => out.push(format!(
765 "{voter}'s ballot is signed by a key other than its pinned one"
766 )),
767 None => out.push(format!("{voter} has no pinned key on this tracker")),
768 },
769 }
770 }
771 (out, old)
772}
773
774#[must_use]
776pub fn notes_of(v: &Value) -> Vec<String> {
777 v["logbook"]
778 .as_array()
779 .into_iter()
780 .flatten()
781 .filter_map(|e| e["note"].as_str().map(str::to_string))
782 .collect()
783}
784
785#[must_use]
788pub fn unsigned_allowed() -> bool {
789 std::env::var("LJOS_UNSIGNED_BALLOTS").is_ok_and(|v| v == "allow")
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795
796 fn key(b: u8) -> SigningKey {
797 SigningKey::from_bytes(&[b; 32])
798 }
799
800 #[test]
801 fn a_signed_pinned_ballot_counts_and_every_forgery_does_not() {
802 let alice = key(1);
803 let mallory = key(2);
804 let pins: BTreeMap<String, String> = [(
805 "alice".to_string(),
806 hex::encode(alice.verifying_key().to_bytes()),
807 )]
808 .into();
809 let good = sign_with(&alice, "d-1", "alice", "ship");
810 assert!(seat_problems(
811 "d-1",
812 &[("alice", "ship")],
813 std::slice::from_ref(&good),
814 &pins
815 )
816 .is_empty());
817 let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
819 assert!(p[0].contains("unsigned"), "{p:?}");
820 let forged = sign_with(&mallory, "d-1", "alice", "ship");
822 let p = seat_problems("d-1", &[("alice", "ship")], &[forged], &pins);
823 assert!(p[0].contains("other than its pinned"), "{p:?}");
824 let p = seat_problems(
826 "d-1",
827 &[("alice", "hold")],
828 std::slice::from_ref(&good),
829 &pins,
830 );
831 assert!(p[0].contains("is for ship"), "{p:?}");
832 let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
834 assert!(p[0].contains("does not verify"), "{p:?}");
835 let lone = sign_with(&mallory, "d-1", "bob", "ship");
837 let p = seat_problems("d-1", &[("bob", "ship")], &[lone], &pins);
838 assert!(p[0].contains("no pinned key"), "{p:?}");
839 }
840
841 #[test]
842 fn an_old_unsigned_ballot_is_legacy_and_a_new_one_is_not() {
843 let pins = BTreeMap::new();
844 let legacy = |v: &str, _: &str| v == "inky";
845 let (bad, old) = seat_check(
846 "d-1",
847 &[("inky", "ship"), ("bob", "ship")],
848 &[],
849 &pins,
850 &legacy,
851 );
852 assert_eq!(old, vec!["inky".to_string()]);
853 assert_eq!(bad, vec!["bob's ballot is unsigned".to_string()]);
854 }
855
856 #[test]
857 fn seats_sharing_a_home_get_their_own_keys() {
858 let dir = tempfile::tempdir().unwrap();
859 let a = dir.path().join("ljos/seats/inky/ballot.key");
860 let b = dir.path().join("ljos/seats/disk-saver/ballot.key");
861 let ka = load_or_create(&a).unwrap();
862 let kb = load_or_create(&b).unwrap();
863 assert_ne!(ka.verifying_key(), kb.verifying_key());
864 assert_eq!(
865 load_or_create(&a).unwrap().verifying_key(),
866 ka.verifying_key()
867 );
868 use std::os::unix::fs::PermissionsExt;
869 assert_eq!(
870 std::fs::metadata(&a).unwrap().permissions().mode() & 0o777,
871 0o600
872 );
873 }
874
875 #[test]
876 fn the_newest_signature_wins() {
877 let a = key(3);
878 let pins: BTreeMap<String, String> =
879 [("a".to_string(), hex::encode(a.verifying_key().to_bytes()))].into();
880 let new = sign_with(&a, "d-1", "a", "hold");
881 let old = sign_with(&a, "d-1", "a", "ship");
882 assert!(seat_problems("d-1", &[("a", "hold")], &[new, old], &pins).is_empty());
883 }
884
885 #[test]
886 fn a_choice_with_a_space_parses_whole_and_verifies() {
887 let k = key(5);
890 let bound = sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
891 let parsed = parse_note(&bound).expect("a bound note with a spaced choice");
892 assert_eq!(parsed.voter, "seat-a");
893 assert_eq!(parsed.choice, "beside binary");
894 assert_eq!(parsed.harness.as_deref(), Some("spaced-1"));
895 assert_eq!(parsed.nonce.as_deref(), Some("-"));
896 assert!(verifies("panel-spcd", &parsed));
897 assert_eq!(
898 note_session(&bound),
899 Some(("spaced-1".to_string(), "-".to_string()))
900 );
901 let plain = sign_with(&k, "panel-spcd", "seat-a", "xdg home dir");
903 let parsed = parse_note(&plain).expect("a v1 note with a spaced choice");
904 assert_eq!(parsed.choice, "xdg home dir");
905 assert_eq!(parsed.harness, None);
906 assert!(verifies("panel-spcd", &parsed));
907 let wide = sign_bound(&k, "panel-spcd", "seat-a", "xdg home", "spaced-1", "-");
909 let parsed = parse_note(&wide).unwrap();
910 assert_eq!(parsed.choice, "xdg home");
911 assert!(verifies("panel-spcd", &parsed));
912 let pins: BTreeMap<String, String> = [(
913 "seat-a".to_string(),
914 hex::encode(k.verifying_key().to_bytes()),
915 )]
916 .into();
917 assert!(seat_problems(
918 "panel-spcd",
919 &[("seat-a", "beside binary")],
920 &[bound],
921 &pins
922 )
923 .is_empty());
924 }
925
926 #[test]
927 fn a_note_that_does_not_parse_still_names_its_session() {
928 let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -";
929 assert_eq!(parse_note(odd), None);
930 assert_eq!(
931 note_session(odd),
932 Some(("spaced-1".to_string(), "-".to_string()))
933 );
934 assert_eq!(note_session("ballot-sig: seat-a ship ab"), None);
935 assert_eq!(note_session("finding: seat-a: nit: name the unit"), None);
936 }
937
938 #[test]
939 fn a_session_binding_is_signed_and_a_changed_session_is_not() {
940 let alice = key(4);
941 let note = sign_bound(&alice, "d-1", "alice", "ship", "sess-alice-0001", "nonce-1");
942 let parsed = parse_note(¬e).unwrap();
943 assert_eq!(parsed.harness.as_deref(), Some("sess-alice-0001"));
944 assert_eq!(parsed.nonce.as_deref(), Some("nonce-1"));
945 assert!(verifies("d-1", &parsed));
946 let mut moved = parsed.clone();
947 moved.harness = Some("sess-other-0001".into());
948 assert!(
949 !verifies("d-1", &moved),
950 "a rewritten session still verified"
951 );
952 let pins: BTreeMap<String, String> = [(
953 "alice".to_string(),
954 hex::encode(alice.verifying_key().to_bytes()),
955 )]
956 .into();
957 assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
958 }
959}