//! Applying a `memory` call (M5a spec, section 5). `note` records experience in any session; //! the identity actions change `memory.md` or `user.md`, and only a session that has not read //! untrusted content may use them. Every refusal is a fixed sentence: it reaches the model. use std::collections::HashSet; use bxcore::{DataClass, MemoryArgs, MemoryFile, Timestamp}; use crate::check::check; use crate::entries; use crate::files::{Store, grouped}; use crate::notes::{Kind, Note, minute}; /// The session a call comes from, as `brokerd` saw it when it decided the call. #[derive(Debug, Clone)] pub struct Context { pub session: String, pub call: u64, pub taint: DataClass, pub untrusted: bool, pub now: Timestamp, } /// What an applied call changed: an identity file's entries before and after (both empty for a /// note), and the notes it appended. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Change { pub file: Option, pub before: Vec, pub after: Vec, pub notes: Vec, } /// The answer to the model, and the change for the journal. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Outcome { pub action: &'static str, pub answer: String, pub change: Change, } /// The sentence for a write over a file's limit. pub fn over_limit(file: MemoryFile, size: usize) -> String { format!( "{} would be {} of {} characters; use consolidate to move entries to notes", file.name(), grouped(size), grouped(file.limit()) ) } /// The sentence for an identity edit other than `add` from a session that read untrusted content. pub fn untrusted_refusal(file: MemoryFile) -> String { format!( "this session has read untrusted content; it can take notes but not change {}", file.name() ) } /// The answer when an untrusted session's `add` became a note. It names the file it did not change: /// on the device the model read a shorter answer as success. pub fn saved_as_note(file: MemoryFile) -> String { format!( "not added to {}: this session has read untrusted content, so it was saved in notes.md as an untrusted note", file.name() ) } /// The answer to a `note` from a session that has not read untrusted content. pub const NOTED: &str = "saved in notes.md"; /// The answer to a `note` from a session that has read untrusted content. pub const NOTED_UNTRUSTED: &str = "saved in notes.md, marked untrusted: this session has read untrusted content"; fn stale(file: MemoryFile) -> String { format!( "no entry of {} is exactly this text; the file may have changed; nothing was changed", file.name() ) } fn checked(text: &str) -> Result { check(text).map_err(|refused| refused.sentence().to_string())?; Ok(text.trim().to_string()) } fn note(ctx: &Context, kind: Kind, text: &str) -> Note { Note { kind, time: minute(ctx.now), session: ctx.session.clone(), text: text.to_string(), } } /// Apply `args` for the session in `ctx`. `Err` is the sentence for the model; nothing changed. pub fn apply(store: &Store, args: &MemoryArgs, ctx: &Context) -> Result { // 1. `args.check()?`. args.check()?; // 2. A Note: return `noted(store, ctx, text, "note")`. Otherwise take the file. let file = match args { MemoryArgs::Note { text } => { return noted(store, ctx, text, "note"); } MemoryArgs::Add { file, .. } | MemoryArgs::Replace { file, .. } | MemoryArgs::Remove { file, .. } | MemoryArgs::Consolidate { file, .. } => *file, }; // 3. When `ctx.untrusted`: an Add becomes `noted(store, ctx, text, "add")?` with its answer // replaced by saved_as_note(file); any other action -> Err(untrusted_refusal(file)). if ctx.untrusted { match args { MemoryArgs::Add { text, .. } => { let outcome = noted(store, ctx, text, "add")?; return Ok(Outcome { answer: saved_as_note(file), ..outcome }); } _ => return Err(untrusted_refusal(file)), } } // 4. `let before = store.entries(file)?;` then `let (action, after, moved) = edited(file, // &before, args, ctx)?;`. let before = store.entries(file)?; let (action, after, moved) = edited(file, &before, args, ctx)?; // 5. When `after` holds the same entry twice (collect into a HashSet and compare lengths): // Err(format!("{} would hold the same entry twice", file.name())). let unique: HashSet<&String> = after.iter().collect(); if unique.len() != after.len() { return Err(format!("{} would hold the same entry twice", file.name())); } // 6. `size = entries::size(&after)`; size > file.limit() -> Err(over_limit(file, size)). let size = entries::size(&after); if size > file.limit() { return Err(over_limit(file, size)); } // 7. `store.write_entries(file, &after)?`, then `store.append("notes.md", ¬e.render())?` for // each note in `moved`. store.write_entries(file, &after)?; for entry in &moved { store.append("notes.md", &entry.render())?; } // 8. Ok(Outcome { action, answer: format!("{} updated ({} of {} characters); it takes effect in // the next session", file.name(), grouped(size), grouped(file.limit())), change: Change { // file: Some(file), before, after, notes: moved } }). Ok(Outcome { action, answer: format!( "{} updated ({} of {} characters); it takes effect in the next session", file.name(), grouped(size), grouped(file.limit()) ), change: Change { file: Some(file), before, after, notes: moved, }, }) } fn edited( file: MemoryFile, before: &[String], args: &MemoryArgs, ctx: &Context, ) -> Result<(&'static str, Vec, Vec), String> { match args { MemoryArgs::Add { text, .. } => { let text = checked(text)?; if before.contains(&text) { return Err(format!("{} already has this entry", file.name())); } let mut after = before.to_vec(); after.push(text); Ok(("add", after, Vec::new())) } MemoryArgs::Replace { old, text, .. } => { let pos = position(before, old).ok_or_else(|| stale(file))?; let text = checked(text)?; if before .iter() .enumerate() .any(|(i, e)| i != pos && *e == text) { return Err(format!("{} already has this entry", file.name())); } let mut after = before.to_vec(); after[pos] = text; Ok(("replace", after, Vec::new())) } MemoryArgs::Remove { old, .. } => { let pos = position(before, old).ok_or_else(|| stale(file))?; let gone = before[pos].clone(); let mut after = before.to_vec(); after.remove(pos); Ok(("remove", after, vec![note(ctx, Kind::Dropped(file), &gone)])) } MemoryArgs::Consolidate { entries, .. } => { let (after, notes) = consolidate(file, before, entries, ctx)?; Ok(("consolidate", after, notes)) } MemoryArgs::Note { .. } => Err( "a note is not an identity edit".to_string(), // `apply` takes notes before it asks for an identity edit. ), } } /// Where `old` is, matched exactly (after trimming, as entries are stored). fn position(list: &[String], old: &str) -> Option { list.iter().position(|e| e == old.trim()) } fn noted( store: &Store, ctx: &Context, text: &str, action: &'static str, ) -> Result { let text = checked(text)?; let entry = note( ctx, Kind::Noted { class: ctx.taint, untrusted: ctx.untrusted, }, &text, ); store.append("notes.md", &entry.render())?; Ok(Outcome { action, answer: if ctx.untrusted { NOTED_UNTRUSTED } else { NOTED } .to_string(), change: Change { file: None, before: Vec::new(), after: Vec::new(), notes: vec![entry], }, }) } /// Mark the entry `old` names as named: stale if `before` has no such entry, an error if it was /// named already. fn claim( file: MemoryFile, before: &[String], named: &mut HashSet, old: &str, ) -> Result<(), String> { let pos = position(before, old).ok_or_else(|| stale(file))?; if named.insert(pos) { Ok(()) } else { Err(format!( "consolidate names an entry of {} twice", file.name() )) } } fn consolidate( file: MemoryFile, before: &[String], lines: &[bxcore::ConsolidateEntry], ctx: &Context, ) -> Result<(Vec, Vec), String> { let mut named: HashSet = HashSet::new(); let mut after = Vec::new(); let mut out = Vec::new(); for line in lines { let action = line.action().map_err(|e| e.to_string())?; match action { bxcore::EntryAction::Keep(old) => { claim(file, before, &mut named, &old)?; after.push(old.trim().to_string()); } bxcore::EntryAction::Rewrite { old, text } => { claim(file, before, &mut named, &old)?; after.push(checked(&text)?); } bxcore::EntryAction::Move(old) => { claim(file, before, &mut named, &old)?; out.push(note(ctx, Kind::Moved(file), old.trim())); } bxcore::EntryAction::Drop(old) => { claim(file, before, &mut named, &old)?; out.push(note(ctx, Kind::Dropped(file), old.trim())); } bxcore::EntryAction::New(text) => { after.push(checked(&text)?); } } } let missing = before.len() - named.len(); if missing != 0 { return Err(format!( "consolidate must name every entry of {}; it leaves out {missing}", file.name() )); } Ok((after, out)) }