P3: the books' format moves only with the owner's go-ahead (D-39)
Books an update could write a newer format for open on their old format and are asked at every open; anything needing a new feature is refused until the owner agrees with the passphrase, after a verified backup that includes the keys. New books start on the current format. Each projection schema has its own database file, so an older version still opens the books until the owner agrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
62 files changed
+5309
-628
No files matched your search
@@ -19,7 +19,9 @@
|
||||
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
|
||||
//! other request is refused.
|
||||
//! 3. consent_format_upgrade carries the owner passphrase; without one
|
||||
//! it is refused before anything is done.
|
||||
//! it is refused before anything is done. With one, the host hands it
|
||||
//! to the books server, which checks it and upgrades (host.rs
|
||||
//! INVARIANT 12; plan 3.9, D-39).
|
||||
//! 4. Before the host ends a lease it sends the reason; a lease that
|
||||
//! ends with no reason means the host is gone.
|
||||
//! 5. Each side accepts its own protocol version and the one before.
|
||||
@@ -114,9 +116,9 @@ pub enum Intent {
|
||||
ConsentFormatUpgrade {
|
||||
/// Which.
|
||||
book: String,
|
||||
/// The owner passphrase (INVARIANT 3).
|
||||
/// The owner passphrase (INVARIANT 3): Debug never shows it.
|
||||
#[serde(default)]
|
||||
passphrase: Option<String>,
|
||||
passphrase: Option<ledger_mcp::admin::Passphrase>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -124,10 +126,11 @@ impl Intent {
|
||||
/// Refuse an intent that needs the passphrase and has none
|
||||
/// (INVARIANT 3).
|
||||
pub fn check_passphrase(&self) -> Result<(), String> {
|
||||
let missing = |p: &Option<String>| p.as_deref().is_none_or(|p| p.is_empty());
|
||||
let missing =
|
||||
|p: &Option<ledger_mcp::admin::Passphrase>| p.as_ref().is_none_or(|p| p.0.is_empty());
|
||||
match self {
|
||||
Intent::ConsentFormatUpgrade { passphrase, .. } if missing(passphrase) => Err(
|
||||
"consenting to a format upgrade needs the owner passphrase, typed in the app"
|
||||
"consenting to a format upgrade needs the owner passphrase, typed in the app or at a terminal"
|
||||
.into(),
|
||||
),
|
||||
_ => Ok(()),
|
||||
@@ -296,7 +299,7 @@ mod tests {
|
||||
fn only_consent_needs_the_passphrase() {
|
||||
let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade {
|
||||
book: "acme".into(),
|
||||
passphrase: p.map(str::to_string),
|
||||
passphrase: p.map(|p| ledger_mcp::admin::Passphrase(p.to_string())),
|
||||
};
|
||||
assert!(consent(None).check_passphrase().is_err());
|
||||
assert!(consent(Some("")).check_passphrase().is_err());
|
||||
@@ -304,6 +307,23 @@ mod tests {
|
||||
assert!(Intent::Close.check_passphrase().is_ok());
|
||||
}
|
||||
|
||||
/// INVARIANT 3: the passphrase a consent carries is sent as written,
|
||||
/// and never shown by Debug, of the intent or the line that carried it
|
||||
/// (an error or a log line that prints either names no passphrase).
|
||||
#[test]
|
||||
fn a_consent_never_shows_its_passphrase() {
|
||||
let text = r#"{"proto":1,"intent":{"consent_format_upgrade":{"book":"acme","passphrase":"correct horse battery"}}}"#;
|
||||
let hello = parse_hello(text).unwrap();
|
||||
let Ask::Intent(intent) = &hello.ask else {
|
||||
panic!("{hello:?}")
|
||||
};
|
||||
for shown in [format!("{hello:?}"), format!("{intent:?}")] {
|
||||
assert!(!shown.contains("horse"), "{shown}");
|
||||
assert!(shown.contains("acme"), "{shown}");
|
||||
}
|
||||
assert_eq!(line(&hello).trim_end(), text, "sent as written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_this_one_and_the_one_before() {
|
||||
assert!(accepts(PROTO));
|
||||
|
||||
Reference in new issue
Block a user