From a58103ff7e4ededaccf9e42601d2c3a3338ae965 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 14:33:03 +0200 Subject: [PATCH] Migration : reecrire aussi les chemins sous leur forme JSON echappee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trouve en rejouant la migration dans un bac a sable isole. Les fichiers JSON du dossier de donnees (model_dirs.json, mcp.json, webprefs.json) sont ecrits par json.Marshal : sous Windows chaque antislash y est DOUBLE. La reecriture ne cherchait que la forme native et la forme a slashs, elle laissait donc ces fichiers pointer vers l'ancien dossier. Pire, la fonction d'echappement etait ecrite avec des litteraux bruts et remplacait en fait un antislash par lui-meme : elle ne doublait rien. Le remplacement natif matchait alors la forme JSON et produisait un fichier invalide (« invalid character 'U' in string escape code »), c'est-a-dire la liste des dossiers de modeles perdue. Un bug pire que celui qu'on corrigeait. jsonEscapePath utilise desormais des litteraux entre guillemets, avec un commentaire explicite sur le piege ("\\" est UN antislash, "\\\\" en fait deux). Test dedie qui verifie que le chemin est bien reecrit ET que le JSON reste analysable apres coup. Verifie en bac a sable sur les trois ecritures simultanement (native, slashs, JSON echappe) : les trois sont reecrites, le JSON se reparse et pointe sur le nouveau dossier. --- internal/ajean/sys_migrate.go | 27 ++++++++++++++++-- internal/ajean/sys_migrate_test.go | 44 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index dfb7d7c..73cf727 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -129,13 +129,22 @@ func configFilesToRewrite(home string) []string { // dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus. // Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal. // -// On couvre les deux écritures de séparateur, Windows acceptant indifféremment -// « \ » et « / » dans une même valeur. Best-effort par fichier : un fichier -// illisible est sauté sans compromettre les autres. +// Trois écritures d'un même chemin doivent être couvertes, sans quoi on croit +// avoir tout réécrit alors qu'il reste des références mortes : +// +// C:\ProgramData\jean forme native, dans config.env +// C:/ProgramData/jean Windows accepte les deux séparateurs dans une valeur +// C:\\ProgramData\\jean forme JSON, où l'antislash est échappé (model_dirs.json, +// mcp.json, webprefs.json sont écrits par json.Marshal) +// +// Best-effort par fichier : un fichier illisible est sauté sans compromettre +// les autres. func rewriteHomeReferences(oldHome, newHome string) { variants := [][2]string{{oldHome, newHome}} if slash := filepath.ToSlash(oldHome); slash != oldHome { variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)}) + // Forme JSON : chaque antislash est doublé. + variants = append(variants, [2]string{jsonEscapePath(oldHome), jsonEscapePath(newHome)}) } for _, path := range configFilesToRewrite(newHome) { b, err := os.ReadFile(path) @@ -163,6 +172,18 @@ func rewriteHomeReferences(oldHome, newHome string) { adoptLegacyStateFiles(newHome) } +// jsonEscapePath renvoie l'écriture d'un chemin telle qu'elle apparaît DANS un +// fichier JSON : sous Windows, json.Marshal double chaque antislash. Le +// remplacement littéral doit donc chercher cette forme-là, sinon model_dirs.json +// et mcp.json gardent des chemins morts. +// +// Attention en modifiant : "\\" est UN antislash et "\\\\" en est deux. Une +// version antérieure utilisait des littéraux bruts et remplaçait en fait un +// antislash par lui-même, ce qui laissait passer le remplacement natif et +// produisait un JSON invalide (« invalid character 'U' in string escape code »), +// donc une liste de dossiers de modèles perdue. Couvert par un test. +func jsonEscapePath(p string) string { return strings.ReplaceAll(p, "\\", "\\\\") } + // adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service // (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le // service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go index ec1501f..da4b885 100644 --- a/internal/ajean/sys_migrate_test.go +++ b/internal/ajean/sys_migrate_test.go @@ -1,6 +1,7 @@ package ajean import ( + "encoding/json" "os" "path/filepath" "runtime" @@ -172,3 +173,46 @@ func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) { t.Fatalf("PID perdu: %q", b) } } + +// Les fichiers JSON (model_dirs.json, mcp.json, webprefs.json) sont ecrits par +// json.Marshal : sous Windows chaque antislash y est DOUBLE. Chercher la forme +// native ne les trouve donc pas, et on croirait avoir tout reecrit en laissant +// des references mortes. Constate en bac a sable. +func TestMigrateHomeRewritesJSONEscapedPaths(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("l'echappement JSON des separateurs ne concerne que Windows") + } + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + + esc := jsonEscapePath + body := `{"dirs":["` + esc(filepath.Join(legacy, "models")) + `"]}` + if err := os.WriteFile(filepath.Join(legacy, "model_dirs.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + migrateHome(target, legacy) + + b, err := os.ReadFile(filepath.Join(target, "model_dirs.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), esc(legacy)) { + t.Errorf("reference JSON a l'ancien dossier non reecrite:\n%s", b) + } + if !strings.Contains(string(b), esc(target)) { + t.Errorf("le JSON ne pointe pas vers le nouveau dossier:\n%s", b) + } + // Le fichier doit rester du JSON valide apres reecriture. + var parsed struct { + Dirs []string `json:"dirs"` + } + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatalf("JSON casse par la reecriture: %v\n%s", err, b) + } + if len(parsed.Dirs) != 1 || !strings.HasPrefix(parsed.Dirs[0], target) { + t.Fatalf("chemin decode inattendu: %+v", parsed.Dirs) + } +}