mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 04:36:44 +02:00
Les fichiers circulent enfin dans les deux sens. Envoi : trombone dans le composeur, glisser-deposer sur toute la fenetre, ou Ctrl+V. Le fichier est depose dans uploads/ du dossier de travail de l'agent et son chemin est annonce au modele, qui en fait ce qu'il veut avec ses outils. Rien n'est interprete au passage, donc tout type de fichier passe. Le depot n'a lieu qu'a l'ENVOI du message : retirer un fichier de la liste ne laisse aucune trace sur le disque. Transport en base64 dans du JSON et non en multipart, seule forme que le tunnel E2E dispatche : l'acces distant marche sans code specifique. Reception : le modele ecrit un lien Markdown ordinaire vers un fichier de son dossier de travail, et le clic telecharge. Il repondait jusqu'ici par un chemin serveur, inutilisable depuis un navigateur. Le clic passe par fetch + blob (la cle de pilotage voyage dans un en-tete qu'une navigation ne porterait pas, et le chemin de base change derriere le tunnel). Markdown refusant les espaces non echappes dans une cible de lien, ils sont encodes avant rendu : sans ca [x](mon rapport.pdf) ne produisait rien du tout. Perimetre du telechargement strictement limite au dossier de travail : '..', chemins absolus et liens symboliques qui en sortent sont refuses, et le fichier est toujours servi en piece jointe avec nosniff — un .html produit par le modele ne doit pas s'executer dans l'origine de l'UI, ou il lirait la cle de pilotage. Preambule du mode agent : ~2350 -> ~1770 tokens, sans perdre une regle. Le poids etait dans les schemas d'outils (le double du prompt), pas dans le prompt. Celui-ci reenumerait des outils que les schemas decrivent deja, et la regle « write, jamais echo » y figurait trois fois. Trois tests gardent le budget : ce prompt a deja regrossi ligne par ligne deux fois. Aussi : l'export mentionne les pieces jointes (un message sans texte donnait une section vide), et le decompte de contexte perd le mot « contexte » au profit du nom du modele.
165 lines
5.5 KiB
Go
165 lines
5.5 KiB
Go
package ajean
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Le nom vient du client : aucun d'eux ne doit permettre d'écrire hors du
|
|
// dossier de dépôt.
|
|
func TestSafeUploadNameNeverEscapes(t *testing.T) {
|
|
for _, in := range []string{
|
|
"../../evil.sh", `..\..\evil.sh`, "/etc/passwd", `C:\Windows\system32\x.dll`,
|
|
"..", ".", "", " ", "a\x00b", "nul\nname",
|
|
} {
|
|
got := safeUploadName(in)
|
|
if got == "" || got == "." || got == ".." {
|
|
t.Fatalf("%q → %q : nom inutilisable", in, got)
|
|
}
|
|
if strings.ContainsAny(got, `/\`) {
|
|
t.Fatalf("%q → %q : contient un séparateur de chemin", in, got)
|
|
}
|
|
if filepath.Base(got) != got {
|
|
t.Fatalf("%q → %q : n'est pas un nom simple", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSafeUploadNameKeepsOrdinaryNames(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"rapport.pdf": "rapport.pdf",
|
|
"Compte rendu (final).odt": "Compte rendu (final).odt",
|
|
"données-été.csv": "données-été.csv",
|
|
`C:\Users\x\notes.md`: "notes.md",
|
|
} {
|
|
if got := safeUploadName(in); got != want {
|
|
t.Fatalf("safeUploadName(%q) = %q, attendu %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "../notes.txt",
|
|
"data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")),
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body))))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("code %d : %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp struct {
|
|
OK bool `json:"ok"`
|
|
Path string `json:"path"`
|
|
Abs string `json:"abs"`
|
|
Size int `json:"size"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(resp.Abs)
|
|
if !resp.OK || resp.Size != 7 {
|
|
t.Fatalf("réponse inattendue: %+v", resp)
|
|
}
|
|
// Le fichier atterrit DANS uploads/, malgré le "../" du nom.
|
|
dir, err := uploadsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if filepath.Dir(resp.Abs) != dir {
|
|
t.Fatalf("écrit hors du dossier de dépôt : %s", resp.Abs)
|
|
}
|
|
if b, err := os.ReadFile(resp.Abs); err != nil || string(b) != "bonjour" {
|
|
t.Fatalf("contenu = %q, err=%v", b, err)
|
|
}
|
|
// Le chemin renvoyé est celui que le client rejoue dans /api/chat/send.
|
|
got := attachFiles([]string{resp.Path})
|
|
if len(got) != 1 || got[0].Path != resp.Path || got[0].Size != 7 {
|
|
t.Fatalf("attachFiles = %+v", got)
|
|
}
|
|
if note := attachNote(got); !strings.Contains(note, resp.Path) {
|
|
t.Fatalf("note sans le fichier : %q", note)
|
|
}
|
|
// Un chemin inventé par le client n'atteint jamais le modèle.
|
|
if n := attachFiles([]string{"uploads/jamais-deposé.bin", "../../etc/passwd"}); len(n) != 0 {
|
|
t.Fatalf("fichiers absents retenus : %+v", n)
|
|
}
|
|
if attachNote(nil) != "" {
|
|
t.Fatal("note non vide sans pièce jointe")
|
|
}
|
|
}
|
|
|
|
// Le téléchargement est le point sensible : il lit des fichiers du serveur à
|
|
// partir d'un chemin fourni par le client.
|
|
func TestChatFileStaysInsideWorkspace(t *testing.T) {
|
|
dir, err := uploadsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := filepath.Join(dir, "produit.txt")
|
|
if err := os.WriteFile(p, []byte("resultat"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(p)
|
|
|
|
rec := httptest.NewRecorder()
|
|
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path=uploads/produit.txt", nil))
|
|
if rec.Code != 200 || rec.Body.String() != "resultat" {
|
|
t.Fatalf("code %d, corps %q", rec.Code, rec.Body.String())
|
|
}
|
|
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment;") {
|
|
t.Fatalf("pas en pièce jointe : %q", cd)
|
|
}
|
|
// Un .html produit par le modèle ne doit jamais s'exécuter dans l'origine de
|
|
// l'UI : il y lirait la clé de pilotage en localStorage.
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
|
|
t.Fatalf("Content-Type = %q", ct)
|
|
}
|
|
|
|
// Le chemin vient du texte du modèle : il peut pointer n'importe où.
|
|
for _, bad := range []string{
|
|
"../ajean.db", "../../../etc/passwd", `..\..\secret`,
|
|
"/etc/passwd", "C:/Windows/win.ini", "", "uploads/absent.txt",
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(bad), nil))
|
|
if rec.Code == 200 {
|
|
t.Fatalf("chemin %q servi (code 200)", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChatUploadRejectsEmptyAndBadBase64(t *testing.T) {
|
|
for _, data := range []string{"", "pas du base64 !!"} {
|
|
body, _ := json.Marshal(map[string]any{"name": "x.bin", "data": data})
|
|
rec := httptest.NewRecorder()
|
|
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body))))
|
|
if rec.Code != 400 {
|
|
t.Fatalf("data=%q : code %d, attendu 400", data, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// L'export doit garder la trace des pièces jointes : un tour envoyé SANS texte
|
|
// donnait sinon une section « Vous » vide, sans dire qu'un fichier était passé.
|
|
func TestExportFileNamesBothForms(t *testing.T) {
|
|
live := []attachInfo{{Name: "photo.jpg", Path: "uploads/photo.jpg", Size: 10}}
|
|
if got := exportFileNames(live); len(got) != 1 || got[0] != "photo.jpg" {
|
|
t.Fatalf("forme vivante : %v", got)
|
|
}
|
|
// Forme relue du journal persisté (JSON → []any de maps).
|
|
replayed := []any{map[string]any{"name": "photo.jpg", "path": "uploads/photo.jpg"}}
|
|
if got := exportFileNames(replayed); len(got) != 1 || got[0] != "photo.jpg" {
|
|
t.Fatalf("forme rejouée : %v", got)
|
|
}
|
|
if got := exportFileNames(nil); len(got) != 0 {
|
|
t.Fatalf("sans pièce jointe : %v", got)
|
|
}
|
|
}
|