1use anyhow::{bail, Context, Result};
20use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25pub const SIG_NOTE: &str = "ballot-sig: ";
27pub const FROM_NOTE: &str = "ballot-from: ";
30pub const PERSONA_VOTER: &str = "persona:";
33pub const WITHDRAWN_NOTE: &str = "ballot-withdrawn: ";
35pub const PIN_FILE: &str = ".ljos-seat-keys";
37
38#[must_use]
42pub fn message(issue: &str, voter: &str, choice: &str) -> Vec<u8> {
43 format!(
44 "ljos-ballot/v1\n{}\n{}\n{}\n",
45 issue.trim(),
46 voter.trim(),
47 choice.trim()
48 )
49 .into_bytes()
50}
51
52#[must_use]
54pub fn message_v2(issue: &str, voter: &str, choice: &str, harness: &str, nonce: &str) -> Vec<u8> {
55 format!(
56 "ljos-ballot/v2\n{}\n{}\n{}\n{}\n{}\n",
57 issue.trim(),
58 voter.trim(),
59 choice.trim(),
60 harness.trim(),
61 nonce.trim()
62 )
63 .into_bytes()
64}
65
66struct ClaimedSession<'a> {
68 harness: &'a str,
69 nonce: &'a str,
70 link: &'a str,
72}
73
74#[must_use]
77fn message_v3(
78 issue: &str,
79 voter: &str,
80 choice: &str,
81 harness: &str,
82 nonce: &str,
83 claimed: ClaimedSession<'_>,
84) -> Vec<u8> {
85 format!(
86 "ljos-ballot/v3\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
87 issue.trim(),
88 voter.trim(),
89 choice.trim(),
90 harness.trim(),
91 nonce.trim(),
92 claimed.harness.trim(),
93 claimed.nonce.trim(),
94 claimed.link.trim()
95 )
96 .into_bytes()
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct SigNote {
106 pub voter: String,
107 pub choice: String,
108 pub key: String,
109 pub sig: String,
110 pub harness: Option<String>,
111 pub nonce: Option<String>,
112 pub claimed_harness: Option<String>,
113 pub claimed_nonce: Option<String>,
114 pub link: Option<String>,
115}
116
117impl SigNote {
118 #[must_use]
120 pub fn seat_only(&self) -> bool {
121 self.nonce.as_deref() == Some(SEAT_NONCE)
122 }
123}
124
125#[must_use]
134pub fn parse_note(note: &str) -> Option<SigNote> {
135 let rest = note.trim().strip_prefix(SIG_NOTE)?;
136 let at = spans(rest);
137 let n = at.len();
138 if n < 4 {
139 return None;
140 }
141 let tok = |i: usize| &rest[at[i].0..at[i].1];
142 let key_at = |i: usize| is_hex_len(tok(i), 64) && is_hex_len(tok(i + 1), 128);
143 let (k, kind) = if n >= 9 && key_at(n - 7) {
146 (n - 7, 3)
147 } else if n >= 6 && key_at(n - 4) {
148 (n - 4, 2)
149 } else if key_at(n - 2) {
150 (n - 2, 1)
151 } else if n == 6 {
152 (2, 2)
153 } else if n == 4 {
154 (2, 1)
155 } else {
156 return None;
157 };
158 let choice = rest[at[1].0..at[k - 1].1].trim();
159 Some(SigNote {
160 voter: tok(0).to_string(),
161 choice: choice.to_string(),
162 key: tok(k).to_string(),
163 sig: tok(k + 1).to_string(),
164 harness: (kind >= 2).then(|| tok(k + 2).to_string()),
165 nonce: (kind >= 2).then(|| tok(k + 3).to_string()),
166 claimed_harness: (kind == 3).then(|| tok(k + 4).to_string()),
167 claimed_nonce: (kind == 3).then(|| tok(k + 5).to_string()),
168 link: (kind == 3).then(|| tok(k + 6).to_string()),
169 })
170}
171
172#[must_use]
178pub fn note_session(note: &str) -> Option<(String, String)> {
179 if let Some(sig) = parse_note(note) {
180 return Some((
181 sig.harness.unwrap_or_default(),
182 sig.nonce.unwrap_or_default(),
183 ));
184 }
185 let rest = note.trim().strip_prefix(SIG_NOTE)?;
186 let at = spans(rest);
187 let n = at.len();
188 if n < 6 {
189 return None;
190 }
191 let tok = |i: usize| rest[at[i].0..at[i].1].to_string();
192 Some((tok(n - 2), tok(n - 1)))
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct WithdrawnNote {
198 pub voter: String,
199 pub harness: String,
200 pub nonce: String,
201 pub issue: Option<String>,
202}
203
204#[must_use]
206pub fn withdrawn_line(voter: &str, harness: &str, nonce: &str) -> String {
207 withdrawn_line_on(voter, harness, nonce, None)
208}
209
210#[must_use]
213pub fn withdrawn_line_on(voter: &str, harness: &str, nonce: &str, issue: Option<&str>) -> String {
214 let issue = issue.map(field).filter(|id| id != "-");
215 if let Some(issue) = issue {
216 return format!(
217 "{WITHDRAWN_NOTE}{} {} {} {}",
218 field(voter),
219 field(harness),
220 field(nonce),
221 issue
222 );
223 }
224 format!(
225 "{WITHDRAWN_NOTE}{} {} {}",
226 field(voter),
227 field(harness),
228 field(nonce)
229 )
230}
231
232#[must_use]
235pub fn parse_withdrawn(note: &str) -> Option<WithdrawnNote> {
236 let rest = note.trim().strip_prefix(WITHDRAWN_NOTE)?;
237 let parts: Vec<&str> = rest.split_whitespace().collect();
238 let (voter, harness, nonce, issue) = match parts.as_slice() {
239 [voter, harness, nonce] => (*voter, *harness, *nonce, None),
240 [voter, harness, nonce, issue] => (*voter, *harness, *nonce, Some((*issue).to_string())),
241 _ => return None,
242 };
243 Some(WithdrawnNote {
244 voter: voter.to_string(),
245 harness: harness.to_string(),
246 nonce: nonce.to_string(),
247 issue,
248 })
249}
250
251fn spans(text: &str) -> Vec<(usize, usize)> {
253 let mut out = Vec::new();
254 let mut start = None;
255 for (i, c) in text.char_indices() {
256 if c.is_whitespace() {
257 if let Some(b) = start.take() {
258 out.push((b, i));
259 }
260 } else if start.is_none() {
261 start = Some(i);
262 }
263 }
264 if let Some(b) = start {
265 out.push((b, text.len()));
266 }
267 out
268}
269
270pub(crate) fn is_hex_len(text: &str, len: usize) -> bool {
271 text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
272}
273
274#[must_use]
280pub fn signature_ok(key_hex: &str, sig_hex: &str, message: &[u8]) -> bool {
281 let (Ok(key), Ok(sig)) = (hex::decode(key_hex), hex::decode(sig_hex)) else {
282 return false;
283 };
284 let (Ok(key), Ok(sig)) = (<[u8; 32]>::try_from(key), <[u8; 64]>::try_from(sig)) else {
285 return false;
286 };
287 let Ok(verifying) = VerifyingKey::from_bytes(&key) else {
288 return false;
289 };
290 verifying
291 .verify_strict(message, &Signature::from_bytes(&sig))
292 .is_ok()
293}
294
295#[must_use]
297pub fn weak_key(key_hex: &str) -> bool {
298 let Ok(Ok(key)) = hex::decode(key_hex).map(<[u8; 32]>::try_from) else {
299 return false;
300 };
301 VerifyingKey::from_bytes(&key).is_ok_and(|key| key.is_weak())
302}
303
304#[must_use]
311pub fn key_fingerprint(key_hex: &str) -> String {
312 let key = hex::decode(key_hex)
313 .ok()
314 .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
315 .and_then(|bytes| VerifyingKey::from_bytes(&bytes).ok());
316 match key {
317 Some(key) => hex::encode(key.to_edwards().mul_by_cofactor().compress().to_bytes()),
318 None => key_hex.to_ascii_lowercase(),
319 }
320}
321
322#[must_use]
324pub fn signed_hex(key: &SigningKey, message: &[u8]) -> (String, String) {
325 (
326 hex::encode(key.verifying_key().to_bytes()),
327 hex::encode(key.sign(message).to_bytes()),
328 )
329}
330
331pub fn observe_pin(pins: &mut BTreeMap<String, String>, voter: &str, key: &str) -> Result<()> {
337 let fingerprint = key_fingerprint(key);
338 match pins.get(voter) {
339 Some(pinned) if key_fingerprint(pinned) != fingerprint => bail!(
340 "{voter} is pinned to key {}…, got {}…",
341 key_prefix(pinned),
342 key_prefix(key)
343 ),
344 Some(_) => Ok(()),
345 None => {
346 pins.insert(voter.to_string(), key.to_string());
347 Ok(())
348 }
349 }
350}
351
352fn key_prefix(key: &str) -> &str {
353 &key[..12.min(key.len())]
354}
355
356pub(crate) fn key_from_file(path: &std::path::Path) -> Result<SigningKey> {
357 let raw = std::fs::read(path).with_context(|| format!("seat key {}", path.display()))?;
358 let seed: [u8; 32] = raw
359 .get(..32)
360 .and_then(|s| s.try_into().ok())
361 .ok_or_else(|| anyhow::anyhow!("seat key {} holds fewer than 32 bytes", path.display()))?;
362 Ok(SigningKey::from_bytes(&seed))
363}
364
365#[must_use]
371pub fn seat_key_path(seat: &str) -> Option<PathBuf> {
372 if let Some(p) = std::env::var_os("LJOS_SEAT_KEY").filter(|p| !p.is_empty()) {
373 let path = PathBuf::from(p);
374 if !is_box_host_key(&path) && seat_may_use_override(seat) {
375 return Some(path);
376 }
377 }
378 seat_key_file(seat)
379}
380
381#[must_use]
389pub fn seat_key_file(seat: &str) -> Option<PathBuf> {
390 let base = std::env::var_os("XDG_DATA_HOME")
391 .filter(|p| !p.is_empty())
392 .map(PathBuf::from)
393 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
394 let seats = base.join("ljos").join("seats");
395 let seat = seat.trim();
396 let name = seat_dir_name(seat);
397 let path = seats.join(&name).join("ballot.key");
398 let folded = folded_seat_dir_name(seat);
399 if folded != name && !folded.starts_with('.') && !path.exists() {
400 let old = seats.join(folded).join("ballot.key");
401 if old.is_file() {
402 if let Some(dir) = path.parent() {
403 let _ = std::fs::create_dir_all(dir);
404 }
405 let _ = std::fs::rename(&old, &path);
406 }
407 }
408 Some(path)
409}
410
411fn seat_dir_name(seat: &str) -> String {
412 let mut out = String::with_capacity(seat.len());
413 for (at, b) in seat.bytes().enumerate() {
414 if b.is_ascii_alphanumeric() || b == b'-' || (b == b'.' && at > 0) {
415 out.push(char::from(b));
416 } else {
417 out.push_str(&format!("%{b:02x}"));
418 }
419 }
420 out
421}
422
423fn folded_seat_dir_name(seat: &str) -> String {
424 seat.chars()
425 .map(|c| {
426 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
427 c
428 } else {
429 '_'
430 }
431 })
432 .collect()
433}
434
435#[must_use]
437pub fn is_box_host_key(path: &std::path::Path) -> bool {
438 path.ends_with("deedar/host.key")
439}
440
441fn seat_may_use_override(seat: &str) -> bool {
442 let seat = seat.trim();
443 let own = |key: &str| std::env::var(key).ok().is_some_and(|v| v.trim() == seat);
444 match (
445 std::env::var("LJOS_SEAT")
446 .ok()
447 .filter(|s| !s.trim().is_empty()),
448 std::env::var("VISSUE_AGENT")
449 .ok()
450 .filter(|s| !s.trim().is_empty()),
451 ) {
452 (None, None) => true,
453 _ => own("LJOS_SEAT") || own("VISSUE_AGENT"),
454 }
455}
456
457pub fn load_or_create(path: &std::path::Path) -> Result<SigningKey> {
463 if !path.exists() {
464 use std::io::Read;
465 use std::os::unix::fs::OpenOptionsExt;
466 let mut seed = [0u8; 32];
467 std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
468 if let Some(dir) = path.parent() {
469 std::fs::create_dir_all(dir)?;
470 }
471 let mut f = std::fs::OpenOptions::new()
472 .write(true)
473 .create_new(true)
474 .mode(0o600)
475 .open(path)
476 .with_context(|| format!("seat key {}", path.display()))?;
477 std::io::Write::write_all(&mut f, &seed)?;
478 }
479 key_from_file(path)
480}
481
482pub fn recorded_before_signing(issue: &str, voter: &str, choice: &str) -> Result<bool, String> {
495 let Some(root) = env_root() else {
496 return Ok(false);
497 };
498 recorded_before_signing_in(&root, issue, voter, choice)
499}
500
501pub fn recorded_before_signing_in(
508 root: &Path,
509 issue: &str,
510 voter: &str,
511 choice: &str,
512) -> Result<bool, String> {
513 crate::slot::require_tracker_history(root)?;
514 let log = git_in(
515 root,
516 &[
517 "--no-pager",
518 "log",
519 "--full-history",
520 "-m",
521 "--diff-filter=A",
522 "--format=%H %P",
523 "--",
524 PIN_FILE,
525 ],
526 )
527 .unwrap_or_default();
528 let held = |commit: &str| crate::slot::file_at_in(root, commit, PIN_FILE).is_some();
529 let starts: Vec<Vec<&str>> = log
530 .lines()
531 .filter_map(|line| {
532 let mut parts = line.split_whitespace();
533 let commit = parts.next()?;
534 let parents: Vec<&str> = parts.collect();
535 (held(commit) && !parents.iter().any(|p| held(p))).then_some(parents)
536 })
537 .collect();
538 let needle = format!("] {}: {}", voter.trim(), choice.trim());
539 Ok(!starts.is_empty()
540 && starts.iter().all(|parents| {
541 !parents.is_empty()
542 && parents
543 .iter()
544 .all(|p| crate::slot::note_on_issue_at_in(root, p, issue, &needle))
545 }))
546}
547
548pub(crate) fn env_root() -> Option<PathBuf> {
549 std::env::var_os("VISSUE_ROOT")
550 .filter(|r| !r.is_empty())
551 .map(PathBuf::from)
552}
553
554pub(crate) fn tracker_git(args: &[&str]) -> Option<String> {
557 git_in(&env_root()?, args)
558}
559
560#[must_use]
563pub fn tracker_is_git_checkout() -> Option<bool> {
564 let root = env_root()?;
565 let out = git_history(&root)
566 .args(["rev-parse", "--is-inside-work-tree"])
567 .output()
568 .ok()?;
569 if !out.status.success() {
570 return Some(false);
571 }
572 Some(String::from_utf8_lossy(&out.stdout).trim() == "true")
573}
574
575pub(crate) fn git_history(root: &Path) -> std::process::Command {
581 let mut cmd = crate::git_cmd();
582 crate::clear_git_repo_env(&mut cmd);
583 cmd.arg("--no-replace-objects")
584 .arg("-C")
585 .arg(root)
586 .env("GIT_NO_REPLACE_OBJECTS", "1")
587 .env("GIT_GRAFT_FILE", "/dev/null");
588 if let Some(parent) = root
592 .parent()
593 .filter(|parent| !parent.as_os_str().is_empty())
594 {
595 let ceiling = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
596 cmd.env("GIT_CEILING_DIRECTORIES", ceiling);
597 }
598 cmd
599}
600
601pub(crate) fn git_in(root: &Path, args: &[&str]) -> Option<String> {
604 let out = git_history(root).args(args).output().ok()?;
605 out.status
606 .success()
607 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
608}
609
610#[must_use]
612pub fn sign_with(key: &SigningKey, issue: &str, voter: &str, choice: &str) -> String {
613 let (public, sig) = signed_hex(key, &message(issue, voter, choice));
614 format!(
615 "{SIG_NOTE}{} {} {} {}",
616 voter.trim(),
617 choice.trim(),
618 public,
619 sig
620 )
621}
622
623#[must_use]
625pub fn sign_bound(
626 key: &SigningKey,
627 issue: &str,
628 voter: &str,
629 choice: &str,
630 harness: &str,
631 nonce: &str,
632) -> String {
633 let harness = field(harness);
634 let nonce = field(nonce);
635 let (public, sig) = signed_hex(key, &message_v2(issue, voter, choice, &harness, &nonce));
636 format!(
637 "{SIG_NOTE}{} {} {} {} {} {}",
638 voter.trim(),
639 choice.trim(),
640 public,
641 sig,
642 harness,
643 nonce
644 )
645}
646
647pub(crate) struct LinkedFields<'a> {
650 pub harness: &'a str,
651 pub nonce: &'a str,
652 pub claimed_harness: &'a str,
653 pub claimed_nonce: &'a str,
654 pub link: &'a str,
655}
656
657#[must_use]
660pub(crate) fn sign_linked(
661 key: &SigningKey,
662 issue: &str,
663 voter: &str,
664 choice: &str,
665 fields: LinkedFields<'_>,
666) -> String {
667 let harness = field(fields.harness);
668 let nonce = field(fields.nonce);
669 let claimed_harness = field(fields.claimed_harness);
670 let claimed_nonce = field(fields.claimed_nonce);
671 let link = field(fields.link);
672 let (public, sig) = signed_hex(
673 key,
674 &message_v3(
675 issue,
676 voter,
677 choice,
678 &harness,
679 &nonce,
680 ClaimedSession {
681 harness: &claimed_harness,
682 nonce: &claimed_nonce,
683 link: &link,
684 },
685 ),
686 );
687 format!(
688 "{SIG_NOTE}{} {} {} {} {} {} {} {} {}",
689 voter.trim(),
690 choice.trim(),
691 public,
692 sig,
693 harness,
694 nonce,
695 claimed_harness,
696 claimed_nonce,
697 link
698 )
699}
700
701fn covered(issue: &str, note: &SigNote) -> Vec<u8> {
702 if let (Some(h), Some(n), Some(ch), Some(cn), Some(link)) = (
703 ¬e.harness,
704 ¬e.nonce,
705 ¬e.claimed_harness,
706 ¬e.claimed_nonce,
707 ¬e.link,
708 ) {
709 return message_v3(
710 issue,
711 ¬e.voter,
712 ¬e.choice,
713 h,
714 n,
715 ClaimedSession {
716 harness: ch,
717 nonce: cn,
718 link,
719 },
720 );
721 }
722 match (¬e.harness, ¬e.nonce) {
723 (Some(h), Some(n)) => message_v2(issue, ¬e.voter, ¬e.choice, h, n),
724 _ => message(issue, ¬e.voter, ¬e.choice),
725 }
726}
727
728#[must_use]
730pub fn verifies(issue: &str, note: &SigNote) -> bool {
731 signature_ok(¬e.key, ¬e.sig, &covered(issue, note))
732}
733
734fn pin_path() -> Option<PathBuf> {
736 Some(env_root()?.join(PIN_FILE))
737}
738
739#[must_use]
741pub fn parse_pins(text: &str) -> BTreeMap<String, String> {
742 text.lines()
743 .filter_map(|l| {
744 let mut it = l.split_whitespace();
745 Some((it.next()?.to_string(), it.next()?.to_string()))
746 })
747 .collect()
748}
749
750fn parse_pins_strict(text: &str) -> Result<BTreeMap<String, String>> {
751 let mut out = BTreeMap::new();
752 for (idx, raw) in text.lines().enumerate() {
753 let line = raw.trim();
754 if line.is_empty() {
755 continue;
756 }
757 let mut it = line.split_whitespace();
758 let Some(voter) = it.next() else {
759 continue;
760 };
761 let key = it
762 .next()
763 .ok_or_else(|| anyhow::anyhow!("pin line {} must be `voter key`", idx + 1))?;
764 if it.next().is_some() {
765 bail!("pin line {} must be `voter key`", idx + 1);
766 }
767 if out.insert(voter.to_string(), key.to_string()).is_some() {
768 bail!("duplicate pin voter `{voter}`");
769 }
770 }
771 Ok(out)
772}
773
774pub fn pins_for_check() -> Result<(BTreeMap<String, String>, String)> {
780 match env_root() {
781 Some(root) => pins_for_check_in(&root),
782 None => Ok((BTreeMap::new(), NO_PIN_FILE.into())),
783 }
784}
785
786const NO_PIN_FILE: &str = "none: first-seen in this check";
787
788pub fn pins_for_check_in(root: &Path) -> Result<(BTreeMap<String, String>, String)> {
790 let path = root.join(PIN_FILE);
791 let text = match std::fs::read_to_string(&path) {
792 Ok(text) => text,
793 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
794 if std::fs::symlink_metadata(&path).is_ok() {
795 bail!("pin file {} is a link to a missing file", path.display());
796 }
797 return Ok((BTreeMap::new(), NO_PIN_FILE.into()));
798 }
799 Err(err) => return Err(err).with_context(|| format!("pin file {}", path.display())),
800 };
801 let pins = parse_pins_strict(&text).with_context(|| format!("pin file {}", path.display()))?;
802 Ok((pins, path.display().to_string()))
803}
804
805#[must_use]
807pub fn pins() -> BTreeMap<String, String> {
808 pin_path()
809 .and_then(|p| std::fs::read_to_string(p).ok())
810 .map(|t| parse_pins(&t))
811 .unwrap_or_default()
812}
813
814#[must_use]
816pub fn field(raw: &str) -> String {
817 let t: String = raw
818 .trim()
819 .chars()
820 .map(|c| {
821 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@') {
822 c
823 } else {
824 '_'
825 }
826 })
827 .collect();
828 if t.is_empty() {
829 "-".into()
830 } else {
831 t
832 }
833}
834
835#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct SessionIdentity {
842 pub harness: String,
843 pub nonce: String,
844}
845
846pub const SEAT_NONCE: &str = "seat";
850
851impl SessionIdentity {
852 #[must_use]
854 pub fn seat_only(&self) -> bool {
855 self.nonce == SEAT_NONCE
856 }
857}
858
859#[must_use]
862pub fn unverified_sentence(seat: &str) -> String {
863 format!(
864 "unverified session: nothing named the run but seat {seat}, so every run of that seat is one session"
865 )
866}
867
868tokio::task_local! {
869 static CALL_SESSION: String;
870 static CALL_AGENT: String;
871}
872
873std::thread_local! {
874 static HOOK_SESSION: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
875}
876
877#[must_use]
879pub fn call_session() -> Option<String> {
880 CALL_SESSION
881 .try_with(|s| s.clone())
882 .ok()
883 .filter(|s| !s.is_empty())
884}
885
886pub async fn scope_session<F: std::future::Future>(
890 session: Option<String>,
891 agent: Option<String>,
892 f: F,
893) -> F::Output {
894 let agent = agent
895 .map(|a| field(&a))
896 .filter(|a| a != "-")
897 .unwrap_or_default();
898 let f = CALL_AGENT.scope(agent, f);
899 match session.map(|s| field(&s)).filter(|s| s != "-") {
900 Some(s) => CALL_SESSION.scope(s, f).await,
901 None => f.await,
902 }
903}
904
905static PROCESS_AGENT: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
906
907pub fn hook_agent(agent: Option<String>) {
909 let _ = PROCESS_AGENT.set(agent);
910}
911
912fn subagent(parent: &str) -> Option<String> {
917 if let Ok(agent) = CALL_AGENT.try_with(Clone::clone) {
918 return (!agent.is_empty()).then_some(agent);
919 }
920 PROCESS_AGENT
921 .get_or_init(|| {
922 crate::subagent::claim_command(parent)
923 .filter(|a| !a.is_empty())
924 .map(|a| field(&a))
925 .or_else(|| codex_thread(parent))
926 })
927 .clone()
928}
929
930static HOOKED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
931
932fn hooked_session() -> Option<String> {
937 if CALL_AGENT.try_with(|_| ()).is_ok() {
938 return None;
939 }
940 HOOKED
941 .get_or_init(|| {
942 let (session, agent) = crate::subagent::claim_unsessioned_command()?;
943 let (session, agent) = (field(&session), field(&agent));
944 Some(if agent == "-" {
945 session
946 } else {
947 format!("{session}:{agent}")
948 })
949 })
950 .clone()
951}
952
953fn codex_thread(parent: &str) -> Option<String> {
956 let root = env_token("CODEX_SESSION_ID")?;
957 let thread = env_token("CODEX_THREAD_ID")?;
958 (root == parent && thread != root).then_some(thread)
959}
960
961pub fn export_hook_session(id: Option<&str>) {
965 crate::with_env_mutation(|| {
966 let id = id.and_then(crate::usable_session_id).map(field);
967 HOOK_SESSION.with(|c| *c.borrow_mut() = id.clone());
968 if let Some(id) = id {
969 if std::env::var("LJOS_HARNESS_SESSION")
970 .ok()
971 .filter(|s| !s.trim().is_empty())
972 .is_none()
973 {
974 std::env::set_var("LJOS_HARNESS_SESSION", id);
975 }
976 }
977 });
978}
979
980fn env_token(key: &str) -> Option<String> {
981 let v = std::env::var(key).ok()?;
982 let v = v.trim();
983 if v.is_empty() {
984 return None;
985 }
986 Some(field(v))
987}
988
989static CONNECTION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
990
991pub fn pin_connection_session(hint: Option<&str>) {
996 let Some(chosen) = hint
997 .map(str::trim)
998 .filter(|s| !s.is_empty())
999 .map(field)
1000 .filter(|s| s != "-")
1001 .or_else(|| env_token("LJOS_HARNESS_SESSION"))
1002 else {
1003 return;
1004 };
1005 let _ = CONNECTION.set(chosen);
1006}
1007
1008#[must_use]
1011pub fn connection_session() -> Option<String> {
1012 CONNECTION.get().cloned()
1013}
1014
1015#[must_use]
1020pub fn harness_session() -> String {
1021 if let Some(s) = connection_session() {
1022 return s;
1023 }
1024 if let Some(s) = call_session() {
1025 return s;
1026 }
1027 if let Some(s) = HOOK_SESSION.with(|c| c.borrow().clone()) {
1028 return s;
1029 }
1030 let stamped = crate::stamped_sessions();
1031 const PREFER: &[&str] = &[
1032 "CURSOR_SESSION_ID",
1033 "CLAUDE_SESSION_ID",
1034 "CODEX_SESSION_ID",
1037 "CODEX_THREAD_ID",
1038 "GROK_SESSION_ID",
1039 "SEAT_SESSION_ID",
1040 "LJOS_SESSION_ID",
1041 "CLAUDE_CODE_SESSION_ID",
1045 "OPENCODE_SESSION_ID",
1046 ];
1047 for key in PREFER {
1048 if let Some(s) = stamped
1049 .iter()
1050 .find(|(k, _)| k == *key)
1051 .map(|(_, v)| field(v))
1052 .or_else(|| env_token(key))
1053 {
1054 if s.len() >= 8 && !(*key == "LJOS_SESSION_ID" && crate::is_shared_holder_id(&s)) {
1057 return s;
1058 }
1059 }
1060 }
1061 if let Some(s) = crate::runner_session_id().map(|s| field(&s)) {
1062 return s;
1063 }
1064 if let Some(s) = env_token("LJOS_HARNESS_SESSION") {
1065 return s;
1066 }
1067 run_session().unwrap_or_default()
1068}
1069
1070#[cfg(test)]
1071static RUNNER_SESSIONS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
1072
1073#[cfg(test)]
1076#[must_use]
1077pub(crate) fn fake_runner_sessions(ids: &[&str]) -> impl Drop {
1078 struct Reset;
1079 impl Drop for Reset {
1080 fn drop(&mut self) {
1081 *RUNNER_SESSIONS
1082 .lock()
1083 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1084 }
1085 }
1086 *RUNNER_SESSIONS
1087 .lock()
1088 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1089 Some(ids.iter().map(|s| (*s).to_string()).collect());
1090 Reset
1091}
1092
1093#[must_use]
1101pub fn runner_sessions() -> Vec<String> {
1102 #[cfg(test)]
1103 if let Some(ids) = RUNNER_SESSIONS
1104 .lock()
1105 .unwrap_or_else(std::sync::PoisonError::into_inner)
1106 .clone()
1107 {
1108 return ids;
1109 }
1110 let Some(started) = crate::proc_tree::started_by_runner() else {
1111 return Vec::new();
1112 };
1113 crate::proc_tree::environ(started)
1114 .unwrap_or_default()
1115 .into_iter()
1116 .filter(|(k, v)| k == "LJOS_HARNESS_SESSION" || crate::runner_session_var(k, v))
1117 .map(|(_, v)| field(v.trim()))
1118 .filter(|v| !v.is_empty() && v != "-")
1119 .collect()
1120}
1121
1122fn run_session() -> Option<String> {
1126 let actions = std::env::var("GITHUB_ACTIONS").is_ok_and(|v| {
1127 let v = v.trim();
1128 !v.is_empty() && !matches!(v, "0" | "false")
1129 });
1130 let ci: &[&str] = if actions { &["GITHUB_RUN_ID"] } else { &[] };
1131 ci.iter()
1132 .chain(&["CURSOR_AGENT_ID", "CLOUD_AGENT_ID"])
1133 .filter_map(|key| env_token(key))
1134 .find(|s| s.len() >= 8)
1135}
1136
1137fn random_hex(n: usize) -> String {
1138 use std::io::Read;
1139 let mut buf = vec![0u8; n];
1140 if std::fs::File::open("/dev/urandom")
1141 .and_then(|mut f| f.read_exact(&mut buf))
1142 .is_ok()
1143 {
1144 return hex::encode(buf);
1145 }
1146 format!("{:x}{:x}", std::process::id(), std::process::id())
1147}
1148
1149static PROCESS_NONCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
1150
1151#[must_use]
1154pub fn process_nonce() -> String {
1155 if let Some(s) = env_token("LJOS_PROCESS_NONCE") {
1156 if s != "-" {
1157 return s;
1158 }
1159 }
1160 PROCESS_NONCE.get_or_init(|| random_hex(16)).clone()
1161}
1162
1163fn named_session_actor(key: &str) -> Option<String> {
1165 let name = env_token(key)?;
1166 let lower = name.to_ascii_lowercase();
1167 if matches!(lower.as_str(), "seat" | "you" | "agent" | "runner" | "-") {
1168 return None;
1169 }
1170 Some(name)
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq)]
1176pub(crate) struct VoiceBind {
1177 pub harness: String,
1178 pub nonce: String,
1179 pub claimed_harness: String,
1180 pub claimed_nonce: String,
1181 pub why: String,
1183}
1184
1185std::thread_local! {
1186 static CAST_ISSUE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
1187 static VOICE_BIND: std::cell::RefCell<Option<VoiceBind>> = const { std::cell::RefCell::new(None) };
1188}
1189
1190pub(crate) fn begin_cast(issue: &str) {
1193 CAST_ISSUE.with(|c| *c.borrow_mut() = Some(issue.to_string()));
1194 VOICE_BIND.with(|c| *c.borrow_mut() = None);
1195}
1196
1197pub(crate) fn end_cast() {
1199 CAST_ISSUE.with(|c| *c.borrow_mut() = None);
1200 VOICE_BIND.with(|c| *c.borrow_mut() = None);
1201}
1202
1203pub(crate) fn set_voice_bind(bind: VoiceBind) {
1204 VOICE_BIND.with(|c| *c.borrow_mut() = Some(bind));
1205}
1206
1207pub(crate) fn clear_voice_bind() {
1208 VOICE_BIND.with(|c| *c.borrow_mut() = None);
1209}
1210
1211pub(crate) fn current_bind() -> Option<VoiceBind> {
1212 VOICE_BIND.with(|c| c.borrow().clone())
1213}
1214
1215#[must_use]
1218pub fn claimed_identity() -> SessionIdentity {
1219 let harness = harness_session();
1220 if !harness.is_empty() {
1221 let harness = match subagent(&harness) {
1222 Some(agent) => format!("{harness}:{agent}"),
1223 None => harness,
1224 };
1225 return SessionIdentity {
1226 harness,
1227 nonce: "-".into(),
1228 };
1229 }
1230 if let Some(harness) = hooked_session() {
1231 return SessionIdentity {
1232 harness,
1233 nonce: "-".into(),
1234 };
1235 }
1236 match named_session_actor("VISSUE_AGENT").or_else(|| named_session_actor("LJOS_SEAT")) {
1237 Some(seat) => SessionIdentity {
1238 harness: seat,
1239 nonce: SEAT_NONCE.into(),
1240 },
1241 None => SessionIdentity {
1242 harness: "-".into(),
1243 nonce: "-".into(),
1244 },
1245 }
1246}
1247
1248#[must_use]
1255pub fn identity() -> SessionIdentity {
1256 if let Some(bind) = current_bind() {
1257 return SessionIdentity {
1258 harness: bind.harness,
1259 nonce: bind.nonce,
1260 };
1261 }
1262 let claimed = claimed_identity();
1263 if !claimed.seat_only() {
1264 return claimed;
1265 }
1266 let Some(issue) = CAST_ISSUE.with(|c| c.borrow().clone()) else {
1267 return claimed;
1268 };
1269 if let Some(bind) = crate::panel_gate::peek_bound(&issue, &claimed) {
1270 return SessionIdentity {
1271 harness: bind.harness,
1272 nonce: bind.nonce,
1273 };
1274 }
1275 claimed
1276}
1277
1278fn signing_key(voter: &str) -> Result<(SigningKey, String)> {
1280 let path = seat_key_path(voter)
1281 .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"))?;
1282 let key = load_or_create(&path)?;
1283 let mine = hex::encode(key.verifying_key().to_bytes());
1284 let pinned = match pin_path() {
1285 Some(p) if !pins().contains_key(voter.trim()) => pin_first(&p, voter.trim(), &mine)?,
1286 _ => pins(),
1287 };
1288 let mine_fp = key_fingerprint(&mine);
1289 if let Some(k) = pinned
1290 .get(voter.trim())
1291 .filter(|k| key_fingerprint(k) != mine_fp)
1292 {
1293 bail!(
1294 "seat {voter} is pinned to key {}…, and this key is {}…; the ballot is unsigned and no gate counts it",
1295 &k[..12.min(k.len())],
1296 &mine[..12]
1297 );
1298 }
1299 Ok((key, mine))
1300}
1301
1302fn pin_first(path: &Path, voter: &str, mine: &str) -> Result<BTreeMap<String, String>> {
1308 let dir = path.parent().unwrap_or_else(|| Path::new("."));
1309 let Some(_held) =
1310 crate::tracker_commit_lock_by(dir, std::time::Instant::now() + crate::tracker_lock_wait())
1311 else {
1312 bail!(
1313 "another writer held the tracker's commit lock in {} for {}s, so the key pin was not written and this ballot is not signed",
1314 dir.display(),
1315 crate::tracker_lock_wait().as_secs()
1316 );
1317 };
1318 let mut pinned = std::fs::read_to_string(path)
1319 .map(|t| parse_pins(&t))
1320 .unwrap_or_default();
1321 if !pinned.contains_key(voter) {
1322 pinned.insert(voter.to_string(), mine.to_string());
1323 let text: String = pinned.iter().map(|(v, k)| format!("{v} {k}\n")).collect();
1324 vissue_core::store::replace_file_atomically(path, &text)?;
1325 }
1326 Ok(pinned)
1327}
1328
1329#[must_use]
1332pub fn parent_session(harness: &str) -> String {
1333 let harness = harness.trim();
1334 if harness.is_empty() || harness == "-" {
1335 return String::new();
1336 }
1337 match harness.split_once(':') {
1338 Some((parent, agent)) if !parent.is_empty() && !agent.is_empty() => parent.to_string(),
1339 _ => harness.to_string(),
1340 }
1341}
1342
1343#[must_use]
1346pub fn cast_from(persona: &str) -> (String, String, String) {
1347 let id = identity();
1348 let parent = parent_session(&id.harness);
1349 let parent = if parent.is_empty() {
1350 "-".to_string()
1351 } else {
1352 field(&parent)
1353 };
1354 let seat = crate::seat_name();
1355 let caster = if seat.trim().is_empty() {
1356 parent.clone()
1357 } else {
1358 field(&seat)
1359 };
1360 (field(persona), caster, parent)
1361}
1362
1363#[must_use]
1365pub fn provenance_line(persona: &str, caster: &str, parent: &str) -> String {
1366 format!(
1367 "{FROM_NOTE}{} {} {}",
1368 field(persona),
1369 field(caster),
1370 field(parent)
1371 )
1372}
1373
1374#[must_use]
1377pub fn caster_persona_voter(caster: &str, persona: &str) -> String {
1378 format!(
1379 "{PERSONA_VOTER}{}:{}",
1380 field(&crate::canonical_seat_name(caster)),
1381 field(&crate::canonical_seat_name(persona))
1382 )
1383}
1384
1385#[must_use]
1388pub fn prefixed_persona_voter(caster: &str, rest: &str) -> String {
1389 let persona = field(rest);
1390 let persona = if persona.is_empty() || persona == "-" {
1391 "persona".to_string()
1392 } else {
1393 persona
1394 };
1395 format!(
1396 "{PERSONA_VOTER}{}:{persona}",
1397 field(&crate::canonical_seat_name(caster))
1398 )
1399}
1400
1401#[must_use]
1404pub fn parse_caster_persona_voter(voter: &str) -> Option<(String, String)> {
1405 let rest = voter.trim().strip_prefix(PERSONA_VOTER)?;
1406 let (caster, persona) = rest.split_once(':')?;
1407 if caster.is_empty() || persona.is_empty() || caster.contains(':') {
1408 return None;
1409 }
1410 Some((caster.to_string(), persona.to_string()))
1411}
1412
1413#[must_use]
1415pub fn parse_from(note: &str) -> Option<(String, String, String)> {
1416 let rest = note.trim().strip_prefix(FROM_NOTE)?;
1417 let parts: Vec<&str> = rest.split_whitespace().collect();
1418 let [persona, caster, parent] = parts.as_slice() else {
1419 return None;
1420 };
1421 Some((
1422 (*persona).to_string(),
1423 (*caster).to_string(),
1424 (*parent).to_string(),
1425 ))
1426}
1427
1428pub fn sign_ballot(issue: &str, voter: &str, choice: &str) -> Result<String> {
1441 struct ClearCast;
1442 impl Drop for ClearCast {
1443 fn drop(&mut self) {
1444 end_cast();
1445 }
1446 }
1447 let _clear = ClearCast;
1448 let (signer, persona) = match parse_caster_persona_voter(voter) {
1451 Some((caster, persona)) => (caster, persona),
1452 None => (voter.to_string(), voter.to_string()),
1453 };
1454 if let Some((existing, withdrawn)) = newest_sig(issue, voter) {
1455 if verifies(issue, &existing) {
1456 match resign_of(&existing, choice, &signer, withdrawn) {
1457 Resign::Keep => {
1458 return Ok(format!("ballot already signed by {voter}\n"));
1459 }
1460 Resign::Refuse => {
1461 bail!("will not re-sign {voter}'s ballot");
1462 }
1463 Resign::Sign => {}
1464 }
1465 }
1466 }
1467 let (key, mine) = signing_key(&signer)?;
1468 let bind = current_bind();
1469 let id = identity();
1470 let note = if let Some(bind) = &bind {
1471 sign_linked(
1472 &key,
1473 issue,
1474 voter,
1475 choice,
1476 LinkedFields {
1477 harness: &id.harness,
1478 nonce: &id.nonce,
1479 claimed_harness: &bind.claimed_harness,
1480 claimed_nonce: &bind.claimed_nonce,
1481 link: &bind.why,
1482 },
1483 )
1484 } else {
1485 sign_bound(&key, issue, voter, choice, &id.harness, &id.nonce)
1486 };
1487 if write_sig_note(issue, ¬e).is_err() {
1488 crate::run_captured("vissue", &["note", issue, ¬e])?;
1489 }
1490 let session = if id.harness == "-" {
1491 format!("nonce {}", &id.nonce[..12.min(id.nonce.len())])
1492 } else {
1493 id.harness.clone()
1494 };
1495 let mut said = format!(
1496 "ballot signed by {voter} with key {}… session {session}\n",
1497 &mine[..12.min(mine.len())]
1498 );
1499 if id.seat_only() {
1500 said.push_str(&format!("{}\n", unverified_sentence(&id.harness)));
1501 }
1502 if let Some(bind) = &bind {
1503 said.push_str(&format!(
1504 "claimed session {}, folded into {session} (same {})\n",
1505 bind.claimed_harness, bind.why
1506 ));
1507 }
1508 let (persona, caster, parent) = if signer == persona {
1509 cast_from(&persona)
1510 } else {
1511 let parent = parent_session(&id.harness);
1512 let parent = if parent.is_empty() {
1513 "-".to_string()
1514 } else {
1515 field(&parent)
1516 };
1517 (field(&persona), field(&signer), parent)
1518 };
1519 let from = provenance_line(&persona, &caster, &parent);
1520 if write_sig_note(issue, &from).is_err() {
1521 let _ = crate::run_captured("vissue", &["note", issue, &from]);
1522 }
1523 said.push_str(&format!("{persona} cast by {caster}, parent {parent}\n"));
1524 Ok(said)
1525}
1526
1527enum Resign {
1528 Keep,
1529 Refuse,
1530 Sign,
1531}
1532
1533fn resign_of(existing: &SigNote, choice: &str, voter: &str, withdrawn: bool) -> Resign {
1538 let mine = current_public_hex(voter);
1539 if mine.as_deref() == Some(existing.key.as_str()) {
1540 if existing.choice == choice && !withdrawn {
1541 Resign::Keep
1542 } else {
1543 Resign::Sign
1544 }
1545 } else {
1546 Resign::Refuse
1547 }
1548}
1549
1550fn current_public_hex(voter: &str) -> Option<String> {
1551 let path = seat_key_path(voter)?;
1552 let key = key_from_file(&path).ok()?;
1553 Some(hex::encode(key.verifying_key().to_bytes()))
1554}
1555
1556fn same_voter(signed: &str, voter: &str) -> bool {
1557 signed == voter.trim() || signed == field(voter)
1558}
1559
1560fn newest_sig(issue: &str, voter: &str) -> Option<(SigNote, bool)> {
1561 let layout = vissue_core::Layout::resolve(None, None).ok()?;
1562 let heading = match crate::issue_spot::in_layout(&layout, issue) {
1563 Some(spot) => spot.rec().heading.clone(),
1564 None => vissue_core::store::find_by_id(&layout, issue).ok()??.0,
1565 };
1566 let mut cut = None;
1567 let mut newest = None;
1568 for (at, entry) in heading.logbook.iter().enumerate() {
1569 let Some(text) = crate::logbook_text(entry) else {
1570 continue;
1571 };
1572 let text = text.as_str();
1573 if cut.is_none() {
1574 if let Some(withdrawn) = parse_withdrawn(text) {
1575 if same_voter(&withdrawn.voter, voter) {
1576 cut = Some(at);
1577 }
1578 }
1579 }
1580 if newest.is_none() {
1581 if let Some(sig) = parse_note(text) {
1582 if same_voter(&sig.voter, voter) {
1583 newest = Some((at, sig));
1584 }
1585 }
1586 }
1587 if cut.is_some() && newest.is_some() {
1588 break;
1589 }
1590 }
1591 let (at, sig) = newest?;
1592 let withdrawn = cut.is_some_and(|cut_at| cut_at < at);
1593 Some((sig, withdrawn))
1594}
1595
1596pub(crate) fn write_sig_note(issue: &str, note: &str) -> Result<()> {
1597 let layout = vissue_core::Layout::resolve(None, None)?;
1598 vissue_core::ops::note(&layout, issue, note)?;
1599 Ok(())
1600}
1601
1602pub fn sign_own_ballot(issue: &str) -> Result<String> {
1608 let Some(voter) = own_voter() else {
1609 return Ok(String::new());
1610 };
1611 let layout = vissue_core::Layout::resolve(None, None)?;
1612 let ballots = match crate::issue_spot::ballots_in(&layout, issue) {
1614 Some(ballots) => ballots,
1615 None => vissue_core::ops::ballots(&layout, issue)?,
1616 };
1617 let Some(choice) = ballots
1618 .iter()
1619 .find(|b| b.agent == voter)
1620 .map(|b| b.choice.clone())
1621 else {
1622 return Ok(String::new());
1623 };
1624 sign_ballot(issue, &voter, &choice)
1625}
1626
1627#[must_use]
1630pub fn own_voter() -> Option<String> {
1631 crate::checked_seat().or_else(|| {
1632 ["VISSUE_AGENT", "LJOS_SEAT"].iter().find_map(|key| {
1633 std::env::var(key)
1634 .ok()
1635 .map(|s| s.trim().to_string())
1636 .filter(|s| !s.is_empty())
1637 })
1638 })
1639}
1640
1641pub fn note_withdrawn(issue: &str, voter: &str) -> Result<String> {
1647 note_withdrawn_on(issue, voter, issue)
1648}
1649
1650pub fn note_withdrawn_on(
1657 logbook_issue: &str,
1658 voter: &str,
1659 withdrawn_issue: &str,
1660) -> Result<String> {
1661 let id = identity();
1662 let note = withdrawn_line_on(voter, &id.harness, &id.nonce, Some(withdrawn_issue));
1663 crate::run_captured("vissue", &["note", logbook_issue, ¬e]).with_context(|| {
1664 format!("ballot withdrawn; the note on {logbook_issue} was not written")
1665 })?;
1666 Ok(note)
1667}
1668
1669pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
1671
1672#[must_use]
1675pub fn candidate_message(
1676 issue: &str,
1677 seat: &str,
1678 harness: &str,
1679 nonce: &str,
1680 saw: &str,
1681) -> Vec<u8> {
1682 format!(
1683 "ljos-candidate/v1\n{}\n{}\n{}\n{}\n{}\n",
1684 issue.trim(),
1685 seat.trim(),
1686 harness.trim(),
1687 nonce.trim(),
1688 saw.trim()
1689 )
1690 .into_bytes()
1691}
1692
1693#[derive(Debug, Clone, PartialEq, Eq)]
1695pub struct CandidateNote {
1696 pub issue: String,
1697 pub seat: String,
1698 pub harness: String,
1699 pub nonce: String,
1700 pub saw: Vec<String>,
1701 pub key: String,
1702 pub sig: String,
1703}
1704
1705fn saw_token(saw: &[String]) -> String {
1706 if saw.is_empty() {
1707 "-".into()
1708 } else {
1709 saw.iter().map(|s| field(s)).collect::<Vec<_>>().join(",")
1710 }
1711}
1712
1713fn saw_list(token: &str) -> Vec<String> {
1714 if token.is_empty() || token == "-" {
1715 Vec::new()
1716 } else {
1717 token
1718 .split(',')
1719 .map(str::trim)
1720 .filter(|s| !s.is_empty() && *s != "-")
1721 .map(str::to_string)
1722 .collect()
1723 }
1724}
1725
1726#[must_use]
1728pub fn parse_candidate(note: &str) -> Option<CandidateNote> {
1729 let rest = note.trim().strip_prefix(CANDIDATE_NOTE)?;
1730 let parts: Vec<&str> = rest.split_whitespace().collect();
1731 let [issue, seat, harness, nonce, saw, key, sig] = parts.as_slice() else {
1732 return None;
1733 };
1734 Some(CandidateNote {
1735 issue: (*issue).to_string(),
1736 seat: (*seat).to_string(),
1737 harness: (*harness).to_string(),
1738 nonce: (*nonce).to_string(),
1739 saw: saw_list(saw),
1740 key: (*key).to_string(),
1741 sig: (*sig).to_string(),
1742 })
1743}
1744
1745#[must_use]
1747pub fn candidate_verifies(note: &CandidateNote) -> bool {
1748 signature_ok(
1749 ¬e.key,
1750 ¬e.sig,
1751 &candidate_message(
1752 ¬e.issue,
1753 ¬e.seat,
1754 ¬e.harness,
1755 ¬e.nonce,
1756 &saw_token(¬e.saw),
1757 ),
1758 )
1759}
1760
1761pub fn sign_candidate(
1767 issue: &str,
1768 seat: &str,
1769 id: &SessionIdentity,
1770 saw: &[String],
1771) -> Result<String> {
1772 let (key, _) = signing_key(seat)?;
1773 let harness = field(&id.harness);
1774 let nonce = field(&id.nonce);
1775 let saw_tok = saw_token(saw);
1776 let (public, sig) = signed_hex(
1777 &key,
1778 &candidate_message(issue, seat, &harness, &nonce, &saw_tok),
1779 );
1780 Ok(format!(
1781 "{CANDIDATE_NOTE}{} {} {} {} {} {} {}",
1782 field(issue),
1783 field(seat),
1784 harness,
1785 nonce,
1786 saw_tok,
1787 public,
1788 sig
1789 ))
1790}
1791
1792#[must_use]
1796pub fn seat_problems(
1797 issue: &str,
1798 seats: &[(&str, &str)],
1799 notes: &[String],
1800 pins: &BTreeMap<String, String>,
1801) -> Vec<String> {
1802 seat_check(issue, seats, notes, pins, &|_, _| false).0
1803}
1804
1805#[must_use]
1809pub fn seat_check(
1810 issue: &str,
1811 seats: &[(&str, &str)],
1812 notes: &[String],
1813 pins: &BTreeMap<String, String>,
1814 legacy: &dyn Fn(&str, &str) -> bool,
1815) -> (Vec<String>, Vec<String>) {
1816 let mut out = Vec::new();
1817 let mut old = Vec::new();
1818 for (voter, choice) in seats {
1819 let newest = notes
1820 .iter()
1821 .filter_map(|n| parse_note(n))
1822 .find(|n| n.voter == *voter);
1823 match newest {
1824 None if legacy(voter, choice) => old.push((*voter).to_string()),
1825 None => out.push(format!("{voter}'s ballot is unsigned")),
1826 Some(n) if n.choice != *choice => out.push(format!(
1827 "{voter}'s signature is for {} and the ballot says {choice}",
1828 n.choice
1829 )),
1830 Some(n) if !verifies(issue, &n) => {
1831 out.push(format!("{voter}'s signature does not verify"));
1832 }
1833 Some(n) => match pins.get(*voter) {
1834 Some(k) if key_fingerprint(k) == key_fingerprint(&n.key) => {}
1835 Some(_) => out.push(format!(
1836 "{voter}'s ballot is signed by a key other than its pinned one"
1837 )),
1838 None => out.push(format!("{voter} has no pinned key on this tracker")),
1839 },
1840 }
1841 }
1842 (out, old)
1843}
1844
1845#[must_use]
1847pub fn notes_of(v: &Value) -> Vec<String> {
1848 v["logbook"]
1849 .as_array()
1850 .into_iter()
1851 .flatten()
1852 .filter_map(|e| e["note"].as_str().map(str::to_string))
1853 .collect()
1854}
1855
1856#[must_use]
1859pub fn unsigned_allowed() -> bool {
1860 std::env::var("LJOS_UNSIGNED_BALLOTS").is_ok_and(|v| v == "allow")
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865 use super::*;
1866 use std::path::Path;
1867
1868 fn key(b: u8) -> SigningKey {
1869 SigningKey::from_bytes(&[b; 32])
1870 }
1871
1872 fn git(dir: &Path, args: &[&str], env: &[(&str, String)]) {
1873 let mut cmd = crate::git_cmd();
1874 cmd.arg("-C").arg(dir).args(args);
1875 for (k, v) in env {
1876 cmd.env(k, v);
1877 }
1878 let out = cmd.output().unwrap();
1879 assert!(
1880 out.status.success(),
1881 "git {:?} failed: {}",
1882 args,
1883 String::from_utf8_lossy(&out.stderr)
1884 );
1885 }
1886
1887 #[test]
1888 fn a_caster_persona_voter_names_the_caster_and_the_persona() {
1889 let id = caster_persona_voter("Saddle", "INKY");
1890 assert_eq!(id, "persona:saddle:inky");
1891 assert_eq!(
1892 parse_caster_persona_voter(&id),
1893 Some(("saddle".to_string(), "inky".to_string()))
1894 );
1895 assert!(parse_caster_persona_voter("inky").is_none());
1896 assert!(parse_caster_persona_voter("persona:saddle").is_none());
1897 assert_eq!(
1898 prefixed_persona_voter("saddle", "inky:x"),
1899 "persona:saddle:inky:x"
1900 );
1901 assert_eq!(
1902 parse_caster_persona_voter("persona:saddle:inky:x"),
1903 Some(("saddle".to_string(), "inky:x".to_string()))
1904 );
1905 }
1906
1907 #[test]
1908 fn a_signed_pinned_ballot_counts_and_every_forgery_does_not() {
1909 let alice = key(1);
1910 let mallory = key(2);
1911 let pins: BTreeMap<String, String> = [(
1912 "alice".to_string(),
1913 hex::encode(alice.verifying_key().to_bytes()),
1914 )]
1915 .into();
1916 let good = sign_with(&alice, "d-1", "alice", "ship");
1917 assert!(seat_problems(
1918 "d-1",
1919 &[("alice", "ship")],
1920 std::slice::from_ref(&good),
1921 &pins
1922 )
1923 .is_empty());
1924 let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
1926 assert!(p[0].contains("unsigned"), "{p:?}");
1927 let forged = sign_with(&mallory, "d-1", "alice", "ship");
1929 let p = seat_problems("d-1", &[("alice", "ship")], &[forged], &pins);
1930 assert!(p[0].contains("other than its pinned"), "{p:?}");
1931 let p = seat_problems(
1933 "d-1",
1934 &[("alice", "hold")],
1935 std::slice::from_ref(&good),
1936 &pins,
1937 );
1938 assert!(p[0].contains("is for ship"), "{p:?}");
1939 let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
1941 assert!(p[0].contains("does not verify"), "{p:?}");
1942 let lone = sign_with(&mallory, "d-1", "bob", "ship");
1944 let p = seat_problems("d-1", &[("bob", "ship")], &[lone], &pins);
1945 assert!(p[0].contains("no pinned key"), "{p:?}");
1946 }
1947
1948 #[test]
1949 fn an_old_unsigned_ballot_is_legacy_and_a_new_one_is_not() {
1950 let pins = BTreeMap::new();
1951 let legacy = |v: &str, _: &str| v == "inky";
1952 let (bad, old) = seat_check(
1953 "d-1",
1954 &[("inky", "ship"), ("bob", "ship")],
1955 &[],
1956 &pins,
1957 &legacy,
1958 );
1959 assert_eq!(old, vec!["inky".to_string()]);
1960 assert_eq!(bad, vec!["bob's ballot is unsigned".to_string()]);
1961 }
1962
1963 #[test]
1964 fn seats_sharing_a_home_get_their_own_keys() {
1965 let dir = tempfile::tempdir().unwrap();
1966 let a = dir.path().join("ljos/seats/inky/ballot.key");
1967 let b = dir.path().join("ljos/seats/disk-saver/ballot.key");
1968 let ka = load_or_create(&a).unwrap();
1969 let kb = load_or_create(&b).unwrap();
1970 assert_ne!(ka.verifying_key(), kb.verifying_key());
1971 assert_eq!(
1972 load_or_create(&a).unwrap().verifying_key(),
1973 ka.verifying_key()
1974 );
1975 use std::os::unix::fs::PermissionsExt;
1976 assert_eq!(
1977 std::fs::metadata(&a).unwrap().permissions().mode() & 0o777,
1978 0o600
1979 );
1980 }
1981
1982 #[test]
1983 fn a_key_at_its_folded_name_moves_to_the_seat_file() {
1984 let _lock = crate::test_env_lock();
1985 let data = tempfile::tempdir().unwrap();
1986 crate::with_env_mutation(|| {
1987 unsafe { std::env::set_var("XDG_DATA_HOME", data.path()) };
1989 });
1990 let folded = data.path().join("ljos/seats/judge_model-1.5/ballot.key");
1991 std::fs::create_dir_all(folded.parent().unwrap()).unwrap();
1992 std::fs::write(&folded, [7u8; 32]).unwrap();
1993 let path = seat_key_file("judge:model-1.5").unwrap();
1994 assert_ne!(path, folded);
1995 assert_eq!(std::fs::read(&path).unwrap(), vec![7u8; 32]);
1996 assert!(!folded.exists());
1997 assert_eq!(seat_key_file("judge:model-1.5").unwrap(), path);
1998 let slug = seat_key_file("disk-saver").unwrap();
1999 assert_eq!(slug, data.path().join("ljos/seats/disk-saver/ballot.key"));
2000 }
2001
2002 #[test]
2003 fn the_newest_signature_wins() {
2004 let a = key(3);
2005 let pins: BTreeMap<String, String> =
2006 [("a".to_string(), hex::encode(a.verifying_key().to_bytes()))].into();
2007 let new = sign_with(&a, "d-1", "a", "hold");
2008 let old = sign_with(&a, "d-1", "a", "ship");
2009 assert!(seat_problems("d-1", &[("a", "hold")], &[new, old], &pins).is_empty());
2010 }
2011
2012 #[test]
2013 fn a_choice_with_a_space_parses_whole_and_verifies() {
2014 let k = key(5);
2017 let bound = sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
2018 let parsed = parse_note(&bound).expect("a bound note with a spaced choice");
2019 assert_eq!(parsed.voter, "seat-a");
2020 assert_eq!(parsed.choice, "beside binary");
2021 assert_eq!(parsed.harness.as_deref(), Some("spaced-1"));
2022 assert_eq!(parsed.nonce.as_deref(), Some("-"));
2023 assert!(verifies("panel-spcd", &parsed));
2024 assert_eq!(
2025 note_session(&bound),
2026 Some(("spaced-1".to_string(), "-".to_string()))
2027 );
2028 let plain = sign_with(&k, "panel-spcd", "seat-a", "xdg home dir");
2030 let parsed = parse_note(&plain).expect("a v1 note with a spaced choice");
2031 assert_eq!(parsed.choice, "xdg home dir");
2032 assert_eq!(parsed.harness, None);
2033 assert!(verifies("panel-spcd", &parsed));
2034 let wide = sign_bound(&k, "panel-spcd", "seat-a", "xdg home", "spaced-1", "-");
2036 let parsed = parse_note(&wide).unwrap();
2037 assert_eq!(parsed.choice, "xdg home");
2038 assert!(verifies("panel-spcd", &parsed));
2039 let pins: BTreeMap<String, String> = [(
2040 "seat-a".to_string(),
2041 hex::encode(k.verifying_key().to_bytes()),
2042 )]
2043 .into();
2044 assert!(seat_problems(
2045 "panel-spcd",
2046 &[("seat-a", "beside binary")],
2047 &[bound],
2048 &pins
2049 )
2050 .is_empty());
2051 }
2052
2053 #[test]
2054 fn a_linked_note_keeps_a_six_field_note_and_verifies() {
2055 let k = key(8);
2056 let old = sign_bound(&k, "demo-link", "alice", "ship", "sess-a", "-");
2057 let parsed = parse_note(&old).unwrap();
2058 assert_eq!(parsed.harness.as_deref(), Some("sess-a"));
2059 assert_eq!(parsed.link, None);
2060 assert!(verifies("demo-link", &parsed));
2061 let linked = sign_linked(
2062 &k,
2063 "demo-link",
2064 "bea",
2065 "hold beside",
2066 LinkedFields {
2067 harness: "sess-a",
2068 nonce: "-",
2069 claimed_harness: "sess-b",
2070 claimed_nonce: "-",
2071 link: "shell",
2072 },
2073 );
2074 let parsed = parse_note(&linked).unwrap();
2075 assert_eq!(parsed.choice, "hold beside");
2076 assert_eq!(parsed.harness.as_deref(), Some("sess-a"));
2077 assert_eq!(parsed.nonce.as_deref(), Some("-"));
2078 assert_eq!(parsed.claimed_harness.as_deref(), Some("sess-b"));
2079 assert_eq!(parsed.claimed_nonce.as_deref(), Some("-"));
2080 assert_eq!(parsed.link.as_deref(), Some("shell"));
2081 assert!(verifies("demo-link", &parsed));
2082 assert!(!verifies("demo-other", &parsed));
2083 assert_eq!(
2084 note_session(&linked),
2085 Some(("sess-a".to_string(), "-".to_string()))
2086 );
2087 }
2088
2089 #[test]
2090 fn a_note_that_does_not_parse_still_names_its_session() {
2091 let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -";
2092 assert_eq!(parse_note(odd), None);
2093 assert_eq!(
2094 note_session(odd),
2095 Some(("spaced-1".to_string(), "-".to_string()))
2096 );
2097 assert_eq!(note_session("ballot-sig: seat-a ship ab"), None);
2098 assert_eq!(note_session("finding: seat-a: nit: name the unit"), None);
2099 }
2100
2101 #[test]
2102 fn a_withdrawn_note_is_three_fields() {
2103 let line = withdrawn_line("seat a", "sess-1", "-");
2104 assert_eq!(line, "ballot-withdrawn: seat_a sess-1 -");
2105 let parsed = parse_withdrawn(&line).unwrap();
2106 assert_eq!(parsed.voter, "seat_a");
2107 assert_eq!(parsed.harness, "sess-1");
2108 assert_eq!(parsed.nonce, "-");
2109 assert_eq!(parsed.issue, None);
2110 let on = withdrawn_line_on("seat a", "sess-1", "-", Some("child-a"));
2111 assert_eq!(on, "ballot-withdrawn: seat_a sess-1 - child-a");
2112 let parsed = parse_withdrawn(&on).unwrap();
2113 assert_eq!(parsed.issue.as_deref(), Some("child-a"));
2114 assert!(parse_withdrawn("ballot-withdrawn: only-one").is_none());
2115 assert!(parse_withdrawn("ballot-sig: seat-a ship").is_none());
2116 }
2117
2118 #[test]
2119 fn a_session_binding_is_signed_and_a_changed_session_is_not() {
2120 let alice = key(4);
2121 let note = sign_bound(&alice, "d-1", "alice", "ship", "sess-alice-0001", "nonce-1");
2122 let parsed = parse_note(¬e).unwrap();
2123 assert_eq!(parsed.harness.as_deref(), Some("sess-alice-0001"));
2124 assert_eq!(parsed.nonce.as_deref(), Some("nonce-1"));
2125 assert!(verifies("d-1", &parsed));
2126 let mut moved = parsed.clone();
2127 moved.harness = Some("sess-other-0001".into());
2128 assert!(
2129 !verifies("d-1", &moved),
2130 "a rewritten session still verified"
2131 );
2132 let pins: BTreeMap<String, String> = [(
2133 "alice".to_string(),
2134 hex::encode(alice.verifying_key().to_bytes()),
2135 )]
2136 .into();
2137 assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
2138 }
2139
2140 #[test]
2141 fn a_legacy_ballot_is_read_on_its_own_issue() {
2142 let _lock = crate::test_env_lock();
2143 let dir = tempfile::tempdir().unwrap();
2144 let file = dir.path().join("issues.org");
2145 git(dir.path(), &["init"], &[]);
2146 git(dir.path(), &["config", "user.name", "Rohit Goswami"], &[]);
2147 git(
2148 dir.path(),
2149 &["config", "user.email", "rgoswami@ieee.org"],
2150 &[],
2151 );
2152 std::fs::write(
2153 &file,
2154 "* TODO [#C] old\n:PROPERTIES:\n:ID: probe-old\n:END:\n- [X] elder: rail\n",
2155 )
2156 .unwrap();
2157 git(dir.path(), &["add", "issues.org"], &[]);
2158 git(dir.path(), &["commit", "-m", "old ballot"], &[]);
2159 std::fs::write(dir.path().join(PIN_FILE), "inky 00\n").unwrap();
2160 git(dir.path(), &["add", PIN_FILE], &[]);
2161 git(dir.path(), &["commit", "-m", "first pin"], &[]);
2162 std::fs::write(
2163 &file,
2164 "* TODO [#C] old\n:PROPERTIES:\n:ID: probe-old\n:END:\n- [X] elder: rail\n\
2165 * TODO [#C] new\n:PROPERTIES:\n:ID: probe-new\n:END:\n- [X] elder: rail\n",
2166 )
2167 .unwrap();
2168 git(dir.path(), &["add", "issues.org"], &[]);
2169 git(dir.path(), &["commit", "-m", "new ballot"], &[]);
2170 assert!(recorded_before_signing_in(dir.path(), "probe-old", "elder", "rail").unwrap());
2171 assert!(!recorded_before_signing_in(dir.path(), "probe-new", "elder", "rail").unwrap());
2172 }
2173
2174 #[test]
2175 fn seat_check_accepts_the_same_key_in_uppercase_hex() {
2176 let alice = key(8);
2177 let note = sign_with(&alice, "d-1", "alice", "ship");
2178 let pins: BTreeMap<String, String> = [(
2179 "alice".to_string(),
2180 hex::encode(alice.verifying_key().to_bytes()).to_ascii_uppercase(),
2181 )]
2182 .into();
2183 assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
2184 }
2185
2186 #[tokio::test]
2190 async fn an_mcp_call_from_a_subagent_is_a_session_of_its_own() {
2191 let call = |agent: Option<&str>| {
2192 scope_session(
2193 Some("sess-mcp-0001".into()),
2194 agent.map(str::to_string),
2195 async { identity().harness },
2196 )
2197 };
2198 assert_eq!(call(Some("thread-a")).await, "sess-mcp-0001:thread-a");
2199 assert_eq!(call(Some("thread-b")).await, "sess-mcp-0001:thread-b");
2200 assert_eq!(call(None).await, "sess-mcp-0001");
2201 }
2202}