Tokens: one keyring item per token, read back; the bridge says a locked keyring; token check
check / check (push) Canceled after 0s

On the live books a reconnected bridge got no token from a keyring that
couldn't be read, was told to "make a token", and after
`token create --replace` kept on with the new token in memory while
`download` read a revoked one from the keyring.

- tokens.rs INVARIANT 4: keeping a token removes every item for its
  account in every collection, makes one and reads it back; reading finds
  every item and, finding several, refuses with how to keep one again; a
  locked or unreachable keyring is KeyringError::Unavailable, never
  "no token". The logic is shared by the OS keyring and the test files
  (now a folder per collection a test can lock, make unreachable, or
  plant a second item in).
- make_token reads the token back before saying anything, or revokes it.
- The bridge tells the agent the keyring is locked or unreachable and the
  token is fine (or that it holds several items), never to make a token;
  it reads the keyring again before every new session, even with a token
  held.
- `wanderbooks token check --book ID [--name N]`: whether the token kept
  here is the books' live one, asked by its SHA-256 (admin op
  token_check), never its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 04:17:22 -06:00
1 parent 321b94562a
commit d24653558c
9 files changed
+871 -95

No files matched your search

+231 -20
View File
@@ -40,12 +40,16 @@
//! 10. Ended by its stdin or by a signal, the bridge ends its upstream
//! session.
//! 11. Every request upstream carries its token, read from the keyring
//! (tokens.rs) before each new session and each request until it is
//! found, and again when the door refuses it (the call then sent once
//! more if another was found), so a token made or replaced
//! mid-session is used from the next call; without one, calls are
//! answered with the server's refusal, which says how the human makes
//! one. The token is never written to the agent or to stderr.
//! (tokens.rs) before each new session (even with one held: one the
//! keyring no longer gives is said on stderr, and the keyring's used)
//! and before each request until it is found, and again when the
//! door refuses it (the call then sent once more if another was
//! found), so a token made or replaced mid-session is used from the
//! next call. Without one, calls are answered with the server's
//! refusal, which says how the human makes one; but when the keyring
//! couldn't be read (locked, unreachable, or holding several items
//! for the token), with that instead: the token is not said to be
//! missing. The token is never written to the agent or to stderr.
//! ```
use crate::knobs::{FLAG, Knobs};
@@ -140,6 +144,9 @@ struct Bridge {
token: Mutex<Option<String>>,
/// Where it is kept: its account, and the root's `run/`.
keyring: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>,
/// Why the keyring last couldn't be read, if it couldn't (INVARIANT
/// 11).
keyring_trouble: Mutex<Option<crate::tokens::KeyringError>>,
http: reqwest::Client,
knobs: Knobs,
to_agent: mpsc::UnboundedSender<Value>,
@@ -169,6 +176,10 @@ enum Owner {
/// before it counts as not answering.
const UPSTREAM_WAIT: Duration = Duration::from_secs(10);
/// How long the keyring is waited for before a new session when a token
/// is already held (INVARIANT 11): the one held is used if it is slow.
const KEYRING_RECHECK_WAIT: Duration = Duration::from_secs(2);
/// Where one of the agent's requests stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pending {
@@ -340,40 +351,84 @@ impl Bridge {
}
}
/// Whether a token is held.
fn holds_token(&self) -> bool {
self.token
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
}
/// Read the token from the keyring, keeping it if found: whether a
/// token other than the one held was found (one made since, or one
/// that replaced it).
async fn read_token(&self) -> bool {
self.read_token_within(self.knobs.queue_wait).await
}
/// [`Bridge::read_token`], waiting at most `wait` for the keyring.
async fn read_token_within(&self, wait: Duration) -> bool {
let Some((account, run_dir)) = self.keyring.get().cloned() else {
return false;
};
let keeper = crate::tokens::keeper(&run_dir, &self.knobs);
let read = tokio::time::timeout(
self.knobs.queue_wait,
wait,
tokio::task::spawn_blocking(move || keeper.get(&account)),
)
.await;
let read = match read {
Ok(Ok(read)) => read,
Ok(Err(_)) | Err(_) => Err(crate::tokens::KeyringError::Unavailable(
"it didn't give the token in time: is it locked?".into(),
)),
};
let trouble = read.as_ref().err().cloned();
*self
.keyring_trouble
.lock()
.unwrap_or_else(|e| e.into_inner()) = trouble;
match read {
Ok(Ok(Ok(Some(token)))) => {
Ok(Some(token)) => {
let mut held = self.token.lock().unwrap_or_else(|e| e.into_inner());
let new = held.as_deref() != Some(token.as_str());
if new && held.is_some() {
eprintln!(
"wanderbooks mcp: the keyring gives another token for these books than the one held: using the keyring's"
);
}
*held = Some(token);
new
}
Ok(Ok(Ok(None))) => false,
Ok(Ok(Err(why))) => {
Ok(None) => false,
Err(why) => {
eprintln!("wanderbooks mcp: the token for these books couldn't be read: {why}");
false
}
Ok(Err(_)) | Err(_) => {
eprintln!(
"wanderbooks mcp: the keyring didn't give the token for these books in time: is it locked?"
);
false
}
}
}
/// What the agent is told instead of the door's "make a token" when
/// no token is held because the keyring couldn't be read (INVARIANT
/// 11).
fn keyring_refusal(&self) -> Option<String> {
if self.holds_token() {
return None;
}
let trouble = self
.keyring_trouble
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()?;
Some(match trouble {
crate::tokens::KeyringError::Unavailable(why) => format!(
"this computer's keyring is locked or unreachable, so the token for the books {} couldn't be read ({why}). The token itself is fine: unlock the keyring (or log in again) and try once more; don't make a new one (`wanderbooks token check --book {}` says where it stands).",
self.book, self.book
),
duplicates => duplicates.to_string(),
})
}
/// A request upstream, carrying the token (INVARIANT 11).
fn upstream(&self, method: reqwest::Method) -> reqwest::RequestBuilder {
let req = self.http.request(method, self.url());
@@ -416,6 +471,11 @@ impl Bridge {
}
return self.send_once(message, up).await.0;
}
// No token, for a keyring that couldn't be read: that is said,
// not "make one" (INVARIANT 11).
if unauthorized && let Some(why) = self.keyring_refusal() {
return Sent::Refused(why);
}
sent
}
@@ -519,7 +579,15 @@ impl Bridge {
params: &Value,
epoch: u64,
) -> Result<Upstream, Option<String>> {
self.find_token().await;
// Read again before every session, even with a token held
// (INVARIANT 11): a held one the keyring no longer gives isn't
// kept on in silence. A slow keyring leaves the one held in use.
if self.holds_token() {
self.read_token_within(KEYRING_RECHECK_WAIT.min(self.knobs.queue_wait))
.await;
} else {
self.read_token().await;
}
let request = json!({"jsonrpc": "2.0", "id": self.own_id(), "method": "initialize", "params": params});
let post = || {
self.upstream(reqwest::Method::POST)
@@ -530,9 +598,14 @@ impl Bridge {
};
let mut response = post().await.map_err(|_| None)?;
// Refused for its token: once more with one made or replaced
// since (INVARIANT 11).
if response.status() == reqwest::StatusCode::UNAUTHORIZED && self.read_token().await {
response = post().await.map_err(|_| None)?;
// since (INVARIANT 11); with none, for a keyring that couldn't be
// read, that is said instead of the door's words.
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if self.read_token().await {
response = post().await.map_err(|_| None)?;
} else if let Some(why) = self.keyring_refusal() {
return Err(Some(why));
}
}
let session = response
.headers()
@@ -1265,6 +1338,7 @@ pub fn run(
token_name,
token: Mutex::new(None),
keyring: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None),
http,
knobs,
to_agent,
@@ -1610,6 +1684,7 @@ mod against_a_server {
token_name: crate::tokens::DEFAULT_NAME.into(),
token: Mutex::new(Some("wb_test".into())),
keyring: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None),
http,
knobs,
to_agent,
@@ -1821,6 +1896,142 @@ mod against_a_server {
assert!(none.session.is_none() && none.init.is_none());
}
/// A bridge whose token is kept in a test keyring in `dir` (none held
/// yet), and that keyring's folder.
fn bridge_with_keyring(
socket: &Path,
dir: &Path,
) -> (Arc<Bridge>, mpsc::UnboundedReceiver<Value>, PathBuf) {
let knobs = Knobs {
file_keyring: true,
..knobs(1000, 1000)
};
let (b, rx) = bridge_at(socket, knobs);
*b.token.lock().unwrap() = None;
let account = crate::tokens::Account::new(dir, "acme", crate::tokens::DEFAULT_NAME);
b.keyring.set((account, dir.to_path_buf())).unwrap();
let keyring = dir.join("test-keyring");
std::fs::create_dir_all(&keyring).unwrap();
(b, rx, keyring)
}
/// The door's refusal of a request with no token.
fn no_token_at_the_door() -> Reply {
Reply {
status: 401,
headers: vec![(ledger_mcp::local::REFUSED_HEADER, "unauthorized".into())],
body: "these books need a token: the human makes one with `wanderbooks token create`"
.into(),
cut: false,
}
}
/// INVARIANT 11: with no token because the keyring couldn't be read,
/// the agent is told that, never to make a token; with a keyring that
/// simply has none, the door's words.
#[tokio::test]
async fn a_keyring_that_cant_be_read_is_said_never_make_a_token() {
let server = Server::start();
let dir = tempfile::tempdir().unwrap();
let (b, _rx, keyring) = bridge_with_keyring(&server.socket, dir.path());
let call: Value = serde_json::from_str(CALL).unwrap();
let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}});
let unreachable = keyring.join(crate::tokens::TEST_UNREACHABLE);
std::fs::write(&unreachable, "").unwrap();
server.script([no_token_at_the_door(), no_token_at_the_door()]);
let said = what(&b.send(&call, &up(1)).await);
assert!(
said.contains("keyring is locked or unreachable")
&& said.contains("token itself is fine")
&& said.contains("token check --book acme"),
"{said}"
);
assert!(!said.contains("token create"), "{said}");
let refused = b
.initialize_upstream(&params, 1)
.await
.unwrap_err()
.unwrap();
assert!(refused.contains("token itself is fine"), "{refused}");
assert!(
server
.seen()
.iter()
.all(|s| !s.headers.contains_key("authorization")),
"no bearer was sent"
);
// Two items for the token: that, and how to keep one again.
std::fs::remove_file(&unreachable).unwrap();
let account = b.keyring.get().unwrap().0.clone();
crate::tokens::keeper(dir.path(), &b.knobs)
.set(&account, "wb_one")
.unwrap();
let other = keyring.join("login");
std::fs::create_dir_all(&other).unwrap();
for entry in std::fs::read_dir(keyring.join(crate::tokens::TEST_DEFAULT)).unwrap() {
std::fs::write(other.join(entry.unwrap().file_name()), "wb_two").unwrap();
}
server.script([no_token_at_the_door()]);
let said = what(&b.send(&call, &up(1)).await);
assert!(
said.contains("2 items") && said.contains("--replace"),
"{said}"
);
assert!(!said.contains("wb_"), "{said}");
// A keyring that has none: the door's own words.
std::fs::remove_dir_all(&keyring).unwrap();
server.script([no_token_at_the_door()]);
let said = what(&b.send(&call, &up(1)).await);
assert!(said.contains("wanderbooks token create"), "{said}");
}
/// INVARIANT 11: before each new session the keyring is read again,
/// even with a token held, and the keyring's token is the one used.
#[tokio::test]
async fn each_new_session_reads_the_keyring_again() {
let server = Server::start();
let dir = tempfile::tempdir().unwrap();
let (b, _rx, _keyring) = bridge_with_keyring(&server.socket, dir.path());
*b.token.lock().unwrap() = Some("wb_held".into());
let account = b.keyring.get().unwrap().0.clone();
let keeper = crate::tokens::keeper(dir.path(), &b.knobs);
keeper.set(&account, "wb_kept").unwrap();
let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}});
let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
"serverInfo": {"name": "wanderbooks", "version": "0"}});
let opened = || {
[
json_reply(
200,
json!({"jsonrpc": "2.0", "id": "x", "result": result.clone()}),
),
reply(202, "text/plain", ""),
]
};
server.script(opened());
b.initialize_upstream(&params, 1).await.unwrap();
let bearers = |server: &Server| -> Vec<String> {
server
.seen()
.iter()
.filter_map(|s| s.headers.get("authorization").cloned())
.collect()
};
assert_eq!(bearers(&server), ["Bearer wb_kept", "Bearer wb_kept"]);
// A keyring that can't be read then: the token held goes on.
std::fs::write(
dir.path()
.join("test-keyring")
.join(crate::tokens::TEST_UNREACHABLE),
"",
)
.unwrap();
server.script(opened());
b.initialize_upstream(&params, 2).await.unwrap();
assert_eq!(bearers(&server)[2..], ["Bearer wb_kept", "Bearer wb_kept"]);
assert!(b.keyring_refusal().is_none(), "a token is held");
}
#[tokio::test]
async fn the_tool_list_is_read_from_its_answer() {
let server = Server::start();
+86 -7
View File
@@ -22,10 +22,12 @@
//! 3. Adoption and restoring are previewed first and go ahead only on a
//! yes at the terminal, or --yes; a vault beside the books must be
//! named (or --no-vault given): it is never silently left behind.
//! 4. A token made is kept in the keyring before anything is said of it,
//! and its text is printed only by `token print`; one that can't be
//! kept is revoked again. A token revoked is forgotten by the keyring
//! too. `connect` makes the books' agent token only when the keyring
//! 4. A token made is kept in the keyring, and read back from it as
//! kept, before anything is said of it, and its text is printed only
//! by `token print`; one that can't be kept (or read back) is revoked
//! again. A token revoked is forgotten by the keyring too. `token
//! check` says whether the keyring's token is the books' live one,
//! by its SHA-256 alone, never its text. `connect` makes the books' agent token only when the keyring
//! has none, replacing the server's (no one could be using it: the
//! bridge reads its token from this keyring).
//! 5. The viewer's hold lets go only when the owner closes the books;
@@ -1238,7 +1240,16 @@ fn make_token(
};
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, name);
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
// Kept, and read back as kept: else taken back (INVARIANT 4).
let keeper = crate::tokens::keeper(&run_dir, &common.knobs);
let kept = keeper
.set(&account, &text)
.map_err(String::from)
.and_then(|()| match keeper.get(&account) {
Ok(Some(back)) if back == *text => Ok(()),
Ok(_) => Err("it didn't give back the token it was given to keep".to_string()),
Err(e) => Err(e.to_string()),
});
drop(text);
if let Err(why) = kept {
let undone = with_server(
@@ -1960,6 +1971,7 @@ fn token_usage(prog: &str) {
eprintln!("usage: {prog} create --book ID [--name NAME] [--scopes LIST] [--replace]");
eprintln!(" {prog} list");
eprintln!(" {prog} revoke --book ID [--name NAME]");
eprintln!(" {prog} check --book ID [--name NAME]");
eprintln!(" {prog} print --book ID [--name NAME]");
eprintln!(" create make an agent's token for the books ID and keep it in this");
eprintln!(" computer's keyring, where `wanderbooks mcp --book ID` finds it");
@@ -1967,10 +1979,12 @@ fn token_usage(prog: &str) {
eprintln!(" given; --replace ends a token of the same name)");
eprintln!(" list the tokens there are (never their text)");
eprintln!(" revoke end a token, at once, and forget it here");
eprintln!(" check whether the token kept here is the books' live one (never");
eprintln!(" its text: exit 0 if it is)");
eprintln!(" print the token's text, for a client that sends it itself");
}
/// `wanderbooks token create|list|revoke|print`.
/// `wanderbooks token create|list|revoke|check|print`.
pub fn token(
prog: &str,
args: impl IntoIterator<Item = String>,
@@ -2102,6 +2116,71 @@ pub fn token(
other => Err(fail(format!("the books server answered oddly: {other:?}"))),
}
}
["check"] => {
let book = need_book()?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
.map_err(|e| fail(e.to_string()))?;
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
// Its SHA-256 alone leaves here (INVARIANT 4).
let kept = crate::tokens::keeper(&run_dir, &common.knobs)
.get(&account)
.map_err(|e| {
fail(format!(
"this computer's keyring couldn't give the token {name:?} of the books {book}: {e}"
))
})?
.map(|text| {
let text = zeroize::Zeroizing::new(text);
ledger_store::journal::sha256_hex(text.as_bytes())
});
let request = Request::TokenCheck {
book: book.clone(),
name: name.clone(),
sha256: kept.clone().unwrap_or_default(),
};
let (live, matches) = match with_server(&common, request).map_err(fail)? {
Response::TokenChecked { live, matches } => (live, matches),
Response::Refused(why) => {
return Err(fail(format!(
"the books server didn't take the check (one older than this command, perhaps): {why}"
)));
}
other => return Err(fail(format!("the books server answered oddly: {other:?}"))),
};
let named = if name == crate::tokens::DEFAULT_NAME {
String::new()
} else {
format!(" --name {name}")
};
let remake = format!("`wanderbooks token create --book {book}{named} --replace`");
let said = match (kept.is_some(), live, matches) {
(true, Some(t), true) => {
println!(
"The token {name:?} of the books {book} kept in this computer's keyring is the live one (made {}).",
t.created
);
return Ok(());
}
(true, Some(t), false) => format!(
"This computer's keyring holds a token {name:?} for the books {book} that isn't the live one (made {}): {remake} makes one and keeps it here; then reconnect the agent.",
t.created
),
(true, None, _) => format!(
"This computer's keyring holds a token {name:?} for the books {book} that they no longer have (it was revoked): `wanderbooks token create --book {book}{named}` makes one."
),
(false, Some(t), _) => format!(
"This computer's keyring keeps no token {name:?} for the books {book}, though they have a live one (made {}): {remake} makes one kept here.",
t.created
),
(false, None, _) => format!(
"There is no token {name:?} for the books {book}: `wanderbooks token create --book {book}{named}` makes one."
),
};
println!("{said}");
Err(exit(1))
}
["print"] => {
let book = need_book()?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
@@ -2117,7 +2196,7 @@ pub fn token(
Ok(None) => Err(fail(format!(
"this computer keeps no token {name:?} for the books {book}: `wanderbooks token create --book {book}` makes one"
))),
Err(why) => Err(fail(why)),
Err(why) => Err(fail(why.into())),
}
}
_ => {
+344 -60
View File
@@ -19,6 +19,12 @@
//! 3. The keyring stands in for nothing else: with no keyring the
//! command says so; only the test knob `file_keyring` keeps tokens
//! in files instead (in the root's `run/`, 0600).
//! 4. One account, one item: keeping a token first removes every item
//! kept for its account in any collection, then makes one and reads
//! it back (else it is refused); reading finds the items in every
//! collection and, finding several, says so instead of giving one
//! of them. A keyring that is locked or can't be reached is an
//! error (KeyringError::Unavailable), never "no token".
//! ```
use crate::knobs::Knobs;
@@ -29,14 +35,56 @@ pub const SERVICE: &str = "wanderbooks";
/// A token's name when none is given.
pub const DEFAULT_NAME: &str = ledger_store::auth::DEFAULT_NAME;
/// Why the keyring gave no answer (INVARIANT 4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum KeyringError {
/// Locked, unreachable, or it refused, with why: a token kept there
/// is untouched.
Unavailable(String),
/// Several items for one token, where one belongs.
Duplicates {
/// How many.
count: usize,
/// The books.
book: String,
/// The token's name.
name: String,
},
}
impl std::fmt::Display for KeyringError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unavailable(why) => f.write_str(why),
Self::Duplicates { count, book, name } => {
let named = if name == DEFAULT_NAME {
String::new()
} else {
format!(" --name {name}")
};
write!(
f,
"this computer's keyring holds {count} items for the token {name:?} of the books {book}, where one belongs, so none of them is used: `wanderbooks token create --book {book}{named} --replace` keeps one again"
)
}
}
}
}
impl From<KeyringError> for String {
fn from(e: KeyringError) -> Self {
e.to_string()
}
}
/// A place tokens are kept.
pub trait Keeper: Send + Sync {
/// The token kept for `account`, if any.
fn get(&self, account: &Account) -> Result<Option<String>, String>;
/// Keep `text` for `account`, replacing any.
fn set(&self, account: &Account, text: &str) -> Result<(), String>;
/// The token kept for `account`, if any (INVARIANT 4).
fn get(&self, account: &Account) -> Result<Option<String>, KeyringError>;
/// Keep `text` for `account`, replacing any (INVARIANT 4).
fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError>;
/// Forget `account`'s token (no matter if there is none).
fn delete(&self, account: &Account) -> Result<(), String>;
fn delete(&self, account: &Account) -> Result<(), KeyringError>;
}
/// Whose token: a root's book's token of a name (INVARIANT 1).
@@ -68,6 +116,14 @@ impl Account {
fn label(&self) -> String {
format!("wanderbooks token: the books {} ({})", self.book, self.name)
}
fn duplicates(&self, count: usize) -> KeyringError {
KeyringError::Duplicates {
count,
book: self.book.clone(),
name: self.name.clone(),
}
}
}
/// The keeper in force: the OS keyring, or files for tests (INVARIANT 3).
@@ -79,58 +135,147 @@ pub fn keeper(run_dir: &Path, knobs: &Knobs) -> Box<dyn Keeper> {
}
}
/// Items kept under an account, perhaps in several collections: the
/// keyring's or the test files' (INVARIANT 4 is kept once, over these).
trait Items {
/// One item.
type Item;
/// Every item kept for `account`, in any collection.
fn find(&self, account: &Account) -> Result<Vec<Self::Item>, String>;
/// An item's text.
fn read(&self, item: &Self::Item) -> Result<String, String>;
/// Remove an item.
fn remove(&self, item: Self::Item) -> Result<(), String>;
/// Make an item for `account` in the default collection.
fn make(&self, account: &Account, text: &str) -> Result<(), String>;
}
/// The one token kept for `account` (INVARIANT 4).
fn get_one<I: Items>(items: &I, account: &Account) -> Result<Option<String>, KeyringError> {
let mut found = items.find(account).map_err(KeyringError::Unavailable)?;
match found.len() {
0 => Ok(None),
1 => items
.read(&found.remove(0))
.map(Some)
.map_err(KeyringError::Unavailable),
n => Err(account.duplicates(n)),
}
}
/// Keep `text` as the one item for `account`, read back (INVARIANT 4).
fn set_one<I: Items>(items: &I, account: &Account, text: &str) -> Result<(), KeyringError> {
delete_all(items, account)?;
items
.make(account, text)
.map_err(KeyringError::Unavailable)?;
match get_one(items, account)? {
Some(back) if back == text => Ok(()),
_ => Err(KeyringError::Unavailable(
"it didn't give back the token it was given to keep".into(),
)),
}
}
/// Remove every item kept for `account`.
fn delete_all<I: Items>(items: &I, account: &Account) -> Result<(), KeyringError> {
for item in items.find(account).map_err(KeyringError::Unavailable)? {
items.remove(item).map_err(KeyringError::Unavailable)?;
}
Ok(())
}
/// The OS keyring.
struct Os;
#[cfg(target_os = "linux")]
impl Os {
fn entry(&self, account: &Account) -> Result<keyring_core::Entry, String> {
use keyring_core::api::CredentialStoreApi;
let store = zbus_secret_service_keyring_store::Store::new().map_err(|e| {
format!("this computer's keyring can't be reached ({e}): is a Secret Service (GNOME Keyring, KWallet) running and unlocked?")
})?;
let label = account.label();
let modifiers = std::collections::HashMap::from([("label", label.as_str())]);
store
.build(SERVICE, &account.key(), Some(&modifiers))
.map_err(|e| format!("the keyring refused: {e}"))
mod os {
use super::{Account, Items, SERVICE};
use keyring_core::api::CredentialStoreApi;
use std::collections::HashMap;
use std::sync::Arc;
use zbus_secret_service_keyring_store::Store;
/// The Secret Service, reached for one keeper call.
pub(super) struct Service(Arc<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
Store::new().map(Self).map_err(|e| {
format!("this computer's keyring can't be reached ({e}): is a Secret Service (GNOME Keyring, KWallet) running and unlocked?")
})
}
}
/// What a keyring error says (INVARIANT 4: locked is said as such).
fn refused(e: keyring_core::Error) -> String {
match e {
keyring_core::Error::NoStorageAccess(_) => {
format!("this computer's keyring is locked, or wouldn't let it be read ({e})")
}
e => format!("the keyring refused: {e}"),
}
}
impl Items for Service {
type Item = keyring_core::Entry;
fn find(&self, account: &Account) -> Result<Vec<Self::Item>, String> {
let key = account.key();
let spec = HashMap::from([("service", SERVICE), ("username", key.as_str())]);
self.0.search(&spec).map_err(refused)
}
fn read(&self, item: &Self::Item) -> Result<String, String> {
item.get_password().map_err(refused)
}
fn remove(&self, item: Self::Item) -> Result<(), String> {
match item.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(refused(e)),
}
}
fn make(&self, account: &Account, text: &str) -> Result<(), String> {
let label = account.label();
let modifiers = HashMap::from([("label", label.as_str())]);
self.0
.build(SERVICE, &account.key(), Some(&modifiers))
.and_then(|entry| entry.set_password(text))
.map_err(refused)
}
}
}
#[cfg(target_os = "linux")]
impl Keeper for Os {
fn get(&self, account: &Account) -> Result<Option<String>, String> {
match self.entry(account)?.get_password() {
Ok(text) => Ok(Some(text)),
Err(keyring_core::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("the keyring refused: {e}")),
}
fn get(&self, account: &Account) -> Result<Option<String>, KeyringError> {
let service = os::Service::reach().map_err(KeyringError::Unavailable)?;
get_one(&service, account)
}
fn set(&self, account: &Account, text: &str) -> Result<(), String> {
self.entry(account)?
.set_password(text)
.map_err(|e| format!("the keyring refused: {e}"))
fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError> {
let service = os::Service::reach().map_err(KeyringError::Unavailable)?;
set_one(&service, account, text)
}
fn delete(&self, account: &Account) -> Result<(), String> {
match self.entry(account)?.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("the keyring refused: {e}")),
}
fn delete(&self, account: &Account) -> Result<(), KeyringError> {
let service = os::Service::reach().map_err(KeyringError::Unavailable)?;
delete_all(&service, account)
}
}
#[cfg(not(target_os = "linux"))]
impl Keeper for Os {
fn get(&self, _: &Account) -> Result<Option<String>, String> {
Err(NOT_YET.into())
fn get(&self, _: &Account) -> Result<Option<String>, KeyringError> {
Err(KeyringError::Unavailable(NOT_YET.into()))
}
fn set(&self, _: &Account, _: &str) -> Result<(), String> {
Err(NOT_YET.into())
fn set(&self, _: &Account, _: &str) -> Result<(), KeyringError> {
Err(KeyringError::Unavailable(NOT_YET.into()))
}
fn delete(&self, _: &Account) -> Result<(), String> {
Err(NOT_YET.into())
fn delete(&self, _: &Account) -> Result<(), KeyringError> {
Err(KeyringError::Unavailable(NOT_YET.into()))
}
}
@@ -138,31 +283,70 @@ impl Keeper for Os {
const NOT_YET: &str =
"agents' tokens are kept in the keyring on Linux only, until the macOS and Windows apps (P11)";
/// Files, one a token, for tests (INVARIANT 3).
/// Files, for tests (INVARIANT 3): a folder per collection (`default/`
/// where tokens are kept), a file a token. A collection holding a file
/// `locked` can't be read; the keyring holding a file `unreachable`
/// can't be reached. So the tests can lock it, or plant a second item.
struct Files(PathBuf);
/// The test keyring's default collection.
pub const TEST_DEFAULT: &str = "default";
/// In a test collection: its items can't be read.
pub const TEST_LOCKED: &str = "locked";
/// In the test keyring: it can't be reached.
pub const TEST_UNREACHABLE: &str = "unreachable";
impl Files {
fn path(&self, account: &Account) -> PathBuf {
self.0
.join(ledger_store::journal::sha256_hex(account.key().as_bytes()))
fn file_name(account: &Account) -> String {
ledger_store::journal::sha256_hex(account.key().as_bytes())
}
}
impl Keeper for Files {
fn get(&self, account: &Account) -> Result<Option<String>, String> {
match std::fs::read_to_string(self.path(account)) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
impl Items for Files {
type Item = PathBuf;
fn find(&self, account: &Account) -> Result<Vec<PathBuf>, String> {
let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind());
if self.0.join(TEST_UNREACHABLE).exists() {
return Err("the test keyring can't be reached".into());
}
let collections = match std::fs::read_dir(&self.0) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(fail(e)),
};
let name = Self::file_name(account);
let mut found = Vec::new();
for c in collections {
let file = c.map_err(fail)?.path().join(&name);
if file.is_file() {
found.push(file);
}
}
found.sort();
Ok(found)
}
fn read(&self, item: &PathBuf) -> Result<String, String> {
if item.with_file_name(TEST_LOCKED).exists() {
return Err("the test keyring is locked".into());
}
std::fs::read_to_string(item).map_err(|e| format!("the test keyring refused: {}", e.kind()))
}
fn remove(&self, item: PathBuf) -> Result<(), String> {
match std::fs::remove_file(item) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("the test keyring refused: {}", e.kind())),
}
}
fn set(&self, account: &Account, text: &str) -> Result<(), String> {
fn make(&self, account: &Account, text: &str) -> Result<(), String> {
use std::io::Write;
let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind());
std::fs::create_dir_all(&self.0).map_err(fail)?;
let path = self.path(account);
let _ = std::fs::remove_file(&path);
let dir = self.0.join(TEST_DEFAULT);
std::fs::create_dir_all(&dir).map_err(fail)?;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
@@ -171,17 +355,23 @@ impl Keeper for Files {
options.mode(0o600);
}
options
.open(&path)
.open(dir.join(Self::file_name(account)))
.and_then(|mut f| f.write_all(text.as_bytes()))
.map_err(fail)
}
}
fn delete(&self, account: &Account) -> Result<(), String> {
match std::fs::remove_file(self.path(account)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("the test keyring refused: {}", e.kind())),
}
impl Keeper for Files {
fn get(&self, account: &Account) -> Result<Option<String>, KeyringError> {
get_one(self, account)
}
fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError> {
set_one(self, account, text)
}
fn delete(&self, account: &Account) -> Result<(), KeyringError> {
delete_all(self, account)
}
}
@@ -230,9 +420,10 @@ mod tests {
{
k.set(&a, "wb_three").unwrap();
use std::os::unix::fs::PermissionsExt;
let files: Vec<_> = std::fs::read_dir(dir.path().join("test-keyring"))
.unwrap()
.collect();
let files: Vec<_> =
std::fs::read_dir(dir.path().join("test-keyring").join(TEST_DEFAULT))
.unwrap()
.collect();
assert_eq!(files.len(), 1);
let mode = files[0]
.as_ref()
@@ -245,6 +436,99 @@ mod tests {
}
}
/// The test keyring with a second collection (INVARIANT 4).
fn two_collections() -> (tempfile::TempDir, Files, Account, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let files = Files(dir.path().join("test-keyring"));
let a = Account::new(dir.path(), "acme", "agent");
let other = files.0.join("login");
std::fs::create_dir_all(&other).unwrap();
(dir, files, a, other)
}
#[test]
fn two_items_for_one_token_are_said_never_one_of_them_given() {
let (_dir, k, a, other) = two_collections();
k.set(&a, "wb_new").unwrap();
std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap();
let err = k.get(&a).unwrap_err();
assert_eq!(err, a.duplicates(2));
let said = err.to_string();
assert!(
said.contains("2 items") && said.contains("token create --book acme --replace"),
"{said}"
);
assert!(!said.contains("wb_"), "{said}");
// Kept again: every item goes first, and one is left.
k.set(&a, "wb_newer").unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_newer"));
assert!(!other.join(Files::file_name(&a)).exists());
// Forgotten in every collection.
std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap();
k.delete(&a).unwrap();
assert_eq!(k.get(&a).unwrap(), None);
// Another token's name is said in the repair.
let b = Account::new(_dir.path(), "acme", "codex");
assert!(
b.duplicates(3)
.to_string()
.contains("--name codex --replace")
);
}
#[test]
fn a_locked_or_unreachable_keyring_is_an_error_never_no_token() {
let (_dir, k, a, other) = two_collections();
// The only item, in a locked collection.
std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap();
std::fs::write(other.join(TEST_LOCKED), "").unwrap();
let err = k.get(&a).unwrap_err();
assert!(matches!(err, KeyringError::Unavailable(_)), "{err:?}");
std::fs::remove_file(other.join(TEST_LOCKED)).unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_old"));
std::fs::write(k.0.join(TEST_UNREACHABLE), "").unwrap();
for err in [
k.get(&a).unwrap_err(),
k.set(&a, "wb_new").unwrap_err(),
k.delete(&a).unwrap_err(),
] {
assert!(matches!(err, KeyringError::Unavailable(_)), "{err:?}");
}
std::fs::remove_file(k.0.join(TEST_UNREACHABLE)).unwrap();
assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_old"), "untouched");
}
/// A keyring that keeps something other than it was given.
struct Forgetful;
impl Items for Forgetful {
type Item = ();
fn find(&self, _: &Account) -> Result<Vec<()>, String> {
Ok(vec![()])
}
fn read(&self, _: &()) -> Result<String, String> {
Ok("wb_someone_elses".into())
}
fn remove(&self, _: ()) -> Result<(), String> {
Ok(())
}
fn make(&self, _: &Account, _: &str) -> Result<(), String> {
Ok(())
}
}
#[test]
fn a_token_kept_is_read_back_or_refused() {
let dir = tempfile::tempdir().unwrap();
let a = Account::new(dir.path(), "acme", "agent");
let err = set_one(&Forgetful, &a, "wb_mine").unwrap_err();
assert!(
matches!(&err, KeyringError::Unavailable(w) if w.contains("didn't give back")),
"{err:?}"
);
assert!(!err.to_string().contains("wb_"));
}
/// The real keyring, on a desktop with one (`cargo test -p
/// ledger-host -- --ignored os_keyring`): kept, read, replaced and
/// forgotten, under a service no one else uses.