gestion_table_tournante_libre/test/fichiers_electron.long.test.js

410 lines
19 KiB
JavaScript
Raw Normal View History

[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le système de fichiers de la coquille sur un vrai disque (§ 8.6, § 8.8,
// § 14.4). La suite de contrat de test/contrat_fichiers.js s'y joue sur la
// page, src/stockage/fichiers_electron.js, derrière le vrai préchargement,
// devant les primitives d'electron/fichiers.js ; seul l'IPC d'Electron est
// remplacé, et ses arguments et réponses y sont copiés comme il les copie.
// Suivent la sonde d'écriture, qui ne s'éprouve utilement que contre un vrai
// système de fichiers, un dossier en lecture seule, un lien qui sort de la
// racine, un dossier voisin dont le nom prolonge celui de la racine, le
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
// renommage qui remplace la cible, le verrou d'un processus vivant ou
// terminé, sous Linux celui d'un pid vivant dont le démarrage n'est pas
// celui qu'il inscrit, et le démarrage que ps écrit en temps universel quel
// que soit le fuseau du processus qui le lit. Chaque épreuve écrit sous ses
// propres dossiers temporaires, retirés à sa fin, qu'elle passe ou non.
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { hostname, tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compileFunction } from 'node:vm';
import { TEMOIN, creerFichiers, decrireSysteme, servir } from '../electron/fichiers.js';
import { ErreurStockage } from '../src/stockage/erreurs.js';
import { creerFichiersElectron } from '../src/stockage/fichiers_electron.js';
import { eprouverContrat } from './contrat_fichiers.js';
import { describe, test } from './lanceur.js';
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
const NOM_PRODUIT = 'Gestion table tournante Libre';
// Sous Windows, un dossier ne passe pas en lecture seule par ses droits, et
// un lien symbolique demande un privilège : un lien de dossier s'y pose en
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
const SOUS_WINDOWS = process.platform === 'win32';
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
const SOUS_LINUX = process.platform === 'linux';
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
// Dossiers temporaires de l'épreuve en cours.
const ouverts = [];
function dossierTemporaire() {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-fichiers-'));
ouverts.push(dossier);
return dossier;
}
// test du lanceur, dont chaque épreuve retire à sa fin les dossiers
// temporaires qu'elle a ouverts ; le corps reçoit le contexte de l'épreuve.
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
function testNettoye(nom, ...suite) {
const corps = suite.pop();
test(nom, ...suite, async (contexte) => {
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
try {
await corps(contexte);
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
} finally {
for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true });
}
});
}
// Ce que dit une commande lancée par spawnSync : l'exception qui l'a
// empêchée de démarrer, sinon sa sortie d'erreur. Toujours une chaîne,
// seul message que node:assert accepte.
const ditPar = (lancee) => String(lancee.error?.message ?? lancee.stderr ?? '');
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
// Charge electron/preload.cjs comme un rendu en bac à sable, contre un
// electron dont invoke porte chaque appel au gestionnaire que servir a posé
// sur ce canal ; arguments et réponse sont copiés comme l'IPC les copie, et
// d'une erreur levée ne passe que le message. Rend gtt.fichiers.
function parLePrechargement(fichiers) {
const gestionnaires = new Map();
servir(fichiers, (canal, gestionnaire) => gestionnaires.set(canal, gestionnaire));
let gtt;
const electron = {
contextBridge: {
exposeInMainWorld: (cle, api) => {
assert.equal(cle, 'gtt');
gtt = api;
},
},
ipcRenderer: {
async invoke(canal, ...parametres) {
const gestionnaire = gestionnaires.get(canal);
if (gestionnaire === undefined) throw new Error(`No handler registered for '${canal}'`);
let reponse;
try {
reponse = await gestionnaire({ sender: null }, ...structuredClone(parametres));
} catch (erreur) {
throw new Error(`Error invoking remote method '${canal}': ${erreur}`);
}
return structuredClone(reponse);
},
},
};
const module = { exports: {} };
const requerir = (nom) => {
if (nom === 'electron') return electron;
throw new Error(`module « ${nom} » indisponible dans un préchargement en bac à sable`);
};
compileFunction(readFileSync(PRECHARGEMENT, 'utf8'), ['require', 'module', 'exports'], {
filename: PRECHARGEMENT,
})(requerir, module, module.exports);
return gtt.fichiers;
}
// Les dialogues et l'explorateur ne servent à aucune de ces épreuves.
const SANS_APPEL = new Proxy({}, {
get: (_, nom) => () => assert.fail(`appel inattendu : ${String(nom)}`),
});
/**
* Le système de la page sur un dossier temporaire neuf : les Documents du
* poste y sont Documents/, et le dossier publié par le lanceur portable,
* quand il est donné, y est relatif. Rend le système et le chemin de ses
* racines sur le disque.
*/
function systemeSur(dossier, { publie = null } = {}) {
const chemins = { appData: join(dossier, 'AppData'), temp: tmpdir(), documents: join(dossier, 'Documents') };
const systeme = decrireSysteme({
env: publie === null ? {} : { PORTABLE_EXECUTABLE_DIR: join(dossier, publie) },
plateforme: process.platform,
cheminSysteme: (nom) => chemins[nom],
nomProduit: NOM_PRODUIT,
});
const fs = creerFichiersElectron(parLePrechargement(creerFichiers({ ...systeme, dialog: SANS_APPEL, shell: SANS_APPEL })));
return { fs, ...systeme.racines };
}
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
async function rejette(promesse, message) {
let raison;
await assert.rejects(
promesse,
(erreur) => {
raison = erreur;
return true;
},
message,
);
return raison;
}
// Attend le rejet d'une ErreurStockage de ce code et de ces détails.
async function echoue(promesse, code, details, message = code) {
const erreur = await rejette(promesse, message);
assert.ok(erreur instanceof ErreurStockage, `${message} : ${erreur}`);
assert.equal(erreur.code, code, message);
assert.deepEqual(erreur.details, details, message);
}
eprouverContrat(
'electron, sur un vrai dossier',
async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
return { fs, racine: (await fs.racines()).documents };
},
{
describe,
test: testNettoye,
egal: (reel, attendu, message) => assert.deepEqual(reel, attendu, message),
vrai: (valeur, message) => assert.ok(valeur, message),
rejette,
},
);
describe('coquille sur un vrai disque', () => {
testNettoye("la sonde crée la racine et ses parents, efface un témoin resté, et ne laisse que les autres fichiers", async () => {
const { fs, portable } = systemeSur(dossierTemporaire(), { publie: join('cle', 'soirees') });
const racine = (await fs.racines()).portable;
assert.equal(racine.chemin, portable);
assert.deepEqual(await fs.sonder(racine), { inscriptible: true, cause: null });
assert.deepEqual(readdirSync(portable), []);
writeFileSync(join(portable, TEMOIN), 'resté d\u{2019}une séance précédente');
writeFileSync(join(portable, 'garde.txt'), 'garde');
assert.deepEqual(await fs.sonder(racine), { inscriptible: true, cause: null });
assert.deepEqual(readdirSync(portable), ['garde.txt']);
});
[ADD] storage: copy, rename, import, delete events; local settings § 8.7 operations reuse the event store's pairing and alignment instead of copying them. Renaming writes the complete pair under the new name — the store now writes a gesture with the pair it writes elsewhere — before deleting the old one, so no cut leaves zero pairs; an unchanged name writes nothing. Deleting moves state, .precedent, journal, lock and leftovers into a dated trash, matching names as the case-insensitive disk does. creerDossier creates only the root it is aimed at, so a vanished working folder never comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings; its deferred writer always keeps the last demand. Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %. --- FR --- [ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom — le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et restes dans une corbeille datée, en comparant les noms comme le disque insensible à la casse. creerDossier ne crée que la racine visée : un dossier de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux réglages du § 8.5 ; son écrivain différé garde la dernière demande. Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:05 -04:00
testNettoye("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
const dossier = dossierTemporaire();
const { fs, portable } = systemeSur(dossier, { publie: join('cle', 'soirees') });
const racine = (await fs.racines()).portable;
await echoue(fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', {
chemin: 'corbeille/2026-01-02_03-04-05',
dossier: join(portable, 'corbeille'),
cause: 'ENOENT',
});
assert.deepEqual(readdirSync(dossier), []);
await fs.creerDossier(racine, '');
assert.deepEqual(readdirSync(portable), []);
await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05');
assert.deepEqual(readdirSync(join(portable, 'corbeille')), ['2026-01-02_03-04-05']);
});
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
testNettoye(
"un dossier en lecture seule fait échouer la sonde sur EACCES, qu'elle doive y écrire ou y créer la racine",
{ skip: SOUS_ROOT || SOUS_WINDOWS },
async () => {
const { fs, documents, portable } = systemeSur(dossierTemporaire(), {
publie: join('Documents', NOM_PRODUIT, 'cle'),
});
mkdirSync(documents, { recursive: true });
chmodSync(documents, 0o555);
try {
const racines = await fs.racines();
assert.deepEqual(await fs.sonder(racines.documents), { inscriptible: false, cause: 'EACCES' });
assert.deepEqual(await fs.sonder(racines.portable), { inscriptible: false, cause: 'EACCES' });
assert.ok(portable.startsWith(documents), portable);
assert.deepEqual(readdirSync(documents), []);
} finally {
chmodSync(documents, 0o755);
}
},
);
testNettoye("un lien qui sort de la racine lève CHEMIN_REFUSE, et rien ne s'écrit, ne se lit ni ne s'efface au-dehors", async () => {
const dossier = dossierTemporaire();
const dehors = join(dossierTemporaire(), 'dehors');
// Au-dehors, un fichier et un verrou de la séance qui appelle, que
// deverrouiller effacerait.
const verrou = `${JSON.stringify({ seance: 'seance-a', pid: 777, hote: 'poste-essai', depuis: '2026-10-06T19:02:11-04:00' })}\n`;
mkdirSync(dehors);
writeFileSync(join(dehors, 'secret.txt'), 'hors de la racine');
writeFileSync(join(dehors, 'soiree.gtt.verrou'), verrou);
const { fs, documents } = systemeSur(dossier);
mkdirSync(documents, { recursive: true });
writeFileSync(join(documents, 'ici.txt'), 'ici');
symlinkSync(dehors, join(documents, 'lien'), SOUS_WINDOWS ? 'junction' : 'dir');
const racine = (await fs.racines()).documents;
await echoue(fs.lireTexte(racine, 'lien/secret.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
await echoue(fs.ecrireAtomique(racine, 'lien/neuf.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.txt' });
await echoue(fs.ajouterLigne(racine, 'lien/secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
await echoue(fs.lister(racine, 'lien'), 'CHEMIN_REFUSE', { chemin: 'lien' });
await echoue(fs.deplacer(racine, 'ici.txt', 'lien/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/vole.txt' });
await echoue(fs.verrouiller(racine, 'lien/neuf.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.gtt.verrou' });
await echoue(fs.deverrouiller(racine, 'lien/soiree.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/soiree.gtt.verrou' });
if (!SOUS_WINDOWS) {
symlinkSync(join(dehors, 'secret.txt'), join(documents, 'secret.txt'));
await echoue(fs.lireTexte(racine, 'secret.txt'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
await echoue(fs.ajouterLigne(racine, 'secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
}
assert.deepEqual(readdirSync(dehors).sort(), ['secret.txt', 'soiree.gtt.verrou']);
assert.equal(readFileSync(join(dehors, 'secret.txt'), 'utf8'), 'hors de la racine');
assert.equal(readFileSync(join(dehors, 'soiree.gtt.verrou'), 'utf8'), verrou);
assert.equal(readFileSync(join(documents, 'ici.txt'), 'utf8'), 'ici');
});
testNettoye("un dossier voisin dont le nom prolonge celui de la racine est hors d'elle, et reste intact", async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
const voisin = `${documents} (copie)`;
mkdirSync(documents, { recursive: true });
mkdirSync(voisin);
writeFileSync(join(voisin, 'secret.txt'), 'dans le dossier voisin');
writeFileSync(join(documents, 'ici.txt'), 'ici');
symlinkSync(voisin, join(documents, 'voisin'), SOUS_WINDOWS ? 'junction' : 'dir');
const racine = (await fs.racines()).documents;
await echoue(fs.lireTexte(racine, 'voisin/secret.txt'), 'CHEMIN_REFUSE', { chemin: 'voisin/secret.txt' });
await echoue(fs.ecrireAtomique(racine, 'voisin/neuf.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'voisin/neuf.txt' });
await echoue(fs.deplacer(racine, 'ici.txt', 'voisin/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'voisin/vole.txt' });
assert.deepEqual(readdirSync(voisin), ['secret.txt']);
assert.equal(readFileSync(join(voisin, 'secret.txt'), 'utf8'), 'dans le dossier voisin');
assert.equal(readFileSync(join(documents, 'ici.txt'), 'utf8'), 'ici');
});
testNettoye("l'écriture atomique remplace la cible par un renommage : le nom passe à un autre fichier, sans résidu", async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
const racine = (await fs.racines()).documents;
const cible = join(documents, 'soiree.gtt.json');
writeFileSync(cible, 'ancien');
const avant = statSync(cible).ino;
await fs.ecrireAtomique(racine, 'soiree.gtt.json', 'nouveau');
assert.equal(readFileSync(cible, 'utf8'), 'nouveau');
assert.notEqual(statSync(cible).ino, avant);
assert.deepEqual(readdirSync(documents), ['soiree.gtt.json']);
});
testNettoye("le verrou d'un processus vivant de ce poste se dit vivant, celui d'un processus terminé non", async () => {
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
const racine = (await fs.racines()).documents;
assert.deepEqual(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
assert.deepEqual([pose.pid, pose.hote], [process.pid, hostname()]);
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
assert.deepEqual(tenu, { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true });
const termine = spawnSync(process.execPath, ['-e', '']);
assert.equal(termine.status, 0);
const depuis = '2026-10-06T19:02:11-04:00';
writeFileSync(
join(documents, 'ancienne.gtt.verrou'),
`${JSON.stringify({ seance: 'seance-terminee', pid: termine.pid, hote: hostname(), depuis })}\n`,
);
assert.deepEqual(await fs.verrouiller(racine, 'ancienne.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'seance-terminee',
depuis,
vivant: false,
});
});
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
testNettoye(
"sous Linux, le verrou inscrit le champ 22 de /proc/<pid>/stat ; un pid vivant d'un autre démarrage est une séance morte, et un verrou sans démarrage laisse vivant inconnu",
{ skip: !SOUS_LINUX },
async () => {
// Le démarrage d'un processus, lu ici sans passer par la coquille.
const demarrageDe = (cible) => {
const texte = readFileSync(`/proc/${cible}/stat`, 'utf8');
return texte.slice(texte.lastIndexOf(')') + 1).trim().split(/\s+/)[19];
};
const { fs, documents } = systemeSur(dossierTemporaire());
mkdirSync(documents, { recursive: true });
const racine = (await fs.racines()).documents;
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
assert.deepEqual(Object.keys(pose), ['seance', 'pid', 'hote', 'depuis', 'demarrage']);
assert.equal(pose.demarrage, demarrageDe(process.pid));
// Le processus parent vit ; un verrou à son pid ne vit qu'avec son
// démarrage, et sans démarrage inscrit, rien ne dit s'il vit encore.
const parent = process.ppid;
const depuis = '2026-10-06T19:02:11-04:00';
for (const [nom, inscrit, vivant] of [
['son démarrage', { demarrage: demarrageDe(parent) }, true],
['son démarrage, une clé étrangère', { demarrage: demarrageDe(parent), inconnue: 1 }, true],
['un autre démarrage', { demarrage: `${demarrageDe(parent)}0` }, false],
['sans démarrage', {}, null],
]) {
writeFileSync(
join(documents, 'parent.gtt.verrou'),
`${JSON.stringify({ seance: 'seance-p', pid: parent, hote: hostname(), depuis, ...inscrit })}\n`,
);
assert.deepEqual(
await fs.verrouiller(racine, 'parent.gtt.verrou', 'seance-b'),
{ pris: false, seance: 'seance-p', depuis, vivant },
nom,
);
}
},
);
testNettoye(
"la lecture par ps (macOS) passe TZ=UTC et LC_ALL=C à la commande : le démarrage inscrit est en temps universel, et un processus d'un autre fuseau lit la séance vivante",
{ skip: SOUS_WINDOWS },
async (contexte) => {
// L'épreuve joue macOS avec le ps de l'hôte. Sous Linux, la coquille
// lit /proc et ne demande pas ps, qu'une image de système minimale ne
// porte pas : sans lui, l'épreuve se saute et dit pourquoi.
if (spawnSync('ps', ['-p', String(process.pid)]).error?.code === 'ENOENT') {
contexte.skip("ps introuvable sur ce système : la lecture du démarrage par ps ne s'y éprouve pas");
return;
}
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
const documents = join(dossierTemporaire(), 'Documents');
mkdirSync(documents, { recursive: true });
const fichiers = creerFichiers({
emplacements: {},
racines: { portable: null, documents },
dialog: SANS_APPEL,
shell: SANS_APPEL,
plateforme: 'darwin',
});
assert.deepEqual(await fichiers.verrouiller({ id: 'documents' }, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
const enUtc = spawnSync('ps', ['-o', 'lstart=', '-p', String(process.pid)], {
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
encoding: 'utf8',
});
assert.equal(enUtc.status, 0, ditPar(enUtc));
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
assert.equal(pose.demarrage, enUtc.stdout.trim());
// Un second processus, dans un fuseau qui n'est pas celui de
// l'épreuve, relit le verrou par la même coquille.
const autre = Intl.DateTimeFormat().resolvedOptions().timeZone === 'Pacific/Kiritimati' ? 'Pacific/Pago_Pago' : 'Pacific/Kiritimati';
const lecteur = [
'const { creerFichiers } = await import(process.env.GTT_MODULE);',
'const fichiers = creerFichiers({ emplacements: {}, racines: { portable: null, documents: process.env.GTT_DOCUMENTS }, dialog: {}, shell: {}, plateforme: "darwin" });',
'const tenu = await fichiers.verrouiller({ id: "documents" }, "soiree.gtt.verrou", "seance-b");',
'process.stdout.write(JSON.stringify({ fuseau: Intl.DateTimeFormat().resolvedOptions().timeZone, tenu }));',
].join('\n');
const relu = spawnSync(process.execPath, ['--input-type=module', '-e', lecteur], {
env: {
...process.env,
TZ: autre,
GTT_MODULE: new URL('../electron/fichiers.js', import.meta.url).href,
GTT_DOCUMENTS: documents,
},
encoding: 'utf8',
});
assert.equal(relu.status, 0, ditPar(relu));
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
assert.deepEqual(JSON.parse(relu.stdout), {
fuseau: autre,
tenu: { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true },
});
},
);
[ADD] electron: confined file system, atomic writes, lock, bridge The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:34:31 -04:00
});