//! 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. //! //! Every text a write would store goes through the sanitizer (M5b spec, section 7): a text that //! breaks a refuse rule is refused; one that matches a flag rule is kept, one tier lower, as a //! note marked untrusted (section 6.3). use std::collections::HashSet; use bxcore::sanitize::{Refusal, flag, refuse}; use bxcore::{DataClass, MemoryArgs, MemoryFile, Timestamp}; 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 flag rule a text of the write matched, which made it an untrusted note. pub flag: Option<&'static str>, } /// 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() ) } /// The answer when an identity write matched a flag rule and became notes (M5b spec, section 6.3). pub fn flagged_as_note(file: MemoryFile, rule: &str) -> String { format!( "not added to {}: it matches the rule {rule}, so it was saved in notes.md as an untrusted note", file.name() ) } /// The answer to a `note` that matched a flag rule, from a session that has not read untrusted /// content. pub fn noted_flagged(rule: &str) -> String { format!("saved in notes.md, marked untrusted: it matches the rule {rule}") } /// The texts `args` would store: the `text` of an add, a replace or a note, and every `text` of a /// consolidate (its rewrites and new entries). An `old` is not here: it names what is stored. fn texts(args: &MemoryArgs) -> Vec<&str> { match args { MemoryArgs::Add { text, .. } | MemoryArgs::Replace { text, .. } | MemoryArgs::Note { text } => vec![text.as_str()], MemoryArgs::Remove { .. } => Vec::new(), MemoryArgs::Consolidate { entries, .. } => { entries.iter().filter_map(|e| e.text.as_deref()).collect() } } } /// The refusal of the first text `args` would store that breaks a refuse rule. pub fn refused(args: &MemoryArgs) -> Option { texts(args).into_iter().find_map(refuse) } /// The rule of the first text `args` would store that matches a flag rule. pub fn flagged(args: &MemoryArgs) -> Option<&'static str> { texts(args).into_iter().find_map(flag).map(|f| f.rule) } 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()?; // 1a. A text that breaks a refuse rule: Err(refusal.sentence), before anything is written. if let Some(refusal) = refused(args) { return Err(refusal.sentence.to_string()); } // 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)?; // 4a. A text that matches a flag rule: `as_notes` saves every text of the write as an // untrusted note instead, and the file is not changed. if let Some(rule) = flagged(args) { return as_notes(store, ctx, file, action, &texts(args), rule); } // 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, }, flag: None, }) } fn edited( file: MemoryFile, before: &[String], args: &MemoryArgs, ctx: &Context, ) -> Result<(&'static str, Vec, Vec), String> { match args { MemoryArgs::Add { text, .. } => { let text = text.trim().to_string(); 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 = text.trim().to_string(); 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 rule = flag(text).map(|f| f.rule); let entry = note( ctx, Kind::Noted { class: ctx.taint, untrusted: ctx.untrusted || rule.is_some(), }, text.trim(), ); store.append("notes.md", &entry.render())?; let answer = match (ctx.untrusted, rule) { (true, _) => NOTED_UNTRUSTED.to_string(), (false, Some(rule)) => noted_flagged(rule), (false, None) => NOTED.to_string(), }; Ok(Outcome { action, answer, change: Change { file: None, before: Vec::new(), after: Vec::new(), notes: vec![entry], }, flag: rule, }) } /// An identity write that matched the flag rule `rule`: each of `texts`, trimmed, is appended to /// `notes.md` as a note marked untrusted, in order, and the identity file is not changed. fn as_notes( store: &Store, ctx: &Context, file: MemoryFile, action: &'static str, texts: &[&str], rule: &'static str, ) -> Result { let mut notes = Vec::new(); for text in texts { let entry = note( ctx, Kind::Noted { class: ctx.taint, untrusted: true, }, text.trim(), ); store.append("notes.md", &entry.render())?; notes.push(entry); } Ok(Outcome { action, answer: flagged_as_note(file, rule), change: Change { file: None, before: Vec::new(), after: Vec::new(), notes, }, flag: Some(rule), }) } /// 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(text.trim().to_string()); } 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(text.trim().to_string()); } } } 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)) }