use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::Utc;
use sha2::{Digest, Sha256};
use trusty_common::memory_core::filter::{check_secret, FilterReject};
use trusty_common::memory_core::palace::Drawer;
use trusty_common::memory_core::retrieval::RememberOptions;
use uuid::Uuid;
use std::panic::AssertUnwindSafe;
use trusty_common::memory_core::store::concurrent_open::try_open_or_snapshot;
use trusty_common::memory_core::store::OpenIntent;
use super::{COPIED_SUFFIXES, INDEX_FILE, LEGACY_KG_FILE};
use crate::commands::store_snapshot::{with_store_copy, KG_FILE, SCRATCH_PREFIX};
use crate::tools::helpers::{blocklist_gate, content_gate, mcp_remember_opts};
pub(crate) const BACKUP_PREFIX: &str = "legacy-kg-backup-";
pub(crate) const MANIFEST_FILE: &str = "MANIFEST.sha256";
pub(crate) type CopyFn = fn(&Path, &Path) -> std::io::Result<u64>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RejectReason {
Secret,
Noise(&'static str),
}
impl std::fmt::Display for RejectReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Secret => f.write_str("secret"),
Self::Noise(kind) => write!(f, "noise ({kind})"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rejected {
pub id: Uuid,
pub reason: RejectReason,
}
fn screen_content(
opts: &RememberOptions,
content: &str,
allow_short: bool,
) -> Option<RejectReason> {
if check_secret(content.trim()).is_err() {
return Some(RejectReason::Secret);
}
if blocklist_gate(content).is_some() {
return Some(RejectReason::Noise("blocklisted"));
}
if content_gate(content, None, false).is_none() {
return Some(RejectReason::Noise("too_few_words"));
}
let enforce = [false, opts.enforce_min_tokens && !allow_short];
enforce
.into_iter()
.find_map(|min| match opts.filter.apply(content, min) {
Ok(()) => None,
Err(FilterReject::PotentialSecret { .. }) => Some(RejectReason::Secret),
Err(FilterReject::TooShort { .. }) => Some(RejectReason::Noise(TOO_SHORT)),
Err(FilterReject::NoisePattern { .. }) => Some(RejectReason::Noise("noise_pattern")),
Err(FilterReject::NonAlphabetic { .. }) => Some(RejectReason::Noise("non_alphabetic")),
})
}
pub(crate) const TOO_SHORT: &str = "too_short";
pub(crate) fn screen_drawers(
drawers: impl IntoIterator<Item = Drawer>,
allow_short: bool,
) -> (Vec<Drawer>, Vec<Rejected>) {
let opts = mcp_remember_opts(false, false, false);
let mut passed = Vec::new();
let mut rejected = Vec::new();
for d in drawers {
match screen_content(&opts, d.content(), allow_short) {
None => passed.push(d),
Some(reason) => rejected.push(Rejected { id: d.id, reason }),
}
}
(passed, rejected)
}
pub(crate) fn probe_stores(data_dir: &Path) -> Result<()> {
with_store_copy(data_dir, &std::env::temp_dir(), |s| s.load_drawer_ids())
.context("kg.redb failed a read-only probe; refusing to open it for writing")?;
let live = data_dir.join(INDEX_FILE);
if !live
.try_exists()
.with_context(|| format!("cannot stat {}", live.display()))?
{
return Ok(());
}
let scratch = tempfile::TempDir::with_prefix_in(SCRATCH_PREFIX, std::env::temp_dir())
.context("create scratch dir for the vector index probe")?;
let copy = scratch.path().join(INDEX_FILE);
std::fs::copy(&live, ©).with_context(|| format!("copy {}", live.display()))?;
match std::panic::catch_unwind(AssertUnwindSafe(|| {
try_open_or_snapshot(©, OpenIntent::ReadOnlyClient).map(drop)
})) {
Ok(opened) => opened.with_context(|| {
format!(
"{} failed a read-only probe; refusing to open it for writing",
live.display()
)
}),
Err(_) => bail!("panic while probing a copy of {}", live.display()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackedUpFile {
pub name: String,
pub bytes: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Backup {
pub dir: PathBuf,
pub files: Vec<BackedUpFile>,
}
fn store_file_names() -> Vec<String> {
let mut names = vec![KG_FILE.to_string(), INDEX_FILE.to_string()];
names.extend(
COPIED_SUFFIXES
.iter()
.map(|s| format!("{LEGACY_KG_FILE}{s}")),
);
names
}
pub(crate) fn backup_stores(data_dir: &Path, parent: &Path, copy: CopyFn) -> Result<Backup> {
let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let tail = &Uuid::new_v4().simple().to_string()[..8];
let dir = parent.join(format!("{BACKUP_PREFIX}{stamp}-{tail}"));
std::fs::create_dir(&dir)
.with_context(|| format!("create backup dir {}; nothing was written", dir.display()))?;
match copy_and_verify(data_dir, &dir, copy) {
Ok(files) => Ok(Backup { dir, files }),
Err(e) => {
let e = match std::fs::remove_dir_all(&dir) {
Ok(()) => e,
Err(rm) => e.context(format!("partial backup left at {} ({rm})", dir.display())),
};
Err(e.context(format!(
"backup to {} failed; nothing was written",
dir.display()
)))
}
}
}
fn copy_and_verify(data_dir: &Path, dir: &Path, copy: CopyFn) -> Result<Vec<BackedUpFile>> {
let mut files = Vec::new();
for name in store_file_names() {
let src = data_dir.join(&name);
if !src
.try_exists()
.with_context(|| format!("cannot stat {}", src.display()))?
{
continue;
}
let dst = dir.join(&name);
copy(&src, &dst).with_context(|| format!("copy {}", src.display()))?;
std::fs::File::open(&dst)
.and_then(|f| f.sync_all())
.with_context(|| format!("sync {}", dst.display()))?;
let original = digest(&src)?;
let copied = digest(&dst)?;
if original != copied {
bail!(
"backup of {} does not match the original ({} bytes sha256={} vs {} bytes \
sha256={})",
src.display(),
original.0,
original.1,
copied.0,
copied.1
);
}
files.push(BackedUpFile {
name,
bytes: copied.0,
sha256: copied.1,
});
}
let manifest: String = files
.iter()
.map(|f| format!("{} {}\n", f.sha256, f.name))
.collect();
let path = dir.join(MANIFEST_FILE);
std::fs::write(&path, manifest).with_context(|| format!("write {}", path.display()))?;
for synced in [path.as_path(), dir] {
std::fs::File::open(synced)
.and_then(|f| f.sync_all())
.with_context(|| format!("sync {}", synced.display()))?;
}
Ok(files)
}
fn digest(path: &Path) -> Result<(u64, String)> {
let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
let len = std::io::copy(&mut file, &mut hasher)
.with_context(|| format!("read {}", path.display()))?;
Ok((len, format!("{:x}", hasher.finalize())))
}
pub(crate) fn render_guards(
dry_run: bool,
allow_short: bool,
rejected: &[Rejected],
backup: Option<&Backup>,
) -> String {
let count = |want: RejectReason| rejected.iter().filter(|r| r.reason == want).count();
let secret = count(RejectReason::Secret);
let short = count(RejectReason::Noise(TOO_SHORT));
let fate = if dry_run {
"--apply will not import them"
} else {
"not imported"
};
let mut out = format!(
" screen: rejected_secret={secret} rejected_noise={} (too_short={short}) (the \
memory_remember write gates; {fate}; content not shown)\n",
rejected.len() - secret
);
if allow_short {
out.push_str(
" --allow-short: the 8-token minimum was skipped; the secret, blocklist, \
word-count and noise-pattern gates still applied\n",
);
} else if short > 0 {
out.push_str(&format!(
" --allow-short would import these {short} too_short drawer(s) (fewer than 8 \
tokens; memory_note's rule), except any content duplicate, which \
--include-content-duplicates governs\n"
));
}
for r in rejected {
out.push_str(&format!(" rejected {}: {}\n", r.id, r.reason));
}
match backup {
Some(b) => {
out.push_str(&format!(" backup: {}\n", b.dir.display()));
for f in &b.files {
out.push_str(&format!(
" verified {} bytes={} sha256={}\n",
f.name, f.bytes, f.sha256
));
}
}
None if dry_run => out.push_str(
" backup: none (a dry run writes nothing; --apply backs up and verifies the \
store files first)\n",
),
None => {}
}
out
}