From f2b66f2f92ac8de4984cc613cdc342e024685d41 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 17:34:31 -0400 Subject: [PATCH] [ADD] electron: confined file system, atomic writes, lock, bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/fichiers.js | 834 ++++++++++++++++ electron/main.js | 79 +- electron/preload.cjs | 46 +- src/stockage/fichiers_electron.js | 59 ++ test/coquille.test.js | 235 ++++- test/electron_factice.js | 155 ++- test/fichiers_electron.long.test.js | 290 ++++++ test/fichiers_electron.test.js | 1400 +++++++++++++++++++++++++++ 8 files changed, 3002 insertions(+), 96 deletions(-) create mode 100644 electron/fichiers.js create mode 100644 src/stockage/fichiers_electron.js create mode 100644 test/fichiers_electron.long.test.js create mode 100644 test/fichiers_electron.test.js diff --git a/electron/fichiers.js b/electron/fichiers.js new file mode 100644 index 0000000..ee295b5 --- /dev/null +++ b/electron/fichiers.js @@ -0,0 +1,834 @@ +// © 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 (§ 8.6, § 8.8, § 13.1) : les dix-sept +// primitives de l'interface de src/stockage/systeme_fichiers.js, exécutées +// sous Node par le processus principal, et le service qui les offre à la +// page, un canal IPC par primitive, gtt:fichiers:. +// +// La page ne désigne jamais un fichier par un chemin absolu : elle donne une +// racine, que ce module connaît par son seul identifiant — portable, +// documents, ou choisi-N qu'a rendu le dialogue de choix d'un dossier —, et +// un chemin relatif à elle. Un chemin que refuse la règle des segments, une +// racine inconnue, ou une cible dont le chemin réel sort de celui de sa +// racine — un lien symbolique en chemin — lèvent CHEMIN_REFUSE avant toute +// écriture. +// +// Le paquet n'emporte de src/ que la page construite (§ 14.15) : ce module ne +// charge que des modules de Node. Ce qu'il partage avec src/stockage — la +// règle des segments, le suffixe .ecriture, les codes d'échec — s'y +// recopie, et test/fichiers_electron.test.js éprouve que les deux +// définitions s'accordent. +// +// Un échec que l'interface nomme lève RefusFichier, porteur du code et des +// détails d'une ErreurStockage. Une erreur ne traverse pas l'IPC : repondre +// fait de chaque appel une réponse { ok: true, valeur } ou { ok: false, code, +// details }, que src/stockage/fichiers_electron.js retransforme en +// ErreurStockage. Une faute du code, ou un argument de forme fausse, lève une +// autre erreur, dont Electron ne transmet que le message. +// +// Deux codes s'ajoutent à ceux de l'interface : LECTURE {chemin, dossier, +// cause}, une lecture que le système refuse autrement que sur un absent — un +// dossier illisible n'est pas un dossier vide —, et NON_DISPONIBLE {cause}, +// l'explorateur du système qui ne s'ouvre pas. +import { execFile } from 'node:child_process'; +import { realpath as realpathRappel } from 'node:fs'; +import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises'; +import { hostname } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +/** Les primitives de l'interface, dans l'ordre où elle les déclare. */ +export const PRIMITIVES = Object.freeze([ + 'emplacements', + 'racines', + 'choisirDossier', + 'sonder', + 'typeSupport', + 'lireTexte', + 'ecrireAtomique', + 'ajouterLigne', + 'lister', + 'creerDossier', + 'deplacer', + 'supprimer', + 'verrouiller', + 'deverrouiller', + 'ouvrirDansExplorateur', + 'choisirFichierAImporter', + 'enregistrerSous', +]); + +/** Le canal d'une primitive est ce préfixe suivi de son nom. */ +export const PREFIXE_CANAL = 'gtt:fichiers:'; + +/** Ajouté au nom d'un fichier pendant son écriture atomique (§ 8.8). */ +export const SUFFIXE_ECRITURE = '.ecriture'; + +/** Le témoin de la sonde d'écriture, à la racine sondée (§ 8.6). */ +export const TEMOIN = '.gtt-temoin'; + +// Ce que la sonde écrit, puis attend à la relecture : un texte en UTF-8 hors +// de l'ASCII, qu'un support qui altère les octets ne rend pas tel quel. +const TEXTE_TEMOIN = 'T\u{E9}moin d\u{2019}\u{E9}criture de Gestion table tournante Libre.\n'; + +/** + * Le renommage de l'écriture atomique se réessaie, après son premier essai, + * REESSAIS_RENOMMAGE fois sur une cause passagère : un antivirus ou un agent + * de synchronisation qui tient la cible ouverte sous Windows (EPERM, EBUSY, + * EACCES). La pause entre deux essais part de PAUSE_INITIALE_MS et double + * jusqu'à PAUSE_MAX_MS : 3,75 s d'attente au plus avant de renoncer. + */ +export const REESSAIS_RENOMMAGE = 10; +export const PAUSE_INITIALE_MS = 50; +export const PAUSE_MAX_MS = 500; +const CAUSES_PASSAGERES = new Set(['EPERM', 'EBUSY', 'EACCES']); + +/** La commande qui lit le type d'un lecteur sous Windows est bornée à 5 s. */ +export const DELAI_SUPPORT_MS = 5000; + +// Lit le type du lecteur de la racine que reçoit la variable GTT_RACINE : le +// chemin passe par l'environnement, jamais par le texte de la commande, où +// une apostrophe le ferait lire autrement. +const COMMANDE_LECTEUR = '[System.IO.DriveInfo]::new($env:GTT_RACINE).DriveType'; + +// La règle des segments d'exigerCheminRelatif (src/stockage/systeme_fichiers.js), +// recopiée : un segment ne finit ni par un point ni par une espace — « . », +// « .. » et ce que Windows y ramène —, et ne porte ni barre oblique inverse, +// ni deux-points, ni caractère nul. Un segment vide vient d'un chemin absolu, +// d'un séparateur doublé ou final. +const SEGMENT_ADMIS = /^[^\\:\u{0}]*[^\\:\u{0}. ]$/u; + +// Codes d'une cible absente : elle-même, un dossier parent qui est un +// fichier ; pour une lecture, un dossier là où l'on attend un fichier. +const ABSENCE = new Set(['ENOENT', 'ENOTDIR']); +const ABSENCE_EN_LECTURE = new Set(['ENOENT', 'ENOTDIR', 'EISDIR']); + +/** + * Un échec que l'interface nomme : le code et les détails d'une + * ErreurStockage (src/stockage/types.js en donne la table). + */ +export class RefusFichier extends Error { + /** + * @param {string} code + * @param {Object} details + */ + constructor(code, details) { + super(`${code} ${JSON.stringify(details)}`); + this.code = code; + this.details = details; + } +} +RefusFichier.prototype.name = 'RefusFichier'; + +/** + * Les segments d'un chemin relatif à une racine : [] pour '', la racine + * elle-même. Le contrôle ne lit que le texte. + * + * @param {unknown} chemin + * @returns {string[]} + * @throws {RefusFichier} CHEMIN_REFUSE {chemin} : autre chose qu'une chaîne, + * ou un segment que la règle refuse + */ +export function segmentsAdmis(chemin) { + if (typeof chemin !== 'string') throw new RefusFichier('CHEMIN_REFUSE', { chemin }); + if (chemin === '') return []; + const segments = chemin.split('/'); + if (!segments.every((segment) => SEGMENT_ADMIS.test(segment))) { + throw new RefusFichier('CHEMIN_REFUSE', { chemin }); + } + return segments; +} + +/** + * Les emplacements et les racines du poste, lus une fois, au démarrage + * (§ 8.6). Le dossier de l'exécutable est celui que publie le lanceur + * portable dans PORTABLE_EXECUTABLE_DIR — absente ou vide, aucun —, jamais + * celui du processus, qui tourne dans le dossier temporaire où l'archive + * s'extrait. Les données applicatives sont AppData, LOCALAPPDATA quand le + * poste le définit, et le dossier temporaire. Les chemins se composent à la + * manière de la plateforme donnée. + * + * @param {Object} poste + * @param {Object} poste.env l'environnement du processus + * @param {string} poste.plateforme process.platform + * @param {(nom: 'appData'|'temp'|'documents') => string} poste.cheminSysteme + * app.getPath d'Electron + * @param {string} poste.nomProduit le dossier du produit dans les Documents + * @returns {{emplacements: Object, racines: {portable: string|null, documents: string}}} + * emplacements a la forme Emplacements de src/stockage/systeme_fichiers.js + */ +export function decrireSysteme({ env, plateforme, cheminSysteme, nomProduit }) { + const chemins = plateforme === 'win32' ? path.win32 : path.posix; + const executable = env.PORTABLE_EXECUTABLE_DIR || null; + const donneesApplicatives = [cheminSysteme('appData'), env.LOCALAPPDATA, cheminSysteme('temp')].filter( + (dossier) => typeof dossier === 'string' && dossier !== '', + ); + return { + emplacements: { + executable, + donneesApplicatives, + insensibleCasse: plateforme === 'win32' || plateforme === 'darwin', + separateur: chemins.sep, + }, + racines: { + portable: executable === null ? null : chemins.join(executable, 'data'), + documents: chemins.join(cheminSysteme('documents'), nomProduit), + }, + }; +} + +// Les fonctions de node:fs/promises dont les primitives se servent. realpath +// est celle de node:fs, qui résout les liens segment par segment ; celle de +// node:fs/promises demande au système le chemin final d'un fichier ouvert, +// ce que des lecteurs de Windows refusent. +const FS_NODE = Object.freeze({ + lstat, + stat, + realpath: promisify(realpathRappel), + readFile, + open, + rename, + unlink, + mkdir, + readdir, +}); + +// Lance une commande sans shell, ces variables ajoutées à l'environnement du +// processus, tuée au-delà du délai ; rend sa sortie standard, ou rejette. +function lancerCommande(commande, arguments_, { variables, delai }) { + return new Promise((resoudre, rejeter) => { + execFile( + commande, + arguments_, + { env: { ...process.env, ...variables }, timeout: delai, windowsHide: true, encoding: 'utf8' }, + (erreur, sortie) => (erreur ? rejeter(erreur) : resoudre(sortie)), + ); + }); +} + +const pause = (ms) => new Promise((resoudre) => setTimeout(resoudre, ms)); + +// Vrai pour une erreur du système, telle que node:fs la lève : un code et +// l'appel système qui a échoué. Les autres sont des fautes du code. +const estErreurSysteme = (erreur) => + erreur instanceof Error && typeof erreur.code === 'string' && typeof erreur.syscall === 'string'; + +const estObjet = (valeur) => valeur !== null && typeof valeur === 'object'; + +function exigerTexte(valeur, quoi) { + if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`); +} + +function exigerLigne(ligne) { + exigerTexte(ligne, 'la ligne'); + if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne'); +} + +function exigerSeance(seance) { + if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide"); +} + +// Le contenu d'un verrou, {seance, pid, hote, depuis}, ou null quand le texte +// n'en est pas un : JSON invalide, champ manquant ou de type faux, pid qui +// ne désigne pas un processus — 0 et les négatifs désignent des groupes. +function analyserVerrou(texte) { + let contenu; + try { + contenu = JSON.parse(texte); + } catch { + return null; + } + const valide = + estObjet(contenu) && + typeof contenu.seance === 'string' && + Number.isSafeInteger(contenu.pid) && + contenu.pid > 0 && + typeof contenu.hote === 'string' && + typeof contenu.depuis === 'string'; + return valide ? contenu : null; +} + +// Un instant sous la forme de l'horloge de l'application, +// AAAA-MM-JJTHH:MM:SS±HH:MM, à l'heure locale du poste et avec son décalage. +function horodatage(instant) { + const deux = (n) => String(n).padStart(2, '0'); + const decalage = -instant.getTimezoneOffset(); + const ecart = Math.abs(decalage); + return ( + `${instant.getFullYear()}-${deux(instant.getMonth() + 1)}-${deux(instant.getDate())}` + + `T${deux(instant.getHours())}:${deux(instant.getMinutes())}:${deux(instant.getSeconds())}` + + `${decalage < 0 ? '-' : '+'}${deux(Math.floor(ecart / 60))}:${deux(ecart % 60)}` + ); +} + +const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0); + +/** + * Vrai quand le processus de ce pid tourne sur ce poste : il répond au + * signal 0, ou refuse de le recevoir (EPERM) parce qu'il tourne sous un + * autre utilisateur. + * + * @param {number} pid + * @param {(pid: number) => void} [signaler] process.kill(pid, 0) + * @returns {boolean} + */ +export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0)) { + try { + signaler(pid); + return true; + } catch (erreur) { + return erreur?.code === 'EPERM'; + } +} + +/** + * Les primitives du système de fichiers, sous Node. Chacune prend les + * arguments de l'interface, tels que l'IPC les a copiés, et rend sa valeur ou + * lève : RefusFichier pour un échec que l'interface nomme, TypeError pour un + * argument de forme fausse. + * + * @param {Object} reglages + * @param {Object} reglages.emplacements ceux de decrireSysteme + * @param {{portable: string|null, documents: string}} reglages.racines + * chemins absolus des racines du poste + * @param {Object} reglages.dialog le module dialog d'Electron + * @param {Object} reglages.shell le module shell d'Electron + * @param {() => Object|null} [reglages.fenetre] la fenêtre parente des dialogues + * Le reste remplace le système, pour l'épreuve : + * @param {Object} [reglages.fs] lstat, stat, realpath, readFile, open, + * rename, unlink, mkdir, readdir, comme ceux de node:fs/promises + * @param {Object} [reglages.chemins] node:path, ou l'une de ses variantes + * @param {string} [reglages.plateforme] process.platform + * @param {Function} [reglages.lancer] (commande, arguments, {variables, + * delai}) → la sortie standard de la commande + * @param {(ms: number) => Promise} [reglages.attendre] + * @param {(pid: number) => void} [reglages.signaler] celui de processusVivant + * @param {number} [reglages.pid] + * @param {string} [reglages.hote] + * @param {() => Date} [reglages.maintenant] + * @returns {Object} les primitives, par nom + */ +export function creerFichiers({ + emplacements, + racines: { portable, documents }, + dialog, + shell, + fenetre = () => null, + fs = FS_NODE, + chemins = path, + plateforme = process.platform, + lancer = lancerCommande, + attendre = pause, + signaler, + pid = process.pid, + hote = hostname(), + maintenant = () => new Date(), +}) { + // Chemin absolu de chaque racine connue, par identifiant. Une Map : un + // identifiant reçu de la page ne rencontre aucune propriété héritée. + const bases = new Map([['documents', documents]]); + if (portable !== null) bases.set('portable', portable); + let choisis = 0; + + // Ce que désigne un chemin relatif à une racine : sa racine et sa cible en + // absolu, et le dossier que nomme un échec — celui qui porte la cible, la + // racine elle-même pour ''. Seul le texte se lit : CHEMIN_REFUSE pour un + // chemin que la règle des segments refuse ou une racine inconnue. + function localiser(racine, chemin) { + const segments = segmentsAdmis(chemin); + const base = estObjet(racine) && typeof racine.id === 'string' ? bases.get(racine.id) : undefined; + if (base === undefined) throw new RefusFichier('CHEMIN_REFUSE', { chemin }); + return { + chemin, + racine: chemins.join(base), + absolu: chemins.join(base, ...segments), + dossier: chemins.join(base, ...segments.slice(0, -1)), + }; + } + + // Un fichier choisi hors des racines, nommé dans un échec par son nom et + // son dossier. + const horsRacine = (absolu) => ({ chemin: chemins.basename(absolu), dossier: chemins.dirname(absolu) }); + + const refus = (loc, cause) => new RefusFichier('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause }); + + // Ce que devient une erreur levée pendant une lecture, ou une écriture : + // un refus déjà nommé passe tel quel, une faute du code aussi ; une erreur + // du système devient ABSENT, LECTURE ou ECRITURE. + function echecLecture(loc, erreur) { + if (!estErreurSysteme(erreur)) return erreur; + if (ABSENCE_EN_LECTURE.has(erreur.code)) return new RefusFichier('ABSENT', { chemin: loc.chemin }); + return new RefusFichier('LECTURE', { chemin: loc.chemin, dossier: loc.dossier, cause: erreur.code }); + } + + const echecEcriture = (loc, erreur) => (estErreurSysteme(erreur) ? refus(loc, erreur.code) : erreur); + + // L'état d'un chemin sans suivre son dernier lien, ou null quand il n'existe + // pas ; l'état de ce qu'il désigne, liens suivis, ou null quand rien n'y + // mène — absent, lien sans cible, boucle de liens. + async function etatDe(absolu) { + try { + return await fs.lstat(absolu); + } catch (erreur) { + if (ABSENCE.has(erreur?.code)) return null; + throw erreur; + } + } + + async function etatSuivi(absolu) { + try { + return await fs.stat(absolu); + } catch (erreur) { + if (ABSENCE.has(erreur?.code) || erreur?.code === 'ELOOP') return null; + throw erreur; + } + } + + // Efface un fichier, ou le lien lui-même ; un absent n'est pas une faute. + async function effacer(absolu) { + try { + await fs.unlink(absolu); + } catch (erreur) { + if (!ABSENCE.has(erreur?.code)) throw erreur; + } + } + + // Écrit dans une poignée ouverte, la vide sur le disque et la ferme ; un + // échec la ferme aussi, puis se lève. + async function ecrireDans(poignee, donnees) { + try { + await poignee.writeFile(donnees); + await poignee.sync(); + } catch (erreur) { + await poignee.close().catch(() => {}); + throw erreur; + } + await poignee.close(); + } + + // Chemin réel d'un chemin absolu : celui de son plus long préfixe qui + // existe, liens résolus, suivi des segments qui n'existent pas encore et + // qu'aucun lien ne peut donc détourner. Rien n'existe, pas même le + // volume — une clé retirée — : le chemin tel quel. Un lien sans cible, ou + // qui boucle, n'a pas de chemin réel : null. + async function cheminReel(absolu) { + const absents = []; + let existant = absolu; + while ((await etatDe(existant)) === null) { + const parent = chemins.dirname(existant); + if (parent === existant) return absolu; + absents.unshift(chemins.basename(existant)); + existant = parent; + } + try { + return chemins.join(await fs.realpath(existant), ...absents); + } catch (erreur) { + if (ABSENCE.has(erreur?.code) || erreur?.code === 'ELOOP') return null; + throw erreur; + } + } + + // Vrai quand un chemin est le dossier donné ou se trouve sous lui, sur une + // frontière de segment ; la racine d'un volume, qui finit par le + // séparateur, porte tout le volume. + function estSous(chemin, dossier) { + return chemin === dossier || chemin.startsWith(dossier.endsWith(chemins.sep) ? dossier : `${dossier}${chemins.sep}`); + } + + // Lève CHEMIN_REFUSE quand le chemin réel de la cible n'est ni celui de sa + // racine ni sous lui : un lien, en chemin ou visé, ne fait pas sortir. Les + // deux chemins réels se résolvent de la même façon, si bien qu'un lien + // dans le chemin de la racine elle-même ne compte pas. + async function confiner(loc) { + const racineReelle = await cheminReel(loc.racine); + const cibleReelle = await cheminReel(loc.absolu); + if (racineReelle === null || cibleReelle === null || !estSous(cibleReelle, racineReelle)) { + throw new RefusFichier('CHEMIN_REFUSE', { chemin: loc.chemin }); + } + } + + // Renomme, et réessaie sur une cause passagère, après une pause qui + // double ; la dernière erreur se lève. + async function renommer(de, vers) { + let attente = PAUSE_INITIALE_MS; + for (let essai = 0; ; essai += 1) { + try { + await fs.rename(de, vers); + return; + } catch (erreur) { + if (essai === REESSAIS_RENOMMAGE || !CAUSES_PASSAGERES.has(erreur?.code)) throw erreur; + } + await attendre(attente); + attente = Math.min(2 * attente, PAUSE_MAX_MS); + } + } + + // L'écriture atomique d'un chemin absolu (§ 8.8) : .ecriture, + // effacé s'il reste d'une écriture interrompue — lien compris, que + // l'effacement ne suit pas —, créé en exclusif, écrit, vidé sur le disque, + // fermé, puis renommé par-dessus la cible. La cible n'est jamais ouverte : + // un échec la laisse intacte, et retire le temporaire. + async function remplacer(absolu, octets) { + const temporaire = `${absolu}${SUFFIXE_ECRITURE}`; + await effacer(temporaire); + const poignee = await fs.open(temporaire, 'wx'); + try { + await ecrireDans(poignee, octets); + await renommer(temporaire, absolu); + } catch (erreur) { + await effacer(temporaire).catch(() => {}); + throw erreur; + } + } + + // Ce que rend verrouiller d'un verrou présent, ou null quand il a disparu + // depuis. Un verrou qui ne se lit pas rend seance, depuis et vivant à null. + async function verrouPresent(absolu) { + let texte; + try { + texte = (await fs.readFile(absolu)).toString('utf8'); + } catch (erreur) { + if (erreur?.code === 'ENOENT') return null; + throw erreur; + } + const contenu = analyserVerrou(texte); + if (contenu === null) return { pris: false, seance: null, depuis: null, vivant: null }; + return { + pris: false, + seance: contenu.seance, + depuis: contenu.depuis, + vivant: contenu.hote === hote ? processusVivant(contenu.pid, signaler) : null, + }; + } + + // Le type de support du volume qui porte un chemin, sous Windows : le type + // de lecteur que rend DriveInfo, par PowerShell. + async function supportWindows(absolu) { + const sortie = await lancer('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', COMMANDE_LECTEUR], { + variables: { GTT_RACINE: absolu }, + delai: DELAI_SUPPORT_MS, + }); + const type = String(sortie).trim(); + return type === 'Removable' ? 'amovible' : type === 'Fixed' ? 'fixe' : 'inconnu'; + } + + // Sous Linux : l'attribut removable du disque qui porte le plus proche + // dossier existant du chemin. Le numéro du périphérique se décompose comme + // le fait glibc ; /sys/dev/block/: mène au périphérique, + // dont une partition porte le fichier partition et laisse removable à son + // disque, le dossier parent. Un périphérique que /sys/dev/block ne décrit + // pas — tmpfs, btrfs, un partage réseau — rejette. + async function supportLinux(absolu) { + let existant = absolu; + while ((await etatSuivi(existant)) === null && chemins.dirname(existant) !== existant) { + existant = chemins.dirname(existant); + } + const { dev } = await fs.stat(existant, { bigint: true }); + const majeur = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn); + const mineur = (dev & 0xffn) | ((dev >> 12n) & 0xffffff00n); + const peripherique = await fs.realpath(`/sys/dev/block/${majeur}:${mineur}`); + const disque = (await etatDe(chemins.join(peripherique, 'partition'))) === null + ? peripherique + : chemins.dirname(peripherique); + const attribut = (await fs.readFile(chemins.join(disque, 'removable'))).toString('utf8').trim(); + return attribut === '1' ? 'amovible' : attribut === '0' ? 'fixe' : 'inconnu'; + } + + return { + async emplacements() { + return { ...emplacements, donneesApplicatives: [...emplacements.donneesApplicatives] }; + }, + + async racines() { + return { + portable: portable === null ? null : { id: 'portable', chemin: portable }, + documents: { id: 'documents', chemin: documents }, + }; + }, + + // Le dossier choisi devient une racine pour le reste de la séance. + async choisirDossier() { + const { canceled, filePaths } = await dialog.showOpenDialog(fenetre(), { + properties: ['openDirectory', 'createDirectory'], + }); + if (canceled || filePaths.length === 0) return null; + choisis += 1; + const id = `choisi-${choisis}`; + bases.set(id, filePaths[0]); + return { id, chemin: filePaths[0] }; + }, + + // Crée la racine et ses parents, efface un témoin resté — ou un lien posé + // sous son nom, que l'effacement ne suit pas —, écrit le témoin en + // exclusif, le vide sur le disque, le relit et l'efface. Un échec rend sa + // cause, et le témoin s'efface. + async sonder(racine) { + const loc = localiser(racine, ''); + const temoin = chemins.join(loc.absolu, TEMOIN); + try { + await fs.mkdir(loc.absolu, { recursive: true }); + await effacer(temoin); + await ecrireDans(await fs.open(temoin, 'wx'), TEXTE_TEMOIN); + const relu = (await fs.readFile(temoin)).toString('utf8'); + await fs.unlink(temoin); + return relu === TEXTE_TEMOIN ? { inscriptible: true, cause: null } : { inscriptible: false, cause: 'RELECTURE' }; + } catch (erreur) { + if (!estErreurSysteme(erreur)) throw erreur; + await effacer(temoin).catch(() => {}); + return { inscriptible: false, cause: erreur.code }; + } + }, + + // Windows et Linux savent le dire ; ailleurs, et quand la commande ou + // /sys ne répondent pas, inconnu. + async typeSupport(racine) { + const loc = localiser(racine, ''); + try { + if (plateforme === 'win32') return await supportWindows(loc.absolu); + if (plateforme === 'linux') return await supportLinux(loc.absolu); + } catch { + // Une commande qui échoue, qui dépasse son délai, ou un périphérique + // que /sys ne décrit pas : le type reste inconnu. + } + return 'inconnu'; + }, + + async lireTexte(racine, chemin) { + const loc = localiser(racine, chemin); + try { + await confiner(loc); + return (await fs.readFile(loc.absolu)).toString('utf8'); + } catch (erreur) { + throw echecLecture(loc, erreur); + } + }, + + // Une cible qui est un dossier est refusée avant d'écrire : le + // renommage par-dessus un dossier échouerait, sous Windows après ses + // réessais. + async ecrireAtomique(racine, chemin, texte) { + exigerTexte(texte, 'le texte'); + const loc = localiser(racine, chemin); + try { + await confiner(loc); + if ((await etatDe(loc.absolu))?.isDirectory()) throw refus(loc, 'EISDIR'); + await remplacer(loc.absolu, Buffer.from(texte, 'utf8')); + } catch (erreur) { + throw echecEcriture(loc, erreur); + } + }, + + // Ouvre en ajout — le fichier absent se crée, son dossier jamais —, + // écrit la ligne et sa fin de ligne, vide sur le disque et ferme. + async ajouterLigne(racine, chemin, ligne) { + exigerLigne(ligne); + const loc = localiser(racine, chemin); + try { + await confiner(loc); + await ecrireDans(await fs.open(loc.absolu, 'a'), `${ligne}\n`); + } catch (erreur) { + throw echecEcriture(loc, erreur); + } + }, + + // Les fichiers et les dossiers, liens suivis ; une entrée qui disparaît + // entre la liste et son état, ou un lien sans cible, ne compte pas. + async lister(racine, dossier) { + const loc = localiser(racine, dossier); + try { + await confiner(loc); + const entrees = []; + for (const nom of await fs.readdir(loc.absolu)) { + const etat = await etatSuivi(chemins.join(loc.absolu, nom)); + if (etat?.isFile()) entrees.push({ nom, type: 'fichier', taille: etat.size, modifie: etat.mtimeMs }); + else if (etat?.isDirectory()) entrees.push({ nom, type: 'dossier', taille: 0, modifie: etat.mtimeMs }); + } + return entrees.sort(parNom); + } catch (erreur) { + throw echecLecture(loc, erreur); + } + }, + + async creerDossier(racine, chemin) { + const loc = localiser(racine, chemin); + try { + await confiner(loc); + await fs.mkdir(loc.absolu, { recursive: true }); + } catch (erreur) { + throw echecEcriture(loc, erreur); + } + }, + + // Un fichier, en un seul essai. rename écrase une cible existante sous + // POSIX : sa présence se contrôle d'abord, comme le système compare les + // noms, si bien que sans égard à la casse une autre casse du même nom + // est une cible présente. Un dossier absent sous la cible nomme la + // cible ; un renommage refusé nomme la source. + async deplacer(racine, de, vers) { + const source = localiser(racine, de); + const cible = localiser(racine, vers); + let etat; + try { + await confiner(source); + etat = await etatDe(source.absolu); + } catch (erreur) { + throw echecEcriture(source, erreur); + } + if (etat === null) throw new RefusFichier('ABSENT', { chemin: de }); + if (etat.isDirectory()) throw refus(source, 'EISDIR'); + try { + await confiner(cible); + if ((await etatDe(cible.absolu)) !== null) throw new RefusFichier('EXISTE', { chemin: vers }); + if (!(await etatSuivi(chemins.dirname(cible.absolu)))?.isDirectory()) throw refus(cible, 'ENOENT'); + } catch (erreur) { + throw echecEcriture(cible, erreur); + } + try { + await fs.rename(source.absolu, cible.absolu); + } catch (erreur) { + throw echecEcriture(source, erreur); + } + }, + + async supprimer(racine, chemin) { + const loc = localiser(racine, chemin); + try { + await confiner(loc); + const etat = await etatDe(loc.absolu); + if (etat?.isDirectory()) throw refus(loc, 'EISDIR'); + if (etat !== null) await effacer(loc.absolu); + } catch (erreur) { + throw echecEcriture(loc, erreur); + } + }, + + // Création exclusive (wx), qui ne suit aucun lien. Un verrou présent se + // lit ; s'il disparaît entre la création refusée et sa lecture, la prise + // se tente une seconde fois. Un verrou écrit à moitié s'efface. + async verrouiller(racine, chemin, seance) { + exigerSeance(seance); + const loc = localiser(racine, chemin); + try { + await confiner(loc); + const contenu = `${JSON.stringify({ seance, pid, hote, depuis: horodatage(maintenant()) })}\n`; + for (let essai = 0; ; essai += 1) { + let poignee; + try { + poignee = await fs.open(loc.absolu, 'wx'); + } catch (erreur) { + if (erreur?.code !== 'EEXIST') throw erreur; + const present = await verrouPresent(loc.absolu); + if (present !== null) return present; + if (essai === 1) throw erreur; + continue; + } + try { + await ecrireDans(poignee, contenu); + } catch (erreur) { + await effacer(loc.absolu).catch(() => {}); + throw erreur; + } + return { pris: true }; + } + } catch (erreur) { + throw echecEcriture(loc, erreur); + } + }, + + // N'efface que le verrou que cette séance a pris ; un verrou absent, un + // dossier à sa place ou un verrou illisible restent tels. + async deverrouiller(racine, chemin, seance) { + exigerSeance(seance); + const loc = localiser(racine, chemin); + try { + await confiner(loc); + let texte; + try { + texte = (await fs.readFile(loc.absolu)).toString('utf8'); + } catch (erreur) { + if (ABSENCE_EN_LECTURE.has(erreur?.code)) return; + throw erreur; + } + if (analyserVerrou(texte)?.seance === seance) await effacer(loc.absolu); + } catch (erreur) { + throw echecEcriture(loc, erreur); + } + }, + + // shell.openPath rend '' quand l'explorateur s'ouvre, et sinon la raison + // de l'échec. + async ouvrirDansExplorateur(racine) { + const loc = localiser(racine, ''); + let etat; + try { + etat = await etatSuivi(loc.absolu); + } catch (erreur) { + throw echecLecture(loc, erreur); + } + if (!etat?.isDirectory()) throw new RefusFichier('ABSENT', { chemin: '' }); + const raison = await shell.openPath(loc.absolu); + if (raison !== '') throw new RefusFichier('NON_DISPONIBLE', { cause: raison }); + }, + + async choisirFichierAImporter() { + const { canceled, filePaths } = await dialog.showOpenDialog(fenetre(), { properties: ['openFile'] }); + if (canceled || filePaths.length === 0) return null; + const [absolu] = filePaths; + try { + return { nom: chemins.basename(absolu), octets: new Uint8Array(await fs.readFile(absolu)) }; + } catch (erreur) { + throw echecLecture(horsRacine(absolu), erreur); + } + }, + + // Le dialogue propose le nom, sans ses dossiers, dans les Documents du + // poste ; le fichier choisi s'écrit par écriture atomique, le dialogue + // ayant déjà fait confirmer le remplacement d'un existant. + async enregistrerSous(nomPropose, octets) { + exigerTexte(nomPropose, 'le nom proposé'); + if (!(octets instanceof Uint8Array)) throw new TypeError('les octets ne sont pas un Uint8Array'); + const { canceled, filePath } = await dialog.showSaveDialog(fenetre(), { + defaultPath: chemins.join(chemins.dirname(documents), chemins.basename(nomPropose)), + }); + if (canceled || !filePath) return null; + try { + await remplacer(filePath, octets); + } catch (erreur) { + throw echecEcriture(horsRacine(filePath), erreur); + } + return filePath; + }, + }; +} + +/** + * La réponse d'un appel de primitive, telle qu'elle traverse l'IPC : + * { ok: true, valeur }, ou { ok: false, code, details } pour un RefusFichier. + * Toute autre erreur se lève. + * + * @param {() => Promise} travail + */ +export async function repondre(travail) { + try { + return { ok: true, valeur: await travail() }; + } catch (erreur) { + if (erreur instanceof RefusFichier) return { ok: false, code: erreur.code, details: erreur.details }; + throw erreur; + } +} + +/** + * Offre les primitives à la page : pour chacune, dans l'ordre de PRIMITIVES, + * poser(canal, gestionnaire) — ipcMain.handle dans le processus principal. + * Le gestionnaire reçoit l'évènement d'Electron, puis les arguments de la + * primitive, et rend sa réponse. + * + * @param {Object} fichiers ce que rend creerFichiers + * @param {(canal: string, gestionnaire: Function) => void} poser + */ +export function servir(fichiers, poser) { + for (const primitive of PRIMITIVES) { + poser(`${PREFIXE_CANAL}${primitive}`, (_evenement, ...parametres) => repondre(() => fichiers[primitive](...parametres))); + } +} diff --git a/electron/main.js b/electron/main.js index dd0b6c3..608a8cf 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2,33 +2,81 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Processus principal de la coquille Electron (§ 13.1) : une fenêtre qui -// charge la page construite par Vite dans www/, et un seul canal IPC, que -// electron/preload.cjs expose à la page sous window.gtt. +// charge la page construite par Vite dans www/, et le système de fichiers de +// la plateforme (electron/fichiers.js), offert à la page par un canal IPC +// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers. // // La page n'a ni Node ni accès au disque : isolation de contexte active, // intégration de Node coupée, rendu en bac à sable. Elle n'atteint le -// processus principal que par les canaux que ce module traite. Un nom importé -// interprété comme du balisage reste ainsi une faute d'affichage, et non un -// accès au disque. -import { app, BrowserWindow, ipcMain, Menu } from 'electron'; +// processus principal que par ces canaux, et n'y désigne un fichier que par +// une racine que ce processus connaît et un chemin relatif à elle. Un nom +// importé interprété comme du balisage reste ainsi une faute d'affichage, et +// non un accès au disque. +import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { creerFichiers, decrireSysteme, processusVivant, servir } from './fichiers.js'; // Chemins relatifs à ce module : ils valent dans le dépôt comme dans // l'archive app.asar de l'exécutable, qui reproduit electron/ et www/. const PRECHARGEMENT = fileURLToPath(new URL('./preload.cjs', import.meta.url)); const PAGE = fileURLToPath(new URL('../www/index.html', import.meta.url)); -// Dossier de l'exécutable livré (§ 8.6, point 1). L'exécutable portable est -// une archive auto-extractible : l'application tourne depuis un dossier -// temporaire effacé à la sortie, et le lanceur publie son propre dossier dans -// PORTABLE_EXECUTABLE_DIR avant de la lancer. Hors de ce lanceur, la variable -// est absente ; vide, elle ne désigne aucun dossier. Dans les deux cas, le -// canal rend null. Le gestionnaire est posé avant le chargement de la page, -// qui peut l'appeler dès son premier script. -ipcMain.handle('gtt:dossier-executable', () => process.env.PORTABLE_EXECUTABLE_DIR || null); +// Profil de Chromium (§ 8.6) : un dossier neuf sous le dossier temporaire du +// système, nommé gtt-profil--…, posé avant que l'application soit +// prête — Chromium l'ouvre alors —, et retiré à la sortie. L'application ne +// garde rien dans ce profil, et un exécutable portable ne laisse ainsi rien +// dans AppData, ni dans le dossier de travail. +// +// Le retrait de la sortie n'est qu'un premier temps : Chromium écrit encore +// dans le profil après l'évènement quit — son état local, ses préférences, +// le stockage de session —, et le dossier renaît, réduit à quelques fichiers. +// Une séance qui s'arrête sans sortir n'en retire rien. Chaque démarrage +// retire donc, après l'ouverture de la fenêtre, les profils des séances +// terminées : le pid de leur nom ne désigne plus aucun processus de ce +// poste. Le profil d'une séance qui tourne encore reste. +const TEMPORAIRE = app.getPath('temp'); +const PROFIL_DE_SEANCE = /^gtt-profil-(\d+)-/; +const PROFIL = mkdtempSync(join(TEMPORAIRE, `gtt-profil-${process.pid}-`)); +app.setPath('userData', PROFIL); +app.on('quit', () => { + try { + rmSync(PROFIL, { recursive: true, force: true }); + } catch { + // Au mieux : le démarrage suivant le retire. + } +}); + +async function retirerProfilsTermines() { + for (const nom of await readdir(TEMPORAIRE)) { + const pid = Number(PROFIL_DE_SEANCE.exec(nom)?.[1]); + if (!(pid > 0) || pid === process.pid || processusVivant(pid)) continue; + await rm(join(TEMPORAIRE, nom), { recursive: true, force: true }).catch(() => {}); + } +} + +// Le système de fichiers : emplacements et racines lus une fois, au +// démarrage (§ 8.6) ; les dialogues s'ouvrent sur la fenêtre de la page. Les +// gestionnaires sont posés avant le chargement de la page, qui peut les +// appeler dès son premier script. +let fenetre = null; +const fichiers = creerFichiers({ + ...decrireSysteme({ + env: process.env, + plateforme: process.platform, + cheminSysteme: (nom) => app.getPath(nom), + nomProduit: app.getName(), + }), + dialog, + shell, + fenetre: () => fenetre, +}); +servir(fichiers, (canal, gestionnaire) => ipcMain.handle(canal, gestionnaire)); function creerFenetre() { - const fenetre = new BrowserWindow({ + fenetre = new BrowserWindow({ width: 1366, height: 768, webPreferences: { @@ -53,4 +101,5 @@ function creerFenetre() { app.whenReady().then(() => { Menu.setApplicationMenu(null); creerFenetre(); + retirerProfilsTermines().catch(() => {}); }); diff --git a/electron/preload.cjs b/electron/preload.cjs index ef6a102..c087e1a 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -2,18 +2,44 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Préchargement de la coquille (§ 13.1) : le seul pont entre la page et le -// processus principal. Il expose window.gtt et rien d'autre ; la page n'en -// reçoit que des fonctions qui appellent un canal nommé, jamais ipcRenderer -// lui-même. +// processus principal. Il expose window.gtt.fichiers et rien d'autre : une +// fonction par primitive du système de fichiers, qui invoque le canal de +// cette primitive, gtt:fichiers:, et rend la réponse du processus +// principal (electron/fichiers.js). La page ne reçoit jamais ipcRenderer +// lui-même, ni une fonction qui invoquerait un canal de son choix. // // Un préchargement exécuté en bac à sable est un script CommonJS, d'où // l'extension .cjs dans un paquet de type module. Son require ne connaît -// qu'electron et quelques modules de Node. +// qu'electron et quelques modules de Node, et non les fichiers du projet : +// les primitives s'écrivent donc ici, dans l'ordre de l'interface, et +// test/coquille.test.js éprouve qu'elles sont celles des canaux que pose le +// processus principal. const { contextBridge, ipcRenderer } = require('electron'); -contextBridge.exposeInMainWorld('gtt', { - plateforme: 'electron', - // Dossier publié par le lanceur portable (§ 8.6, point 1), ou null hors de - // lui ; voir electron/main.js. - dossierExecutable: () => ipcRenderer.invoke('gtt:dossier-executable'), -}); +const PRIMITIVES = [ + 'emplacements', + 'racines', + 'choisirDossier', + 'sonder', + 'typeSupport', + 'lireTexte', + 'ecrireAtomique', + 'ajouterLigne', + 'lister', + 'creerDossier', + 'deplacer', + 'supprimer', + 'verrouiller', + 'deverrouiller', + 'ouvrirDansExplorateur', + 'choisirFichierAImporter', + 'enregistrerSous', +]; + +const fichiers = {}; +for (const primitive of PRIMITIVES) { + const canal = `gtt:fichiers:${primitive}`; + fichiers[primitive] = (...parametres) => ipcRenderer.invoke(canal, ...parametres); +} + +contextBridge.exposeInMainWorld('gtt', { fichiers }); diff --git a/src/stockage/fichiers_electron.js b/src/stockage/fichiers_electron.js new file mode 100644 index 0000000..409f72b --- /dev/null +++ b/src/stockage/fichiers_electron.js @@ -0,0 +1,59 @@ +// © 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 plateforme electron, côté page (§ 13.1, +// § 13.4) : l'une des deux exceptions nommées de la frontière des couches, +// qui lit le pont window.gtt. Chaque primitive appelle la fonction du même +// nom que electron/preload.cjs expose sous window.gtt.fichiers ; le +// processus principal l'exécute (electron/fichiers.js) et rend +// { ok: true, valeur }, ou { ok: false, code, details } pour un échec, dont +// une ErreurStockage de ce code et de ces détails prend ici la place : une +// erreur levée ne traverse pas l'IPC. Une faute de la coquille, qu'Electron +// rapporte par son seul message, se lève telle quelle. +// +// L'écriture atomique et le verrou du § 8.8 sont ceux de la coquille. +import { ErreurStockage } from './erreurs.js'; + +/** + * Le système de fichiers de la plateforme electron. + * + * @param {Object} [pont] une fonction par primitive, qui + * rend la réponse du processus principal ; window.gtt.fichiers par + * défaut + * @returns {import('./systeme_fichiers.js').SystemeFichiers} + */ +export function creerFichiersElectron(pont = window.gtt.fichiers) { + // La valeur de la réponse ; l'ErreurStockage d'un échec ; une TypeError + // pour une réponse d'une autre forme. + async function appeler(primitive, ...parametres) { + const reponse = await pont[primitive](...parametres); + if (reponse?.ok === true) return reponse.valeur; + if (reponse?.ok === false && typeof reponse.code === 'string') { + throw new ErreurStockage(reponse.code, reponse.details); + } + throw new TypeError(`réponse du pont illisible pour ${primitive}`); + } + + return { + nature: 'electron', + renommageAtomique: true, + verrouDisponible: true, + emplacements: () => appeler('emplacements'), + racines: () => appeler('racines'), + choisirDossier: () => appeler('choisirDossier'), + sonder: (racine) => appeler('sonder', racine), + typeSupport: (racine) => appeler('typeSupport', racine), + lireTexte: (racine, chemin) => appeler('lireTexte', racine, chemin), + ecrireAtomique: (racine, chemin, texte) => appeler('ecrireAtomique', racine, chemin, texte), + ajouterLigne: (racine, chemin, ligne) => appeler('ajouterLigne', racine, chemin, ligne), + lister: (racine, dossier) => appeler('lister', racine, dossier), + creerDossier: (racine, chemin) => appeler('creerDossier', racine, chemin), + deplacer: (racine, de, vers) => appeler('deplacer', racine, de, vers), + supprimer: (racine, chemin) => appeler('supprimer', racine, chemin), + verrouiller: (racine, chemin, seance) => appeler('verrouiller', racine, chemin, seance), + deverrouiller: (racine, chemin, seance) => appeler('deverrouiller', racine, chemin, seance), + ouvrirDansExplorateur: (racine) => appeler('ouvrirDansExplorateur', racine), + choisirFichierAImporter: () => appeler('choisirFichierAImporter'), + enregistrerSous: (nomPropose, octets) => appeler('enregistrerSous', nomPropose, octets), + }; +} diff --git a/test/coquille.test.js b/test/coquille.test.js index 55965ac..50d72a0 100644 --- a/test/coquille.test.js +++ b/test/coquille.test.js @@ -2,28 +2,45 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // La coquille Electron (§ 13.1) : la fenêtre qu'ouvre le processus principal, -// la page qu'elle charge, le pont étroit que le préchargement expose, et le -// trajet du dossier publié par le lanceur portable jusqu'à la page (§ 8.6, -// point 1). +// la page qu'elle charge, le profil de Chromium hors d'AppData (§ 8.6), le +// pont étroit et nommé que le préchargement expose — une fonction par +// primitive du système de fichiers, un canal par fonction —, et ce que le +// processus principal y répond : emplacements, racines, dialogues, +// explorateur. // // electron/main.js s'exécute sous Node, dans un processus à part, contre // test/electron_factice.js, qui consigne ce que la coquille demande à // Electron puis rend son journal en JSON. Ce qui s'éprouve ici est ce que la // coquille demande ; qu'Electron l'applique relève d'une épreuve dans un vrai -// Electron. +// Electron (make essai_demarrage). Les primitives elles-mêmes s'éprouvent +// dans test/fichiers_electron.test.js. import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { PRIMITIVES } from '../electron/fichiers.js'; import { describe, test } from './lanceur.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); const FACTICE = pathToFileURL(join(RACINE, 'test', 'electron_factice.js')).href; const PRINCIPAL = join(RACINE, 'electron', 'main.js'); const PRECHARGEMENT = join(RACINE, 'electron', 'preload.cjs'); -const CANAL = 'gtt:dossier-executable'; +const NOM_PRODUIT = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8')).productName; +const CANAUX = PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`); + +// Dossier que publie le lanceur portable, et LOCALAPPDATA, dans le scénario +// qui les donne. Aucun des deux n'est lu ni écrit. +const PUBLIE = join(sep, 'media', 'cle', 'soirees'); +const LOCAL = join(sep, 'donnees', 'locales'); + +// Ce que la coquille voit de l'environnement, par scénario. +const SCENARIOS = { + publie: { PORTABLE_EXECUTABLE_DIR: PUBLIE, LOCALAPPDATA: LOCAL }, + absent: {}, + vide: { PORTABLE_EXECUTABLE_DIR: '' }, +}; // Délai de l'exécution de la coquille. Le factice ne joue la page qu'une // fois le processus sans rien à faire ; un minuteur ou un écouteur laissé @@ -34,19 +51,41 @@ const CANAL = 'gtt:dossier-executable'; // ne retient. const DELAI_MS = 4_000; -// Exécute la coquille contre le factice et rend son journal. Le répertoire -// courant est un dossier vide hors du projet : dans l'exécutable, il n'est -// jamais l'intérieur d'app.asar, si bien qu'un chemin résolu contre lui ne -// désigne ni la page ni le préchargement. -function executerLaCoquille() { +// Le pid d'un processus terminé : aucun processus de ce poste n'y répond +// plus. +const PID_TERMINE = spawnSync(process.execPath, ['-e', '']).pid; + +// Ce que le dossier temporaire porte avant la coquille : les Documents du +// produit, où l'explorateur s'ouvre ; le profil qu'a laissé une séance +// terminée, celui d'une séance qui tourne encore — ce processus d'épreuve —, +// et un dossier dont le nom ne porte aucun pid. +const PROFIL_TERMINE = `gtt-profil-${PID_TERMINE}-Ab12Cd`; +const PROFIL_VIVANT = `gtt-profil-${process.pid}-Ef34Gh`; +const AUTRE = 'gtt-profil-sans-pid'; +const DOSSIERS_POSES = ['Documents', PROFIL_TERMINE, PROFIL_VIVANT, AUTRE]; + +// Exécute la coquille contre le factice dans un scénario, et rend son +// journal, son pid, le dossier temporaire qu'elle a reçu, et ce qu'elle y a +// laissé. Le répertoire courant est un dossier vide hors du projet : dans +// l'exécutable, il n'est jamais l'intérieur d'app.asar, si bien qu'un chemin +// résolu contre lui ne désigne ni la page ni le préchargement. Le dossier +// temporaire est neuf, et porte les DOSSIERS_POSES. +function executerLaCoquille(scenario) { const environnement = { ...process.env }; delete environnement.PORTABLE_EXECUTABLE_DIR; + delete environnement.LOCALAPPDATA; const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-')); + const temporaire = mkdtempSync(join(tmpdir(), 'coquille-temp-')); + mkdirSync(join(temporaire, 'Documents', NOM_PRODUIT), { recursive: true }); + for (const nom of [PROFIL_TERMINE, PROFIL_VIVANT, AUTRE]) { + mkdirSync(join(temporaire, nom, 'Session Storage'), { recursive: true }); + writeFileSync(join(temporaire, nom, 'Local State'), '{}'); + } try { const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], { cwd: ailleurs, encoding: 'utf8', - env: environnement, + env: { ...environnement, TMPDIR: temporaire, TEMP: temporaire, TMP: temporaire, ...SCENARIOS[scenario] }, timeout: DELAI_MS, killSignal: 'SIGKILL', }); @@ -56,26 +95,37 @@ function executerLaCoquille() { `la coquille ne s'arrête pas en ${DELAI_MS} ms : un minuteur ou un écouteur la tient en vie`, ); assert.equal(resultat.status, 0, `la coquille ne s'exécute pas :\n${resultat.stderr}`); - return JSON.parse(resultat.stdout); + return { + journal: JSON.parse(resultat.stdout), + pid: resultat.pid, + temporaire, + restes: readdirSync(temporaire).sort(), + }; } finally { rmSync(ailleurs, { recursive: true, force: true }); + rmSync(temporaire, { recursive: true, force: true }); } } -// L'exécution est partagée par les épreuves : le processus ne se lance -// qu'une fois, et son échec se rend à chacune sans relancer. -let execution; -function journalDeLaCoquille() { - if (execution === undefined) { +// Chaque scénario ne s'exécute qu'une fois, et son échec se rend à chaque +// épreuve qui le demande, sans relancer. +const executions = new Map(); +function coquille(scenario = 'publie') { + if (!executions.has(scenario)) { try { - execution = { journal: executerLaCoquille() }; + executions.set(scenario, { resultat: executerLaCoquille(scenario) }); } catch (erreur) { - execution = { erreur }; + executions.set(scenario, { erreur }); } } + const execution = executions.get(scenario); if (execution.erreur !== undefined) throw execution.erreur; - return execution.journal; + return execution.resultat; } +const journalDeLaCoquille = (scenario) => coquille(scenario).journal; + +// L'appel de la page à une fonction du pont, consigné par le factice. +const appelDe = (journal, membre) => journal.prechargement.appels.find((appel) => appel.membre === membre); describe('coquille : processus principal', () => { test("une seule fenêtre, 1366 × 768 : page isolée, sans Node, en bac à sable, préchargée par le pont", () => { @@ -106,12 +156,16 @@ describe('coquille : processus principal', () => { ]); }); - test(`un seul canal IPC, ${CANAL}, posé avant le chargement de la page`, () => { + test('un canal gtt:fichiers: par primitive, et aucun autre, chacun posé avant le chargement de la page', () => { const { canaux, sequence } = journalDeLaCoquille(); - assert.deepEqual(canaux, [CANAL]); - const pose = sequence.indexOf(`ipcMain.handle:${CANAL}`); + assert.deepEqual(canaux, CANAUX); + assert.equal(CANAUX.length, 17); const charge = sequence.indexOf('loadFile'); - assert.ok(pose !== -1 && charge !== -1 && pose < charge, `ordre des appels : ${sequence.join(', ')}`); + assert.notEqual(charge, -1); + for (const canal of CANAUX) { + const pose = sequence.indexOf(`ipcMain.handle:${canal}`); + assert.ok(pose !== -1 && pose < charge, `${canal} : ${sequence.join(', ')}`); + } }); test("la page n'ouvre aucune fenêtre et ne navigue nulle part", () => { @@ -120,29 +174,128 @@ describe('coquille : processus principal', () => { assert.ok(navigations.length > 0, 'aucun écouteur de will-navigate'); assert.deepEqual(navigations, navigations.map(() => true)); }); + + test('le profil de Chromium est un dossier neuf sous le dossier temporaire, nommé par le pid de la séance, posé avant whenReady, retiré à la sortie (§ 8.6)', () => { + const { journal, pid, temporaire } = coquille(); + assert.equal(journal.setPath.length, 1, JSON.stringify(journal.setPath)); + const [{ nom, chemin }] = journal.setPath; + assert.equal(nom, 'userData'); + assert.equal(dirname(chemin), temporaire); + assert.ok(basename(chemin).startsWith(`gtt-profil-${pid}-`), basename(chemin)); + const pose = journal.sequence.indexOf('app.setPath:userData'); + const pret = journal.sequence.indexOf('app.whenReady'); + assert.ok(pose !== -1 && pret !== -1 && pose < pret, `ordre des appels : ${journal.sequence.join(', ')}`); + assert.deepEqual(journal.profil, { avant: true, apres: false }); + }); + + test("au démarrage, les profils des séances terminées se retirent : ni celui d'une séance vivante, ni un dossier sans pid", () => { + const { restes } = coquille(); + assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort()); + }); }); describe('coquille : préchargement', () => { - test("n'expose que window.gtt, et ne requiert qu'electron", () => { + test("n'expose que window.gtt.fichiers, une fonction par primitive, et ne requiert qu'electron", () => { const { prechargement } = journalDeLaCoquille(); assert.equal(prechargement.erreur, undefined, prechargement.erreur); assert.deepEqual(prechargement.requis, ['electron']); - assert.deepEqual(prechargement.exposes, [ - { cle: 'gtt', membres: ['dossierExecutable', 'plateforme'] }, - ]); - assert.equal(prechargement.plateforme, 'electron'); + assert.deepEqual(prechargement.exposes, [{ cle: 'gtt', membres: ['fichiers'] }]); + assert.deepEqual(prechargement.fichiers, [...PRIMITIVES]); + assert.deepEqual( + prechargement.appels.map(({ membre, type }) => ({ membre, type })), + PRIMITIVES.map((membre) => ({ membre, type: 'function' })), + ); }); - test(`gtt.dossierExecutable() demande à chaque appel, par ${CANAL}, le dossier publié par le lanceur portable, null sans lui`, () => { - const { prechargement, invocations } = journalDeLaCoquille(); - assert.equal(prechargement.erreur, undefined, prechargement.erreur); - assert.deepEqual(prechargement.reponses, { - publie: { valeur: 'E:\\soirees' }, - absent: { valeur: null }, - vide: { valeur: null }, + test('chaque fonction invoque son propre canal, une fois par appel, et rend la réponse du processus principal', () => { + const journal = journalDeLaCoquille(); + assert.deepEqual( + journal.prechargement.appels.map(({ membre, canaux }) => ({ membre, canaux })), + PRIMITIVES.map((membre) => ({ membre, canaux: [`gtt:fichiers:${membre}`] })), + ); + assert.deepEqual(journal.invocations, CANAUX); + // Une racine que le processus principal ne connaît pas est refusée par + // lui, en réponse et non en erreur. + for (const [membre, chemin] of [ + ['sonder', ''], + ['typeSupport', ''], + ['lireTexte', 'soiree.gtt.json'], + ['ecrireAtomique', 'soiree.gtt.json'], + ['ajouterLigne', 'soiree.gtt-journal.jsonl'], + ['lister', ''], + ['creerDossier', 'corbeille'], + ['deplacer', 'soiree.gtt.json'], + ['supprimer', 'soiree.gtt.json'], + ['verrouiller', 'soiree.gtt.verrou'], + ['deverrouiller', 'soiree.gtt.verrou'], + ]) { + assert.deepEqual( + appelDe(journal, membre).reponse, + { ok: false, code: 'CHEMIN_REFUSE', details: { chemin } }, + membre, + ); + } + }); + + test("emplacements et racines viennent du processus principal : le dossier publié et son data/, AppData, LOCALAPPDATA, le temporaire, les Documents du produit", () => { + const journal = journalDeLaCoquille('publie'); + const { chemins } = journal; + assert.deepEqual(appelDe(journal, 'emplacements').reponse, { + ok: true, + valeur: { + executable: PUBLIE, + donneesApplicatives: [chemins.appData, LOCAL, chemins.temp], + insensibleCasse: process.platform === 'win32' || process.platform === 'darwin', + separateur: sep, + }, }); - // Le préchargement voit lui aussi process.env : seules les invocations - // montrent que la réponse vient du processus principal, une par appel. - assert.deepEqual(invocations, [CANAL, CANAL, CANAL]); + assert.deepEqual(appelDe(journal, 'racines').reponse, { + ok: true, + valeur: { + portable: { id: 'portable', chemin: join(PUBLIE, 'data') }, + documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) }, + }, + }); + }); + + test('sans dossier publié, ou publié vide, ni exécutable ni racine portable', () => { + for (const scenario of ['absent', 'vide']) { + const journal = journalDeLaCoquille(scenario); + const { chemins } = journal; + assert.deepEqual( + appelDe(journal, 'emplacements').reponse.valeur, + { + executable: null, + donneesApplicatives: [chemins.appData, chemins.temp], + insensibleCasse: process.platform === 'win32' || process.platform === 'darwin', + separateur: sep, + }, + scenario, + ); + assert.deepEqual( + appelDe(journal, 'racines').reponse.valeur, + { portable: null, documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) } }, + scenario, + ); + } + }); + + test("les dialogues s'ouvrent sur la fenêtre de la page, et une annulation rend null", () => { + const journal = journalDeLaCoquille(); + assert.deepEqual(journal.dialogues, [ + { methode: 'showOpenDialog', parent: true, options: { properties: ['openDirectory', 'createDirectory'] } }, + { methode: 'showOpenDialog', parent: true, options: { properties: ['openFile'] } }, + { methode: 'showSaveDialog', parent: true, options: { defaultPath: join(journal.chemins.documents, 'liste.csv') } }, + ]); + for (const membre of ['choisirDossier', 'choisirFichierAImporter', 'enregistrerSous']) { + assert.deepEqual(appelDe(journal, membre).reponse, { ok: true, valeur: null }, membre); + } + }); + + test("l'explorateur s'ouvre sur la racine que la page désigne", () => { + const journal = journalDeLaCoquille(); + assert.deepEqual(journal.explorateur, [join(journal.chemins.documents, NOM_PRODUIT)]); + // La réponse porte valeur: undefined, que le journal en JSON ne garde pas. + assert.deepEqual(appelDe(journal, 'ouvrirDansExplorateur').reponse, { ok: true }); }); }); diff --git a/test/electron_factice.js b/test/electron_factice.js index dcf6d9b..b1bb25d 100644 --- a/test/electron_factice.js +++ b/test/electron_factice.js @@ -10,14 +10,15 @@ // fausses API exportées ici, qui consignent chaque appel. Quand le processus // n'a plus rien à faire, le module joue le rôle de la page : il exécute // electron/preload.cjs dans une fonction qui reçoit require, module et -// exports, appelle le pont exposé et les gestionnaires posés par le processus -// principal, puis imprime le journal en JSON sur la sortie standard. +// exports, et appelle chaque fonction du pont exposé, qui atteint les +// gestionnaires posés par le processus principal. Il joue ensuite la sortie +// de l'application — before-quit, will-quit, quit — puis imprime le journal +// en JSON sur la sortie standard. // // Seul require est restreint, à electron, comme dans un rendu en bac à // sable. Les globales de Node du processus d'épreuve, dont process et Buffer, // restent visibles du préchargement, et un vrai préchargement en bac à sable -// lit lui aussi process.env. Un pont qui lirait lui-même la variable -// répondrait donc juste : le journal des invocations, et non l'absence de +// lit lui aussi process.env. Le journal des invocations, et non l'absence de // process, montre que chaque réponse vient du processus principal. // // Les exports sont la surface d'Electron que la coquille a le droit @@ -25,9 +26,17 @@ // electron/main.js. Ils refusent ce qu'Electron refuse et qu'une erreur de // la coquille provoquerait : une fenêtre créée avant que l'application soit // prête, un second gestionnaire pour un même canal, un appel à un canal sans -// gestionnaire. -import { readFileSync } from 'node:fs'; +// gestionnaire, un nom de chemin qu'app.getPath ne connaît pas. +// +// app.getPath rend le dossier temporaire du processus — que l'épreuve +// désigne par TMPDIR, et par TEMP et TMP sous Windows —, et des Documents et +// un AppData qui y vivent ; app.getName, le nom de produit de package.json, +// comme Electron. Les dialogues rendent une annulation ; l'explorateur +// consigne le dossier qu'on lui demande d'ouvrir, et réussit. +import { existsSync, readFileSync } from 'node:fs'; import { registerHooks } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { compileFunction } from 'node:vm'; @@ -39,13 +48,45 @@ registerHooks({ }); const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url)); +const NOM_PRODUIT = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).productName; -// Valeur de PORTABLE_EXECUTABLE_DIR quand le lanceur portable la publie. -const DOSSIER_PUBLIE = 'E:\\soirees'; +// Ce que rend app.getPath, par nom. +const TEMPORAIRE = tmpdir(); +const CHEMINS = Object.freeze({ + temp: TEMPORAIRE, + documents: join(TEMPORAIRE, 'Documents'), + appData: join(TEMPORAIRE, 'AppData'), +}); + +// Arguments que la page passe à chaque fonction du pont, selon la réponse de +// racines() quand elle a précédé : une racine que le processus principal ne +// connaît pas, pour que la réponse vienne du confinement sans toucher au +// disque ; la racine documents pour l'explorateur ; un nom et des octets +// pour l'enregistrement. Une fonction absente d'ici s'appelle sans argument. +const INCONNUE = { id: 'racine-inconnue', chemin: TEMPORAIRE }; +const ARGUMENTS_DE_LA_PAGE = { + sonder: () => [INCONNUE], + typeSupport: () => [INCONNUE], + lireTexte: () => [INCONNUE, 'soiree.gtt.json'], + ecrireAtomique: () => [INCONNUE, 'soiree.gtt.json', '{}\n'], + ajouterLigne: () => [INCONNUE, 'soiree.gtt-journal.jsonl', '{}'], + lister: () => [INCONNUE, ''], + creerDossier: () => [INCONNUE, 'corbeille'], + deplacer: () => [INCONNUE, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'], + supprimer: () => [INCONNUE, 'soiree.gtt.json'], + verrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'], + deverrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'], + ouvrirDansExplorateur: (racines) => [racines?.documents], + enregistrerSous: () => ['liste.csv', new Uint8Array([0x6e, 0x6f, 0x6d])], +}; const journal = { // Appels qui comptent pour l'ordre, dans l'ordre où ils arrivent. sequence: [], + // Ce que rend app.getPath, par nom. + chemins: CHEMINS, + // Chaque app.setPath : { nom, chemin }. + setPath: [], // Options de chaque new BrowserWindow. fenetres: [], // Argument de chaque Menu.setApplicationMenu. @@ -57,17 +98,26 @@ const journal = { // Canal de chaque ipcRenderer.invoke du préchargement, dans l'ordre des // appels. invocations: [], + // Chaque dialogue : sa méthode, sa fenêtre parente est-elle une + // BrowserWindow, ses options. + dialogues: [], + // Dossier de chaque shell.openPath. + explorateur: [], // Réponse du gestionnaire d'ouverture de chaque fenêtre à une adresse // externe. ouvertures: [], // Pour chaque écouteur de will-navigate : la navigation est-elle annulée ? navigations: [], - // Ce que fait le préchargement : modules requis, objets exposés, et la - // réponse du pont selon PORTABLE_EXECUTABLE_DIR. + // Ce que fait le préchargement : modules requis, objets exposés, membres + // de gtt.fichiers, et pour chacun les canaux qu'il invoque et sa réponse. prechargement: null, + // Le dossier userData existe-t-il avant la sortie de l'application, puis + // après ; null sans app.setPath('userData'). + profil: null, }; const gestionnaires = new Map(); +const ecouteurs = new Map(); const pageDeFenetre = []; // L'application n'est jamais prête pendant l'évaluation du module principal, @@ -88,12 +138,25 @@ export const app = { }); return quandPret; }, - on(evenement) { + on(evenement, ecouteur) { journal.sequence.push(`app.on:${evenement}`); + if (!ecouteurs.has(evenement)) ecouteurs.set(evenement, []); + ecouteurs.get(evenement).push(ecouteur); }, quit() { journal.sequence.push('app.quit'); }, + getName() { + return NOM_PRODUIT; + }, + getPath(nom) { + if (!Object.hasOwn(CHEMINS, nom)) throw new Error(`Failed to get '${nom}' path`); + return CHEMINS[nom]; + }, + setPath(nom, chemin) { + journal.sequence.push(`app.setPath:${nom}`); + journal.setPath.push({ nom, chemin }); + }, }; export class BrowserWindow { @@ -148,6 +211,24 @@ export const ipcMain = { }, }; +export const dialog = { + async showOpenDialog(parent, options) { + journal.dialogues.push({ methode: 'showOpenDialog', parent: parent instanceof BrowserWindow, options }); + return { canceled: true, filePaths: [] }; + }, + async showSaveDialog(parent, options) { + journal.dialogues.push({ methode: 'showSaveDialog', parent: parent instanceof BrowserWindow, options }); + return { canceled: true, filePath: '' }; + }, +}; + +export const shell = { + async openPath(chemin) { + journal.explorateur.push(chemin); + return ''; + }, +}; + // Côté page : ce que require('electron') rend au préchargement. invoke se // consigne, atteint le gestionnaire posé par le processus principal pour ce // canal, et rejette comme Electron quand il n'y en a pas. @@ -169,16 +250,25 @@ function electronDuRendu(exposes) { }; } -// Réponse du pont pour une valeur de PORTABLE_EXECUTABLE_DIR, undefined -// valant variable absente ; une erreur se consigne par son message. -async function reponseDuPont(api, valeur) { - if (valeur === undefined) delete process.env.PORTABLE_EXECUTABLE_DIR; - else process.env.PORTABLE_EXECUTABLE_DIR = valeur; - try { - return { valeur: await api.dossierExecutable() }; - } catch (erreur) { - return { erreur: erreur.message }; +// Appelle chaque fonction de gtt.fichiers, dans l'ordre de ses membres, et +// consigne pour chacune son type, les canaux qu'elle invoque et sa réponse, +// une erreur par son message. +async function appelerLePont(fichiers) { + const appels = []; + let racines; + for (const membre of Object.keys(fichiers)) { + const avant = journal.invocations.length; + const appel = { membre, type: typeof fichiers[membre] }; + try { + appel.reponse = await fichiers[membre](...(ARGUMENTS_DE_LA_PAGE[membre]?.(racines) ?? [])); + if (membre === 'racines') racines = appel.reponse?.valeur; + } catch (erreur) { + appel.erreur = erreur.message; + } + appel.canaux = journal.invocations.slice(avant); + appels.push(appel); } + return appels; } async function jouerLePrechargement() { @@ -195,22 +285,23 @@ async function jouerLePrechargement() { filename: PRECHARGEMENT, })(requerir, module, module.exports); - const gtt = exposes.find(({ cle }) => cle === 'gtt')?.api; + const fichiers = exposes.find(({ cle }) => cle === 'gtt')?.api?.fichiers; return { requis, exposes: exposes.map(({ cle, api }) => ({ cle, membres: Object.keys(api).sort() })), - plateforme: gtt?.plateforme ?? null, - reponses: - typeof gtt?.dossierExecutable === 'function' - ? { - publie: await reponseDuPont(gtt, DOSSIER_PUBLIE), - absent: await reponseDuPont(gtt, undefined), - vide: await reponseDuPont(gtt, ''), - } - : null, + fichiers: fichiers === undefined ? null : Object.keys(fichiers), + appels: fichiers === undefined ? [] : await appelerLePont(fichiers), }; } +// Joue la sortie de l'application : chaque écouteur de ces évènements, dans +// l'ordre où Electron les émet. +function sortir() { + for (const evenement of ['before-quit', 'will-quit', 'quit']) { + for (const ecouteur of ecouteurs.get(evenement) ?? []) ecouteur({ preventDefault() {} }, 0); + } +} + async function jouerLaPage() { const externe = 'https://exemple.invalid/'; for (const page of pageDeFenetre) { @@ -227,6 +318,10 @@ async function jouerLaPage() { } catch (erreur) { journal.prechargement = { erreur: erreur.message }; } + const profil = journal.setPath.find(({ nom }) => nom === 'userData')?.chemin; + const avant = profil !== undefined && existsSync(profil); + sortir(); + journal.profil = profil === undefined ? null : { avant, apres: existsSync(profil) }; process.stdout.write(`${JSON.stringify(journal)}\n`); } diff --git a/test/fichiers_electron.long.test.js b/test/fichiers_electron.long.test.js new file mode 100644 index 0000000..20be7f0 --- /dev/null +++ b/test/fichiers_electron.long.test.js @@ -0,0 +1,290 @@ +// © 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 +// renommage qui remplace la cible, et le verrou d'un processus vivant ou +// terminé. Chaque épreuve écrit sous ses propres dossiers +// temporaires, retirés à sa fin, qu'elle passe ou non. +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'; +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. +function testNettoye(nom, ...suite) { + const corps = suite.pop(); + test(nom, ...suite, async () => { + try { + await corps(); + } finally { + for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true }); + } + }); +} + +// 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']); + }); + + 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, + }); + }); +}); diff --git a/test/fichiers_electron.test.js b/test/fichiers_electron.test.js new file mode 100644 index 0000000..b860226 --- /dev/null +++ b/test/fichiers_electron.test.js @@ -0,0 +1,1400 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du système de fichiers de la coquille (§ 8.6, § 8.8, § 13.1), +// sous node et sans disque réel. electron/fichiers.js y reçoit un disque en +// mémoire qui répond comme node:fs/promises sous POSIX, liens symboliques +// compris, consigne chaque appel et refuse ceux qu'on lui désigne. Les +// dialogues, l'explorateur, le lanceur de commandes, l'horloge, les pauses +// entre deux renommages et le signal aux processus y sont injectés de même. +// Suivent la page, src/stockage/fichiers_electron.js, derrière le vrai +// préchargement, et les définitions que la coquille recopie de src/stockage +// faute de pouvoir les importer. La suite de contrat se joue sur un vrai +// dossier, dans test/fichiers_electron.long.test.js. Les noms d'épreuve sont +// inventés. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compileFunction } from 'node:vm'; +import { + DELAI_SUPPORT_MS, + PAUSE_INITIALE_MS, + PAUSE_MAX_MS, + PREFIXE_CANAL, + PRIMITIVES, + REESSAIS_RENOMMAGE, + RefusFichier, + SUFFIXE_ECRITURE, + TEMOIN, + creerFichiers, + decrireSysteme, + processusVivant, + repondre, + segmentsAdmis, + servir, +} from '../electron/fichiers.js'; +import { ErreurStockage } from '../src/stockage/erreurs.js'; +import { creerFichiersElectron } from '../src/stockage/fichiers_electron.js'; +import { SUFFIXE_ECRITURE as SUFFIXE_DU_STOCKAGE } from '../src/stockage/noms.js'; +import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js'; +import { describe, test } from './lanceur.js'; + +const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url)); + +// Le périphérique du disque en mémoire, majeur 8 et mineur 1 dans le codage +// de glibc, et la machine du processus d'épreuve. +const DEV_DISQUE = 0x801n; +const HOTE = 'poste-essai'; +const PID = 4242; + +// Instant que rend l'horloge injectée. +const INSTANT = new Date(Date.UTC(2026, 9, 6, 23, 2, 11)); + +// Les racines de l'épreuve, et ce que les échecs y nomment. +const DOCUMENTS = '/poste/exemple/Documents/Gestion table tournante Libre'; +const PUBLIE = '/media/cle/soirees'; +const DATA = `${PUBLIE}/data`; +const AILLEURS = '/poste/exemple/ailleurs'; +const DOC = { id: 'documents', chemin: DOCUMENTS }; +const PORTABLE = { id: 'portable', chemin: DATA }; +const FENETRE = { fenetre: 'principale' }; + +const EMPLACEMENTS = Object.freeze({ + executable: PUBLIE, + donneesApplicatives: Object.freeze(['/poste/exemple/.config', '/tmp']), + insensibleCasse: false, + separateur: '/', +}); + +// --------------------------------------------------------------------------- +// Le disque en mémoire + +// Une erreur du système telle que node:fs la lève : un code, l'appel +// système, le chemin. La coquille ne traduit que celles-là. +function erreurSysteme(code, syscall, chemin) { + return Object.assign(new Error(`${code}: ${syscall} '${chemin}'`), { code, syscall, path: chemin }); +} + +/** + * Disque en mémoire, sous POSIX : dossiers, fichiers et liens symboliques à + * cible absolue. fs porte les fonctions de node:fs/promises dont la coquille + * se sert et lève les erreurs du système dans les mêmes cas — ENOENT, + * ENOTDIR, EISDIR, EEXIST, ELOOP. Un lien se suit partout, sauf en dernier + * segment de lstat, unlink, rename et d'une ouverture exclusive (wx). Les + * noms que rend readdir sortent dans l'ordre inverse de leur création : un + * tri omis se voit. + * + * appels chaque appel, « fonction chemin [drapeaux | cible] », les + * écritures d'une poignée comprises (write, sync, close) + * panne(fonction, code, { fois, chemin }) les fois prochains appels de + * cette fonction lèvent ce code, sur ce chemin seulement quand il + * est donné + * deposer, deposerDossier, deposerLien, contenu, existe préparent et + * lisent le disque sans rien consigner + */ +function creerDisque() { + const noeuds = new Map([['/', { type: 'dossier', octets: null, cible: null, modifie: 0, dev: DEV_DISQUE }]]); + const appels = []; + const pannes = []; + let horloge = 0; + + const tic = () => { + horloge += 1000; + return horloge; + }; + + function appel(fonction, chemin, ...suite) { + appels.push([fonction, chemin, ...suite].join(' ')); + const panne = pannes.find( + (p) => p.fonction === fonction && p.fois > 0 && (p.chemin === undefined || p.chemin === chemin), + ); + if (panne !== undefined) { + panne.fois -= 1; + throw erreurSysteme(panne.code, fonction, chemin); + } + } + + // Ce que désigne un chemin absolu : son chemin résolu et son nœud, + // undefined quand le dernier segment manque. Les liens des segments + // intermédiaires se suivent toujours, celui du dernier quand suivre est + // vrai. + function resoudre(chemin, suivre, syscall, profondeur = 0) { + if (profondeur > 8) throw erreurSysteme('ELOOP', syscall, chemin); + const segments = chemin.split('/').filter((segment) => segment !== ''); + let courant = '/'; + for (let i = 0; i < segments.length; i += 1) { + const suivant = posix.join(courant, segments[i]); + const noeud = noeuds.get(suivant); + const dernier = i === segments.length - 1; + if (noeud === undefined) { + if (dernier) return { chemin: suivant, noeud: undefined }; + throw erreurSysteme('ENOENT', syscall, chemin); + } + if (noeud.type === 'lien' && (suivre || !dernier)) { + return resoudre(posix.join(noeud.cible, ...segments.slice(i + 1)), suivre, syscall, profondeur + 1); + } + if (!dernier && noeud.type !== 'dossier') throw erreurSysteme('ENOTDIR', syscall, chemin); + courant = suivant; + } + return { chemin: courant, noeud: noeuds.get(courant) }; + } + + function creer(chemin, type, champs = {}) { + const parent = noeuds.get(posix.dirname(chemin)); + const noeud = { type, octets: null, cible: null, modifie: tic(), dev: parent?.dev ?? DEV_DISQUE, ...champs }; + noeuds.set(chemin, noeud); + return noeud; + } + + function statistiques(noeud, enGrandsEntiers = false) { + return { + isFile: () => noeud.type === 'fichier', + isDirectory: () => noeud.type === 'dossier', + isSymbolicLink: () => noeud.type === 'lien', + size: noeud.type === 'fichier' ? noeud.octets.length : 4096, + mtimeMs: noeud.modifie, + dev: enGrandsEntiers ? noeud.dev : Number(noeud.dev), + }; + } + + function poignee(chemin, noeud) { + return { + async writeFile(donnees) { + appel('write', chemin); + noeud.octets = Buffer.concat([noeud.octets, Buffer.from(donnees)]); + noeud.modifie = tic(); + }, + async sync() { + appel('sync', chemin); + }, + async close() { + appel('close', chemin); + }, + }; + } + + const fs = { + async lstat(chemin) { + appel('lstat', chemin); + const { noeud } = resoudre(chemin, false, 'lstat'); + if (noeud === undefined) throw erreurSysteme('ENOENT', 'lstat', chemin); + return statistiques(noeud); + }, + async stat(chemin, options) { + appel('stat', chemin); + const { noeud } = resoudre(chemin, true, 'stat'); + if (noeud === undefined) throw erreurSysteme('ENOENT', 'stat', chemin); + return statistiques(noeud, options?.bigint === true); + }, + async realpath(chemin) { + appel('realpath', chemin); + const resolu = resoudre(chemin, true, 'realpath'); + if (resolu.noeud === undefined) throw erreurSysteme('ENOENT', 'realpath', chemin); + return resolu.chemin; + }, + async readFile(chemin) { + appel('readFile', chemin); + const { noeud } = resoudre(chemin, true, 'open'); + if (noeud === undefined) throw erreurSysteme('ENOENT', 'open', chemin); + if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'read', chemin); + return Buffer.from(noeud.octets); + }, + async open(chemin, drapeaux) { + appel('open', chemin, drapeaux); + const exclusif = drapeaux === 'wx'; + const resolu = resoudre(chemin, !exclusif, 'open'); + if (exclusif && resolu.noeud !== undefined) throw erreurSysteme('EEXIST', 'open', chemin); + if (resolu.noeud?.type === 'dossier') throw erreurSysteme('EISDIR', 'open', chemin); + let noeud = resolu.noeud; + if (noeud === undefined) noeud = creer(resolu.chemin, 'fichier', { octets: Buffer.alloc(0) }); + else if (drapeaux === 'w') noeud.octets = Buffer.alloc(0); + return poignee(chemin, noeud); + }, + async rename(de, vers) { + appel('rename', de, vers); + const source = resoudre(de, false, 'rename'); + if (source.noeud === undefined) throw erreurSysteme('ENOENT', 'rename', de); + const cible = resoudre(vers, false, 'rename'); + if (cible.noeud?.type === 'dossier') throw erreurSysteme('EISDIR', 'rename', vers); + noeuds.delete(source.chemin); + noeuds.set(cible.chemin, source.noeud); + }, + async unlink(chemin) { + appel('unlink', chemin); + const { chemin: resolu, noeud } = resoudre(chemin, false, 'unlink'); + if (noeud === undefined) throw erreurSysteme('ENOENT', 'unlink', chemin); + if (noeud.type === 'dossier') throw erreurSysteme('EISDIR', 'unlink', chemin); + noeuds.delete(resolu); + }, + async mkdir(chemin, options) { + appel('mkdir', chemin); + assert.deepEqual(options, { recursive: true }, 'mkdir sans recursive'); + const segments = chemin.split('/').filter((segment) => segment !== ''); + let courant = '/'; + for (let i = 0; i < segments.length; i += 1) { + const resolu = resoudre(posix.join(courant, segments[i]), true, 'mkdir'); + if (resolu.noeud === undefined) creer(resolu.chemin, 'dossier'); + else if (resolu.noeud.type !== 'dossier') { + throw erreurSysteme(i === segments.length - 1 ? 'EEXIST' : 'ENOTDIR', 'mkdir', chemin); + } + courant = resolu.chemin; + } + }, + async readdir(chemin) { + appel('readdir', chemin); + const { chemin: resolu, noeud } = resoudre(chemin, true, 'scandir'); + if (noeud === undefined) throw erreurSysteme('ENOENT', 'scandir', chemin); + if (noeud.type !== 'dossier') throw erreurSysteme('ENOTDIR', 'scandir', chemin); + return [...noeuds.keys()] + .filter((cle) => cle !== '/' && posix.dirname(cle) === resolu) + .map((cle) => posix.basename(cle)) + .reverse(); + }, + }; + + // Crée les dossiers d'un chemin absolu qui manquent, sans rien consigner. + function dossiers(chemin, champs) { + let courant = '/'; + for (const segment of chemin.split('/').filter((s) => s !== '')) { + courant = posix.join(courant, segment); + if (!noeuds.has(courant)) creer(courant, 'dossier', champs); + } + } + + return { + fs, + appels, + panne(fonction, code, { fois = Infinity, chemin } = {}) { + pannes.push({ fonction, code, fois, chemin }); + }, + deposer(chemin, texte) { + dossiers(posix.dirname(chemin)); + creer(chemin, 'fichier', { octets: Buffer.from(texte) }); + }, + deposerDossier(chemin, champs = {}) { + dossiers(chemin, champs); + }, + deposerLien(chemin, cible) { + dossiers(posix.dirname(chemin)); + creer(chemin, 'lien', { cible }); + }, + // Le texte d'un fichier, liens suivis ; null pour un absent ou un dossier. + contenu(chemin) { + const { noeud } = resoudre(chemin, true, 'contenu'); + return noeud?.type === 'fichier' ? noeud.octets.toString('utf8') : null; + }, + // Vrai quand le chemin désigne un nœud, liens des dossiers parents + // suivis, celui du dernier segment non. + existe(chemin) { + try { + return resoudre(chemin, false, 'existe').noeud !== undefined; + } catch { + return false; + } + }, + // Noms des entrées directes d'un dossier, triés. + noms: (chemin) => + [...noeuds.keys()] + .filter((cle) => cle !== '/' && posix.dirname(cle) === chemin) + .map((cle) => posix.basename(cle)) + .sort(), + }; +} + +// Appels qui écrivent, dans l'ordre : ouvertures, écritures, vidages, +// fermetures, renommages, effacements, créations de dossier. +const ECRITURES = /^(open|write|sync|close|rename|unlink|mkdir) /; +const ecritures = (disque) => disque.appels.filter((ligne) => ECRITURES.test(ligne)); + +// --------------------------------------------------------------------------- +// La coquille sur ce disque + +// Un dialogue d'Electron dont chaque appel consigne sa méthode, sa fenêtre +// parente et ses options, puis rend la réponse suivante, ou une annulation. +function dialogueFactice(reponses = []) { + const appels = []; + return { + appels, + async showOpenDialog(parent, options) { + appels.push({ methode: 'showOpenDialog', parent, options }); + return reponses.shift() ?? { canceled: true, filePaths: [] }; + }, + async showSaveDialog(parent, options) { + appels.push({ methode: 'showSaveDialog', parent, options }); + return reponses.shift() ?? { canceled: true, filePath: '' }; + }, + }; +} + +/** + * La coquille sur un disque en mémoire où la racine documents existe. Rend + * le disque, les primitives, et ce que l'épreuve lit des injections : les + * pauses demandées, les dossiers ouverts dans l'explorateur, les commandes + * lancées, les pid signalés. + */ +function preparer({ + disque = creerDisque(), + dialog = dialogueFactice(), + ouvrir = async () => '', + lancer = async () => '', + signaler = () => {}, + plateforme = 'linux', + hote = HOTE, + fs = disque.fs, +} = {}) { + if (!disque.existe(DOCUMENTS)) disque.deposerDossier(DOCUMENTS); + const pauses = []; + const ouvertures = []; + const commandes = []; + const signaux = []; + const fichiers = creerFichiers({ + emplacements: EMPLACEMENTS, + racines: { portable: DATA, documents: DOCUMENTS }, + dialog, + shell: { + openPath: async (chemin) => { + ouvertures.push(chemin); + return ouvrir(chemin); + }, + }, + fenetre: () => FENETRE, + fs, + chemins: posix, + plateforme, + lancer: async (commande, arguments_, options) => { + commandes.push({ commande, arguments_, options }); + return lancer(commande, arguments_, options); + }, + attendre: async (ms) => { + pauses.push(ms); + }, + signaler: (pid) => { + signaux.push(pid); + return signaler(pid); + }, + pid: PID, + hote, + maintenant: () => INSTANT, + }); + return { disque, fichiers, dialog, pauses, ouvertures, commandes, signaux }; +} + +// 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 un refus de la coquille, de ce code et de ces détails exactement. +async function refuse(promesse, code, details, message = code) { + const erreur = await rejette(promesse, message); + assert.ok(erreur instanceof RefusFichier, `${message} : ${erreur}`); + assert.equal(erreur.code, code, message); + assert.deepEqual(erreur.details, details, message); + return erreur; +} + +const ecriture = (chemin, dossier, cause) => ({ chemin, dossier, cause }); + +// Les primitives qui reçoivent un chemin, chacune appelée avec le chemin +// donné à la place qu'elle éprouve. deplacer s'éprouve par sa source, vers +// libre.txt, puis par sa cible, depuis ici.txt : l'épreuve pose ici.txt et +// laisse libre.txt absent, si bien que seul le chemin éprouvé peut faire +// échouer l'appel. +const APPELS_A_CHEMIN = [ + ['lireTexte', (fichiers, racine, chemin) => fichiers.lireTexte(racine, chemin)], + ['ecrireAtomique', (fichiers, racine, chemin) => fichiers.ecrireAtomique(racine, chemin, 'x')], + ['ajouterLigne', (fichiers, racine, chemin) => fichiers.ajouterLigne(racine, chemin, 'x')], + ['lister', (fichiers, racine, chemin) => fichiers.lister(racine, chemin)], + ['creerDossier', (fichiers, racine, chemin) => fichiers.creerDossier(racine, chemin)], + ['deplacer, source', (fichiers, racine, chemin) => fichiers.deplacer(racine, chemin, 'libre.txt')], + ['deplacer, cible', (fichiers, racine, chemin) => fichiers.deplacer(racine, 'ici.txt', chemin)], + ['supprimer', (fichiers, racine, chemin) => fichiers.supprimer(racine, chemin)], + ['verrouiller', (fichiers, racine, chemin) => fichiers.verrouiller(racine, chemin, 'seance-a')], + ['deverrouiller', (fichiers, racine, chemin) => fichiers.deverrouiller(racine, chemin, 'seance-a')], +]; + +// --------------------------------------------------------------------------- + +describe('coquille, fichiers : écriture atomique (§ 8.8)', () => { + const CIBLE = `${DOCUMENTS}/etat.json`; + const TEMPORAIRE = `${CIBLE}${SUFFIXE_ECRITURE}`; + + test("écrit .ecriture, le vide sur le disque, le ferme, puis le renomme par-dessus la cible, qui n'est jamais ouverte", async () => { + const { disque, fichiers, pauses } = preparer(); + disque.deposer(CIBLE, 'ancien \u{E9}tat'); + await fichiers.ecrireAtomique(DOC, 'etat.json', 'nouvel \u{E9}tat\n'); + assert.equal(disque.contenu(CIBLE), 'nouvel \u{E9}tat\n'); + assert.equal(disque.existe(TEMPORAIRE), false); + assert.deepEqual( + ecritures(disque).filter((ligne) => !ligne.startsWith('unlink ')), + [ + `open ${TEMPORAIRE} wx`, + `write ${TEMPORAIRE}`, + `sync ${TEMPORAIRE}`, + `close ${TEMPORAIRE}`, + `rename ${TEMPORAIRE} ${CIBLE}`, + ], + ); + assert.deepEqual(disque.appels.filter((ligne) => ligne.startsWith(`open ${CIBLE} `)), []); + assert.deepEqual(pauses, []); + }); + + test('un renommage refusé deux fois puis accepté réussit, après des pauses de 50 puis 100 ms', async () => { + const { disque, fichiers, pauses } = preparer(); + disque.deposer(CIBLE, 'ancien'); + disque.panne('rename', 'EBUSY', { fois: 2 }); + await fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'); + assert.equal(disque.contenu(CIBLE), 'nouveau'); + assert.equal(disque.existe(TEMPORAIRE), false); + assert.deepEqual(pauses, [50, 100]); + assert.equal(disque.appels.filter((ligne) => ligne.startsWith('rename ')).length, 3); + }); + + test('EPERM, EBUSY et EACCES se réessaient dix fois : dix refus passent, onze lèvent ECRITURE {chemin, dossier, cause}, la cible intacte, le temporaire retiré', async () => { + assert.equal(REESSAIS_RENOMMAGE, 10); + assert.deepEqual([PAUSE_INITIALE_MS, PAUSE_MAX_MS], [50, 500]); + const PAUSES = [50, 100, 200, 400, 500, 500, 500, 500, 500, 500]; + for (const cause of ['EPERM', 'EBUSY', 'EACCES']) { + const passe = preparer(); + passe.disque.deposer(CIBLE, 'ancien'); + passe.disque.panne('rename', cause, { fois: 10 }); + await passe.fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'); + assert.equal(passe.disque.contenu(CIBLE), 'nouveau', cause); + assert.deepEqual(passe.pauses, PAUSES, cause); + + const echoue = preparer(); + echoue.disque.deposer(CIBLE, 'ancien'); + echoue.disque.panne('rename', cause, { fois: 11 }); + await refuse( + echoue.fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'), + 'ECRITURE', + ecriture('etat.json', DOCUMENTS, cause), + cause, + ); + assert.equal(echoue.disque.contenu(CIBLE), 'ancien', cause); + assert.equal(echoue.disque.existe(TEMPORAIRE), false, cause); + assert.deepEqual(echoue.pauses, PAUSES, cause); + assert.equal(echoue.disque.appels.filter((ligne) => ligne.startsWith('rename ')).length, 11, cause); + } + }); + + test('une autre cause de refus du renommage ne se réessaie pas', async () => { + for (const cause of ['EXDEV', 'EROFS', 'ENOSPC']) { + const { disque, fichiers, pauses } = preparer(); + disque.deposer(CIBLE, 'ancien'); + disque.panne('rename', cause, { fois: 1 }); + await refuse(fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'), 'ECRITURE', ecriture('etat.json', DOCUMENTS, cause), cause); + assert.deepEqual(pauses, [], cause); + assert.equal(disque.appels.filter((ligne) => ligne.startsWith('rename ')).length, 1, cause); + assert.equal(disque.contenu(CIBLE), 'ancien', cause); + assert.equal(disque.existe(TEMPORAIRE), false, cause); + } + }); + + test("un temporaire qui ne s'écrit, ne se vide ou ne se ferme pas est retiré sans renommage, et la cible reste intacte", async () => { + for (const [fonction, cause] of [['open', 'EROFS'], ['write', 'ENOSPC'], ['sync', 'EIO'], ['close', 'EIO']]) { + const { disque, fichiers } = preparer(); + disque.deposer(CIBLE, 'ancien'); + disque.panne(fonction, cause, { chemin: TEMPORAIRE }); + await refuse(fichiers.ecrireAtomique(DOC, 'etat.json', 'nouveau'), 'ECRITURE', ecriture('etat.json', DOCUMENTS, cause), fonction); + assert.equal(disque.contenu(CIBLE), 'ancien', fonction); + assert.equal(disque.existe(TEMPORAIRE), false, fonction); + assert.deepEqual(disque.appels.filter((ligne) => ligne.startsWith('rename ')), [], fonction); + } + }); + + test("un temporaire resté d'une écriture interrompue est remplacé, et un lien posé à sa place n'est pas suivi", async () => { + const { disque, fichiers } = preparer(); + disque.deposer(TEMPORAIRE, 'reste d\u{2019}une panne'); + await fichiers.ecrireAtomique(DOC, 'etat.json', 'neuf'); + assert.equal(disque.contenu(CIBLE), 'neuf'); + + disque.deposer(`${AILLEURS}/garde.json`, 'hors de la racine'); + disque.deposerLien(TEMPORAIRE, `${AILLEURS}/garde.json`); + await fichiers.ecrireAtomique(DOC, 'etat.json', 'encore'); + assert.equal(disque.contenu(CIBLE), 'encore'); + assert.equal(disque.contenu(`${AILLEURS}/garde.json`), 'hors de la racine'); + }); + + test("le dossier de la cible doit exister, et une cible qui est un dossier n'est pas remplacée", async () => { + const { disque, fichiers } = preparer(); + await refuse( + fichiers.ecrireAtomique(DOC, 'disparu/etat.json', 'x'), + 'ECRITURE', + ecriture('disparu/etat.json', `${DOCUMENTS}/disparu`, 'ENOENT'), + ); + assert.equal(disque.existe(`${DOCUMENTS}/disparu`), false); + disque.deposerDossier(`${DOCUMENTS}/corbeille`); + const avant = disque.appels.length; + await refuse(fichiers.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', ecriture('corbeille', DOCUMENTS, 'EISDIR')); + // Refusé avant d'écrire : aucun temporaire, aucun renommage, que Windows + // réessaierait en vain. + assert.deepEqual(ecritures({ appels: disque.appels.slice(avant) }), []); + assert.deepEqual(disque.noms(`${DOCUMENTS}/corbeille`), []); + assert.deepEqual(disque.noms(DOCUMENTS), ['corbeille']); + }); + + test("le texte doit être une chaîne : autre chose lève TypeError, sans toucher au disque", async () => { + const { disque, fichiers } = preparer(); + await assert.rejects(fichiers.ecrireAtomique(DOC, 'etat.json', 42), TypeError); + assert.deepEqual(disque.appels, []); + }); +}); + +describe("coquille, fichiers : l'ajout en fin", () => { + const JOURNAL = `${DOCUMENTS}/soiree.gtt-journal.jsonl`; + + test('ouvre en ajout, écrit la ligne et sa fin de ligne, vide sur le disque et ferme ; crée le fichier absent', async () => { + const { disque, fichiers } = preparer(); + await fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"type":"journal"}'); + await fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"revision":1}'); + assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n{"revision":1}\n'); + assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${JOURNAL} a`, `write ${JOURNAL}`, `sync ${JOURNAL}`, `close ${JOURNAL}`]); + }); + + test("refuse une ligne qui porte une fin de ligne, et un dossier absent lève ECRITURE sans le créer", async () => { + const { disque, fichiers } = preparer(); + for (const ligne of ['a\nb', 'a\rb', 'fin\n', 42]) { + await assert.rejects(fichiers.ajouterLigne(DOC, 'journal.jsonl', ligne), TypeError, String(ligne)); + } + assert.deepEqual(disque.appels, []); + await refuse( + fichiers.ajouterLigne(DOC, 'disparu/journal.jsonl', 'x'), + 'ECRITURE', + ecriture('disparu/journal.jsonl', `${DOCUMENTS}/disparu`, 'ENOENT'), + ); + assert.equal(disque.existe(`${DOCUMENTS}/disparu`), false); + }); + + test("un ajout que le système refuse lève ECRITURE et ne retire pas le fichier", async () => { + const { disque, fichiers } = preparer(); + disque.deposer(JOURNAL, '{"type":"journal"}\n'); + disque.panne('write', 'ENOSPC'); + await refuse(fichiers.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', ecriture('soiree.gtt-journal.jsonl', DOCUMENTS, 'ENOSPC')); + assert.equal(disque.contenu(JOURNAL), '{"type":"journal"}\n'); + assert.ok(disque.appels.includes(`close ${JOURNAL}`), 'poignée non fermée'); + }); +}); + +describe('coquille, fichiers : confinement aux racines', () => { + test("un chemin absolu, remontant, sous Windows ou porteur d'un NUL lève CHEMIN_REFUSE {chemin} à chaque primitive qui prend un chemin, avant tout accès au disque", async () => { + const { disque, fichiers } = preparer(); + for (const chemin of ['../x', '/etc/x', 'C:\\x', 'a\u{0}b', 'a/../../x', '..']) { + for (const [primitive, appel] of APPELS_A_CHEMIN) { + await refuse(appel(fichiers, DOC, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`); + } + } + assert.deepEqual(disque.appels, []); + }); + + test("une racine inconnue, absente ou de forme fausse lève CHEMIN_REFUSE, même sous le chemin d'une racine connue", async () => { + const { disque, fichiers } = preparer(); + for (const racine of [{ id: 'choisi-1', chemin: DOCUMENTS }, { id: 'constructor', chemin: DOCUMENTS }, null, 'documents', { chemin: DOCUMENTS }]) { + await refuse(fichiers.lireTexte(racine, 'x.txt'), 'CHEMIN_REFUSE', { chemin: 'x.txt' }, JSON.stringify(racine)); + await refuse(fichiers.sonder(racine), 'CHEMIN_REFUSE', { chemin: '' }, JSON.stringify(racine)); + await refuse(fichiers.typeSupport(racine), 'CHEMIN_REFUSE', { chemin: '' }, JSON.stringify(racine)); + await refuse(fichiers.ouvrirDansExplorateur(racine), 'CHEMIN_REFUSE', { chemin: '' }, JSON.stringify(racine)); + } + assert.deepEqual(disque.appels, []); + }); + + test("un lien symbolique qui sort de la racine ne fait pas sortir : dossier en chemin, fichier visé, lien sans cible", async () => { + const { disque, fichiers } = preparer(); + // 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: HOTE, depuis: '2026-10-06T19:02:11-04:00' })}\n`; + disque.deposer(`${AILLEURS}/secret.txt`, 'hors de la racine'); + disque.deposer(`${AILLEURS}/soiree.gtt.verrou`, verrou); + disque.deposerLien(`${DOCUMENTS}/dehors`, AILLEURS); + disque.deposerLien(`${DOCUMENTS}/secret.txt`, `${AILLEURS}/secret.txt`); + disque.deposerLien(`${DOCUMENTS}/fuite.jsonl`, `${AILLEURS}/neuf.jsonl`); + disque.deposer(`${DOCUMENTS}/ici.txt`, 'ici'); + + // Par le dossier lié : chaque primitive, sur un absent, sur un fichier, + // sur le verrou ; et la liste du dossier lié lui-même. + for (const chemin of ['dehors/neuf.txt', 'dehors/secret.txt', 'dehors/soiree.gtt.verrou']) { + for (const [primitive, appel] of APPELS_A_CHEMIN) { + await refuse(appel(fichiers, DOC, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`); + } + } + await refuse(fichiers.lister(DOC, 'dehors'), 'CHEMIN_REFUSE', { chemin: 'dehors' }); + // Par un lien de fichier, et par un lien sans cible, que l'ouverture en + // ajout suivrait pour créer sa cible au-dehors. + await refuse(fichiers.lireTexte(DOC, 'secret.txt'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' }); + await refuse(fichiers.ajouterLigne(DOC, 'secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' }); + await refuse(fichiers.ajouterLigne(DOC, 'fuite.jsonl', 'x'), 'CHEMIN_REFUSE', { chemin: 'fuite.jsonl' }); + await refuse(fichiers.deplacer(DOC, 'secret.txt', 'pris.txt'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' }); + + assert.deepEqual(ecritures(disque), []); + assert.deepEqual(disque.noms(AILLEURS), ['secret.txt', 'soiree.gtt.verrou']); + assert.equal(disque.contenu(`${AILLEURS}/secret.txt`), 'hors de la racine'); + assert.equal(disque.contenu(`${AILLEURS}/soiree.gtt.verrou`), verrou); + assert.equal(disque.contenu(`${DOCUMENTS}/ici.txt`), 'ici'); + }); + + test("un dossier voisin dont le nom prolonge celui de la racine est hors d'elle : les chemins réels se comparent segment par segment", async () => { + const voisin = `${DOCUMENTS} (copie)`; + const { disque, fichiers } = preparer(); + disque.deposer(`${voisin}/secret.txt`, 'dans le dossier voisin'); + disque.deposerLien(`${DOCUMENTS}/voisin`, voisin); + disque.deposer(`${DOCUMENTS}/ici.txt`, 'ici'); + for (const chemin of ['voisin/secret.txt', 'voisin/neuf.txt']) { + for (const [primitive, appel] of APPELS_A_CHEMIN) { + await refuse(appel(fichiers, DOC, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`); + } + } + await refuse(fichiers.lister(DOC, 'voisin'), 'CHEMIN_REFUSE', { chemin: 'voisin' }); + assert.deepEqual(ecritures(disque), []); + assert.deepEqual(disque.noms(voisin), ['secret.txt']); + assert.equal(disque.contenu(`${voisin}/secret.txt`), 'dans le dossier voisin'); + assert.equal(disque.contenu(`${DOCUMENTS}/ici.txt`), 'ici'); + }); + + test("une racine qui finit par le séparateur, celle d'un volume, porte tout ce qu'il contient", async () => { + // Sous Windows, la racine d'une clé choisie, E:\ ; sur ce disque, /. + const dialog = dialogueFactice([{ canceled: false, filePaths: ['/'] }]); + const { disque, fichiers } = preparer({ dialog }); + const volume = await fichiers.choisirDossier(); + assert.deepEqual(volume, { id: 'choisi-1', chemin: '/' }); + await fichiers.ecrireAtomique(volume, 'soiree.gtt.json', 'sur le volume'); + assert.equal(disque.contenu('/soiree.gtt.json'), 'sur le volume'); + assert.equal(await fichiers.lireTexte(volume, 'soiree.gtt.json'), 'sur le volume'); + }); + + test("un lien qui reste sous la racine se suit", async () => { + const { disque, fichiers } = preparer(); + disque.deposerDossier(`${DOCUMENTS}/vrai`); + disque.deposerLien(`${DOCUMENTS}/alias`, `${DOCUMENTS}/vrai`); + await fichiers.ecrireAtomique(DOC, 'alias/etat.json', 'par le lien'); + assert.equal(disque.contenu(`${DOCUMENTS}/vrai/etat.json`), 'par le lien'); + assert.equal(await fichiers.lireTexte(DOC, 'alias/etat.json'), 'par le lien'); + }); + + test("la racine se résout par son identifiant, et un chemin réel se compare à celui de la racine, liens résolus des deux côtés", async () => { + const disque = creerDisque(); + disque.deposerDossier('/volume/reel/Documents/Gestion table tournante Libre'); + disque.deposerLien('/poste/exemple/Documents', '/volume/reel/Documents'); + const { fichiers } = preparer({ disque }); + await fichiers.ecrireAtomique(DOC, 'etat.json', 'sous un lien de racine'); + assert.equal(disque.contenu('/volume/reel/Documents/Gestion table tournante Libre/etat.json'), 'sous un lien de racine'); + }); +}); + +describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppression', () => { + test("lireTexte rend le texte tel qu'écrit, marque d'ordre d'octets comprise ; ABSENT pour un absent, un dossier ou un parent fichier ; LECTURE pour un autre refus", async () => { + const { disque, fichiers } = preparer(); + disque.deposer(`${DOCUMENTS}/bom.csv`, '\u{FEFF}nom;pr\u{E9}nom\r\n'); + assert.equal(await fichiers.lireTexte(DOC, 'bom.csv'), '\u{FEFF}nom;pr\u{E9}nom\r\n'); + disque.deposerDossier(`${DOCUMENTS}/corbeille`); + for (const chemin of ['absent.gtt.json', 'corbeille', 'bom.csv/x']) { + await refuse(fichiers.lireTexte(DOC, chemin), 'ABSENT', { chemin }, chemin); + } + disque.panne('readFile', 'EACCES'); + await refuse(fichiers.lireTexte(DOC, 'bom.csv'), 'LECTURE', { chemin: 'bom.csv', dossier: DOCUMENTS, cause: 'EACCES' }); + }); + + test("lister trie par unités UTF-16, type les entrées, donne 0 octet à un dossier et passe un lien sans cible", async () => { + const { disque, fichiers } = preparer(); + disque.deposer(`${DOCUMENTS}/b.txt`, 'bb'); + disque.deposerDossier(`${DOCUMENTS}/corbeille`); + disque.deposer(`${DOCUMENTS}/\u{E9}.txt`, '\u{E9}'); + disque.deposer(`${DOCUMENTS}/Z.txt`, '\u{1D11E}'); + disque.deposer(`${DOCUMENTS}/a.txt`, ''); + disque.deposerLien(`${DOCUMENTS}/mort`, `${DOCUMENTS}/nulle-part`); + disque.deposerLien(`${DOCUMENTS}/vers-b`, `${DOCUMENTS}/b.txt`); + const entrees = await fichiers.lister(DOC, ''); + assert.deepEqual( + entrees.map(({ nom, type, taille }) => ({ nom, type, taille })), + [ + { nom: 'Z.txt', type: 'fichier', taille: 4 }, + { nom: 'a.txt', type: 'fichier', taille: 0 }, + { nom: 'b.txt', type: 'fichier', taille: 2 }, + { nom: 'corbeille', type: 'dossier', taille: 0 }, + { nom: 'vers-b', type: 'fichier', taille: 2 }, + { nom: '\u{E9}.txt', type: 'fichier', taille: 2 }, + ], + ); + assert.ok(entrees.every((entree) => Number.isFinite(entree.modifie)), 'modifie'); + }); + + test("lister d'un dossier absent ou d'un fichier lève ABSENT, et un autre refus LECTURE", async () => { + const { disque, fichiers } = preparer(); + disque.deposer(`${DOCUMENTS}/fichier.txt`, 'x'); + await refuse(fichiers.lister(DOC, 'absent'), 'ABSENT', { chemin: 'absent' }); + await refuse(fichiers.lister(DOC, 'fichier.txt'), 'ABSENT', { chemin: 'fichier.txt' }); + disque.panne('readdir', 'EACCES'); + await refuse(fichiers.lister(DOC, ''), 'LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EACCES' }); + }); + + test("creerDossier crée les parents ; un fichier en travers lève ECRITURE", async () => { + const { disque, fichiers } = preparer(); + await fichiers.creerDossier(DOC, 'corbeille/2026-01-02_03-04-05'); + await fichiers.creerDossier(DOC, 'corbeille'); + assert.deepEqual(disque.noms(`${DOCUMENTS}/corbeille`), ['2026-01-02_03-04-05']); + disque.deposer(`${DOCUMENTS}/occupe`, 'x'); + await refuse(fichiers.creerDossier(DOC, 'occupe'), 'ECRITURE', ecriture('occupe', DOCUMENTS, 'EEXIST')); + await refuse(fichiers.creerDossier(DOC, 'occupe/sous'), 'ECRITURE', ecriture('occupe/sous', `${DOCUMENTS}/occupe`, 'ENOTDIR')); + }); + + test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => { + const { disque, fichiers, pauses } = preparer(); + disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat'); + disque.deposerDossier(`${DOCUMENTS}/corbeille`); + await fichiers.deplacer(DOC, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'); + assert.equal(disque.contenu(`${DOCUMENTS}/corbeille/soiree.gtt.json`), '\u{E9}tat'); + assert.equal(disque.existe(`${DOCUMENTS}/soiree.gtt.json`), false); + + disque.deposer(`${DOCUMENTS}/source.txt`, 'source'); + disque.deposer(`${DOCUMENTS}/cible.txt`, 'cible'); + await refuse(fichiers.deplacer(DOC, 'source.txt', 'cible.txt'), 'EXISTE', { chemin: 'cible.txt' }); + await refuse(fichiers.deplacer(DOC, 'absent.txt', 'ailleurs.txt'), 'ABSENT', { chemin: 'absent.txt' }); + await refuse(fichiers.deplacer(DOC, 'corbeille', 'poubelle'), 'ECRITURE', ecriture('corbeille', DOCUMENTS, 'EISDIR')); + await refuse( + fichiers.deplacer(DOC, 'source.txt', 'disparu/source.txt'), + 'ECRITURE', + ecriture('disparu/source.txt', `${DOCUMENTS}/disparu`, 'ENOENT'), + ); + disque.panne('rename', 'EBUSY'); + await refuse(fichiers.deplacer(DOC, 'source.txt', 'autre.txt'), 'ECRITURE', ecriture('source.txt', DOCUMENTS, 'EBUSY')); + assert.equal(disque.appels.filter((ligne) => ligne.startsWith(`rename ${DOCUMENTS}/source.txt`)).length, 1); + assert.deepEqual(pauses, []); + assert.equal(disque.contenu(`${DOCUMENTS}/source.txt`), 'source'); + assert.equal(disque.contenu(`${DOCUMENTS}/cible.txt`), 'cible'); + }); + + test("supprimer retire un fichier ; un absent n'est pas une faute ; un dossier lève ECRITURE", async () => { + const { disque, fichiers } = preparer(); + disque.deposer(`${DOCUMENTS}/retire.txt`, 'x'); + disque.deposerDossier(`${DOCUMENTS}/corbeille`); + await fichiers.supprimer(DOC, 'retire.txt'); + await fichiers.supprimer(DOC, 'retire.txt'); + assert.deepEqual(disque.noms(DOCUMENTS), ['corbeille']); + await refuse(fichiers.supprimer(DOC, 'corbeille'), 'ECRITURE', ecriture('corbeille', DOCUMENTS, 'EISDIR')); + disque.deposer(`${DOCUMENTS}/tenu.txt`, 'x'); + disque.panne('unlink', 'EBUSY'); + await refuse(fichiers.supprimer(DOC, 'tenu.txt'), 'ECRITURE', ecriture('tenu.txt', DOCUMENTS, 'EBUSY')); + }); +}); + +describe('coquille, fichiers : le verrou (§ 8.8)', () => { + const VERROU = `${DOCUMENTS}/soiree.gtt.verrou`; + const deposerVerrou = (disque, contenu) => disque.deposer(VERROU, `${JSON.stringify(contenu)}\n`); + + test("se crée en exclusif, porte {seance, pid, hote, depuis} et une fin de ligne ; depuis est l'instant de l'horloge, à l'heure locale et avec son décalage", async () => { + const { disque, fichiers } = preparer(); + assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true }); + assert.deepEqual(ecritures(disque).slice(0, 4), [`open ${VERROU} wx`, `write ${VERROU}`, `sync ${VERROU}`, `close ${VERROU}`]); + const texte = disque.contenu(VERROU); + assert.ok(texte.endsWith('}\n'), texte); + const contenu = JSON.parse(texte); + assert.deepEqual(Object.keys(contenu), ['seance', 'pid', 'hote', 'depuis']); + assert.deepEqual({ ...contenu, depuis: null }, { seance: 'seance-a', pid: PID, hote: HOTE, depuis: null }); + assert.match(contenu.depuis, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/); + assert.equal(Date.parse(contenu.depuis), INSTANT.getTime()); + }); + + test('un verrou présent rend sa séance : vivant quand son processus répond sur cette machine, même sans droit de le signaler', async () => { + for (const [nom, signaler, vivant] of [ + ['répond', () => {}, true], + ['EPERM', () => { throw erreurSysteme('EPERM', 'kill', ''); }, true], + ['ESRCH', () => { throw erreurSysteme('ESRCH', 'kill', ''); }, false], + ]) { + const { disque, fichiers, signaux } = preparer({ signaler }); + deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: HOTE, depuis: '2026-10-06T19:02:11-04:00' }); + assert.deepEqual( + await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), + { pris: false, seance: 'seance-a', depuis: '2026-10-06T19:02:11-04:00', vivant }, + nom, + ); + assert.deepEqual(signaux, [777], nom); + } + }); + + test("le verrou d'une autre machine rend vivant à null sans signaler personne ; un verrou illisible rend tout à null", async () => { + const { disque, fichiers, signaux } = preparer(); + deposerVerrou(disque, { seance: 'seance-a', pid: 777, hote: 'poste-voisin', depuis: '2026-10-06T19:02:11-04:00' }); + assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), { + pris: false, + seance: 'seance-a', + depuis: '2026-10-06T19:02:11-04:00', + vivant: null, + }); + assert.deepEqual(signaux, []); + const illisible = { pris: false, seance: null, depuis: null, vivant: null }; + for (const texte of ['{"seance":"seance-a"', '{"seance":"seance-a","pid":0,"hote":"poste-essai","depuis":"x"}', '[]']) { + disque.deposer(VERROU, texte); + assert.deepEqual(await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), illisible, texte); + } + assert.deepEqual(signaux, []); + }); + + test("deverrouiller n'efface que le verrou de sa séance ; ni un absent ni un illisible ne sont des fautes", async () => { + const { disque, fichiers } = preparer(); + await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + await fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'); + assert.equal(disque.existe(VERROU), true); + await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + assert.equal(disque.existe(VERROU), false); + disque.deposer(VERROU, 'illisible'); + await fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + assert.equal(disque.contenu(VERROU), 'illisible'); + }); + + test("un verrou qui ne s'écrit pas jusqu'au bout est retiré, et lève ECRITURE ; un dossier à sa place lève ECRITURE", async () => { + const { disque, fichiers } = preparer(); + disque.panne('write', 'ENOSPC', { chemin: VERROU }); + await refuse(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', ecriture('soiree.gtt.verrou', DOCUMENTS, 'ENOSPC')); + assert.equal(disque.existe(VERROU), false); + disque.deposerDossier(`${DOCUMENTS}/dossier.gtt.verrou`); + await refuse(fichiers.verrouiller(DOC, 'dossier.gtt.verrou', 'seance-a'), 'ECRITURE', ecriture('dossier.gtt.verrou', DOCUMENTS, 'EISDIR')); + }); + + test("processusVivant : un processus qui répond au signal 0 vit, comme celui qu'on n'a pas le droit de signaler ; un absent non", () => { + const signaux = []; + const signalerEnPanne = (code) => (pid) => { + signaux.push(pid); + if (code !== null) throw erreurSysteme(code, 'kill', ''); + }; + assert.equal(processusVivant(31, signalerEnPanne(null)), true); + assert.equal(processusVivant(32, signalerEnPanne('EPERM')), true); + assert.equal(processusVivant(33, signalerEnPanne('ESRCH')), false); + assert.deepEqual(signaux, [31, 32, 33]); + assert.equal(processusVivant(process.pid), true); + }); + + test('la séance est une chaîne non vide', async () => { + const { disque, fichiers } = preparer(); + for (const seance of ['', null, 7]) { + await assert.rejects(fichiers.verrouiller(DOC, 'soiree.gtt.verrou', seance), TypeError, String(seance)); + await assert.rejects(fichiers.deverrouiller(DOC, 'soiree.gtt.verrou', seance), TypeError, String(seance)); + } + assert.deepEqual(disque.appels, []); + }); +}); + +describe("coquille, fichiers : la sonde d'écriture (§ 8.6)", () => { + const TEMOIN_DATA = `${DATA}/${TEMOIN}`; + + test('crée la racine et ses parents, efface un témoin resté, écrit un témoin en exclusif, le vide, le relit et l\u{2019}efface', async () => { + const { disque, fichiers } = preparer(); + assert.equal(TEMOIN, '.gtt-temoin'); + assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: true, cause: null }); + assert.deepEqual(disque.noms(DATA), []); + const suite = ecritures(disque).filter((ligne) => !ligne.startsWith('unlink ') || ligne === `unlink ${TEMOIN_DATA}`); + assert.deepEqual(suite, [ + `mkdir ${DATA}`, + `unlink ${TEMOIN_DATA}`, + `open ${TEMOIN_DATA} wx`, + `write ${TEMOIN_DATA}`, + `sync ${TEMOIN_DATA}`, + `close ${TEMOIN_DATA}`, + `unlink ${TEMOIN_DATA}`, + ]); + assert.ok(disque.appels.includes(`readFile ${TEMOIN_DATA}`), 'témoin non relu'); + + disque.deposer(TEMOIN_DATA, 'resté d\u{2019}une séance précédente'); + disque.deposer(`${DATA}/garde.txt`, 'garde'); + assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: true, cause: null }); + assert.deepEqual(disque.noms(DATA), ['garde.txt']); + }); + + test("rend la cause d'un échec sans lever, et ne laisse aucun témoin", async () => { + for (const [fonction, cause] of [['mkdir', 'EACCES'], ['open', 'EROFS'], ['write', 'ENOSPC'], ['readFile', 'EIO'], ['unlink', 'EPERM']]) { + const { disque, fichiers } = preparer(); + disque.panne(fonction, cause, fonction === 'unlink' ? { fois: 1 } : {}); + assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: false, cause }, fonction); + assert.deepEqual(disque.existe(DATA) ? disque.noms(DATA) : [], [], fonction); + } + }); + + test('un témoin relu autrement qu\u{2019}écrit fait échouer la sonde : RELECTURE', async () => { + const disque = creerDisque(); + const fs = { + ...disque.fs, + readFile: async (chemin) => (chemin.endsWith(TEMOIN) ? Buffer.from('autre chose') : disque.fs.readFile(chemin)), + }; + const { fichiers } = preparer({ disque, fs }); + assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: false, cause: 'RELECTURE' }); + assert.deepEqual(disque.noms(DATA), []); + }); + + test("une racine occupée par un fichier ne s'éprouve pas : sa cause est rendue", async () => { + const { disque, fichiers } = preparer(); + disque.deposer(DATA, 'un fichier, pas un dossier'); + assert.deepEqual(await fichiers.sonder(PORTABLE), { inscriptible: false, cause: 'EEXIST' }); + assert.equal(disque.contenu(DATA), 'un fichier, pas un dossier'); + }); +}); + +describe('coquille, fichiers : le support de la racine (§ 8.6)', () => { + // Numéro de périphérique d'un majeur et d'un mineur dans le codage de + // glibc : les douze bits bas du majeur au-dessus des huit bits bas du + // mineur, le reste du mineur au-dessus, le reste du majeur au-dessus de + // tout. + const peripherique = (majeur, mineur) => + BigInt(mineur & 0xff) | + (BigInt(majeur & 0xfff) << 8n) | + (BigInt(mineur & ~0xff) << 12n) | + (BigInt(Math.floor(majeur / 0x1000)) << 44n); + + // Un disque dont la racine documents est sur le périphérique donné, et + // dont /sys/dev/block décrit ce périphérique comme le fait Linux. + function surPeripherique(majeur, mineur, { partition, amovible }) { + const disque = creerDisque(); + disque.deposerDossier('/poste', { dev: peripherique(majeur, mineur) }); + disque.deposerDossier(DOCUMENTS); + const bloc = '/sys/devices/pci0000:00/usb1/1-1/block/sdq'; + const appareil = partition ? `${bloc}/sdq1` : bloc; + disque.deposerDossier(appareil); + if (partition) disque.deposer(`${appareil}/partition`, '1\n'); + disque.deposer(`${bloc}/removable`, `${amovible}\n`); + disque.deposerLien(`/sys/dev/block/${majeur}:${mineur}`, appareil); + return disque; + } + + test("sous Linux, l'attribut removable du disque qui porte la racine : celui du parent pour une partition, le sien pour un disque entier", async () => { + for (const [majeur, mineur, partition, amovible, attendu] of [ + [8, 17, true, 1, 'amovible'], + [8, 300, true, 0, 'fixe'], + [259, 3, false, 1, 'amovible'], + [4097, 5, true, 1, 'amovible'], + ]) { + const { fichiers } = preparer({ disque: surPeripherique(majeur, mineur, { partition, amovible }) }); + assert.equal(await fichiers.typeSupport(DOC), attendu, `${majeur}:${mineur}`); + } + }); + + test("sous Linux, une racine pas encore créée se lit sur son plus proche parent, et un périphérique que /sys ne décrit pas rend inconnu", async () => { + const disque = surPeripherique(8, 17, { partition: true, amovible: 1 }); + const { fichiers } = preparer({ disque }); + // data/ et le dossier publié manquent : le plus proche parent est la + // racine du disque, sur un périphérique que /sys/dev/block ne décrit pas. + assert.equal(await fichiers.typeSupport(PORTABLE), 'inconnu'); + disque.deposerDossier(PUBLIE, { dev: peripherique(8, 17) }); + assert.equal(await fichiers.typeSupport(PORTABLE), 'amovible'); + }); + + test("sous Windows, DriveInfo par PowerShell, la racine passée par l'environnement, borné à cinq secondes", async () => { + assert.equal(DELAI_SUPPORT_MS, 5000); + for (const [sortie, attendu] of [['Removable\r\n', 'amovible'], ['Fixed\r\n', 'fixe'], ['Network\r\n', 'inconnu'], ['', 'inconnu']]) { + const { fichiers, commandes } = preparer({ plateforme: 'win32', lancer: async () => sortie }); + assert.equal(await fichiers.typeSupport(DOC), attendu, sortie); + assert.deepEqual(commandes, [ + { + commande: 'powershell.exe', + arguments_: ['-NoProfile', '-NonInteractive', '-Command', '[System.IO.DriveInfo]::new($env:GTT_RACINE).DriveType'], + options: { variables: { GTT_RACINE: DOCUMENTS }, delai: 5000 }, + }, + ]); + } + const { fichiers: enEchec } = preparer({ + plateforme: 'win32', + lancer: async () => { + throw Object.assign(new Error('délai dépassé'), { killed: true, signal: 'SIGTERM' }); + }, + }); + assert.equal(await enEchec.typeSupport(DOC), 'inconnu'); + }); + + test('ailleurs, inconnu, sans commande', async () => { + const { fichiers, commandes } = preparer({ plateforme: 'darwin' }); + assert.equal(await fichiers.typeSupport(DOC), 'inconnu'); + assert.deepEqual(commandes, []); + }); +}); + +describe('coquille, fichiers : dialogues et explorateur', () => { + test("choisirDossier ouvre le dialogue sur la fenêtre et rend une racine choisi-N, que les primitives connaissent dès lors ; une annulation rend null", async () => { + const choisi = '/poste/exemple/Bureau/soirees'; + const dialog = dialogueFactice([ + { canceled: false, filePaths: [choisi] }, + { canceled: true, filePaths: [] }, + { canceled: false, filePaths: [AILLEURS] }, + ]); + const { disque, fichiers } = preparer({ dialog }); + disque.deposerDossier(choisi); + const racine = await fichiers.choisirDossier(); + assert.deepEqual(racine, { id: 'choisi-1', chemin: choisi }); + assert.equal(await fichiers.choisirDossier(), null); + assert.deepEqual(await fichiers.choisirDossier(), { id: 'choisi-2', chemin: AILLEURS }); + assert.deepEqual( + dialog.appels.map(({ methode, parent, options }) => ({ methode, parent, options })), + Array(3).fill({ methode: 'showOpenDialog', parent: FENETRE, options: { properties: ['openDirectory', 'createDirectory'] } }), + ); + await fichiers.ecrireAtomique({ id: 'choisi-1' }, 'etat.json', 'choisi'); + assert.equal(disque.contenu(`${choisi}/etat.json`), 'choisi'); + await refuse(fichiers.lireTexte({ id: 'choisi-3' }, 'etat.json'), 'CHEMIN_REFUSE', { chemin: 'etat.json' }); + }); + + test("choisirFichierAImporter rend le nom et les octets du fichier choisi ; une annulation rend null", async () => { + const fichier = '/poste/exemple/T\u{E9}l\u{E9}chargements/liste.csv'; + const dialog = dialogueFactice([{ canceled: false, filePaths: [fichier] }]); + const { disque, fichiers } = preparer({ dialog }); + disque.deposer(fichier, 'nom\r\n\u{C9}lan\r\n'); + const choisi = await fichiers.choisirFichierAImporter(); + assert.equal(choisi.nom, 'liste.csv'); + assert.ok(choisi.octets instanceof Uint8Array); + assert.deepEqual([...choisi.octets], [...Buffer.from('nom\r\n\u{C9}lan\r\n')]); + assert.deepEqual(dialog.appels.map(({ parent, options }) => ({ parent, options })), [{ parent: FENETRE, options: { properties: ['openFile'] } }]); + assert.equal(await fichiers.choisirFichierAImporter(), null); + }); + + test("enregistrerSous propose le nom dans les Documents, écrit le fichier choisi par écriture atomique et rend son chemin ; une annulation n'écrit rien", async () => { + const choisi = '/poste/exemple/Documents/liste-retenue.csv'; + const dialog = dialogueFactice([{ canceled: false, filePath: choisi }]); + const { disque, fichiers } = preparer({ dialog }); + const octets = new Uint8Array(Buffer.from('nom\n\u{C9}lan\n')); + assert.equal(await fichiers.enregistrerSous('liste.csv', octets), choisi); + assert.equal(disque.contenu(choisi), 'nom\n\u{C9}lan\n'); + assert.ok(ecritures(disque).includes(`rename ${choisi}${SUFFIXE_ECRITURE} ${choisi}`), 'pas de renommage'); + assert.equal(await fichiers.enregistrerSous('../../liste.csv', octets), null); + assert.deepEqual( + dialog.appels.map(({ parent, options }) => ({ parent, options })), + [ + { parent: FENETRE, options: { defaultPath: '/poste/exemple/Documents/liste.csv' } }, + { parent: FENETRE, options: { defaultPath: '/poste/exemple/Documents/liste.csv' } }, + ], + ); + await assert.rejects(fichiers.enregistrerSous('liste.csv', 'pas des octets'), TypeError); + assert.equal(dialog.appels.length, 2); + }); + + test("enregistrerSous qui ne peut écrire lève ECRITURE en nommant le fichier et son dossier", async () => { + const choisi = '/poste/exemple/Documents/liste.csv'; + const dialog = dialogueFactice([{ canceled: false, filePath: choisi }]); + const { disque, fichiers } = preparer({ dialog }); + disque.panne('open', 'EROFS'); + await refuse(fichiers.enregistrerSous('liste.csv', new Uint8Array(1)), 'ECRITURE', ecriture('liste.csv', '/poste/exemple/Documents', 'EROFS')); + }); + + test("ouvrirDansExplorateur ouvre la racine ; une racine absente lève ABSENT sans rien ouvrir, un refus de l'explorateur NON_DISPONIBLE", async () => { + const { fichiers, ouvertures } = preparer(); + await fichiers.ouvrirDansExplorateur(DOC); + assert.deepEqual(ouvertures, [DOCUMENTS]); + await refuse(fichiers.ouvrirDansExplorateur(PORTABLE), 'ABSENT', { chemin: '' }); + assert.deepEqual(ouvertures, [DOCUMENTS]); + const { fichiers: sansExplorateur } = preparer({ ouvrir: async () => 'Failed to open path' }); + await refuse(sansExplorateur.ouvrirDansExplorateur(DOC), 'NON_DISPONIBLE', { cause: 'Failed to open path' }); + }); +}); + +describe('coquille, fichiers : emplacements et racines du système (§ 8.6)', () => { + // app.getPath d'un poste d'épreuve, par plateforme. + const CHEMINS = { + win32: { + appData: 'C:\\Users\\Exemple\\AppData\\Roaming', + temp: 'C:\\Users\\Exemple\\AppData\\Local\\Temp', + documents: 'C:\\Users\\Exemple\\Documents', + }, + linux: { appData: '/poste/exemple/.config', temp: '/tmp', documents: '/poste/exemple/Documents' }, + darwin: { + appData: '/Users/exemple/Library/Application Support', + temp: '/var/folders/xy/T', + documents: '/Users/exemple/Documents', + }, + }; + const decrire = (plateforme, env) => + decrireSysteme({ + env, + plateforme, + cheminSysteme: (nom) => { + assert.ok(Object.hasOwn(CHEMINS[plateforme], nom), `app.getPath('${nom}')`); + return CHEMINS[plateforme][nom]; + }, + nomProduit: 'Gestion table tournante Libre', + }); + + test("sous Windows : le dossier publié et son data/, les Documents du produit, AppData, LOCALAPPDATA et le temporaire, sans égard à la casse", () => { + assert.deepEqual( + decrire('win32', { PORTABLE_EXECUTABLE_DIR: 'E:\\soirees', LOCALAPPDATA: 'C:\\Users\\Exemple\\AppData\\Local' }), + { + emplacements: { + executable: 'E:\\soirees', + donneesApplicatives: [ + 'C:\\Users\\Exemple\\AppData\\Roaming', + 'C:\\Users\\Exemple\\AppData\\Local', + 'C:\\Users\\Exemple\\AppData\\Local\\Temp', + ], + insensibleCasse: true, + separateur: '\\', + }, + racines: { + portable: 'E:\\soirees\\data', + documents: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre', + }, + }, + ); + }); + + test('sans dossier publié, ou publié vide, ni exécutable ni racine portable', () => { + for (const env of [{}, { PORTABLE_EXECUTABLE_DIR: '' }]) { + const { emplacements, racines } = decrire('win32', env); + assert.equal(emplacements.executable, null, JSON.stringify(env)); + assert.equal(racines.portable, null, JSON.stringify(env)); + assert.deepEqual(emplacements.donneesApplicatives, [ + 'C:\\Users\\Exemple\\AppData\\Roaming', + 'C:\\Users\\Exemple\\AppData\\Local\\Temp', + ]); + } + }); + + test('sous Linux la casse compte, sous macOS non ; le séparateur suit la plateforme', () => { + const linux = decrire('linux', { PORTABLE_EXECUTABLE_DIR: '/media/cle/soirees' }); + assert.deepEqual(linux.emplacements, { + executable: '/media/cle/soirees', + donneesApplicatives: ['/poste/exemple/.config', '/tmp'], + insensibleCasse: false, + separateur: '/', + }); + assert.deepEqual(linux.racines, { + portable: '/media/cle/soirees/data', + documents: '/poste/exemple/Documents/Gestion table tournante Libre', + }); + assert.equal(decrire('darwin', {}).emplacements.insensibleCasse, true); + }); + + test('emplacements et racines rendent à chaque appel des copies de ce que la coquille a reçu', async () => { + const { fichiers } = preparer(); + const premier = await fichiers.emplacements(); + assert.deepEqual(premier, EMPLACEMENTS); + premier.donneesApplicatives.push('/ajout'); + premier.executable = null; + assert.deepEqual(await fichiers.emplacements(), EMPLACEMENTS); + assert.deepEqual(await fichiers.racines(), { portable: PORTABLE, documents: DOC }); + const sansPortable = creerFichiers({ + emplacements: { ...EMPLACEMENTS, executable: null }, + racines: { portable: null, documents: DOCUMENTS }, + fs: creerDisque().fs, + chemins: posix, + }); + assert.deepEqual(await sansPortable.racines(), { portable: null, documents: DOC }); + await refuse(sansPortable.lireTexte(PORTABLE, 'x.txt'), 'CHEMIN_REFUSE', { chemin: 'x.txt' }); + }); +}); + +describe('coquille, fichiers : le service IPC', () => { + test('servir pose un canal gtt:fichiers: par primitive, dans l\u{2019}ordre de l\u{2019}interface', () => { + assert.equal(PREFIXE_CANAL, 'gtt:fichiers:'); + const canaux = []; + servir(preparer().fichiers, (canal, gestionnaire) => { + assert.equal(typeof gestionnaire, 'function'); + canaux.push(canal); + }); + assert.deepEqual(canaux, PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`)); + assert.equal(PRIMITIVES.length, 17); + assert.ok(Object.isFrozen(PRIMITIVES)); + }); + + test("repondre rend { ok: true, valeur } ou, pour un refus, { ok: false, code, details } ; toute autre erreur se lève", async () => { + assert.deepEqual(await repondre(async () => 3), { ok: true, valeur: 3 }); + assert.deepEqual(await repondre(async () => undefined), { ok: true, valeur: undefined }); + assert.deepEqual(await repondre(async () => { throw new RefusFichier('EXISTE', { chemin: 'a.txt' }); }), { + ok: false, + code: 'EXISTE', + details: { chemin: 'a.txt' }, + }); + await assert.rejects(repondre(async () => { throw new TypeError('faute'); }), TypeError); + }); + + test("le gestionnaire d'un canal reçoit l'évènement d'Electron, puis les arguments de la primitive", async () => { + const gestionnaires = new Map(); + const { disque, fichiers } = preparer(); + servir(fichiers, (canal, gestionnaire) => gestionnaires.set(canal, gestionnaire)); + const evenement = { sender: null }; + assert.deepEqual(await gestionnaires.get('gtt:fichiers:ecrireAtomique')(evenement, DOC, 'etat.json', 'servi'), { ok: true, valeur: undefined }); + assert.equal(disque.contenu(`${DOCUMENTS}/etat.json`), 'servi'); + assert.deepEqual(await gestionnaires.get('gtt:fichiers:lireTexte')(evenement, DOC, 'absent.txt'), { + ok: false, + code: 'ABSENT', + details: { chemin: 'absent.txt' }, + }); + }); +}); + +describe('coquille, fichiers : ce que la coquille recopie de src/stockage', () => { + test('la règle des segments est celle de exigerCheminRelatif, chemin par chemin', () => { + const chemins = [ + '', 'soiree.gtt.json', 'corbeille/2026-01-02_03-04-05/soiree.gtt.json', '.gtt-temoin', '..soiree', ' devant', + 'Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json', '/x', '//serveur/partage/x', 'a//b', 'a/', '..', '../x', 'a/../../x', + '.', './x', 'a/./b', '...', 'a/.. /x', 'soiree.', 'soiree ', 'C:\\x', 'C:x', 'C:/x', '\\\\serveur\\partage\\x', + 'a\\b', 'soiree.gtt.json:flux', 'a\u{0}b', null, undefined, 42, + ]; + for (const chemin of chemins) { + let attendu; + try { + attendu = { segments: exigerCheminRelatif(chemin) }; + } catch (erreur) { + assert.ok(erreur instanceof ErreurStockage, String(erreur)); + attendu = { code: erreur.code, details: erreur.details }; + } + let obtenu; + try { + obtenu = { segments: segmentsAdmis(chemin) }; + } catch (erreur) { + assert.ok(erreur instanceof RefusFichier, String(erreur)); + obtenu = { code: erreur.code, details: erreur.details }; + } + assert.deepEqual(obtenu, attendu, String(chemin)); + } + }); + + test("le suffixe de l'écriture atomique est celui de src/stockage/noms.js", () => { + assert.equal(SUFFIXE_ECRITURE, SUFFIXE_DU_STOCKAGE); + }); +}); + +// --------------------------------------------------------------------------- +// La page derrière le pont + +// 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 +// une erreur levée n'en laisse passer que le message. Rend ce que le +// préchargement expose sous gtt, et les canaux invoqués. +function parLePrechargement(fichiers) { + const gestionnaires = new Map(); + servir(fichiers, (canal, gestionnaire) => gestionnaires.set(canal, gestionnaire)); + const exposes = new Map(); + const invoques = []; + const electron = { + contextBridge: { + exposeInMainWorld: (cle, api) => exposes.set(cle, api), + }, + ipcRenderer: { + async invoke(canal, ...parametres) { + invoques.push(canal); + 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); + assert.deepEqual([...exposes.keys()], ['gtt']); + return { gtt: exposes.get('gtt'), invoques }; +} + +describe('page : src/stockage/fichiers_electron.js', () => { + // Un pont qui consigne chaque appel et rend la réponse suivante. + function pontFactice(reponses) { + const appels = []; + const pont = {}; + for (const primitive of PRIMITIVES) { + pont[primitive] = async (...parametres) => { + appels.push([primitive, ...parametres]); + return reponses.shift(); + }; + } + return { pont, appels }; + } + + test('nature electron, renommage atomique et verrou ; les dix-sept primitives, dans l\u{2019}ordre de la coquille', () => { + const fs = creerFichiersElectron(pontFactice([]).pont); + assert.deepEqual( + { nature: fs.nature, renommageAtomique: fs.renommageAtomique, verrouDisponible: fs.verrouDisponible }, + { nature: 'electron', renommageAtomique: true, verrouDisponible: true }, + ); + assert.deepEqual( + Object.keys(fs).filter((cle) => typeof fs[cle] === 'function'), + [...PRIMITIVES], + ); + }); + + test('chaque primitive passe ses arguments, et eux seuls, à la fonction du même nom du pont, et rend sa valeur', async () => { + const octets = new Uint8Array([1, 2]); + const appels = [ + ['emplacements', []], + ['racines', []], + ['choisirDossier', []], + ['sonder', [DOC]], + ['typeSupport', [DOC]], + ['lireTexte', [DOC, 'a.txt']], + ['ecrireAtomique', [DOC, 'a.txt', 'texte']], + ['ajouterLigne', [DOC, 'a.jsonl', 'ligne']], + ['lister', [DOC, '']], + ['creerDossier', [DOC, 'corbeille']], + ['deplacer', [DOC, 'a.txt', 'b.txt']], + ['supprimer', [DOC, 'a.txt']], + ['verrouiller', [DOC, 'a.gtt.verrou', 'seance-a']], + ['deverrouiller', [DOC, 'a.gtt.verrou', 'seance-a']], + ['ouvrirDansExplorateur', [DOC]], + ['choisirFichierAImporter', []], + ['enregistrerSous', ['liste.csv', octets]], + ]; + const { pont, appels: recus } = pontFactice(appels.map((_, rang) => ({ ok: true, valeur: rang }))); + const fs = creerFichiersElectron(pont); + for (const [rang, [primitive, parametres]] of appels.entries()) { + assert.equal(await fs[primitive](...parametres, 'argument en trop'), rang, primitive); + } + assert.deepEqual(recus, appels.map(([primitive, parametres]) => [primitive, ...parametres])); + }); + + test("une réponse { ok: false } devient l'ErreurStockage de ce code et de ces détails ; une réponse d'une autre forme, une TypeError", async () => { + const { pont } = pontFactice([ + { ok: false, code: 'ECRITURE', details: { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' } }, + { ok: false, code: 'CHEMIN_REFUSE', details: { chemin: '../x' } }, + undefined, + { ok: 'oui', valeur: 1 }, + { ok: false, details: {} }, + ]); + const fs = creerFichiersElectron(pont); + const premiere = await rejette(fs.ecrireAtomique(DOC, 'a.txt', 'x')); + assert.ok(premiere instanceof ErreurStockage); + assert.deepEqual([premiere.code, premiere.details], ['ECRITURE', { chemin: 'a.txt', dossier: DOCUMENTS, cause: 'EBUSY' }]); + const seconde = await rejette(fs.lireTexte(DOC, '../x')); + assert.deepEqual([seconde.code, seconde.details], ['CHEMIN_REFUSE', { chemin: '../x' }]); + for (let i = 0; i < 3; i += 1) { + const erreur = await rejette(fs.lister(DOC, '')); + assert.ok(erreur instanceof TypeError && !(erreur instanceof ErreurStockage), String(erreur)); + } + }); + + test("à travers le vrai préchargement : chaque primitive de la page appelle son canal, et un refus de la coquille revient en ErreurStockage", async () => { + const { disque, fichiers } = preparer(); + const { gtt, invoques } = parLePrechargement(fichiers); + assert.deepEqual(Object.keys(gtt), ['fichiers']); + assert.deepEqual(Object.keys(gtt.fichiers), [...PRIMITIVES]); + assert.ok(Object.values(gtt.fichiers).every((fonction) => typeof fonction === 'function')); + + const fs = creerFichiersElectron(gtt.fichiers); + await fs.ecrireAtomique(DOC, 'soiree.gtt.json', '{"entete":{}}\n'); + assert.equal(await fs.lireTexte(DOC, 'soiree.gtt.json'), '{"entete":{}}\n'); + assert.equal(disque.contenu(`${DOCUMENTS}/soiree.gtt.json`), '{"entete":{}}\n'); + const refus = await rejette(fs.deplacer(DOC, 'soiree.gtt.json', '../dehors.json')); + assert.ok(refus instanceof ErreurStockage, String(refus)); + assert.deepEqual([refus.code, refus.details], ['CHEMIN_REFUSE', { chemin: '../dehors.json' }]); + const faute = await rejette(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 42)); + assert.ok(!(faute instanceof ErreurStockage), String(faute)); + assert.match(faute.message, /gtt:fichiers:ecrireAtomique/); + assert.deepEqual(invoques, [ + 'gtt:fichiers:ecrireAtomique', + 'gtt:fichiers:lireTexte', + 'gtt:fichiers:deplacer', + 'gtt:fichiers:ecrireAtomique', + ]); + }); +});