mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 13 : mémoire tenue par la façade (LOKI_MEM=rust)
La façade sert l'éditeur des pages mémoire (lecture, enregistrement, renommage, suppression, déplacement vers un autre projet, index MEMORY.md tenu par le code), le panneau des trackers, le mode mémoire du projet actif et les interrupteurs du mode agent, quand LOKI_MEM=rust (expérimental ; le Go reste le défaut). Les pages restent des fichiers memory/<projet>/*.md écrits de façon sûre (.bak, temporaire synchronisé puis renommé, relecture vérifiée), les trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et n'écrit jamais en clair quand la mémoire est verrouillée. gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF, erreurs du scanner et de type, structs anonymes) pour renvoyer au client les mêmes messages que le Go ; il servira aux lots suivants. Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go. Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ; parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de route avec le disque et la base après chacun) → tests/memory.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
20 files changed
+11944
-18
No files matched your search
@@ -38,6 +38,8 @@ pub struct Ctx {
|
||||
pub files: Option<std::sync::Arc<crate::convfiles::ConvFiles>>,
|
||||
/// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`).
|
||||
pub push: Option<std::sync::Arc<crate::push::Push>>,
|
||||
/// Mémoire tenue par la façade (`LOKI_MEM=rust`).
|
||||
pub mem: Option<std::sync::Arc<crate::memory::Memory>>,
|
||||
/// Jeton de l'API interne : une requête qui le porte vient du Go.
|
||||
pub internal_token: String,
|
||||
}
|
||||
@@ -52,6 +54,7 @@ impl Ctx {
|
||||
|| self.settings
|
||||
|| self.files.is_some()
|
||||
|| self.push.is_some()
|
||||
|| self.mem.is_some()
|
||||
}
|
||||
|
||||
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
//! Décodage JSON au comportement d'`encoding/json` du Go
|
||||
//! (`json.NewDecoder(r.Body).Decode(&req)`), pour les routes portées qui
|
||||
//! renvoient le message d'erreur du Go au client (`{"error": err.Error()}`).
|
||||
//!
|
||||
//! Ce qui est reproduit :
|
||||
//! - seule la PREMIÈRE valeur du corps compte (ce qui suit est ignoré) ;
|
||||
//! - corps vide → `EOF`, valeur tronquée → `unexpected EOF` ;
|
||||
//! - erreurs de syntaxe du scanner Go (`invalid character 'p' looking for
|
||||
//! beginning of value`, `… after object key`, `… in string literal`…) ;
|
||||
//! - `null` ne touche à rien ; noms de champs sans égard à la casse ;
|
||||
//! - erreurs de type (`json: cannot unmarshal number into Go struct field
|
||||
//! saveReq.name of type string`) : la première est rendue, les autres champs
|
||||
//! sont décodés quand même, comme le Go.
|
||||
|
||||
use serde::de::{Deserialize, Deserializer, MapAccess, Visitor};
|
||||
use serde_json::value::RawValue;
|
||||
use serde_json::{Map, Value};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// Type Go d'un champ de la struct cible.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// `string`
|
||||
Str,
|
||||
/// `bool`
|
||||
Bool,
|
||||
/// `int`
|
||||
Int,
|
||||
/// `[]string`
|
||||
StrList,
|
||||
/// `map[string]string`
|
||||
StrMap,
|
||||
/// `*string` : présent même vide, `null` = absent.
|
||||
OptStr,
|
||||
/// `*bool`
|
||||
OptBool,
|
||||
/// `any` / `json.RawMessage` : tout est accepté tel quel.
|
||||
Any,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn go_type(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Str => "string",
|
||||
Kind::Bool => "bool",
|
||||
Kind::Int => "int",
|
||||
Kind::StrList => "[]string",
|
||||
Kind::StrMap => "map[string]string",
|
||||
Kind::OptStr => "*string",
|
||||
Kind::OptBool => "*bool",
|
||||
Kind::Any => "interface {}",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Résultat d'un décodage : les champs posés (clé = nom JSON de la struct,
|
||||
/// valeur déjà au bon type) et la première erreur, comme `Decode` la rend.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Decoded {
|
||||
pub fields: Map<String, Value>,
|
||||
pub err: Option<String>,
|
||||
}
|
||||
|
||||
impl Decoded {
|
||||
/// Champ texte (`""` si absent).
|
||||
pub fn str(&self, name: &str) -> String {
|
||||
self.fields
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Champ `*string` : `None` si absent ou `null`.
|
||||
pub fn opt_str(&self, name: &str) -> Option<String> {
|
||||
self.fields
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Champ booléen (`false` si absent).
|
||||
pub fn bool(&self, name: &str) -> bool {
|
||||
self.fields
|
||||
.get(name)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Champ `*bool`.
|
||||
pub fn opt_bool(&self, name: &str) -> Option<bool> {
|
||||
self.fields.get(name).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
/// Champ entier (`0` si absent).
|
||||
pub fn int(&self, name: &str) -> i64 {
|
||||
self.fields.get(name).and_then(Value::as_i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Champ `[]string` (`None` = nil côté Go).
|
||||
pub fn str_list(&self, name: &str) -> Option<Vec<String>> {
|
||||
self.fields.get(name).and_then(Value::as_array).map(|a| {
|
||||
a.iter()
|
||||
.map(|v| v.as_str().unwrap_or("").to_string())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Champ `map[string]string` (`None` = nil côté Go).
|
||||
pub fn str_map(&self, name: &str) -> Option<Vec<(String, String)>> {
|
||||
self.fields.get(name).and_then(Value::as_object).map(|m| {
|
||||
m.iter()
|
||||
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `json.NewDecoder(body).Decode(&v)` avec `v` une struct Go dont les
|
||||
/// champs sont `fields` (nom JSON, type). `ty` : nom de la struct sans le
|
||||
/// paquet (`saveReq`), ou, pour une struct anonyme, son type complet tel que
|
||||
/// `reflect` l'écrit (`struct { On bool "json:\"on\"" }`).
|
||||
pub fn decode_struct(body: &[u8], ty: &str, fields: &[(&str, Kind)]) -> Decoded {
|
||||
let (prefix, full) = if ty.starts_with("struct {") {
|
||||
("", ty.to_string())
|
||||
} else {
|
||||
(ty, format!("loki.{ty}"))
|
||||
};
|
||||
let ty = prefix;
|
||||
let mut out = Decoded::default();
|
||||
let raw = match first_value(body) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
out.err = Some(e);
|
||||
return out;
|
||||
}
|
||||
};
|
||||
let first = raw.first().copied().unwrap_or(b' ');
|
||||
if first != b'{' {
|
||||
return match serde_json::from_slice::<Value>(raw) {
|
||||
Ok(Value::Null) => out,
|
||||
Ok(other) => {
|
||||
out.err = Some(format!(
|
||||
"json: cannot unmarshal {} into Go value of type {full}",
|
||||
kind_of(&other)
|
||||
));
|
||||
out
|
||||
}
|
||||
Err(e) => {
|
||||
out.err = Some(e.to_string());
|
||||
out
|
||||
}
|
||||
};
|
||||
}
|
||||
// Paires dans l'ordre du document (la dernière l'emporte, comme en Go),
|
||||
// valeurs brutes (le texte d'un nombre entre dans le message d'erreur).
|
||||
let obj = match serde_json::from_slice::<Pairs>(raw) {
|
||||
Ok(p) => p.0,
|
||||
Err(e) => {
|
||||
out.err = Some(e.to_string());
|
||||
return out;
|
||||
}
|
||||
};
|
||||
let fail = |out: &mut Decoded, what: String, field: &str, kind: Kind| {
|
||||
if out.err.is_none() {
|
||||
out.err = Some(format!(
|
||||
"json: cannot unmarshal {what} into Go struct field {ty}.{field} of type {}",
|
||||
kind.go_type()
|
||||
));
|
||||
}
|
||||
};
|
||||
for (k, rawv) in obj {
|
||||
let Ok(v) = serde_json::from_str::<Value>(rawv.get()) else {
|
||||
continue;
|
||||
};
|
||||
// Correspondance exacte d'abord, puis sans égard à la casse.
|
||||
let f = fields
|
||||
.iter()
|
||||
.find(|(n, _)| *n == k)
|
||||
.or_else(|| fields.iter().find(|(n, _)| n.eq_ignore_ascii_case(&k)));
|
||||
let Some(&(name, kind)) = f else { continue };
|
||||
if v.is_null() {
|
||||
if matches!(kind, Kind::OptStr | Kind::OptBool) {
|
||||
out.fields.remove(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match kind {
|
||||
Kind::Str | Kind::OptStr => match v {
|
||||
Value::String(_) => {
|
||||
out.fields.insert(name.into(), v);
|
||||
}
|
||||
other => {
|
||||
fail(&mut out, kind_of(&other).into(), name, Kind::Str);
|
||||
// Un pointeur est alloué avant l'échec : il reste posé.
|
||||
if kind == Kind::OptStr {
|
||||
out.fields
|
||||
.entry(name)
|
||||
.or_insert(Value::String(String::new()));
|
||||
}
|
||||
}
|
||||
},
|
||||
Kind::Bool | Kind::OptBool => match v {
|
||||
Value::Bool(_) => {
|
||||
out.fields.insert(name.into(), v);
|
||||
}
|
||||
other => {
|
||||
fail(&mut out, kind_of(&other).into(), name, Kind::Bool);
|
||||
if kind == Kind::OptBool {
|
||||
out.fields.entry(name).or_insert(Value::Bool(false));
|
||||
}
|
||||
}
|
||||
},
|
||||
Kind::Int => match &v {
|
||||
Value::Number(n) => match n.as_i64() {
|
||||
Some(_) => {
|
||||
out.fields.insert(name.into(), v);
|
||||
}
|
||||
None => fail(&mut out, format!("number {}", rawv.get()), name, kind),
|
||||
},
|
||||
other => fail(&mut out, kind_of(other).into(), name, kind),
|
||||
},
|
||||
Kind::StrList => match v {
|
||||
Value::Array(a) => {
|
||||
let mut list = Vec::with_capacity(a.len());
|
||||
for e in a {
|
||||
match e {
|
||||
Value::String(s) => list.push(Value::String(s)),
|
||||
Value::Null => list.push(Value::String(String::new())),
|
||||
other => {
|
||||
fail(&mut out, kind_of(&other).into(), name, Kind::Str);
|
||||
list.push(Value::String(String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.fields.insert(name.into(), Value::Array(list));
|
||||
}
|
||||
other => fail(&mut out, kind_of(&other).into(), name, kind),
|
||||
},
|
||||
Kind::StrMap => match v {
|
||||
Value::Object(m) => {
|
||||
let mut dst = match out.fields.remove(name) {
|
||||
Some(Value::Object(d)) => d,
|
||||
_ => Map::new(),
|
||||
};
|
||||
for (mk, mv) in m {
|
||||
match mv {
|
||||
Value::String(s) => {
|
||||
dst.insert(mk, Value::String(s));
|
||||
}
|
||||
Value::Null => {
|
||||
dst.insert(mk, Value::String(String::new()));
|
||||
}
|
||||
other => {
|
||||
fail(&mut out, kind_of(&other).into(), name, Kind::Str);
|
||||
dst.insert(mk, Value::String(String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.fields.insert(name.into(), Value::Object(dst));
|
||||
}
|
||||
other => fail(&mut out, kind_of(&other).into(), name, kind),
|
||||
},
|
||||
Kind::Any => {
|
||||
out.fields.insert(name.into(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Objet JSON en paires ordonnées, valeurs brutes.
|
||||
struct Pairs<'a>(Vec<(String, &'a RawValue)>);
|
||||
|
||||
impl<'de: 'a, 'a> Deserialize<'de> for Pairs<'a> {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct V<'a>(PhantomData<&'a ()>);
|
||||
impl<'de: 'a, 'a> Visitor<'de> for V<'a> {
|
||||
type Value = Pairs<'a>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("un objet JSON")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut m: A) -> Result<Pairs<'a>, A::Error> {
|
||||
let mut v = Vec::new();
|
||||
while let Some((k, val)) = m.next_entry::<String, &'a RawValue>()? {
|
||||
v.push((k, val));
|
||||
}
|
||||
Ok(Pairs(v))
|
||||
}
|
||||
}
|
||||
d.deserialize_map(V(PhantomData))
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_of(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
/// `quoteChar` du scanner Go.
|
||||
fn quote_char(c: u8) -> String {
|
||||
match c {
|
||||
b'\'' => r"'\''".into(),
|
||||
b'"' => "'\"'".into(),
|
||||
_ => {
|
||||
let ch = c as char; // octet → U+00XX, comme string(byte) en Go
|
||||
let inner = match ch {
|
||||
'\x07' => r"\a".to_string(),
|
||||
'\x08' => r"\b".to_string(),
|
||||
'\x0c' => r"\f".to_string(),
|
||||
'\n' => r"\n".to_string(),
|
||||
'\r' => r"\r".to_string(),
|
||||
'\t' => r"\t".to_string(),
|
||||
'\x0b' => r"\v".to_string(),
|
||||
'\\' => r"\\".to_string(),
|
||||
c if (c as u32) < 0x20 || c as u32 == 0x7f => format!(r"\x{:02x}", c as u32),
|
||||
c if (0x80..0xa1).contains(&(c as u32)) || c as u32 == 0xad => {
|
||||
format!(r"\u{:04x}", c as u32)
|
||||
}
|
||||
c => c.to_string(),
|
||||
};
|
||||
format!("'{inner}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn syntax(c: u8, ctx: &str) -> String {
|
||||
format!("invalid character {} {ctx}", quote_char(c))
|
||||
}
|
||||
|
||||
fn is_space(c: u8) -> bool {
|
||||
matches!(c, b' ' | b'\t' | b'\r' | b'\n')
|
||||
}
|
||||
|
||||
/// Première valeur JSON du corps, validée par le scanner du Go
|
||||
/// (`Decoder.readValue`) : la tranche de la valeur, ou l'erreur du Go.
|
||||
pub fn first_value(body: &[u8]) -> Result<&[u8], String> {
|
||||
let mut i = 0;
|
||||
while i < body.len() && is_space(body[i]) {
|
||||
i += 1;
|
||||
}
|
||||
if i == body.len() {
|
||||
return Err("EOF".into());
|
||||
}
|
||||
let start = i;
|
||||
let mut p = Scan {
|
||||
b: body,
|
||||
i,
|
||||
depth: 0,
|
||||
};
|
||||
p.value()?;
|
||||
Ok(&body[start..p.i])
|
||||
}
|
||||
|
||||
struct Scan<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
const MAX_DEPTH: usize = 10000;
|
||||
|
||||
impl Scan<'_> {
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.b.get(self.i).copied()
|
||||
}
|
||||
|
||||
fn eof() -> String {
|
||||
"unexpected EOF".into()
|
||||
}
|
||||
|
||||
fn skip_ws(&mut self) {
|
||||
while let Some(c) = self.peek() {
|
||||
if !is_space(c) {
|
||||
break;
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn value(&mut self) -> Result<(), String> {
|
||||
self.skip_ws();
|
||||
let c = self.peek().ok_or_else(Self::eof)?;
|
||||
match c {
|
||||
b'{' => self.object(),
|
||||
b'[' => self.array(),
|
||||
b'"' => self.string(),
|
||||
b'-' | b'0'..=b'9' => self.number(),
|
||||
b't' => self.literal(b"true", "true"),
|
||||
b'f' => self.literal(b"false", "false"),
|
||||
b'n' => self.literal(b"null", "null"),
|
||||
_ => Err(syntax(c, "looking for beginning of value")),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self) -> Result<(), String> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH {
|
||||
return Err("exceeded max depth".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn object(&mut self) -> Result<(), String> {
|
||||
self.push()?;
|
||||
self.i += 1; // {
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
self.depth -= 1;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b'"') => self.string()?,
|
||||
Some(c) => return Err(syntax(c, "looking for beginning of object key string")),
|
||||
}
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b':') => self.i += 1,
|
||||
Some(c) => return Err(syntax(c, "after object key")),
|
||||
}
|
||||
self.value()?;
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
self.depth -= 1;
|
||||
return Ok(());
|
||||
}
|
||||
Some(c) => return Err(syntax(c, "after object key:value pair")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn array(&mut self) -> Result<(), String> {
|
||||
self.push()?;
|
||||
self.i += 1; // [
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
self.depth -= 1;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
loop {
|
||||
self.value()?;
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
self.depth -= 1;
|
||||
return Ok(());
|
||||
}
|
||||
Some(c) => return Err(syntax(c, "after array element")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<(), String> {
|
||||
self.i += 1; // "
|
||||
loop {
|
||||
let c = self.peek().ok_or_else(Self::eof)?;
|
||||
self.i += 1;
|
||||
match c {
|
||||
b'"' => return Ok(()),
|
||||
b'\\' => {
|
||||
let e = self.peek().ok_or_else(Self::eof)?;
|
||||
self.i += 1;
|
||||
match e {
|
||||
b'b' | b'f' | b'n' | b'r' | b't' | b'\\' | b'/' | b'"' => {}
|
||||
b'u' => {
|
||||
for _ in 0..4 {
|
||||
let h = self.peek().ok_or_else(Self::eof)?;
|
||||
if !h.is_ascii_hexdigit() {
|
||||
return Err(syntax(h, "in \\u hexadecimal character escape"));
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
_ => return Err(syntax(e, "in string escape code")),
|
||||
}
|
||||
}
|
||||
c if c < 0x20 => return Err(syntax(c, "in string literal")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn digits(&mut self) {
|
||||
while matches!(self.peek(), Some(b'0'..=b'9')) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn number(&mut self) -> Result<(), String> {
|
||||
if self.peek() == Some(b'-') {
|
||||
self.i += 1;
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b'0'..=b'9') => {}
|
||||
Some(c) => return Err(syntax(c, "in numeric literal")),
|
||||
}
|
||||
}
|
||||
if self.peek() == Some(b'0') {
|
||||
self.i += 1;
|
||||
} else {
|
||||
self.digits();
|
||||
}
|
||||
if self.peek() == Some(b'.') {
|
||||
self.i += 1;
|
||||
match self.peek() {
|
||||
// Valeur de tête tronquée : le Go la complète d'un espace,
|
||||
// qui échoue ici.
|
||||
None => return Err(Self::eof()),
|
||||
Some(b'0'..=b'9') => self.digits(),
|
||||
Some(c) => return Err(syntax(c, "after decimal point in numeric literal")),
|
||||
}
|
||||
}
|
||||
if matches!(self.peek(), Some(b'e' | b'E')) {
|
||||
self.i += 1;
|
||||
if matches!(self.peek(), Some(b'+' | b'-')) {
|
||||
self.i += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(b'0'..=b'9') => self.digits(),
|
||||
Some(c) => return Err(syntax(c, "in exponent of numeric literal")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn literal(&mut self, word: &[u8], name: &str) -> Result<(), String> {
|
||||
self.i += 1;
|
||||
for &want in &word[1..] {
|
||||
match self.peek() {
|
||||
None => return Err(Self::eof()),
|
||||
Some(c) if c == want => self.i += 1,
|
||||
Some(c) => {
|
||||
return Err(syntax(
|
||||
c,
|
||||
&format!("in literal {name} (expecting {})", quote_char(want)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `json.Marshal` du Go : comme serde_json, avec l'échappement HTML du Go
|
||||
/// (`<`, `>`, `&`, U+2028, U+2029), pour écrire en base les mêmes octets.
|
||||
pub fn marshal<T: serde::Serialize + ?Sized>(v: &T) -> String {
|
||||
let s = serde_json::to_string(v).unwrap_or_default();
|
||||
if !s.contains(['<', '>', '&', '\u{2028}', '\u{2029}']) {
|
||||
return s;
|
||||
}
|
||||
let mut out = String::with_capacity(s.len() + 16);
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'<' => out.push_str("\\u003c"),
|
||||
'>' => out.push_str("\\u003e"),
|
||||
'&' => out.push_str("\\u0026"),
|
||||
'\u{2028}' => out.push_str("\\u2028"),
|
||||
'\u{2029}' => out.push_str("\\u2029"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `unicode.IsPrint` (approché : catégories de contrôle, de format, privées,
|
||||
/// séparateurs autres que l'espace ASCII → non imprimables).
|
||||
pub fn is_print(c: char) -> bool {
|
||||
let u = c as u32;
|
||||
if c == ' ' {
|
||||
return true;
|
||||
}
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
!matches!(u,
|
||||
0xAD | 0x600..=0x605 | 0x61C | 0x6DD | 0x70F | 0x890..=0x891 | 0x8E2 | 0x180E
|
||||
| 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F
|
||||
| 0xFEFF | 0xFFF9..=0xFFFB | 0xE000..=0xF8FF | 0xFFFE | 0xFFFF
|
||||
| 0x110BD | 0x110CD | 0x13430..=0x1343F | 0x1BCA0..=0x1BCA3
|
||||
| 0x1D173..=0x1D17A | 0xE0001 | 0xE0020..=0xE007F
|
||||
| 0xF0000..=0x10FFFF)
|
||||
}
|
||||
|
||||
/// `strconv.Quote` du Go (`%q` des messages d'erreur).
|
||||
pub fn quote(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\x07' => out.push_str("\\a"),
|
||||
'\x08' => out.push_str("\\b"),
|
||||
'\x0c' => out.push_str("\\f"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\x0b' => out.push_str("\\v"),
|
||||
c if is_print(c) => out.push(c),
|
||||
c if (c as u32) < 0x80 => out.push_str(&format!("\\x{:02x}", c as u32)),
|
||||
c if (c as u32) < 0x10000 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push_str(&format!("\\U{:08x}", c as u32)),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const T: &[(&str, Kind)] = &[("name", Kind::Str), ("on", Kind::Bool)];
|
||||
|
||||
#[test]
|
||||
fn erreurs_du_scanner() {
|
||||
let e = |b: &str| decode_struct(b.as_bytes(), "t", T).err.unwrap_or_default();
|
||||
assert_eq!(e(""), "EOF");
|
||||
assert_eq!(e(" \n"), "EOF");
|
||||
assert_eq!(
|
||||
e("pas du json"),
|
||||
"invalid character 'p' looking for beginning of value"
|
||||
);
|
||||
assert_eq!(e("{\"name\""), "unexpected EOF");
|
||||
assert_eq!(e("{\"name\" 1}"), "invalid character '1' after object key");
|
||||
assert_eq!(
|
||||
e("{'a':1}"),
|
||||
r"invalid character '\'' looking for beginning of object key string"
|
||||
);
|
||||
assert_eq!(e("tru"), "unexpected EOF");
|
||||
assert_eq!(
|
||||
e("trux"),
|
||||
"invalid character 'x' in literal true (expecting 'e')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premiere_valeur_seulement() {
|
||||
let d = decode_struct(br#"{"name":"a"} et la suite"#, "t", T);
|
||||
assert_eq!(d.err, None);
|
||||
assert_eq!(d.str("name"), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erreurs_de_type_et_casse() {
|
||||
let d = decode_struct(br#"{"NAME":"x","on":"oui"}"#, "saveReq", T);
|
||||
assert_eq!(d.str("name"), "x");
|
||||
assert_eq!(
|
||||
d.err.as_deref(),
|
||||
Some("json: cannot unmarshal string into Go struct field saveReq.on of type bool")
|
||||
);
|
||||
let d = decode_struct(b"[1]", "saveReq", T);
|
||||
assert_eq!(
|
||||
d.err.as_deref(),
|
||||
Some("json: cannot unmarshal array into Go value of type loki.saveReq")
|
||||
);
|
||||
assert!(decode_struct(b"null", "t", T).err.is_none());
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ pub mod downloads;
|
||||
pub mod engine;
|
||||
pub mod engstatus;
|
||||
pub mod gguf;
|
||||
pub mod gojson;
|
||||
pub mod hf;
|
||||
pub mod memory;
|
||||
pub mod models;
|
||||
pub mod oai;
|
||||
pub mod paths;
|
||||
|
||||
+19
-18
@@ -133,26 +133,25 @@ fn serve() -> ExitCode {
|
||||
// LOKI_PUSH=rust : les notifications Web Push (le Go lui délègue ses
|
||||
// envois de fin de tour et de fin de tâche).
|
||||
let push_rust = held_by_facade("LOKI_PUSH");
|
||||
// LOKI_MEM=rust : la mémoire (pages, index, trackers, mode mémoire,
|
||||
// interrupteurs du mode agent).
|
||||
let mem_rust = held_by_facade("LOKI_MEM");
|
||||
let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())];
|
||||
if chat_rust {
|
||||
env.push(("LOKI_CHAT".into(), "rust".into()));
|
||||
let held = [
|
||||
("LOKI_CHAT", chat_rust),
|
||||
("LOKI_MODELS", models_rust),
|
||||
("LOKI_DICTATE", dictate_rust),
|
||||
("LOKI_SETTINGS", settings_rust),
|
||||
("LOKI_FILES", files_rust),
|
||||
("LOKI_PUSH", push_rust),
|
||||
("LOKI_MEM", mem_rust),
|
||||
];
|
||||
for (var, on) in held {
|
||||
if on {
|
||||
env.push((var.into(), "rust".into()));
|
||||
}
|
||||
}
|
||||
if models_rust {
|
||||
env.push(("LOKI_MODELS".into(), "rust".into()));
|
||||
}
|
||||
if dictate_rust {
|
||||
env.push(("LOKI_DICTATE".into(), "rust".into()));
|
||||
}
|
||||
if settings_rust {
|
||||
env.push(("LOKI_SETTINGS".into(), "rust".into()));
|
||||
}
|
||||
if files_rust {
|
||||
env.push(("LOKI_FILES".into(), "rust".into()));
|
||||
}
|
||||
if push_rust {
|
||||
env.push(("LOKI_PUSH".into(), "rust".into()));
|
||||
}
|
||||
if chat_rust || models_rust || dictate_rust || settings_rust || files_rust || push_rust {
|
||||
if held.iter().any(|(_, on)| *on) {
|
||||
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
|
||||
}
|
||||
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
||||
@@ -212,6 +211,7 @@ fn serve() -> ExitCode {
|
||||
}
|
||||
};
|
||||
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
|
||||
let mem_home = engine_paths.home.clone();
|
||||
let cfg = proxy::Config {
|
||||
backend,
|
||||
internal_token: token,
|
||||
@@ -223,6 +223,7 @@ fn serve() -> ExitCode {
|
||||
settings: settings_rust,
|
||||
files,
|
||||
push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())),
|
||||
mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))),
|
||||
};
|
||||
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
|
||||
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -75,6 +75,8 @@ pub struct Config {
|
||||
pub files: Option<Arc<crate::convfiles::ConvFiles>>,
|
||||
/// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`).
|
||||
pub push: Option<Arc<crate::push::Push>>,
|
||||
/// Mémoire (pages, trackers, mode) tenue par la façade (`LOKI_MEM=rust`).
|
||||
pub mem: Option<Arc<crate::memory::Memory>>,
|
||||
}
|
||||
|
||||
struct Proxy {
|
||||
@@ -107,6 +109,7 @@ pub async fn serve(
|
||||
settings: cfg.settings,
|
||||
files: cfg.files,
|
||||
push: cfg.push,
|
||||
mem: cfg.mem,
|
||||
internal_token: cfg.internal_token.clone(),
|
||||
},
|
||||
});
|
||||
@@ -194,6 +197,11 @@ impl Proxy {
|
||||
{
|
||||
return chatapi::serve(&self.ctx, chat, req, from_go).await;
|
||||
}
|
||||
if let Some(m) = &self.ctx.mem
|
||||
&& crate::memory::handles(path)
|
||||
{
|
||||
return crate::memory::serve(&self.ctx, m, req, from_go).await;
|
||||
}
|
||||
if let Some(p) = &self.ctx.push
|
||||
&& crate::push::handles(path)
|
||||
{
|
||||
|
||||
@@ -16,6 +16,18 @@ use tokio::sync::oneshot;
|
||||
|
||||
pub const JETON: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// Scellement simulé par le faux Go.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CryptMode {
|
||||
/// « SCELLE: » + clair (historique des essais de la conversation).
|
||||
#[default]
|
||||
Scelle,
|
||||
/// Chiffrement coupé : rien ne change.
|
||||
Plain,
|
||||
/// Magic des pages mémoire (`LOKIMEMv1`) + clair.
|
||||
Magic,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FakeGo {
|
||||
/// bucket → clé → valeur.
|
||||
@@ -62,6 +74,8 @@ pub struct FakeGo {
|
||||
pub turn_view: Mutex<Option<serde_json::Value>>,
|
||||
pub turn_verifier: Mutex<Option<serde_json::Value>>,
|
||||
pub tool_results: Mutex<HashMap<String, String>>,
|
||||
/// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut).
|
||||
pub crypt_mode: Mutex<CryptMode>,
|
||||
/// Réponse de /internal/v1/conv/workspace (None : 404).
|
||||
pub conv_workspace: Mutex<Option<serde_json::Value>>,
|
||||
}
|
||||
@@ -148,6 +162,24 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
return resp(423, r#"{"error":"mémoire verrouillée"}"#);
|
||||
}
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
match *st.crypt_mode.lock().unwrap() {
|
||||
// Chiffrement coupé : clair rendu tel quel.
|
||||
CryptMode::Plain => return resp(200, body),
|
||||
// Enveloppe au magic des pages (`LOKIMEMv1`).
|
||||
CryptMode::Magic => {
|
||||
return if path.ends_with("seal") {
|
||||
let mut out = b"LOKIMEMv1".to_vec();
|
||||
out.extend_from_slice(&body);
|
||||
resp(200, out)
|
||||
} else {
|
||||
resp(
|
||||
200,
|
||||
body.strip_prefix(b"LOKIMEMv1").unwrap_or(&body).to_vec(),
|
||||
)
|
||||
};
|
||||
}
|
||||
CryptMode::Scelle => {}
|
||||
}
|
||||
if path.ends_with("seal") {
|
||||
let mut out = b"SCELLE:".to_vec();
|
||||
out.extend_from_slice(&body);
|
||||
@@ -474,6 +506,7 @@ pub struct Opts {
|
||||
pub settings: bool,
|
||||
pub files: Option<Arc<loki_rs::convfiles::ConvFiles>>,
|
||||
pub push: Option<Arc<loki_rs::push::Push>>,
|
||||
pub mem: Option<Arc<loki_rs::memory::Memory>>,
|
||||
}
|
||||
|
||||
/// Comme [`start`], avec la conversation tenue par la façade
|
||||
@@ -568,6 +601,7 @@ pub async fn start_with(
|
||||
settings: opts.settings,
|
||||
files: opts.files,
|
||||
push: opts.push,
|
||||
mem: opts.mem,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Décodage JSON des requêtes au comportement d'`encoding/json` : le journal
|
||||
//! tiré du Go (`internal/loki/parity_gojson_test.go` →
|
||||
//! `tests/parity_gojson/decode.json`) est rejoué contre `gojson`.
|
||||
|
||||
use loki_rs::gojson::{Kind, decode_struct};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const FIELDS: &[(&str, Kind)] = &[
|
||||
("name", Kind::Str),
|
||||
("on", Kind::Bool),
|
||||
("n", Kind::Int),
|
||||
("args", Kind::StrList),
|
||||
("env", Kind::StrMap),
|
||||
("opt", Kind::OptStr),
|
||||
("flag", Kind::OptBool),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn parite_du_decodage() {
|
||||
let fx: Value =
|
||||
serde_json::from_str(include_str!("parity_gojson/decode.json")).expect("journal");
|
||||
for c in fx["cases"].as_array().unwrap() {
|
||||
let input = c["in"].as_str().unwrap();
|
||||
let d = decode_struct(input.as_bytes(), "parityDecodeT", FIELDS);
|
||||
let out = json!({
|
||||
"name": d.str("name"),
|
||||
"on": d.bool("on"),
|
||||
"n": d.int("n"),
|
||||
"args": d.str_list("args"),
|
||||
"env": d.str_map("env").map(|m| m.into_iter().map(|(k, v)| (k, Value::String(v))).collect::<serde_json::Map<_, _>>()),
|
||||
"opt": d.opt_str("opt"),
|
||||
"flag": d.opt_bool("flag"),
|
||||
});
|
||||
assert_eq!(out, c["out"], "{input:?} : champs");
|
||||
assert_eq!(
|
||||
d.err.as_deref().unwrap_or(""),
|
||||
c["err"].as_str().unwrap_or(""),
|
||||
"{input:?} : erreur"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ async fn banc(cle: Option<&str>) -> Banc {
|
||||
settings: false,
|
||||
files: None,
|
||||
push: None,
|
||||
mem: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Lot 13 : mémoire tenue par la façade (`LOKI_MEM=rust`).
|
||||
//!
|
||||
//! Le journal tiré du Go (`internal/loki/parity_mem_test.go` →
|
||||
//! `tests/parity_mem/mem.json`) est rejoué : noms et titres de pages, slugs,
|
||||
//! modes mémoire, dates des trackers (fuseau fixe UTC+2), puis la suite
|
||||
//! d'appels aux routes par la façade, avec après chacun le dossier memory/ et
|
||||
//! les clés touchées en base.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::fakego::{self, CryptMode};
|
||||
use loki_rs::memory::{self, Memory, Tz};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(include_str!("parity_mem/mem.json")).expect("journal")
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// Masque identifiants de points et instants « maintenant », dans l'ordre
|
||||
/// d'apparition (même règle que le test Go).
|
||||
#[derive(Default)]
|
||||
struct Masker {
|
||||
ids: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Masker {
|
||||
fn mask(&mut self, s: &str) -> String {
|
||||
let id_re = regex::Regex::new(r#""id":"(\d{15,})""#).unwrap();
|
||||
let mut out = String::new();
|
||||
let mut last = 0;
|
||||
for c in id_re.captures_iter(s) {
|
||||
let m = c.get(0).unwrap();
|
||||
let id = c[1].to_string();
|
||||
let ph = match self.ids.iter().find(|(i, _)| *i == id) {
|
||||
Some((_, p)) => p.clone(),
|
||||
None => {
|
||||
let p = format!("{{{{ID{}}}}}", self.ids.len() + 1);
|
||||
self.ids.push((id, p.clone()));
|
||||
p
|
||||
}
|
||||
};
|
||||
out.push_str(&s[last..m.start()]);
|
||||
out.push_str(&format!(r#""id":"{ph}""#));
|
||||
last = m.end();
|
||||
}
|
||||
out.push_str(&s[last..]);
|
||||
let now_re = regex::Regex::new(r#""(ts|first_ts|last_ts)":(\d{12,14})"#).unwrap();
|
||||
let now = now_ms();
|
||||
now_re
|
||||
.replace_all(&out, |c: ®ex::Captures| {
|
||||
let v: i64 = c[2].parse().unwrap();
|
||||
if (v - now).abs() < 86_400_000 {
|
||||
format!(r#""{}":"{{{{NOW}}}}""#, &c[1])
|
||||
} else {
|
||||
c[0].to_string()
|
||||
}
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn unmask(&self, s: &str) -> String {
|
||||
let mut s = s.to_string();
|
||||
for (id, ph) in &self.ids {
|
||||
s = s.replace(ph, id);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn canon(&mut self, v: &Value) -> Value {
|
||||
serde_json::from_str(&self.mask(&serde_json::to_string(v).unwrap())).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parite_des_fonctions_pures() {
|
||||
let fx = fixture();
|
||||
for c in fx["names"].as_array().unwrap() {
|
||||
let input = c["in"].as_str().unwrap();
|
||||
match memory::mem_file_name(input) {
|
||||
Ok(o) => assert_eq!(o, c["out"].as_str().unwrap_or("<erreur>"), "{input:?}"),
|
||||
Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or("<ok>"), "{input:?}"),
|
||||
}
|
||||
}
|
||||
for (input, want) in fx["titles"].as_object().unwrap() {
|
||||
assert_eq!(memory::title_of(input), want.as_str().unwrap(), "{input:?}");
|
||||
}
|
||||
for (input, want) in fx["slugs"].as_object().unwrap() {
|
||||
assert_eq!(
|
||||
memory::slugify_ident(input),
|
||||
want.as_str().unwrap(),
|
||||
"{input:?}"
|
||||
);
|
||||
}
|
||||
for (input, want) in fx["modes"].as_object().unwrap() {
|
||||
assert_eq!(
|
||||
memory::normalize_mem_mode(input),
|
||||
want.as_str().unwrap(),
|
||||
"{input:?}"
|
||||
);
|
||||
}
|
||||
for c in fx["when"].as_array().unwrap() {
|
||||
let input = c["in"].as_str().unwrap();
|
||||
let now = now_ms();
|
||||
match memory::parse_when(input, now, Tz::Fixed(7200)) {
|
||||
Ok((ts, date_only)) => {
|
||||
assert!(c.get("err").is_none(), "{input:?} : erreur attendue");
|
||||
let got = if (ts - now).abs() < 86_400_000 {
|
||||
json!("{{NOW}}")
|
||||
} else {
|
||||
json!(ts)
|
||||
};
|
||||
assert_eq!(got, c["ts"], "{input:?}");
|
||||
assert_eq!(
|
||||
date_only,
|
||||
c["date_only"].as_bool().unwrap_or(false),
|
||||
"{input:?}"
|
||||
);
|
||||
}
|
||||
Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or("<ok>"), "{input:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_store(go: &fakego::FakeGo, m: &serde_json::Map<String, Value>) {
|
||||
for (k, v) in m {
|
||||
let (b, key) = k.split_once('/').unwrap();
|
||||
let v = v.as_str().unwrap();
|
||||
let mut s = go.store.lock().unwrap();
|
||||
let bucket = s.entry(b.to_string()).or_default();
|
||||
if v.is_empty() {
|
||||
bucket.remove(key);
|
||||
} else {
|
||||
bucket.insert(key.to_string(), v.as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disk(root: &Path) -> BTreeMap<String, String> {
|
||||
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, String>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if e.file_type().unwrap().is_dir() {
|
||||
walk(root, &p, out);
|
||||
} else {
|
||||
let rel = p
|
||||
.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
out.insert(
|
||||
rel,
|
||||
String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(root, root, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn state(go: &fakego::FakeGo) -> Value {
|
||||
let get = |b: &str, k: &str| go.get(b, k).unwrap_or_default();
|
||||
let projects = go
|
||||
.get("state", "projects")
|
||||
.and_then(|p| serde_json::from_str::<Value>(&p).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let mut trackers = serde_json::Map::new();
|
||||
let mut raw = serde_json::Map::new();
|
||||
if let Some(b) = go.store.lock().unwrap().get("tracker") {
|
||||
for (k, v) in b {
|
||||
let s = String::from_utf8_lossy(v).into_owned();
|
||||
match serde_json::from_str::<Value>(&s) {
|
||||
Ok(x) => {
|
||||
trackers.insert(k.clone(), x);
|
||||
}
|
||||
Err(_) => {
|
||||
raw.insert(k.clone(), Value::String(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"agent": get("state", "agent"),
|
||||
"active": get("state", "active_project"),
|
||||
"compact": get("config", "COMPACT"),
|
||||
"mem_mode": get("config", "MEM_MODE"),
|
||||
"projects": projects,
|
||||
"trackers": trackers,
|
||||
"tracker_raw": raw,
|
||||
})
|
||||
}
|
||||
|
||||
async fn start(home: &Path, tz: Tz) -> (std::net::SocketAddr, Arc<fakego::FakeGo>, impl Drop) {
|
||||
let opts = fakego::Opts {
|
||||
mem: Some(Arc::new(Memory::with_tz(home.to_path_buf(), tz))),
|
||||
..Default::default()
|
||||
};
|
||||
let (facade, go, stop, _) = fakego::start_with(home.to_path_buf(), opts).await;
|
||||
struct Stop(Option<tokio::sync::oneshot::Sender<()>>);
|
||||
impl Drop for Stop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(s) = self.0.take() {
|
||||
let _ = s.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
(facade, go, Stop(Some(stop)))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parite_des_routes() {
|
||||
let fx = fixture();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().to_path_buf();
|
||||
let (facade, go, _stop) = start(&home, Tz::Fixed(7200)).await;
|
||||
*go.crypt_mode.lock().unwrap() = CryptMode::Plain;
|
||||
apply_store(&go, fx["seed"].as_object().unwrap());
|
||||
let root = home.join("memory");
|
||||
let mut mk = Masker::default();
|
||||
for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() {
|
||||
if let Some(files) = st.get("files").and_then(Value::as_object) {
|
||||
for (rel, content) in files {
|
||||
let p = root.join(rel);
|
||||
let content = content.as_str().unwrap();
|
||||
if content.is_empty() {
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
} else if rel.ends_with('/') {
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
} else {
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, content).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = st.get("store").and_then(Value::as_object) {
|
||||
apply_store(&go, s);
|
||||
}
|
||||
let (m, p) = (st["method"].as_str().unwrap(), st["path"].as_str().unwrap());
|
||||
let body = st.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
let (path, body) = (mk.unmask(p), mk.unmask(body));
|
||||
let (code, b) = fakego::send(facade, m, &path, &[], &body).await;
|
||||
let ctx = format!("étape {i} : {m} {p} {body}");
|
||||
assert_eq!(
|
||||
u64::from(code.as_u16()),
|
||||
st["status"].as_u64().unwrap(),
|
||||
"{ctx} : {}",
|
||||
String::from_utf8_lossy(&b)
|
||||
);
|
||||
let got: Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(mk.canon(&got), st["out"], "{ctx}");
|
||||
let want_disk: BTreeMap<String, String> =
|
||||
serde_json::from_value(st["disk"].clone()).unwrap();
|
||||
assert_eq!(disk(&root), want_disk, "{ctx} (disque)");
|
||||
assert_eq!(mk.canon(&state(&go)), st["state"], "{ctx} (base)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages chiffrées : ouvertes par le Go, écrites scellées ; mémoire
|
||||
/// verrouillée : listées sans contenu, jamais réécrites en clair.
|
||||
#[tokio::test]
|
||||
async fn pages_et_trackers_chiffres() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().to_path_buf();
|
||||
let (facade, go, _stop) = start(&home, Tz::Fixed(0)).await;
|
||||
*go.crypt_mode.lock().unwrap() = CryptMode::Magic;
|
||||
let dir = home.join("memory").join("generale");
|
||||
let (code, _) = fakego::send(
|
||||
facade,
|
||||
"POST",
|
||||
"/api/mem/save",
|
||||
&[],
|
||||
r##"{"name":"secret","content":"# Secret\nmot de passe"}"##,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 200);
|
||||
let raw = std::fs::read(dir.join("secret.md")).unwrap();
|
||||
assert_eq!(raw, b"LOKIMEMv1# Secret\nmot de passe\n");
|
||||
// L'index, lui, reste en clair (comme le Go).
|
||||
let idx = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap();
|
||||
assert!(idx.ends_with("- [Secret](secret.md)\n"), "{idx}");
|
||||
let (_, b) = fakego::send(facade, "GET", "/api/mem?name=secret", &[], "").await;
|
||||
let v: Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["content"], "# Secret\nmot de passe\n");
|
||||
let (_, b) = fakego::send(
|
||||
facade,
|
||||
"POST",
|
||||
"/api/tracker",
|
||||
&[],
|
||||
r#"{"action":"add","name":"Tension","when":"2024-01-02","text":"12/8"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(serde_json::from_slice::<Value>(&b).unwrap()["ok"], true);
|
||||
let sealed = go.get("tracker", "generale/tension").unwrap();
|
||||
assert!(sealed.starts_with("LOKIMEMv1{"), "{sealed}");
|
||||
|
||||
// Verrouillée.
|
||||
*go.locked.lock().unwrap() = true;
|
||||
let (_, b) = fakego::send(facade, "GET", "/api/agent", &[], "").await;
|
||||
let v: Value = serde_json::from_slice(&b).unwrap();
|
||||
let pages = v["pages"].as_array().unwrap();
|
||||
let secret = pages.iter().find(|p| p["name"] == "secret.md").unwrap();
|
||||
assert_eq!(secret["desc"], "🔒 (chiffré — mémoire verrouillée)");
|
||||
let (code, _) = fakego::send(facade, "GET", "/api/mem?name=secret", &[], "").await;
|
||||
assert_eq!(code, 404);
|
||||
let (code, b) = fakego::send(
|
||||
facade,
|
||||
"POST",
|
||||
"/api/mem/save",
|
||||
&[],
|
||||
r#"{"name":"secret","content":"écrasé"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 400);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&b).unwrap()["error"],
|
||||
"page chiffrée, mémoire verrouillée"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join("secret.md")).unwrap(),
|
||||
raw,
|
||||
"page réécrite alors que la mémoire est verrouillée"
|
||||
);
|
||||
let (code, b) = fakego::send(
|
||||
facade,
|
||||
"POST",
|
||||
"/api/tracker",
|
||||
&[],
|
||||
r#"{"action":"add","name":"Autre","text":"x"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 400);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&b).unwrap()["error"],
|
||||
"valeur chiffrée, mémoire verrouillée"
|
||||
);
|
||||
// Les trackers scellés ne sont plus lisibles : la liste les tait.
|
||||
let (_, b) = fakego::send(facade, "GET", "/api/tracker", &[], "").await;
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&b).unwrap()["trackers"],
|
||||
json!([])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sans_la_variable_la_memoire_reste_au_go() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
|
||||
for p in [
|
||||
"/api/mem",
|
||||
"/api/mem/save",
|
||||
"/api/mem/move",
|
||||
"/api/agent",
|
||||
"/api/skills/toggle",
|
||||
"/api/memory",
|
||||
"/api/tracker",
|
||||
] {
|
||||
let (_, _, b) = fakego::get(facade, p, &[]).await;
|
||||
assert_eq!(&b[..], format!("go:{p}").as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Le coffre, les instantanés et les projets restent au Go même avec la
|
||||
/// variable.
|
||||
#[tokio::test]
|
||||
async fn coffre_et_projets_restent_au_go() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (facade, _go, _stop) = start(tmp.path(), Tz::Local).await;
|
||||
for p in [
|
||||
"/api/mem/health",
|
||||
"/api/mem/unlock",
|
||||
"/api/mem/snapshots",
|
||||
"/api/projects",
|
||||
"/api/backup/export",
|
||||
] {
|
||||
let (_, _, b) = fakego::get(facade, p, &[]).await;
|
||||
assert_eq!(&b[..], format!("go:{p}").as_bytes());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -105,6 +105,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
|
||||
settings: false,
|
||||
files: None,
|
||||
push: None,
|
||||
mem: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
Reference in new issue
Block a user