Skip to main content

ljos_cli/
ballot_sig.rs

1//! Signed seat ballots. A seat signs each ballot it casts with its own
2//! ed25519 key, made on its first vote at
3//! `$XDG_DATA_HOME/ljos/seats/SEAT/ballot.key` (or `LJOS_SEAT_KEY`), so
4//! seats sharing one HOME still sign apart. The signature rides on the
5//! issue as a `ballot-sig:` note. A ballot the tracker held before its
6//! first pin, by commit order ([`recorded_before_signing`]), is legacy
7//! unsigned and still counts. The tracker pins each seat's public key the
8//! first time it signs (`.ljos-seat-keys` at the tracker root, committed),
9//! so a later ballot under that name signed by another key is a mismatch. The push gate counts a seat ballot only
10//! when its latest signature covers exactly that voter, issue and choice
11//! under the pinned key.
12//!
13//! A ballot carries the harness session and a marker (`ljos-ballot/v2`):
14//! `-`, or [`SEAT_NONCE`] when only the seat named the session. The
15//! session is the harness id, else `VISSUE_AGENT`, else `LJOS_SEAT`, else
16//! the MCP connection. It is never a fresh nonce of this process.
17//! Minting another seat inside the same session does not make a second voter.
18
19use 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
25/// The note prefix a signature rides under.
26pub const SIG_NOTE: &str = "ballot-sig: ";
27/// The note prefix for who cast a persona ballot:
28/// `ballot-from: PERSONA CASTER PARENT`.
29pub const FROM_NOTE: &str = "ballot-from: ";
30/// Drawer agent for a caster's persona ballot of a protected name:
31/// `persona:CASTER:PERSONA`. It is not that seat's own ballot.
32pub const PERSONA_VOTER: &str = "persona:";
33/// The note prefix a withdrawn ballot rides under.
34pub const WITHDRAWN_NOTE: &str = "ballot-withdrawn: ";
35/// The pin file at the tracker root.
36pub const PIN_FILE: &str = ".ljos-seat-keys";
37
38/// The bytes a ballot signature covers. v1 is the choice alone. v2 also
39/// covers the harness session and its marker, so a later edit of either
40/// fails the check.
41#[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/// [`message`] plus the session binding.
53#[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
66/// The session the environment claimed, and why the ballot folds.
67struct ClaimedSession<'a> {
68    harness: &'a str,
69    nonce: &'a str,
70    /// `shell` or `harness`.
71    link: &'a str,
72}
73
74/// [`message_v2`] plus the session the environment claimed and why this
75/// ballot folds into the voice (`shell` or `harness`).
76#[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/// A signature note's fields. `harness` and `nonce` are set when the note
100/// is a v2 binding. A tree-bound note also names the session the
101/// environment claimed (`claimed_harness`, `claimed_nonce`) and why the
102/// ballot folds (`link`: `shell` or `harness`). The signature covers
103/// those fields. `harness` and `nonce` are the voice the ballot counts as.
104#[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    /// Whether only the seat named the session this ballot is bound to.
119    #[must_use]
120    pub fn seat_only(&self) -> bool {
121        self.nonce.as_deref() == Some(SEAT_NONCE)
122    }
123}
124
125/// `ballot-sig: VOTER CHOICE KEYHEX SIGHEX`, or the same plus
126/// `HARNESS NONCE` when the ballot is bound to a session. A ballot folded
127/// by the process tree adds `CLAIMED_HARNESS CLAIMED_NONCE LINK`. The
128/// choice may hold spaces (`beside binary`): the key and signature are
129/// found by their hex lengths, counted from the end, and the choice is
130/// the text between the voter and the key. A note whose key is not 64 hex
131/// digits parses only in the four- or six-field shape. A six-field note
132/// has no claimed session and no link.
133#[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    // Where the key sits, and how many session fields follow the signature.
144    // 3: voice, claimed session, and link. 2: voice. 1: choice only.
145    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/// The session a ballot note binds, as [`crate::panel_gate::session_key`]
173/// reads it. A note that does not parse but has six or more fields still
174/// names its session in the last two. The cast gate does not use that
175/// fallback: it requires [`parse_note`] and [`verifies`] for the issue
176/// being cast on. `None` when the note is not a ballot signature.
177#[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/// A withdrawn ballot: `ballot-withdrawn: VOTER HARNESS NONCE`.
196#[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/// One token per field, so the note stays three words.
205#[must_use]
206pub fn withdrawn_line(voter: &str, harness: &str, nonce: &str) -> String {
207    withdrawn_line_on(voter, harness, nonce, None)
208}
209
210/// A withdrawn ballot that names the issue it applies to:
211/// `ballot-withdrawn: VOTER HARNESS NONCE ISSUE`.
212#[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/// `ballot-withdrawn: VOTER HARNESS NONCE` (legacy), or
233/// `ballot-withdrawn: VOTER HARNESS NONCE ISSUE`.
234#[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
251/// Byte ranges of the whitespace-separated fields of `text`.
252fn 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/// Whether `sig_hex` is an ed25519 signature by `key_hex` over `message`.
275///
276/// The check is strict: a small-order key or `R` never verifies. The
277/// plain equation holds for the identity key, `R` the identity and
278/// `s = 0` over every message.
279#[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/// Whether `key_hex` is an ed25519 point of small order.
296#[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/// One name per signing key, for counting keys: the key with its
305/// small-order part cleared, in lower-case hex. Strict verification
306/// refuses a small-order key but not a key with a small-order part, so
307/// one secret signs under eight public keys that differ only there, and
308/// under either case of hex digit. A string that is not a key is its hex
309/// in lower case.
310#[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/// Public key and signature, both lower-case hex, for `message` under `key`.
323#[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
331/// Remember `key` as `voter`'s pin, or refuse a different key.
332///
333/// # Errors
334///
335/// `voter` is already pinned to another key.
336pub 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/// Where `seat`'s ballot key lives: `LJOS_SEAT_KEY` when that path is this
366/// seat's own key and not the machine key, else
367/// `$XDG_DATA_HOME/ljos/seats/SEAT/ballot.key` (`~/.local/share` when unset).
368/// Seats that share one HOME still get one key each. The machine key at
369/// `~/.config/deedar/host.key` is never used.
370#[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/// The per-seat key file, ignoring `LJOS_SEAT_KEY`.
382///
383/// The directory keeps ASCII letters, digits, `-` and `.`, and writes any
384/// other byte, `_` included, and a leading `.` as `%HH`, so no two seats
385/// share a file. Older builds folded those bytes to `_`; a key at that
386/// folded name moves here on first use. No name written here has a `_`,
387/// so the folded file is no seat's current key.
388#[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/// The machine deed key. A seat key must not be this file.
436#[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
457/// The key at `path`, made on first use: 32 random bytes, mode 0600.
458///
459/// # Errors
460///
461/// The key cannot be read or written.
462pub 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
482/// Whether `voter`'s ballot for `choice` on `issue` was recorded before
483/// signing began in this tracker, by commit order: its `] VOTER: CHOICE`
484/// line is in that issue's own entry in every parent of each commit that
485/// brought the pin file into a tree none of its parents held it in. No
486/// git date is read. `Ok(false)` on a tracker that is not a git repository
487/// or has no pin committed; `LJOS_UNSIGNED_BALLOTS=allow` is for a tracker
488/// that never signed.
489///
490/// # Errors
491///
492/// [`crate::slot::require_tracker_history`] could not make the history
493/// readable. A missing parent is not "the ballot was not there yet".
494pub 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
501/// [`recorded_before_signing`] in the tracker at `root`.
502///
503/// # Errors
504///
505/// [`crate::slot::require_tracker_history`] could not make the history
506/// readable.
507pub 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
554/// What `git ARGS` prints in the tracker at `VISSUE_ROOT`, trimmed, when
555/// it succeeds. Replacements and grafts are off ([`git_history`]).
556pub(crate) fn tracker_git(args: &[&str]) -> Option<String> {
557    git_in(&env_root()?, args)
558}
559
560/// Whether `VISSUE_ROOT` names a git checkout. `None` when no tracker root is
561/// configured or git could not run.
562#[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
575/// `git` in `root` with replacements and grafts off. Pin order is the
576/// parents stored in the commits. A `refs/replace` ref or `info/grafts`
577/// rewrites those parents, so a read of tracker history must not follow
578/// either. An exported `GIT_DIR` or object directory would answer for
579/// another repository in place of `root`, so those are dropped too.
580pub(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    // Stop the walk at the parent. A scratch directory inside another
589    // checkout is not that checkout, and a repository of its own is found
590    // before the walk reaches the ceiling.
591    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
601/// What `git ARGS` prints in `root`, trimmed, when it succeeds.
602/// Replacements and grafts are off ([`git_history`]).
603pub(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/// The note text for a ballot signed by `key` (v1, choice only).
611#[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/// The note text for a ballot bound to a harness session and its marker.
624#[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
647/// Fields of a tree-bound signature: the voice, the claimed session, and
648/// why (`shell` or `harness`).
649pub(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/// A ballot signed as the voice in `fields`, keeping the session the
658/// environment claimed.
659#[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        &note.harness,
704        &note.nonce,
705        &note.claimed_harness,
706        &note.claimed_nonce,
707        &note.link,
708    ) {
709        return message_v3(
710            issue,
711            &note.voter,
712            &note.choice,
713            h,
714            n,
715            ClaimedSession {
716                harness: ch,
717                nonce: cn,
718                link,
719            },
720        );
721    }
722    match (&note.harness, &note.nonce) {
723        (Some(h), Some(n)) => message_v2(issue, &note.voter, &note.choice, h, n),
724        _ => message(issue, &note.voter, &note.choice),
725    }
726}
727
728/// Whether `note` is a valid signature over this issue's ballot.
729#[must_use]
730pub fn verifies(issue: &str, note: &SigNote) -> bool {
731    signature_ok(&note.key, &note.sig, &covered(issue, note))
732}
733
734/// The tracker root the pins live at.
735fn pin_path() -> Option<PathBuf> {
736    Some(env_root()?.join(PIN_FILE))
737}
738
739/// `voter keyhex` lines.
740#[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
774/// The pinned keys for record checking, and where they came from.
775///
776/// When there is no pin file, checking starts with no pins and the source is
777/// `none: first-seen in this check`. A link to a missing file is a pin file
778/// that cannot be read, not an absent one.
779pub 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
788/// [`pins_for_check`] for the tracker at `root`.
789pub 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/// The pinned keys, empty when there is no pin file.
806#[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/// A token safe to put in a signature note: no whitespace.
815#[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/// The harness session, and a nonce that is not a process id. The session
836/// comes from the MCP connection, the hook, or the environment, then from
837/// `VISSUE_AGENT` or `LJOS_SEAT`. The nonce is `-`, or [`SEAT_NONCE`] when
838/// the seat stands in for the session: one holder is one session across
839/// processes.
840#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct SessionIdentity {
842    pub harness: String,
843    pub nonce: String,
844}
845
846/// The nonce of a session only the seat names: no harness session and no
847/// run id was found. [`crate::panel_gate::session_key`] reads the harness
848/// alone when one is set, so the key is still the seat.
849pub const SEAT_NONCE: &str = "seat";
850
851impl SessionIdentity {
852    /// Whether only the seat names this session.
853    #[must_use]
854    pub fn seat_only(&self) -> bool {
855        self.nonce == SEAT_NONCE
856    }
857}
858
859/// What a session only the seat names means, where a ballot is cast or
860/// counted.
861#[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/// The session id the MCP call named, when this task is inside one.
878#[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
886/// Run `f` as an MCP call that named `session`, made by the subagent
887/// `agent`. One server answers a parent and all its subagents, so a call
888/// that names no agent is the parent's, whatever this process holds.
889pub 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
907/// Name the subagent a hook payload says this hook process runs for.
908pub fn hook_agent(agent: Option<String>) {
909    let _ = PROCESS_AGENT.set(agent);
910}
911
912/// The subagent this process or MCP call runs for under `parent`, when the
913/// harness named one: the agent a hook saw make this call, else a Codex
914/// thread under its root session. The command line takes no agent, so a
915/// parent cannot cast as its subagent.
916fn 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
932/// The session a hook saw run this process's `ljos` line, plus its
933/// subagent, for a process whose environment names no session: a seat
934/// whose runner stamps no id in the shell still binds what its hook saw,
935/// rather than the seat. An MCP call names its own session.
936fn 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
953/// The thread Codex runs a subagent in, under the root session:
954/// `CODEX_THREAD_ID` when it is not `CODEX_SESSION_ID`, the root's.
955fn 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
961/// Remember the hook payload's session for this process, and export it for
962/// a child when the environment has not already named one. An id that
963/// names nobody ([`crate::usable_session_id`]) is no session.
964pub 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
991/// Pin the MCP connection's session for this process. The first call wins.
992/// A later `_meta` session id and a later `seat` argument do not replace it.
993/// With no hint, the runner's `LJOS_HARNESS_SESSION` is the connection.
994/// Nothing is pinned when that is empty: a per-process nonce is not a session.
995pub 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/// The session pinned for this MCP connection, when [`pin_connection_session`]
1009/// has run.
1010#[must_use]
1011pub fn connection_session() -> Option<String> {
1012    CONNECTION.get().cloned()
1013}
1014
1015/// The harness session id: the MCP connection, then the MCP call, then the
1016/// hook, then a runner-stamped `*_SESSION_ID` / thread / conversation id,
1017/// then `LJOS_HARNESS_SESSION`, then the run ([`run_session`]).
1018/// The connection pin is the process, not the `seat` argument.
1019#[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        // A Codex subagent's thread is not its session: [`codex_thread`]
1035        // names it under the root.
1036        "CODEX_SESSION_ID",
1037        "CODEX_THREAD_ID",
1038        "GROK_SESSION_ID",
1039        "SEAT_SESSION_ID",
1040        "LJOS_SESSION_ID",
1041        // Claude Code also stamps a remote-control id that comes and goes,
1042        // and opencode may run under another runner's id. Each names its
1043        // own session here, so the ballot binds the id its hook reports.
1044        "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            // A shell runner's env file shares `NAME-shell` across every
1055            // conversation; it holds claims and names no session.
1056            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/// Stand in for the runner a unit test runs without, until the value
1074/// returned is dropped.
1075#[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/// The sessions the runner above this process gave the process it
1094/// started, read from that process's environment and never from this
1095/// one's: `LJOS_HARNESS_SESSION=forged ljos ...` sets ours, not that.
1096/// Every id there is the runner's, so a bind may name any of them. The
1097/// session an MCP call's `_meta` names is not: any client of the server
1098/// writes it, so it scopes a call ([`call_session`]) and never vouches
1099/// for one. Empty when no runner is above this process.
1100#[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
1122/// The run this process belongs to, when no conversation id was stamped:
1123/// a GitHub Actions run, else a cloud agent. Every process of one run is
1124/// one session, as `ljos cloud up` gives the run one seat.
1125fn 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/// The nonce of this process, handed to a child `ljos`. No ballot carries
1152/// it: [`identity`] binds the session alone.
1153#[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
1163/// A person-named agent or seat, when it is not a pronoun.
1164fn 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/// The voice a process-tree match binds this cast to, plus the session
1174/// the environment claimed.
1175#[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    /// `shell` or `harness`.
1182    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
1190/// This cast is on `issue`. A later [`identity`] can see that issue's
1191/// tree before it falls back to the seat.
1192pub(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
1197/// Forget the cast scope and any voice bound for it.
1198pub(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/// The session the environment, the hook or the seat names, before a
1216/// process-tree voice replaces a fresh seat.
1217#[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/// This process's session identity. Stable across processes of one holder.
1249/// A subagent the harness named is `SESSION:AGENT`, a session of its own.
1250/// With no session in the environment, the one the hook saw run this
1251/// line comes before the seat. A seat is a fresh voice only when this
1252/// cast has no process tree and no earlier row: the tree-bound voice
1253/// comes first.
1254#[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
1278/// The key `voter` signs with, pinned on first use.
1279fn 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
1302/// Pin `mine` for `voter` unless another pin for it landed first, and
1303/// return the pins. Seats pin on their first ballot, often at once: the
1304/// read and the write hold the tracker's commit lock, so no seat's pin is
1305/// lost, and the file is replaced whole, so a `git add` never hashes half
1306/// of it. The seat's tracker commit carries the pin.
1307fn pin_first(path: &Path, voter: &str, mine: &str) -> Result<BTreeMap<String, String>> {
1308    let _held = crate::tracker_commit_lock(path.parent().unwrap_or_else(|| Path::new(".")));
1309    let mut pinned = std::fs::read_to_string(path)
1310        .map(|t| parse_pins(&t))
1311        .unwrap_or_default();
1312    if !pinned.contains_key(voter) {
1313        pinned.insert(voter.to_string(), mine.to_string());
1314        let text: String = pinned.iter().map(|(v, k)| format!("{v} {k}\n")).collect();
1315        vissue_core::store::replace_file_atomically(path, &text)?;
1316    }
1317    Ok(pinned)
1318}
1319
1320/// The session a subagent's binding sits under. `SESSION:AGENT` yields
1321/// `SESSION`. A session with no agent is its own parent.
1322#[must_use]
1323pub fn parent_session(harness: &str) -> String {
1324    let harness = harness.trim();
1325    if harness.is_empty() || harness == "-" {
1326        return String::new();
1327    }
1328    match harness.split_once(':') {
1329        Some((parent, agent)) if !parent.is_empty() && !agent.is_empty() => parent.to_string(),
1330        _ => harness.to_string(),
1331    }
1332}
1333
1334/// Who cast `persona`: the process seat, or the session when the seat is
1335/// empty, and the parent session.
1336#[must_use]
1337pub fn cast_from(persona: &str) -> (String, String, String) {
1338    let id = identity();
1339    let parent = parent_session(&id.harness);
1340    let parent = if parent.is_empty() {
1341        "-".to_string()
1342    } else {
1343        field(&parent)
1344    };
1345    let seat = crate::seat_name();
1346    let caster = if seat.trim().is_empty() {
1347        parent.clone()
1348    } else {
1349        field(&seat)
1350    };
1351    (field(persona), caster, parent)
1352}
1353
1354/// `ballot-from: PERSONA CASTER PARENT`.
1355#[must_use]
1356pub fn provenance_line(persona: &str, caster: &str, parent: &str) -> String {
1357    format!(
1358        "{FROM_NOTE}{} {} {}",
1359        field(persona),
1360        field(caster),
1361        field(parent)
1362    )
1363}
1364
1365/// The drawer agent for a persona ballot `caster` cast in `persona`'s name.
1366/// Both parts are seat slugs, so the id is stable across spellings.
1367#[must_use]
1368pub fn caster_persona_voter(caster: &str, persona: &str) -> String {
1369    format!(
1370        "{PERSONA_VOTER}{}:{}",
1371        field(&crate::canonical_seat_name(caster)),
1372        field(&crate::canonical_seat_name(persona))
1373    )
1374}
1375
1376/// Drawer id when `--as` itself begins with [`PERSONA_VOTER`].
1377/// The caster field is `caster`. The rest of the given name is kept.
1378#[must_use]
1379pub fn prefixed_persona_voter(caster: &str, rest: &str) -> String {
1380    let persona = field(rest);
1381    let persona = if persona.is_empty() || persona == "-" {
1382        "persona".to_string()
1383    } else {
1384        persona
1385    };
1386    format!(
1387        "{PERSONA_VOTER}{}:{persona}",
1388        field(&crate::canonical_seat_name(caster))
1389    )
1390}
1391
1392/// `CASTER`, `PERSONA` from [`caster_persona_voter`].
1393/// The persona may itself contain colons. The caster is only the first field.
1394#[must_use]
1395pub fn parse_caster_persona_voter(voter: &str) -> Option<(String, String)> {
1396    let rest = voter.trim().strip_prefix(PERSONA_VOTER)?;
1397    let (caster, persona) = rest.split_once(':')?;
1398    if caster.is_empty() || persona.is_empty() || caster.contains(':') {
1399        return None;
1400    }
1401    Some((caster.to_string(), persona.to_string()))
1402}
1403
1404/// `PERSONA`, `CASTER`, `PARENT` from a provenance note.
1405#[must_use]
1406pub fn parse_from(note: &str) -> Option<(String, String, String)> {
1407    let rest = note.trim().strip_prefix(FROM_NOTE)?;
1408    let parts: Vec<&str> = rest.split_whitespace().collect();
1409    let [persona, caster, parent] = parts.as_slice() else {
1410        return None;
1411    };
1412    Some((
1413        (*persona).to_string(),
1414        (*caster).to_string(),
1415        (*parent).to_string(),
1416    ))
1417}
1418
1419/// Sign this seat's ballot, bound to the harness session, and leave the
1420/// note on the issue. A persona cast is signed as that persona. A ballot
1421/// whose agent is [`caster_persona_voter`] is signed with the caster's
1422/// key, never with the named seat's key. The provenance note names the
1423/// persona, the caster and the parent.
1424/// A seat whose key differs from its pin is refused, and the ballot stays
1425/// unsigned (so the gate will not count it).
1426///
1427/// # Errors
1428///
1429/// No seat key, a key that does not match the pin, or a tracker write that
1430/// failed.
1431pub fn sign_ballot(issue: &str, voter: &str, choice: &str) -> Result<String> {
1432    struct ClearCast;
1433    impl Drop for ClearCast {
1434        fn drop(&mut self) {
1435            end_cast();
1436        }
1437    }
1438    let _clear = ClearCast;
1439    // A protected name is signed by the caster embedded in the agent.
1440    // The named seat's key is never loaded.
1441    let (signer, persona) = match parse_caster_persona_voter(voter) {
1442        Some((caster, persona)) => (caster, persona),
1443        None => (voter.to_string(), voter.to_string()),
1444    };
1445    if let Some((existing, withdrawn)) = newest_sig(issue, voter) {
1446        if verifies(issue, &existing) {
1447            match resign_of(&existing, choice, &signer, withdrawn) {
1448                Resign::Keep => {
1449                    return Ok(format!("ballot already signed by {voter}\n"));
1450                }
1451                Resign::Refuse => {
1452                    bail!("will not re-sign {voter}'s ballot");
1453                }
1454                Resign::Sign => {}
1455            }
1456        }
1457    }
1458    let (key, mine) = signing_key(&signer)?;
1459    let bind = current_bind();
1460    let id = identity();
1461    let note = if let Some(bind) = &bind {
1462        sign_linked(
1463            &key,
1464            issue,
1465            voter,
1466            choice,
1467            LinkedFields {
1468                harness: &id.harness,
1469                nonce: &id.nonce,
1470                claimed_harness: &bind.claimed_harness,
1471                claimed_nonce: &bind.claimed_nonce,
1472                link: &bind.why,
1473            },
1474        )
1475    } else {
1476        sign_bound(&key, issue, voter, choice, &id.harness, &id.nonce)
1477    };
1478    if write_sig_note(issue, &note).is_err() {
1479        crate::run_captured("vissue", &["note", issue, &note])?;
1480    }
1481    let session = if id.harness == "-" {
1482        format!("nonce {}", &id.nonce[..12.min(id.nonce.len())])
1483    } else {
1484        id.harness.clone()
1485    };
1486    let mut said = format!(
1487        "ballot signed by {voter} with key {}… session {session}\n",
1488        &mine[..12.min(mine.len())]
1489    );
1490    if id.seat_only() {
1491        said.push_str(&format!("{}\n", unverified_sentence(&id.harness)));
1492    }
1493    if let Some(bind) = &bind {
1494        said.push_str(&format!(
1495            "claimed session {}, folded into {session} (same {})\n",
1496            bind.claimed_harness, bind.why
1497        ));
1498    }
1499    let (persona, caster, parent) = if signer == persona {
1500        cast_from(&persona)
1501    } else {
1502        let parent = parent_session(&id.harness);
1503        let parent = if parent.is_empty() {
1504            "-".to_string()
1505        } else {
1506            field(&parent)
1507        };
1508        (field(&persona), field(&signer), parent)
1509    };
1510    let from = provenance_line(&persona, &caster, &parent);
1511    if write_sig_note(issue, &from).is_err() {
1512        let _ = crate::run_captured("vissue", &["note", issue, &from]);
1513    }
1514    said.push_str(&format!("{persona} cast by {caster}, parent {parent}\n"));
1515    Ok(said)
1516}
1517
1518enum Resign {
1519    Keep,
1520    Refuse,
1521    Sign,
1522}
1523
1524/// A signature that already verifies stays. The same key may sign a new
1525/// choice. A withdrawn ballot is re-signed, even for the same choice, so
1526/// the new ballot stands above the withdrawal cut. Any other key,
1527/// including the machine key, does not sign again.
1528fn resign_of(existing: &SigNote, choice: &str, voter: &str, withdrawn: bool) -> Resign {
1529    let mine = current_public_hex(voter);
1530    if mine.as_deref() == Some(existing.key.as_str()) {
1531        if existing.choice == choice && !withdrawn {
1532            Resign::Keep
1533        } else {
1534            Resign::Sign
1535        }
1536    } else {
1537        Resign::Refuse
1538    }
1539}
1540
1541fn current_public_hex(voter: &str) -> Option<String> {
1542    let path = seat_key_path(voter)?;
1543    let key = key_from_file(&path).ok()?;
1544    Some(hex::encode(key.verifying_key().to_bytes()))
1545}
1546
1547fn same_voter(signed: &str, voter: &str) -> bool {
1548    signed == voter.trim() || signed == field(voter)
1549}
1550
1551fn newest_sig(issue: &str, voter: &str) -> Option<(SigNote, bool)> {
1552    let layout = vissue_core::Layout::resolve(None, None).ok()?;
1553    let (heading, _, _) = vissue_core::store::find_by_id(&layout, issue).ok()??;
1554    let mut cut = None;
1555    let mut newest = None;
1556    for (at, entry) in heading.logbook.iter().enumerate() {
1557        let Some(text) = entry.note.as_deref() else {
1558            continue;
1559        };
1560        if cut.is_none() {
1561            if let Some(withdrawn) = parse_withdrawn(text) {
1562                if same_voter(&withdrawn.voter, voter) {
1563                    cut = Some(at);
1564                }
1565            }
1566        }
1567        if newest.is_none() {
1568            if let Some(sig) = parse_note(text) {
1569                if same_voter(&sig.voter, voter) {
1570                    newest = Some((at, sig));
1571                }
1572            }
1573        }
1574        if cut.is_some() && newest.is_some() {
1575            break;
1576        }
1577    }
1578    let (at, sig) = newest?;
1579    let withdrawn = cut.is_some_and(|cut_at| cut_at < at);
1580    Some((sig, withdrawn))
1581}
1582
1583pub(crate) fn write_sig_note(issue: &str, note: &str) -> Result<()> {
1584    let layout = vissue_core::Layout::resolve(None, None)?;
1585    vissue_core::ops::note(&layout, issue, note)?;
1586    Ok(())
1587}
1588
1589/// Sign the ballot this process's seat cast on `issue`, when it has one.
1590///
1591/// # Errors
1592///
1593/// The tracker cannot be read, or the ballot is already signed by another key.
1594pub fn sign_own_ballot(issue: &str) -> Result<String> {
1595    let Some(voter) = own_voter() else {
1596        return Ok(String::new());
1597    };
1598    let layout = vissue_core::Layout::resolve(None, None)?;
1599    let ballots = vissue_core::ops::ballots(&layout, issue)?;
1600    let Some(choice) = ballots
1601        .iter()
1602        .find(|b| b.agent == voter)
1603        .map(|b| b.choice.clone())
1604    else {
1605        return Ok(String::new());
1606    };
1607    sign_ballot(issue, &voter, &choice)
1608}
1609
1610/// The name this process's seat casts and signs under: the seat its write
1611/// check named, else `VISSUE_AGENT`, else `LJOS_SEAT`.
1612#[must_use]
1613pub fn own_voter() -> Option<String> {
1614    crate::checked_seat().or_else(|| {
1615        ["VISSUE_AGENT", "LJOS_SEAT"].iter().find_map(|key| {
1616            std::env::var(key)
1617                .ok()
1618                .map(|s| s.trim().to_string())
1619                .filter(|s| !s.is_empty())
1620        })
1621    })
1622}
1623
1624/// Leave `ballot-withdrawn: VOTER HARNESS NONCE` on `issue` for this session.
1625///
1626/// # Errors
1627///
1628/// The tracker did not take the note.
1629pub fn note_withdrawn(issue: &str, voter: &str) -> Result<String> {
1630    note_withdrawn_on(issue, voter, issue)
1631}
1632
1633/// Leave `ballot-withdrawn: VOTER HARNESS NONCE ISSUE` on `logbook_issue`
1634/// for this session.
1635///
1636/// # Errors
1637///
1638/// The tracker did not take the note.
1639pub fn note_withdrawn_on(
1640    logbook_issue: &str,
1641    voter: &str,
1642    withdrawn_issue: &str,
1643) -> Result<String> {
1644    let id = identity();
1645    let note = withdrawn_line_on(voter, &id.harness, &id.nonce, Some(withdrawn_issue));
1646    crate::run_captured("vissue", &["note", logbook_issue, &note]).with_context(|| {
1647        format!("ballot withdrawn; the note on {logbook_issue} was not written")
1648    })?;
1649    Ok(note)
1650}
1651
1652/// The note prefix a design candidate's signature rides under.
1653pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
1654
1655/// The bytes a candidate signature covers, including which other candidates
1656/// this session had already read.
1657#[must_use]
1658pub fn candidate_message(
1659    issue: &str,
1660    seat: &str,
1661    harness: &str,
1662    nonce: &str,
1663    saw: &str,
1664) -> Vec<u8> {
1665    format!(
1666        "ljos-candidate/v1\n{}\n{}\n{}\n{}\n{}\n",
1667        issue.trim(),
1668        seat.trim(),
1669        harness.trim(),
1670        nonce.trim(),
1671        saw.trim()
1672    )
1673    .into_bytes()
1674}
1675
1676/// A signed candidate binding.
1677#[derive(Debug, Clone, PartialEq, Eq)]
1678pub struct CandidateNote {
1679    pub issue: String,
1680    pub seat: String,
1681    pub harness: String,
1682    pub nonce: String,
1683    pub saw: Vec<String>,
1684    pub key: String,
1685    pub sig: String,
1686}
1687
1688fn saw_token(saw: &[String]) -> String {
1689    if saw.is_empty() {
1690        "-".into()
1691    } else {
1692        saw.iter().map(|s| field(s)).collect::<Vec<_>>().join(",")
1693    }
1694}
1695
1696fn saw_list(token: &str) -> Vec<String> {
1697    if token.is_empty() || token == "-" {
1698        Vec::new()
1699    } else {
1700        token
1701            .split(',')
1702            .map(str::trim)
1703            .filter(|s| !s.is_empty() && *s != "-")
1704            .map(str::to_string)
1705            .collect()
1706    }
1707}
1708
1709/// `candidate-sig: ISSUE SEAT HARNESS NONCE SAW KEYHEX SIGHEX`.
1710#[must_use]
1711pub fn parse_candidate(note: &str) -> Option<CandidateNote> {
1712    let rest = note.trim().strip_prefix(CANDIDATE_NOTE)?;
1713    let parts: Vec<&str> = rest.split_whitespace().collect();
1714    let [issue, seat, harness, nonce, saw, key, sig] = parts.as_slice() else {
1715        return None;
1716    };
1717    Some(CandidateNote {
1718        issue: (*issue).to_string(),
1719        seat: (*seat).to_string(),
1720        harness: (*harness).to_string(),
1721        nonce: (*nonce).to_string(),
1722        saw: saw_list(saw),
1723        key: (*key).to_string(),
1724        sig: (*sig).to_string(),
1725    })
1726}
1727
1728/// Whether `note` is a valid signature over that candidate binding.
1729#[must_use]
1730pub fn candidate_verifies(note: &CandidateNote) -> bool {
1731    signature_ok(
1732        &note.key,
1733        &note.sig,
1734        &candidate_message(
1735            &note.issue,
1736            &note.seat,
1737            &note.harness,
1738            &note.nonce,
1739            &saw_token(&note.saw),
1740        ),
1741    )
1742}
1743
1744/// Sign `issue` as a design candidate of `seat` and return the note.
1745///
1746/// # Errors
1747///
1748/// No seat key, or a key that does not match the pin.
1749pub fn sign_candidate(
1750    issue: &str,
1751    seat: &str,
1752    id: &SessionIdentity,
1753    saw: &[String],
1754) -> Result<String> {
1755    let (key, _) = signing_key(seat)?;
1756    let harness = field(&id.harness);
1757    let nonce = field(&id.nonce);
1758    let saw_tok = saw_token(saw);
1759    let (public, sig) = signed_hex(
1760        &key,
1761        &candidate_message(issue, seat, &harness, &nonce, &saw_tok),
1762    );
1763    Ok(format!(
1764        "{CANDIDATE_NOTE}{} {} {} {} {} {} {}",
1765        field(issue),
1766        field(seat),
1767        harness,
1768        nonce,
1769        saw_tok,
1770        public,
1771        sig
1772    ))
1773}
1774
1775/// Problems with the seat ballots on `issue`: each `(voter, choice)` needs
1776/// its newest signature note to name that choice, verify, and use the
1777/// pinned key. `notes` are the issue's logbook notes, newest first.
1778#[must_use]
1779pub fn seat_problems(
1780    issue: &str,
1781    seats: &[(&str, &str)],
1782    notes: &[String],
1783    pins: &BTreeMap<String, String>,
1784) -> Vec<String> {
1785    seat_check(issue, seats, notes, pins, &|_, _| false).0
1786}
1787
1788/// [`seat_problems`] with grandfathering: an unsigned ballot `legacy`
1789/// says the tracker recorded before signing began counts, and is named in
1790/// the second list as legacy unsigned.
1791#[must_use]
1792pub fn seat_check(
1793    issue: &str,
1794    seats: &[(&str, &str)],
1795    notes: &[String],
1796    pins: &BTreeMap<String, String>,
1797    legacy: &dyn Fn(&str, &str) -> bool,
1798) -> (Vec<String>, Vec<String>) {
1799    let mut out = Vec::new();
1800    let mut old = Vec::new();
1801    for (voter, choice) in seats {
1802        let newest = notes
1803            .iter()
1804            .filter_map(|n| parse_note(n))
1805            .find(|n| n.voter == *voter);
1806        match newest {
1807            None if legacy(voter, choice) => old.push((*voter).to_string()),
1808            None => out.push(format!("{voter}'s ballot is unsigned")),
1809            Some(n) if n.choice != *choice => out.push(format!(
1810                "{voter}'s signature is for {} and the ballot says {choice}",
1811                n.choice
1812            )),
1813            Some(n) if !verifies(issue, &n) => {
1814                out.push(format!("{voter}'s signature does not verify"));
1815            }
1816            Some(n) => match pins.get(*voter) {
1817                Some(k) if key_fingerprint(k) == key_fingerprint(&n.key) => {}
1818                Some(_) => out.push(format!(
1819                    "{voter}'s ballot is signed by a key other than its pinned one"
1820                )),
1821                None => out.push(format!("{voter} has no pinned key on this tracker")),
1822            },
1823        }
1824    }
1825    (out, old)
1826}
1827
1828/// The logbook notes of an issue JSON, newest first.
1829#[must_use]
1830pub fn notes_of(v: &Value) -> Vec<String> {
1831    v["logbook"]
1832        .as_array()
1833        .into_iter()
1834        .flatten()
1835        .filter_map(|e| e["note"].as_str().map(str::to_string))
1836        .collect()
1837}
1838
1839/// Whether unsigned seat ballots are let through on purpose
1840/// (`LJOS_UNSIGNED_BALLOTS=allow`), for a tracker from before signing.
1841#[must_use]
1842pub fn unsigned_allowed() -> bool {
1843    std::env::var("LJOS_UNSIGNED_BALLOTS").is_ok_and(|v| v == "allow")
1844}
1845
1846#[cfg(test)]
1847mod tests {
1848    use super::*;
1849    use std::path::Path;
1850
1851    fn key(b: u8) -> SigningKey {
1852        SigningKey::from_bytes(&[b; 32])
1853    }
1854
1855    fn git(dir: &Path, args: &[&str], env: &[(&str, String)]) {
1856        let mut cmd = crate::git_cmd();
1857        cmd.arg("-C").arg(dir).args(args);
1858        for (k, v) in env {
1859            cmd.env(k, v);
1860        }
1861        let out = cmd.output().unwrap();
1862        assert!(
1863            out.status.success(),
1864            "git {:?} failed: {}",
1865            args,
1866            String::from_utf8_lossy(&out.stderr)
1867        );
1868    }
1869
1870    #[test]
1871    fn a_caster_persona_voter_names_the_caster_and_the_persona() {
1872        let id = caster_persona_voter("Saddle", "INKY");
1873        assert_eq!(id, "persona:saddle:inky");
1874        assert_eq!(
1875            parse_caster_persona_voter(&id),
1876            Some(("saddle".to_string(), "inky".to_string()))
1877        );
1878        assert!(parse_caster_persona_voter("inky").is_none());
1879        assert!(parse_caster_persona_voter("persona:saddle").is_none());
1880        assert_eq!(
1881            prefixed_persona_voter("saddle", "inky:x"),
1882            "persona:saddle:inky:x"
1883        );
1884        assert_eq!(
1885            parse_caster_persona_voter("persona:saddle:inky:x"),
1886            Some(("saddle".to_string(), "inky:x".to_string()))
1887        );
1888    }
1889
1890    #[test]
1891    fn a_signed_pinned_ballot_counts_and_every_forgery_does_not() {
1892        let alice = key(1);
1893        let mallory = key(2);
1894        let pins: BTreeMap<String, String> = [(
1895            "alice".to_string(),
1896            hex::encode(alice.verifying_key().to_bytes()),
1897        )]
1898        .into();
1899        let good = sign_with(&alice, "d-1", "alice", "ship");
1900        assert!(seat_problems(
1901            "d-1",
1902            &[("alice", "ship")],
1903            std::slice::from_ref(&good),
1904            &pins
1905        )
1906        .is_empty());
1907        // Unsigned.
1908        let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
1909        assert!(p[0].contains("unsigned"), "{p:?}");
1910        // Signed by another key under alice's name.
1911        let forged = sign_with(&mallory, "d-1", "alice", "ship");
1912        let p = seat_problems("d-1", &[("alice", "ship")], &[forged], &pins);
1913        assert!(p[0].contains("other than its pinned"), "{p:?}");
1914        // The ballot changed after signing.
1915        let p = seat_problems(
1916            "d-1",
1917            &[("alice", "hold")],
1918            std::slice::from_ref(&good),
1919            &pins,
1920        );
1921        assert!(p[0].contains("is for ship"), "{p:?}");
1922        // A signature lifted from another issue.
1923        let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
1924        assert!(p[0].contains("does not verify"), "{p:?}");
1925        // No pin on this tracker.
1926        let lone = sign_with(&mallory, "d-1", "bob", "ship");
1927        let p = seat_problems("d-1", &[("bob", "ship")], &[lone], &pins);
1928        assert!(p[0].contains("no pinned key"), "{p:?}");
1929    }
1930
1931    #[test]
1932    fn an_old_unsigned_ballot_is_legacy_and_a_new_one_is_not() {
1933        let pins = BTreeMap::new();
1934        let legacy = |v: &str, _: &str| v == "inky";
1935        let (bad, old) = seat_check(
1936            "d-1",
1937            &[("inky", "ship"), ("bob", "ship")],
1938            &[],
1939            &pins,
1940            &legacy,
1941        );
1942        assert_eq!(old, vec!["inky".to_string()]);
1943        assert_eq!(bad, vec!["bob's ballot is unsigned".to_string()]);
1944    }
1945
1946    #[test]
1947    fn seats_sharing_a_home_get_their_own_keys() {
1948        let dir = tempfile::tempdir().unwrap();
1949        let a = dir.path().join("ljos/seats/inky/ballot.key");
1950        let b = dir.path().join("ljos/seats/disk-saver/ballot.key");
1951        let ka = load_or_create(&a).unwrap();
1952        let kb = load_or_create(&b).unwrap();
1953        assert_ne!(ka.verifying_key(), kb.verifying_key());
1954        assert_eq!(
1955            load_or_create(&a).unwrap().verifying_key(),
1956            ka.verifying_key()
1957        );
1958        use std::os::unix::fs::PermissionsExt;
1959        assert_eq!(
1960            std::fs::metadata(&a).unwrap().permissions().mode() & 0o777,
1961            0o600
1962        );
1963    }
1964
1965    #[test]
1966    fn a_key_at_its_folded_name_moves_to_the_seat_file() {
1967        let _lock = crate::test_env_lock();
1968        let data = tempfile::tempdir().unwrap();
1969        crate::with_env_mutation(|| {
1970            // SAFETY: this test holds the process environment lock.
1971            unsafe { std::env::set_var("XDG_DATA_HOME", data.path()) };
1972        });
1973        let folded = data.path().join("ljos/seats/judge_model-1.5/ballot.key");
1974        std::fs::create_dir_all(folded.parent().unwrap()).unwrap();
1975        std::fs::write(&folded, [7u8; 32]).unwrap();
1976        let path = seat_key_file("judge:model-1.5").unwrap();
1977        assert_ne!(path, folded);
1978        assert_eq!(std::fs::read(&path).unwrap(), vec![7u8; 32]);
1979        assert!(!folded.exists());
1980        assert_eq!(seat_key_file("judge:model-1.5").unwrap(), path);
1981        let slug = seat_key_file("disk-saver").unwrap();
1982        assert_eq!(slug, data.path().join("ljos/seats/disk-saver/ballot.key"));
1983    }
1984
1985    #[test]
1986    fn the_newest_signature_wins() {
1987        let a = key(3);
1988        let pins: BTreeMap<String, String> =
1989            [("a".to_string(), hex::encode(a.verifying_key().to_bytes()))].into();
1990        let new = sign_with(&a, "d-1", "a", "hold");
1991        let old = sign_with(&a, "d-1", "a", "ship");
1992        assert!(seat_problems("d-1", &[("a", "hold")], &[new, old], &pins).is_empty());
1993    }
1994
1995    #[test]
1996    fn a_choice_with_a_space_parses_whole_and_verifies() {
1997        // The panel eval's unscored case: session spaced-1 cast
1998        // `beside binary` as seat-a, then `xdg home`.
1999        let k = key(5);
2000        let bound = sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
2001        let parsed = parse_note(&bound).expect("a bound note with a spaced choice");
2002        assert_eq!(parsed.voter, "seat-a");
2003        assert_eq!(parsed.choice, "beside binary");
2004        assert_eq!(parsed.harness.as_deref(), Some("spaced-1"));
2005        assert_eq!(parsed.nonce.as_deref(), Some("-"));
2006        assert!(verifies("panel-spcd", &parsed));
2007        assert_eq!(
2008            note_session(&bound),
2009            Some(("spaced-1".to_string(), "-".to_string()))
2010        );
2011        // v1, three words: six fields, and still not read as a session.
2012        let plain = sign_with(&k, "panel-spcd", "seat-a", "xdg home dir");
2013        let parsed = parse_note(&plain).expect("a v1 note with a spaced choice");
2014        assert_eq!(parsed.choice, "xdg home dir");
2015        assert_eq!(parsed.harness, None);
2016        assert!(verifies("panel-spcd", &parsed));
2017        // Two spaces inside the choice stay two, so the signature holds.
2018        let wide = sign_bound(&k, "panel-spcd", "seat-a", "xdg  home", "spaced-1", "-");
2019        let parsed = parse_note(&wide).unwrap();
2020        assert_eq!(parsed.choice, "xdg  home");
2021        assert!(verifies("panel-spcd", &parsed));
2022        let pins: BTreeMap<String, String> = [(
2023            "seat-a".to_string(),
2024            hex::encode(k.verifying_key().to_bytes()),
2025        )]
2026        .into();
2027        assert!(seat_problems(
2028            "panel-spcd",
2029            &[("seat-a", "beside binary")],
2030            &[bound],
2031            &pins
2032        )
2033        .is_empty());
2034    }
2035
2036    #[test]
2037    fn a_linked_note_keeps_a_six_field_note_and_verifies() {
2038        let k = key(8);
2039        let old = sign_bound(&k, "demo-link", "alice", "ship", "sess-a", "-");
2040        let parsed = parse_note(&old).unwrap();
2041        assert_eq!(parsed.harness.as_deref(), Some("sess-a"));
2042        assert_eq!(parsed.link, None);
2043        assert!(verifies("demo-link", &parsed));
2044        let linked = sign_linked(
2045            &k,
2046            "demo-link",
2047            "bea",
2048            "hold beside",
2049            LinkedFields {
2050                harness: "sess-a",
2051                nonce: "-",
2052                claimed_harness: "sess-b",
2053                claimed_nonce: "-",
2054                link: "shell",
2055            },
2056        );
2057        let parsed = parse_note(&linked).unwrap();
2058        assert_eq!(parsed.choice, "hold beside");
2059        assert_eq!(parsed.harness.as_deref(), Some("sess-a"));
2060        assert_eq!(parsed.nonce.as_deref(), Some("-"));
2061        assert_eq!(parsed.claimed_harness.as_deref(), Some("sess-b"));
2062        assert_eq!(parsed.claimed_nonce.as_deref(), Some("-"));
2063        assert_eq!(parsed.link.as_deref(), Some("shell"));
2064        assert!(verifies("demo-link", &parsed));
2065        assert!(!verifies("demo-other", &parsed));
2066        assert_eq!(
2067            note_session(&linked),
2068            Some(("sess-a".to_string(), "-".to_string()))
2069        );
2070    }
2071
2072    #[test]
2073    fn a_note_that_does_not_parse_still_names_its_session() {
2074        let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -";
2075        assert_eq!(parse_note(odd), None);
2076        assert_eq!(
2077            note_session(odd),
2078            Some(("spaced-1".to_string(), "-".to_string()))
2079        );
2080        assert_eq!(note_session("ballot-sig: seat-a ship ab"), None);
2081        assert_eq!(note_session("finding: seat-a: nit: name the unit"), None);
2082    }
2083
2084    #[test]
2085    fn a_withdrawn_note_is_three_fields() {
2086        let line = withdrawn_line("seat a", "sess-1", "-");
2087        assert_eq!(line, "ballot-withdrawn: seat_a sess-1 -");
2088        let parsed = parse_withdrawn(&line).unwrap();
2089        assert_eq!(parsed.voter, "seat_a");
2090        assert_eq!(parsed.harness, "sess-1");
2091        assert_eq!(parsed.nonce, "-");
2092        assert_eq!(parsed.issue, None);
2093        let on = withdrawn_line_on("seat a", "sess-1", "-", Some("child-a"));
2094        assert_eq!(on, "ballot-withdrawn: seat_a sess-1 - child-a");
2095        let parsed = parse_withdrawn(&on).unwrap();
2096        assert_eq!(parsed.issue.as_deref(), Some("child-a"));
2097        assert!(parse_withdrawn("ballot-withdrawn: only-one").is_none());
2098        assert!(parse_withdrawn("ballot-sig: seat-a ship").is_none());
2099    }
2100
2101    #[test]
2102    fn a_session_binding_is_signed_and_a_changed_session_is_not() {
2103        let alice = key(4);
2104        let note = sign_bound(&alice, "d-1", "alice", "ship", "sess-alice-0001", "nonce-1");
2105        let parsed = parse_note(&note).unwrap();
2106        assert_eq!(parsed.harness.as_deref(), Some("sess-alice-0001"));
2107        assert_eq!(parsed.nonce.as_deref(), Some("nonce-1"));
2108        assert!(verifies("d-1", &parsed));
2109        let mut moved = parsed.clone();
2110        moved.harness = Some("sess-other-0001".into());
2111        assert!(
2112            !verifies("d-1", &moved),
2113            "a rewritten session still verified"
2114        );
2115        let pins: BTreeMap<String, String> = [(
2116            "alice".to_string(),
2117            hex::encode(alice.verifying_key().to_bytes()),
2118        )]
2119        .into();
2120        assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
2121    }
2122
2123    #[test]
2124    fn a_legacy_ballot_is_read_on_its_own_issue() {
2125        let _lock = crate::test_env_lock();
2126        let dir = tempfile::tempdir().unwrap();
2127        let file = dir.path().join("issues.org");
2128        git(dir.path(), &["init"], &[]);
2129        git(dir.path(), &["config", "user.name", "Rohit Goswami"], &[]);
2130        git(
2131            dir.path(),
2132            &["config", "user.email", "rgoswami@ieee.org"],
2133            &[],
2134        );
2135        std::fs::write(
2136            &file,
2137            "* TODO [#C] old\n:PROPERTIES:\n:ID:         probe-old\n:END:\n- [X] elder: rail\n",
2138        )
2139        .unwrap();
2140        git(dir.path(), &["add", "issues.org"], &[]);
2141        git(dir.path(), &["commit", "-m", "old ballot"], &[]);
2142        std::fs::write(dir.path().join(PIN_FILE), "inky 00\n").unwrap();
2143        git(dir.path(), &["add", PIN_FILE], &[]);
2144        git(dir.path(), &["commit", "-m", "first pin"], &[]);
2145        std::fs::write(
2146            &file,
2147            "* TODO [#C] old\n:PROPERTIES:\n:ID:         probe-old\n:END:\n- [X] elder: rail\n\
2148             * TODO [#C] new\n:PROPERTIES:\n:ID:         probe-new\n:END:\n- [X] elder: rail\n",
2149        )
2150        .unwrap();
2151        git(dir.path(), &["add", "issues.org"], &[]);
2152        git(dir.path(), &["commit", "-m", "new ballot"], &[]);
2153        assert!(recorded_before_signing_in(dir.path(), "probe-old", "elder", "rail").unwrap());
2154        assert!(!recorded_before_signing_in(dir.path(), "probe-new", "elder", "rail").unwrap());
2155    }
2156
2157    #[test]
2158    fn seat_check_accepts_the_same_key_in_uppercase_hex() {
2159        let alice = key(8);
2160        let note = sign_with(&alice, "d-1", "alice", "ship");
2161        let pins: BTreeMap<String, String> = [(
2162            "alice".to_string(),
2163            hex::encode(alice.verifying_key().to_bytes()).to_ascii_uppercase(),
2164        )]
2165        .into();
2166        assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
2167    }
2168
2169    /// One server answers a parent and its subagents: each call is the
2170    /// session plus the agent it names, and a call that names none is the
2171    /// parent's.
2172    #[tokio::test]
2173    async fn an_mcp_call_from_a_subagent_is_a_session_of_its_own() {
2174        let call = |agent: Option<&str>| {
2175            scope_session(
2176                Some("sess-mcp-0001".into()),
2177                agent.map(str::to_string),
2178                async { identity().harness },
2179            )
2180        };
2181        assert_eq!(call(Some("thread-a")).await, "sess-mcp-0001:thread-a");
2182        assert_eq!(call(Some("thread-b")).await, "sess-mcp-0001:thread-b");
2183        assert_eq!(call(None).await, "sess-mcp-0001");
2184    }
2185}