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', + ]); + }); +});