diff --git a/src/stockage/systeme_fichiers.js b/src/stockage/systeme_fichiers.js new file mode 100644 index 0000000..e790f68 --- /dev/null +++ b/src/stockage/systeme_fichiers.js @@ -0,0 +1,161 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le système de fichiers, frontière de plateforme du stockage (§ 13.4). Ce +// module en déclare l'interface, que trois implémentations portent : celle de +// la coquille electron, livrée ; celle de la plateforme web, sur l'OPFS ; celle +// d'épreuve, en mémoire, qui sait tomber en panne (test/fichiers_simules.js). +// Toute fonction du stockage qui touche un fichier reçoit un SystemeFichiers +// en paramètre (§ 14.10) ; la suite de contrat test/contrat_fichiers.js +// éprouve les trois implémentations par les mêmes épreuves. +// +// Une primitive ne reçoit jamais de chemin absolu : une racine, connue par son +// identifiant, et un chemin relatif à elle, aux segments séparés par « / », +// sans « .. » ni chemin absolu ; '' désigne la racine elle-même. Le chemin +// d'une racine sert à l'affichage, jamais à résoudre un fichier. Toutes les +// primitives sont asynchrones. Un échec lève une ErreurStockage : +// +// ABSENT {chemin} lecture d'un fichier, ou liste +// d'un dossier, qui n'existe pas +// ECRITURE {chemin, dossier, cause} une écriture, un ajout, un +// renommage refusés ; la cible est +// intacte. chemin est le chemin +// relatif reçu, dossier le chemin +// affichable du dossier qui le +// porte — la racine elle-même pour +// '' —, cause celle que rapporte +// la plateforme (EBUSY, EROFS…) +// EXISTE {chemin} deplacer vers une cible existante +// CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine +// inconnue +// +// types.js donne la table entière des codes du stockage. +import { ErreurStockage } from './erreurs.js'; + +/** + * Une racine : un dossier que l'implémentation connaît par son identifiant + * — portable, documents, ou un dossier choisi. + * + * @typedef {Object} Racine + * @property {string} id + * @property {string} chemin chemin absolu, affichable + */ + +/** + * @typedef {Object} Emplacements + * @property {string|null} executable dossier publié par le lanceur portable + * @property {string[]} donneesApplicatives AppData, LocalAppData, temporaire, en absolu + * @property {boolean} insensibleCasse les chemins se comparent sans égard à la casse + * @property {'\\'|'/'} separateur + */ + +/** + * Une entrée d'un dossier. + * + * @typedef {Object} Entree + * @property {string} nom + * @property {'fichier'|'dossier'} type + * @property {number} taille octets du fichier en UTF-8 ; 0 pour un dossier + * @property {number} modifie millisecondes, pour l'affichage seulement : + * aucune décision ne s'y fonde, puisque recopier un dossier suffit + * à les fausser (§ 14.10) + */ + +/** + * Ce que rend verrouiller. pris : true quand l'appel a créé le verrou. Sinon, + * la séance qui le tient, l'instant de sa prise, et vivant : vrai quand son + * processus tourne sur la même machine, faux quand il y est mort, null quand + * il tourne sur une autre machine. Un verrou qui ne se lit pas rend seance, + * depuis et vivant à null. + * + * @typedef {{pris: true} + * | {pris: false, seance: string|null, depuis: string|null, vivant: boolean|null}} Verrou + */ + +/** + * @typedef {Object} SystemeFichiers + * @property {'electron'|'web'|'epreuve'} nature + * @property {boolean} renommageAtomique faux sous web (§ 8.8) + * @property {boolean} verrouDisponible faux sous web (§ 8.8) : verrouiller + * rend alors { pris: true } à chaque appel, et deverrouiller n'efface rien + * @property {() => Promise} emplacements + * @property {() => Promise<{portable: Racine|null, documents: Racine}>} racines + * portable : data/ à côté de l'exécutable publié ; documents : le + * dossier du produit dans les Documents (§ 8.6) + * @property {() => Promise} choisirDossier dialogue natif ; + * null quand l'opérateur annule + * @property {(r: Racine) => Promise<{inscriptible: boolean, cause: string|null}>} sonder + * crée la racine, parents compris, efface un .gtt-temoin resté + * d'une séance précédente, écrit .gtt-temoin, le relit, l'efface + * (§ 8.6) ; un échec rend sa cause, sans lever + * @property {(r: Racine) => Promise<'amovible'|'fixe'|'inconnu'>} typeSupport + * @property {(r: Racine, chemin: string) => Promise} lireTexte + * le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et + * forme de normalisation comprises ; ABSENT pour un fichier absent + * ou un dossier + * @property {(r: Racine, chemin: string, texte: string) => Promise} ecrireAtomique + * .ecriture, vidé sur le disque, renommé par-dessus ; réessaie + * le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où + * renommageAtomique est faux, l'écriture va droit sur la cible. Le + * dossier qui porte la cible doit exister : un dossier absent lève + * ECRITURE, car un dossier de travail disparu — un support retiré — + * ne se recrée pas en silence + * @property {(r: Racine, chemin: string, ligne: string) => Promise} ajouterLigne + * ajoute la ligne puis une fin de ligne LF après le dernier octet du + * fichier, sans rien insérer avant, vidé sur le disque ; crée le + * fichier absent, jamais son dossier. La ligne ne porte aucune fin + * de ligne + * @property {(r: Racine, dossier: string) => Promise} lister + * les entrées directes du dossier, triées par nom, unités UTF-16 + * comparées une à une ; ABSENT pour un dossier absent + * @property {(r: Racine, chemin: string) => Promise} creerDossier + * parents compris ; un dossier existant n'est pas une faute + * @property {(r: Racine, de: string, vers: string) => Promise} deplacer + * un fichier ; refuse une cible existante (EXISTE), comparée comme le + * système compare les noms, si bien que sans égard à la casse une + * autre casse du même nom est une cible existante ; ABSENT pour une + * source absente ; le dossier de la cible doit exister + * @property {(r: Racine, chemin: string) => Promise} supprimer + * un fichier ; un fichier absent n'est pas une faute + * @property {(r: Racine, chemin: string, seance: string) => Promise} verrouiller + * création exclusive de {seance, pid, hote, depuis} : un verrou + * présent n'est jamais repris, fût-il de la même séance + * @property {(r: Racine, chemin: string, seance: string) => Promise} deverrouiller + * n'efface que le verrou de cette séance ; un verrou absent n'est + * pas une faute + * @property {(r: Racine) => Promise} ouvrirDansExplorateur + * @property {() => Promise<{nom: string, octets: Uint8Array}|null>} choisirFichierAImporter + * null quand l'opérateur annule + * @property {(nomPropose: string, octets: Uint8Array) => Promise} enregistrerSous + * le chemin choisi, ou null quand l'opérateur annule + */ + +// Motif d'un segment que toute implémentation accepte. Un segment vide +// vient d'un chemin absolu (« /x »), d'un séparateur doublé ou final. Un +// segment qui finit par un point ou une espace comprend « . » et « .. », et +// ce que Windows ramène à eux ou à un autre nom en retirant points et espaces +// finaux : « ... », « .. » suivi d'une espace, « soiree. ». La barre oblique +// inverse sépare les segments sous Windows ; le deux-points y désigne un +// lecteur (« C:\x », « C:x ») ou un flux de données secondaire ; le +// caractère nul tronque un chemin là où la plateforme le lit en C. +const SEGMENT_ADMIS = /^[^\\:\u{0}]*[^\\:\u{0}. ]$/u; + +/** + * Segments d'un chemin relatif à une racine : [] pour '', la racine + * elle-même. Le contrôle ne lit que le texte du chemin ; il ne connaît ni la + * racine ni les liens, que résout chaque implémentation. + * + * @param {string} chemin + * @returns {string[]} + * @throws {ErreurStockage} CHEMIN_REFUSE {chemin} : autre chose qu'une chaîne, + * ou un segment que SEGMENT_ADMIS refuse + */ +export function exigerCheminRelatif(chemin) { + if (typeof chemin !== 'string') throw new ErreurStockage('CHEMIN_REFUSE', { chemin }); + if (chemin === '') return []; + const segments = chemin.split('/'); + if (!segments.every((segment) => SEGMENT_ADMIS.test(segment))) { + throw new ErreurStockage('CHEMIN_REFUSE', { chemin }); + } + return segments; +} diff --git a/src/stockage/systeme_fichiers.test.js b/src/stockage/systeme_fichiers.test.js new file mode 100644 index 0000000..567ad9a --- /dev/null +++ b/src/stockage/systeme_fichiers.test.js @@ -0,0 +1,84 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du contrôle des chemins relatifs à une racine (§ 13.4, § 14.10) : +// exigerCheminRelatif, que partagent l'implémentation web et celle +// d'épreuve, rend les segments d'un chemin admis, et refuse par +// CHEMIN_REFUSE tout chemin absolu, remontant, ou que Windows lirait +// autrement. Le reste du module est l'interface en JSDoc, que la suite de +// contrat test/contrat_fichiers.js éprouve sur chaque implémentation. Les +// noms d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { ErreurStockage } from './erreurs.js'; +import { exigerCheminRelatif } from './systeme_fichiers.js'; + +// Rend l'erreur que lève la fonction ; échoue quand elle n'en lève pas. +function erreurDe(fonction) { + try { + fonction(); + } catch (erreur) { + return erreur; + } + assert.fail('aucune erreur levée'); +} + +describe('exigerCheminRelatif', () => { + test("rend les segments d'un chemin relatif, et aucun pour la racine", () => { + const cas = [ + ['', []], + ['soiree.gtt.json', ['soiree.gtt.json']], + [ + 'corbeille/2026-01-02_03-04-05/soiree.gtt.json', + ['corbeille', '2026-01-02_03-04-05', 'soiree.gtt.json'], + ], + ['.gtt-temoin', ['.gtt-temoin']], + ['..soiree', ['..soiree']], + [' devant', [' devant']], + ['Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json', ['Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json']], + ]; + for (const [chemin, segments] of cas) assert.deepEqual(exigerCheminRelatif(chemin), segments, chemin); + }); + + test('refuse un chemin absolu, remontant, ou que Windows lirait autrement : CHEMIN_REFUSE {chemin}', () => { + const refuses = [ + // absolus, ou porteurs d'un segment vide + '/x', + '//serveur/partage/x', + 'a//b', + 'a/', + // remontants ou désignant le dossier courant + '..', + '../x', + 'a/../../x', + '.', + './x', + 'a/./b', + // ce que Windows ramène à eux, ou à un autre nom, en retirant points et + // espaces finaux + '...', + 'a/.. /x', + 'soiree.', + 'soiree ', + // lecteur, partage, séparateur de Windows, flux de données secondaire + 'C:\\x', + 'C:x', + 'C:/x', + '\\\\serveur\\partage\\x', + 'a\\b', + 'soiree.gtt.json:flux', + // caractère nul + 'a\u{0}b', + // autre chose qu'une chaîne + null, + undefined, + 42, + ]; + for (const chemin of refuses) { + const erreur = erreurDe(() => exigerCheminRelatif(chemin)); + assert.ok(erreur instanceof ErreurStockage, `${String(chemin)} : ${erreur}`); + assert.equal(erreur.code, 'CHEMIN_REFUSE', String(chemin)); + assert.deepEqual(erreur.details, { chemin }, String(chemin)); + } + }); +}); diff --git a/test/contrat_fichiers.js b/test/contrat_fichiers.js new file mode 100644 index 0000000..63d0f8e --- /dev/null +++ b/test/contrat_fichiers.js @@ -0,0 +1,314 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Suite de contrat du système de fichiers (§ 13.4, § 14.10) : ce que les trois +// implémentations de l'interface de src/stockage/systeme_fichiers.js — +// electron, web et celle d'épreuve — promettent pareillement au stockage. La +// suite ne connaît que l'interface, et reçoit de l'appelant son lanceur et +// ses assertions : elle se joue sous node avec node:assert comme dans le +// navigateur avec expect. +// +// describe(nom, corps), test(nom, corps) ceux du lanceur de l'appelant +// egal(reel, attendu, message?) égalité profonde et stricte +// vrai(valeur, message?) valeur vraie +// rejette(promesse, message?) échoue quand la promesse se +// résout ; rend la raison du rejet +// +// Chaque épreuve demande à fabrique() un système neuf et une racine qui +// existe et ne porte rien : aucune ne voit ce qu'une autre a écrit. Les +// pannes — renommage refusé, support qui refuse l'écriture, processus coupé — +// ne se provoquent que sur l'implémentation d'épreuve, et s'éprouvent à côté +// d'elle (test/fichiers_simules.test.js). Là où verrouDisponible est faux, la +// suite exige du verrou ce que la plateforme promet à la place : verrouiller +// rend { pris: true } à chaque appel, et deverrouiller ne lève pas. +import { ErreurStockage } from '../src/stockage/erreurs.js'; + +// Les primitives de l'interface, dans l'ordre où elle les déclare. +const PRIMITIVES = [ + 'emplacements', + 'racines', + 'choisirDossier', + 'sonder', + 'typeSupport', + 'lireTexte', + 'ecrireAtomique', + 'ajouterLigne', + 'lister', + 'creerDossier', + 'deplacer', + 'supprimer', + 'verrouiller', + 'deverrouiller', + 'ouvrirDansExplorateur', + 'choisirFichierAImporter', + 'enregistrerSous', +]; + +// Chemins qu'aucune implémentation n'accepte : remontant, absolu sous Unix, +// absolu sous Windows, remontant après un détour. +const CHEMINS_REFUSES = ['../x', '/x', 'C:\\x', 'a/../../x']; + +// 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 +// une cible absente, puis par sa cible, depuis ici.txt, que l'épreuve pose : +// seul le chemin éprouvé peut alors faire échouer l'appel. +const APPELS_A_CHEMIN = [ + ['lireTexte', (fs, racine, chemin) => fs.lireTexte(racine, chemin)], + ['ecrireAtomique', (fs, racine, chemin) => fs.ecrireAtomique(racine, chemin, 'texte')], + ['ajouterLigne', (fs, racine, chemin) => fs.ajouterLigne(racine, chemin, 'ligne')], + ['lister', (fs, racine, chemin) => fs.lister(racine, chemin)], + ['creerDossier', (fs, racine, chemin) => fs.creerDossier(racine, chemin)], + ['deplacer, source', (fs, racine, chemin) => fs.deplacer(racine, chemin, 'ailleurs.txt')], + ['deplacer, cible', (fs, racine, chemin) => fs.deplacer(racine, 'ici.txt', chemin)], + ['supprimer', (fs, racine, chemin) => fs.supprimer(racine, chemin)], + ['verrouiller', (fs, racine, chemin) => fs.verrouiller(racine, chemin, 'seance-a')], + ['deverrouiller', (fs, racine, chemin) => fs.deverrouiller(racine, chemin, 'seance-a')], +]; + +// Vrai pour la description d'une racine : un identifiant et un chemin +// affichable, chaînes non vides. +const estRacine = (racine) => + racine !== null && + typeof racine === 'object' && + typeof racine.id === 'string' && + racine.id !== '' && + typeof racine.chemin === 'string' && + racine.chemin !== ''; + +// Noms des entrées d'un dossier, dans l'ordre rendu. +const noms = (entrees) => entrees.map((entree) => entree.nom); + +/** + * Enregistre la suite de contrat d'une implémentation. + * + * @param {string} nom nom de l'implémentation, repris dans celui de la suite + * @param {() => Promise<{fs: import('../src/stockage/systeme_fichiers.js').SystemeFichiers, + * racine: import('../src/stockage/systeme_fichiers.js').Racine}>} fabrique + * rend à chaque appel un système neuf, et une racine qui existe et ne + * porte rien + * @param {Object} outils describe, test, egal, vrai, rejette, décrits en tête + */ +export function eprouverContrat(nom, fabrique, { describe, test, egal, vrai, rejette }) { + // Attend le rejet d'une ErreurStockage de ce code et, quand ils sont + // donnés, de ces détails exactement ; rend l'erreur. + async function echoue(promesse, code, details, message = code) { + const erreur = await rejette(promesse, message); + vrai(erreur instanceof ErreurStockage, `${message} : ${erreur}`); + egal(erreur.code, code, message); + if (details !== undefined) egal(erreur.details, details, message); + return erreur; + } + + // Attend un refus d'écriture : ECRITURE, dont les détails nomment l'un des + // chemins admis, le dossier et la cause, chaînes non vides. + async function refuseEcriture(promesse, ...chemins) { + const { details } = await echoue(promesse, 'ECRITURE', undefined, chemins[0]); + vrai(chemins.includes(details.chemin), `chemin ${details.chemin}`); + vrai(typeof details.dossier === 'string' && details.dossier !== '', `dossier ${details.dossier}`); + vrai(typeof details.cause === 'string' && details.cause !== '', `cause ${details.cause}`); + } + + describe(`contrat du système de fichiers : ${nom}`, () => { + test("l'interface porte sa nature, ses deux garanties et ses dix-sept primitives, et décrit ses emplacements et ses racines", async () => { + const { fs, racine } = await fabrique(); + vrai(['electron', 'web', 'epreuve'].includes(fs.nature), `nature ${fs.nature}`); + egal(typeof fs.renommageAtomique, 'boolean', 'renommageAtomique'); + egal(typeof fs.verrouDisponible, 'boolean', 'verrouDisponible'); + for (const primitive of PRIMITIVES) egal(typeof fs[primitive], 'function', primitive); + + const emplacements = await fs.emplacements(); + vrai(emplacements.executable === null || typeof emplacements.executable === 'string', 'executable'); + vrai( + Array.isArray(emplacements.donneesApplicatives) && + emplacements.donneesApplicatives.every((chemin) => typeof chemin === 'string'), + 'donneesApplicatives', + ); + egal(typeof emplacements.insensibleCasse, 'boolean', 'insensibleCasse'); + vrai(emplacements.separateur === '\\' || emplacements.separateur === '/', 'separateur'); + + const racines = await fs.racines(); + vrai(estRacine(racines.documents), 'racine documents'); + vrai(racines.portable === null || estRacine(racines.portable), 'racine portable'); + vrai(['amovible', 'fixe', 'inconnu'].includes(await fs.typeSupport(racine)), 'typeSupport'); + }); + + test("lireTexte rend le texte écrit tel quel : marque d'ordre d'octets, accents composés ou non, CRLF, caractère hors du plan de base", async () => { + const { fs, racine } = await fabrique(); + const texte = '\u{FEFF}Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}\r\n\u{C9}lan \u{AB} \u{F4} \u{BB} e\u{301}\rfin\n\u{1D11E}'; + const chemin = 'Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.txt'; + await fs.ecrireAtomique(racine, chemin, texte); + egal(await fs.lireTexte(racine, chemin), texte); + }); + + test('ecrireAtomique remplace le contenu entier et ne laisse aucun fichier .ecriture', async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'etat.json', 'premier contenu, le plus long des deux'); + await fs.ecrireAtomique(racine, 'etat.json', 'second'); + egal(await fs.lireTexte(racine, 'etat.json'), 'second'); + egal(noms(await fs.lister(racine, '')), ['etat.json']); + }); + + test('ajouterLigne ajoute la ligne et une fin de ligne LF après le dernier octet, et crée le fichier absent', async () => { + const { fs, racine } = await fabrique(); + await fs.ajouterLigne(racine, 'journal.jsonl', '{"type":"journal"}'); + egal(await fs.lireTexte(racine, 'journal.jsonl'), '{"type":"journal"}\n'); + await fs.ajouterLigne(racine, 'journal.jsonl', '{"revision":1}'); + egal(await fs.lireTexte(racine, 'journal.jsonl'), '{"type":"journal"}\n{"revision":1}\n'); + + // Une dernière ligne sans fin de ligne reste telle : l'ajout la prolonge. + await fs.ecrireAtomique(racine, 'tronque.jsonl', '{"type":"jour'); + await fs.ajouterLigne(racine, 'tronque.jsonl', 'nal"}'); + egal(await fs.lireTexte(racine, 'tronque.jsonl'), '{"type":"journal"}\n'); + }); + + test("lister rend les entrées directes du dossier, triées par unités UTF-16, typées, la taille d'un fichier en octets UTF-8 et 0 pour un dossier", async () => { + const { fs, racine } = await fabrique(); + await fs.creerDossier(racine, 'dossier'); + await fs.ecrireAtomique(racine, 'dossier/interne.txt', 'x'); + await fs.ecrireAtomique(racine, 'b.txt', 'bb'); + await fs.ecrireAtomique(racine, '\u{E9}.txt', '\u{E9}'); + await fs.ecrireAtomique(racine, 'a.txt', ''); + await fs.ecrireAtomique(racine, 'Z.txt', '\u{1D11E}'); + + const entrees = await fs.lister(racine, ''); + egal( + entrees.map(({ nom: nomEntree, type, taille }) => ({ nom: nomEntree, type, taille })), + [ + { nom: 'Z.txt', type: 'fichier', taille: 4 }, + { nom: 'a.txt', type: 'fichier', taille: 0 }, + { nom: 'b.txt', type: 'fichier', taille: 2 }, + { nom: 'dossier', type: 'dossier', taille: 0 }, + { nom: '\u{E9}.txt', type: 'fichier', taille: 2 }, + ], + ); + vrai(entrees.every((entree) => Number.isFinite(entree.modifie)), 'modifie'); + egal(noms(await fs.lister(racine, 'dossier')), ['interne.txt']); + }); + + test("creerDossier crée les dossiers parents, et un dossier existant n'est pas une faute", async () => { + const { fs, racine } = await fabrique(); + await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05'); + egal( + (await fs.lister(racine, '')).map(({ nom: nomEntree, type }) => ({ nom: nomEntree, type })), + [{ nom: 'corbeille', type: 'dossier' }], + ); + egal( + (await fs.lister(racine, 'corbeille')).map(({ nom: nomEntree, type }) => ({ nom: nomEntree, type })), + [{ nom: '2026-01-02_03-04-05', type: 'dossier' }], + ); + egal(await fs.lister(racine, 'corbeille/2026-01-02_03-04-05'), []); + await fs.creerDossier(racine, 'corbeille'); + egal(noms(await fs.lister(racine, 'corbeille')), ['2026-01-02_03-04-05']); + }); + + test("deplacer porte le fichier sous son nouveau chemin et retire l'ancien", async () => { + const { fs, racine } = await fabrique(); + await fs.creerDossier(racine, 'corbeille'); + await fs.ecrireAtomique(racine, 'soiree.gtt.json', '\u{E9}tat'); + await fs.deplacer(racine, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'); + egal(await fs.lireTexte(racine, 'corbeille/soiree.gtt.json'), '\u{E9}tat'); + egal(noms(await fs.lister(racine, '')), ['corbeille']); + }); + + test('deplacer refuse une cible existante (EXISTE) et laisse les deux fichiers intacts', async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'source.txt', 'source'); + await fs.ecrireAtomique(racine, 'cible.txt', 'cible'); + await echoue(fs.deplacer(racine, 'source.txt', 'cible.txt'), 'EXISTE', { chemin: 'cible.txt' }); + egal(await fs.lireTexte(racine, 'source.txt'), 'source'); + egal(await fs.lireTexte(racine, 'cible.txt'), 'cible'); + }); + + test('deplacer une source absente lève ABSENT', async () => { + const { fs, racine } = await fabrique(); + await echoue(fs.deplacer(racine, 'absent.txt', 'ailleurs.txt'), 'ABSENT', { chemin: 'absent.txt' }); + egal(await fs.lister(racine, ''), []); + }); + + test("lireTexte d'un fichier absent et lister d'un dossier absent lèvent ABSENT", async () => { + const { fs, racine } = await fabrique(); + await echoue(fs.lireTexte(racine, 'absent.gtt.json'), 'ABSENT', { chemin: 'absent.gtt.json' }); + await echoue(fs.lister(racine, 'absent'), 'ABSENT', { chemin: 'absent' }); + }); + + test('écrire, ajouter ou déplacer dans un dossier absent lève ECRITURE, sans créer le dossier', async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'ici.txt', 'ici'); + await refuseEcriture(fs.ecrireAtomique(racine, 'disparu/etat.json', 'x'), 'disparu/etat.json'); + await refuseEcriture(fs.ajouterLigne(racine, 'disparu/journal.jsonl', 'x'), 'disparu/journal.jsonl'); + await refuseEcriture(fs.deplacer(racine, 'ici.txt', 'disparu/ici.txt'), 'disparu/ici.txt', 'ici.txt'); + egal(noms(await fs.lister(racine, '')), ['ici.txt']); + egal(await fs.lireTexte(racine, 'ici.txt'), 'ici'); + }); + + test("supprimer retire le fichier, et supprimer un absent n'est pas une faute", async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'garde.txt', 'garde'); + await fs.ecrireAtomique(racine, 'retire.txt', 'retire'); + await fs.supprimer(racine, 'retire.txt'); + await echoue(fs.lireTexte(racine, 'retire.txt'), 'ABSENT', { chemin: 'retire.txt' }); + await fs.supprimer(racine, 'retire.txt'); + egal(noms(await fs.lister(racine, '')), ['garde.txt']); + }); + + test('un chemin absolu ou remontant lève CHEMIN_REFUSE {chemin} à chaque primitive, sans rien écrire', async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'ici.txt', 'ici'); + for (const chemin of CHEMINS_REFUSES) { + for (const [primitive, appel] of APPELS_A_CHEMIN) { + await echoue(appel(fs, racine, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`); + } + } + egal(noms(await fs.lister(racine, '')), ['ici.txt']); + egal(await fs.lireTexte(racine, 'ici.txt'), 'ici'); + }); + + test("une racine inconnue lève CHEMIN_REFUSE, même sous le chemin d'une racine connue", async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'ici.txt', 'ici'); + const inconnue = { id: 'racine-inconnue', chemin: racine.chemin }; + for (const [primitive, appel] of APPELS_A_CHEMIN) { + await echoue(appel(fs, inconnue, 'ici.txt'), 'CHEMIN_REFUSE', undefined, primitive); + } + await echoue(fs.sonder(inconnue), 'CHEMIN_REFUSE', undefined, 'sonder'); + egal(noms(await fs.lister(racine, '')), ['ici.txt']); + egal(await fs.lireTexte(racine, 'ici.txt'), 'ici'); + }); + + test('verrouiller est exclusif : un second appel rend la séance du premier, vivante', async () => { + const { fs, racine } = await fabrique(); + egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true }); + const second = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b'); + if (!fs.verrouDisponible) { + egal(second, { pris: true }); + return; + } + vrai(typeof second.depuis === 'string' && second.depuis !== '', `depuis ${second.depuis}`); + egal(second, { pris: false, seance: 'seance-a', depuis: second.depuis, vivant: true }); + }); + + test("deverrouiller n'efface que le verrou de sa séance, et un verrou absent n'est pas une faute", async () => { + const { fs, racine } = await fabrique(); + await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a'); + await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'); + await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-b'); + if (!fs.verrouDisponible) { + egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true }); + return; + } + const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'); + egal([tenu.pris, tenu.seance], [false, 'seance-a']); + await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a'); + egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true }); + }); + + test('sonder rend inscriptible, efface le témoin resté et laisse les autres fichiers', async () => { + const { fs, racine } = await fabrique(); + await fs.ecrireAtomique(racine, 'garde.txt', 'garde'); + await fs.ecrireAtomique(racine, '.gtt-temoin', 'resté d\u{2019}une séance précédente'); + egal(await fs.sonder(racine), { inscriptible: true, cause: null }); + egal(noms(await fs.lister(racine, '')), ['garde.txt']); + egal(await fs.lireTexte(racine, 'garde.txt'), 'garde'); + }); + }); +} diff --git a/test/fichiers_simules.js b/test/fichiers_simules.js new file mode 100644 index 0000000..ddec98b --- /dev/null +++ b/test/fichiers_simules.js @@ -0,0 +1,517 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Système de fichiers d'épreuve (§ 13.4) : la troisième implémentation de +// l'interface de src/stockage/systeme_fichiers.js, en mémoire, injectée par +// les épreuves. Elle simule l'échec de renommage du § 8.8 et les cinq issues +// du § 8.6 — chemin publié par le lanceur portable, dossier sous un +// emplacement de données applicatives, sonde qui échoue, perte +// d'inscriptibilité en cours de séance, support amovible —, et compte ses +// lectures et ses écritures : une épreuve affirme ainsi qu'ouvrir n'écrit +// rien (§ 8.4). +// +// Une instance est un processus. Le disque, une Map, se partage : une +// instance créée sur le disque d'une autre est ce processus redémarré, ou un +// second processus de la même machine, d'une autre quand son hôte diffère. +// Le disque porte les fichiers et les dossiers sous leur chemin absolu, replié +// quand la casse ne compte pas ; à côté de lui, hors de la Map, son horloge et +// sa table des processus. Les pannes et les compteurs sont ceux de l'instance. +// +// Une racine n'existe qu'une fois créée — par sonder, creerDossier ou, sans +// compter, deposerDossier —, et aucune écriture ne crée le dossier qui porte +// sa cible : l'épreuve voit ainsi le dossier de travail qu'aucun code n'a +// créé, ou qui a disparu, comme le verrait la coquille. +import { ErreurStockage } from '../src/stockage/erreurs.js'; +import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js'; + +/** + * Levée par chaque primitive d'une instance dont le processus est coupé + * (pannes.couperApres). Ce n'est pas une ErreurStockage : le stockage ne la + * traite pas, il meurt avec le processus. + */ +export class PanneSimulee extends Error { + constructor() { + super('panne simulée : le processus de cette instance est arrêté'); + } +} +PanneSimulee.prototype.name = 'PanneSimulee'; + +// Suffixe du fichier qu'écrit l'écriture atomique avant de le renommer +// par-dessus sa cible, et nom du témoin de la sonde (§ 8.6, § 8.8). +const SUFFIXE_ECRITURE = '.ecriture'; +const TEMOIN = '.gtt-temoin'; + +// Réessais d'un renommage refusé après le premier essai, ceux de +// l'implémentation de la coquille, sans ses pauses. +const REESSAIS_RENOMMAGE = 10; + +// Horloge d'un disque neuf, en millisecondes depuis l'époque Unix : le +// 1er janvier 2026 à minuit UTC. Chaque fichier ou dossier qu'écrit une +// primitive l'avance d'une seconde, si bien que modifie et la prise d'un +// verrou ne dépendent que de la suite des écritures. +const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1); +const PAS_HORLOGE = 1000; + +// La taille d'un fichier compte les octets de son texte en UTF-8. +const UTF8 = new TextEncoder(); + +// État de chaque disque, hors de sa Map : la casse et le séparateur de sa +// première instance, son horloge, le prochain pid, et ses processus par pid. +const ETATS_DISQUE = new WeakMap(); + +function etatDuDisque(disque, insensibleCasse, separateur) { + const etat = ETATS_DISQUE.get(disque); + if (etat === undefined) { + const neuf = { insensibleCasse, separateur, horloge: ORIGINE_HORLOGE, prochainPid: 1, processus: new Map() }; + ETATS_DISQUE.set(disque, neuf); + return neuf; + } + if (etat.insensibleCasse !== insensibleCasse || etat.separateur !== separateur) { + throw new Error('un disque partagé garde la casse et le séparateur de sa première instance'); + } + return etat; +} + +// Clé d'un chemin quand la casse ne compte pas : chaque caractère vers sa +// majuscule, quand elle tient en un caractère, sans regarder le contexte, +// comme Windows compare deux noms. Un sigma final rejoint ainsi sa +// majuscule, et ß, dont la majuscule s'écrit SS, reste lui-même. +function replier(texte) { + return Array.from(texte, (caractere) => { + const majuscule = caractere.toUpperCase(); + return Array.from(majuscule).length === 1 ? majuscule : caractere; + }).join(''); +} + +// Un instant de l'horloge sous la forme de celle de l'application, +// AAAA-MM-JJTHH:MM:SS±HH:MM. +const horodatage = (millisecondes) => `${new Date(millisecondes).toISOString().slice(0, 19)}+00:00`; + +function exigerChaine(valeur, quoi) { + if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`); +} + +function exigerSeance(seance) { + if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide"); +} + +// Vrai pour le contenu lu d'un verrou : {seance, pid, hote, depuis}. +const estContenuVerrou = (contenu) => + contenu !== null && + typeof contenu === 'object' && + typeof contenu.seance === 'string' && + Number.isInteger(contenu.pid) && + typeof contenu.hote === 'string' && + typeof contenu.depuis === 'string'; + +/** + * Système de fichiers d'épreuve, en mémoire, qui sait tomber en panne. + * `disque` se partage entre deux instances : une instance « redémarrée » relit + * ce que l'autre a écrit. Racines : portable dès qu'executable est donné — la + * règle du dossier de travail décide de s'en servir (§ 8.6) —, et documents. + * + * @param {Object} [reglages] + * @param {Map} [reglages.disque] une Map neuve, ou le disque d'une autre + * instance, dont il faut garder la casse et le séparateur + * @param {string|null} [reglages.executable] dossier publié par le lanceur ; + * la racine portable est son data/ + * @param {string[]} [reglages.donneesApplicatives] + * @param {string} [reglages.documents] chemin de la racine documents + * @param {'\\'|'/'} [reglages.separateur] + * @param {boolean} [reglages.insensibleCasse] + * @param {'electron'|'web'|'epreuve'} [reglages.nature] + * @param {boolean} [reglages.renommageAtomique] faux : ecrireAtomique écrit + * droit sur la cible, sans fichier .ecriture ni renommage + * @param {boolean} [reglages.verrouDisponible] faux : verrouiller rend + * { pris: true } sans rien écrire, deverrouiller ne fait rien + * @param {Object} [reglages.sonde] cause d'échec par racine, + * { portable: 'EACCES' } : la sonde de cette racine la rend, et la + * racine refuse toute écriture avec elle, comme après pannes.ecriture + * @param {Object} [reglages.support] + * ce que rend typeSupport, 'fixe' pour une racine absente de l'objet + * @param {string} [reglages.hote] machine du processus ; un verrou d'un autre + * hôte rend vivant à null + * @returns {import('../src/stockage/systeme_fichiers.js').SystemeFichiers & Object} + * le système, et pour l'épreuve : + * - pid, hote : ceux du processus, qu'écrit son verrou ; les pid d'un disque + * se suivent, 1, 2, 3…, dans l'ordre de création des instances ; + * - pannes.renommage(racineId, n = Infinity, cause = 'EBUSY') : les n + * prochains renommages de la racine sont refusés — chaque essai de + * l'écriture atomique, qui en fait onze, et deplacer ; + * - pannes.ecriture(racineId, cause = 'EROFS') : dès lors, toute primitive + * d'écriture de la racine lève ECRITURE, et sa sonde rend la cause ; + * - pannes.couperApres(n) : après n primitives d'écriture réussies — + * rendues sans lever —, toute primitive lève PanneSimulee : le processus + * est mort, la table des processus du disque le dit à ses verrous, et + * l'on rouvre par une nouvelle instance sur le même disque ; + * - compteurs.ecritures, compteurs.lectures : les appels des primitives + * d'écriture (ecrireAtomique, ajouterLigne, creerDossier, deplacer, + * supprimer, verrouiller, deverrouiller, sonder) et de lecture (lireTexte, + * lister), refusés compris ; les autres ne comptent pas ; + * - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un + * fichier, dossiers parents compris, sans compter ni avancer l'horloge ; + * - deposerDossier(racineId, chemin = '') : pose un dossier, parents compris, + * de même ; + * - contenu(racineId, chemin) : le texte d'un fichier, sans compter ; null + * pour un absent ou un dossier. + * deposer, deposerDossier et contenu servent encore après une coupure : ils + * sont les mains de l'épreuve sur le disque, non celles du processus. + */ +export function creerFichiersSimules({ + disque = new Map(), + executable = null, + donneesApplicatives = ['C:\\Users\\Exemple\\AppData\\Roaming', 'C:\\Users\\Exemple\\AppData\\Local'], + documents = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre', + separateur = '\\', + insensibleCasse = true, + nature = 'epreuve', + renommageAtomique = true, + verrouDisponible = true, + sonde = {}, + support = {}, + hote = 'poste-epreuve', +} = {}) { + const etat = etatDuDisque(disque, insensibleCasse, separateur); + const pid = etat.prochainPid; + etat.prochainPid += 1; + + // Chemin absolu de chaque racine connue, par identifiant. + const joindre = (dossier, nom) => (dossier.endsWith(separateur) ? dossier + nom : dossier + separateur + nom); + const racines = new Map([['documents', documents]]); + if (executable !== null) racines.set('portable', joindre(executable, 'data')); + + // Cause qui refuse toute écriture d'une racine : celle de sa sonde déclarée, + // puis celle de pannes.ecriture. Renommages refusés restants d'une racine, + // et leur cause. + const refusEcriture = new Map(); + for (const id of racines.keys()) if (Object.hasOwn(sonde, id)) refusEcriture.set(id, sonde[id]); + const refusRenommage = new Map(); + + // Écritures réussies que le processus fait encore avant sa coupure ; null + // tant que couperApres n'est pas appelé. Le processus meurt à zéro. + let restantes = null; + const estMort = () => restantes === 0; + etat.processus.set(pid, { hote, estMort }); + + const compteurs = { ecritures: 0, lectures: 0 }; + + const avancer = () => { + etat.horloge += PAS_HORLOGE; + return etat.horloge; + }; + const maintenant = () => etat.horloge; + + // Ce que désigne un chemin relatif à une racine : son chemin absolu en + // parties, la clé dans le disque de chacun de ses préfixes, sa clé et celle + // de son parent, son nom, et le dossier que nomme une ECRITURE — celui qui le + // porte, ou la racine elle-même pour ''. CHEMIN_REFUSE pour un chemin refusé + // ou une racine inconnue. + function localiser(racine, chemin) { + const segments = exigerCheminRelatif(chemin); + const base = racines.get(racine?.id); + if (base === undefined) throw new ErreurStockage('CHEMIN_REFUSE', { chemin }); + const parties = [...base.split(separateur), ...segments]; + const absolu = (n) => parties.slice(0, n).join(separateur); + const cleDe = (n) => (insensibleCasse ? replier(absolu(n)) : absolu(n)); + const n = parties.length; + return { + racine: racine.id, + chemin, + parties, + cleDe, + cle: cleDe(n), + cleParent: n > 1 ? cleDe(n - 1) : null, + nom: parties[n - 1], + dossier: absolu(segments.length > 0 ? n - 1 : n), + }; + } + + const refus = (loc, cause) => new ErreurStockage('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause }); + + function exigerInscriptible(loc) { + const cause = refusEcriture.get(loc.racine); + if (cause !== undefined) throw refus(loc, cause); + } + + function exigerDossierParent(loc) { + if (disque.get(loc.cleParent)?.type !== 'dossier') throw refus(loc, 'ENOENT'); + } + + function exigerPasDossier(loc) { + if (disque.get(loc.cle)?.type === 'dossier') throw refus(loc, 'EISDIR'); + } + + // Crée, dans l'ordre, les dossiers des n premières parties du chemin absolu + // qui manquent, datés par horloge(). Un fichier en travers lève ECRITURE : + // EEXIST quand il occupe le chemin même, ENOTDIR un dossier parent. + function creerDossiers(loc, n, horloge) { + for (let k = 1; k <= n; k += 1) { + const noeud = disque.get(loc.cleDe(k)); + if (noeud === undefined) { + disque.set(loc.cleDe(k), { + nom: loc.parties[k - 1], + type: 'dossier', + texte: null, + modifie: horloge(), + parent: k > 1 ? loc.cleDe(k - 1) : null, + }); + } else if (noeud.type !== 'dossier') { + throw refus(loc, k === loc.parties.length ? 'EEXIST' : 'ENOTDIR'); + } + } + } + + // Écrit le texte d'un fichier : un fichier présent garde son nom, un fichier + // neuf prend celui du chemin. + function poserFichier(loc, texte, modifie) { + const present = disque.get(loc.cle); + disque.set(loc.cle, { nom: present?.nom ?? loc.nom, type: 'fichier', texte, modifie, parent: loc.cleParent }); + } + + // Porte le fichier de la source à la cible, sous le nom de la cible : un + // renommage par-dessus donne au fichier la casse du nouveau nom. + function renommer(source, cible) { + const noeud = disque.get(source.cle); + disque.delete(source.cle); + disque.set(cible.cle, { ...noeud, nom: cible.nom, parent: cible.cleParent }); + } + + // Cause du refus du prochain renommage de la racine, ou null ; chaque refus + // en consomme un. + function renommageRefuse(racineId) { + const panne = refusRenommage.get(racineId); + if (panne === undefined || panne.restants === 0) return null; + panne.restants -= 1; + return panne.cause; + } + + // Ce que rend verrouiller d'un verrou présent : sa séance, l'instant de sa + // prise, et vivant — null pour un autre hôte ; pour celui-ci, vrai quand le + // disque porte un processus de cet hôte sous ce pid, et que ce processus + // n'est pas coupé. Un verrou qui ne se lit pas rend seance, depuis et + // vivant à null. + function verrouPresent(texte) { + let contenu; + try { + contenu = JSON.parse(texte); + } catch { + contenu = null; + } + if (!estContenuVerrou(contenu)) return { pris: false, seance: null, depuis: null, vivant: null }; + let vivant = null; + if (contenu.hote === hote) { + const processus = etat.processus.get(contenu.pid); + vivant = processus !== undefined && processus.hote === hote && !processus.estMort(); + } + return { pris: false, seance: contenu.seance, depuis: contenu.depuis, vivant }; + } + + // Une primitive : le processus coupé lève PanneSimulee avant tout ; l'appel + // compte selon son genre — 'ecritures', 'lectures' ou null ; une primitive + // d'écriture rendue sans lever rapproche la coupure. + function primitive(genre, travail) { + return async (...parametres) => { + if (estMort()) throw new PanneSimulee(); + if (genre !== null) compteurs[genre] += 1; + const resultat = travail(...parametres); + if (genre === 'ecritures' && restantes !== null) restantes -= 1; + return resultat; + }; + } + + return { + nature, + renommageAtomique, + verrouDisponible, + pid, + hote, + + emplacements: primitive(null, () => ({ + executable, + donneesApplicatives: [...donneesApplicatives], + insensibleCasse, + separateur, + })), + + racines: primitive(null, () => ({ + portable: racines.has('portable') ? { id: 'portable', chemin: racines.get('portable') } : null, + documents: { id: 'documents', chemin: documents }, + })), + + // Les dialogues natifs rendent null, comme quand l'opérateur annule. + choisirDossier: primitive(null, () => null), + + // Le témoin s'écrit par-dessus celui qu'une séance précédente a pu + // laisser, puis s'efface ; sa relecture ne peut différer de ce qui vient + // de s'écrire en mémoire. La sonde échoue sur la cause qui refuse + // l'écriture de la racine, ou sur un fichier qui occupe son chemin. + sonder: primitive('ecritures', (racine) => { + const loc = localiser(racine, ''); + const cause = refusEcriture.get(loc.racine); + if (cause !== undefined) return { inscriptible: false, cause }; + try { + creerDossiers(loc, loc.parties.length, avancer); + } catch (erreur) { + return { inscriptible: false, cause: erreur.details.cause }; + } + const temoin = localiser(racine, TEMOIN); + poserFichier(temoin, TEMOIN, avancer()); + disque.delete(temoin.cle); + return { inscriptible: true, cause: null }; + }), + + typeSupport: primitive(null, (racine) => { + const { racine: id } = localiser(racine, ''); + return Object.hasOwn(support, id) ? support[id] : 'fixe'; + }), + + lireTexte: primitive('lectures', (racine, chemin) => { + const noeud = disque.get(localiser(racine, chemin).cle); + if (noeud?.type !== 'fichier') throw new ErreurStockage('ABSENT', { chemin }); + return noeud.texte; + }), + + ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => { + exigerChaine(texte, 'le texte'); + const cible = localiser(racine, chemin); + exigerInscriptible(cible); + exigerDossierParent(cible); + exigerPasDossier(cible); + if (!renommageAtomique) { + poserFichier(cible, texte, avancer()); + return; + } + const ecriture = localiser(racine, chemin + SUFFIXE_ECRITURE); + poserFichier(ecriture, texte, avancer()); + let cause = null; + for (let essai = 0; essai <= REESSAIS_RENOMMAGE; essai += 1) { + cause = renommageRefuse(cible.racine); + if (cause === null) { + renommer(ecriture, cible); + return; + } + } + disque.delete(ecriture.cle); + throw refus(cible, cause); + }), + + ajouterLigne: primitive('ecritures', (racine, chemin, ligne) => { + exigerChaine(ligne, 'la ligne'); + if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne'); + const loc = localiser(racine, chemin); + exigerInscriptible(loc); + exigerDossierParent(loc); + exigerPasDossier(loc); + const avant = disque.get(loc.cle)?.texte ?? ''; + poserFichier(loc, `${avant}${ligne}\n`, avancer()); + }), + + lister: primitive('lectures', (racine, dossier) => { + const loc = localiser(racine, dossier); + if (disque.get(loc.cle)?.type !== 'dossier') throw new ErreurStockage('ABSENT', { chemin: dossier }); + const entrees = []; + for (const noeud of disque.values()) { + if (noeud.parent !== loc.cle) continue; + const taille = noeud.type === 'fichier' ? UTF8.encode(noeud.texte).length : 0; + entrees.push({ nom: noeud.nom, type: noeud.type, taille, modifie: noeud.modifie }); + } + return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0)); + }), + + creerDossier: primitive('ecritures', (racine, chemin) => { + const loc = localiser(racine, chemin); + exigerInscriptible(loc); + creerDossiers(loc, loc.parties.length, avancer); + }), + + // Un dossier absent sous la cible nomme la cible ; une panne nomme la + // source, que le renommage n'a pas pu porter. + deplacer: primitive('ecritures', (racine, de, vers) => { + const source = localiser(racine, de); + const cible = localiser(racine, vers); + exigerInscriptible(source); + const noeud = disque.get(source.cle); + if (noeud === undefined) throw new ErreurStockage('ABSENT', { chemin: de }); + if (noeud.type === 'dossier') throw refus(source, 'EISDIR'); + if (disque.has(cible.cle)) throw new ErreurStockage('EXISTE', { chemin: vers }); + exigerDossierParent(cible); + const cause = renommageRefuse(source.racine); + if (cause !== null) throw refus(source, cause); + renommer(source, cible); + }), + + supprimer: primitive('ecritures', (racine, chemin) => { + const loc = localiser(racine, chemin); + exigerInscriptible(loc); + exigerPasDossier(loc); + disque.delete(loc.cle); + }), + + verrouiller: primitive('ecritures', (racine, chemin, seance) => { + exigerSeance(seance); + const loc = localiser(racine, chemin); + if (!verrouDisponible) return { pris: true }; + exigerInscriptible(loc); + exigerDossierParent(loc); + exigerPasDossier(loc); + const present = disque.get(loc.cle); + if (present !== undefined) return verrouPresent(present.texte); + const prise = avancer(); + poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise); + return { pris: true }; + }), + + deverrouiller: primitive('ecritures', (racine, chemin, seance) => { + exigerSeance(seance); + const loc = localiser(racine, chemin); + if (!verrouDisponible) return; + exigerInscriptible(loc); + const present = disque.get(loc.cle); + if (present?.type === 'fichier' && verrouPresent(present.texte).seance === seance) disque.delete(loc.cle); + }), + + ouvrirDansExplorateur: primitive(null, (racine) => { + localiser(racine, ''); + }), + + choisirFichierAImporter: primitive(null, () => null), + + enregistrerSous: primitive(null, () => null), + + pannes: { + renommage(racineId, n = Infinity, cause = 'EBUSY') { + if (n !== Infinity && !(Number.isInteger(n) && n >= 0)) { + throw new TypeError('pannes.renommage : n est un entier positif ou nul, ou Infinity'); + } + refusRenommage.set(localiser({ id: racineId }, '').racine, { restants: n, cause }); + }, + ecriture(racineId, cause = 'EROFS') { + refusEcriture.set(localiser({ id: racineId }, '').racine, cause); + }, + couperApres(n) { + if (!(Number.isInteger(n) && n >= 0)) throw new TypeError('pannes.couperApres : n est un entier positif ou nul'); + restantes = n; + }, + }, + + compteurs, + + deposer(racineId, chemin, texte, modifie = maintenant()) { + exigerChaine(texte, 'le texte'); + const loc = localiser({ id: racineId }, chemin); + creerDossiers(loc, loc.parties.length - 1, maintenant); + exigerPasDossier(loc); + poserFichier(loc, texte, modifie); + }, + + deposerDossier(racineId, chemin = '') { + const loc = localiser({ id: racineId }, chemin); + creerDossiers(loc, loc.parties.length, maintenant); + }, + + contenu(racineId, chemin) { + const noeud = disque.get(localiser({ id: racineId }, chemin).cle); + return noeud?.type === 'fichier' ? noeud.texte : null; + }, + }; +} diff --git a/test/fichiers_simules.test.js b/test/fichiers_simules.test.js new file mode 100644 index 0000000..2ac4ea6 --- /dev/null +++ b/test/fichiers_simules.test.js @@ -0,0 +1,589 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du système de fichiers d'épreuve (§ 13.4, § 14.10). La suite de +// contrat s'y joue sur trois réglages : Windows, Unix, et celui de la +// plateforme web, sans renommage atomique ni verrou. Suit ce qui n'appartient +// qu'à lui : ses réglages, qui en ressortent tels qu'il les reçoit, ses +// racines et emplacements, la casse des noms, les pannes qu'il simule — +// renommage refusé, support qui refuse l'écriture, processus coupé puis +// rouvert sur le même disque —, le verrou entre processus, les compteurs, et +// ce que l'épreuve pose ou lit sans compter. Les noms d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { ErreurStockage } from '../src/stockage/erreurs.js'; +import { eprouverContrat } from './contrat_fichiers.js'; +import { PanneSimulee, creerFichiersSimules } from './fichiers_simules.js'; +import { describe, test } from './lanceur.js'; + +// 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) { + const erreur = await rejette(promesse, code); + assert.ok(erreur instanceof ErreurStockage, String(erreur)); + assert.equal(erreur.code, code); + assert.deepEqual(erreur.details, details); +} + +// Rend l'erreur que lève la fonction ; échoue quand elle n'en lève pas. +function erreurDe(fonction) { + try { + fonction(); + } catch (erreur) { + return erreur; + } + assert.fail('aucune erreur levée'); +} + +const noms = (entrees) => entrees.map((entree) => entree.nom); + +const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre'; +const DOC = { id: 'documents', chemin: DOCUMENTS }; +const DATA = 'E:\\soirees\\data'; +const PORTABLE = { id: 'portable', chemin: DATA }; + +// Un système d'épreuve dont la racine documents existe, posée sans compter. +function preparer(options = {}) { + const fs = creerFichiersSimules(options); + fs.deposerDossier('documents'); + return fs; +} + +// Les trois réglages sur lesquels la suite de contrat se joue. +const REGLAGES = [ + ['épreuve, sous Windows', {}], + [ + 'épreuve, sous Unix', + { + separateur: '/', + insensibleCasse: false, + documents: '/home/exemple/Documents/Gestion table tournante Libre', + donneesApplicatives: ['/home/exemple/.config', '/tmp'], + }, + ], + [ + 'épreuve, comme la plateforme web', + { + nature: 'web', + renommageAtomique: false, + verrouDisponible: false, + separateur: '/', + insensibleCasse: false, + documents: '/gestion_table_tournante_libre', + }, + ], +]; +for (const [nom, options] of REGLAGES) { + eprouverContrat( + nom, + async () => { + const fs = preparer(options); + return { fs, racine: (await fs.racines()).documents }; + }, + { describe, test, egal: assert.deepStrictEqual, vrai: assert.ok, rejette }, + ); +} + +describe('fichiers simulés : racines et emplacements', () => { + test('par défaut : Windows, sans égard à la casse, sans exécutable publié, la seule racine documents', async () => { + const fs = creerFichiersSimules(); + assert.equal(fs.nature, 'epreuve'); + assert.equal(fs.renommageAtomique, true); + assert.equal(fs.verrouDisponible, true); + assert.deepEqual(await fs.emplacements(), { + executable: null, + donneesApplicatives: ['C:\\Users\\Exemple\\AppData\\Roaming', 'C:\\Users\\Exemple\\AppData\\Local'], + insensibleCasse: true, + separateur: '\\', + }); + assert.deepEqual(await fs.racines(), { portable: null, documents: DOC }); + await echoue(fs.lireTexte(PORTABLE, 'soiree.gtt.json'), 'CHEMIN_REFUSE', { chemin: 'soiree.gtt.json' }); + }); + + test('les réglages donnés en ressortent tels quels : nature, garanties, hôte, emplacements, racine documents', async () => { + // Chaque réglage diffère de sa valeur par défaut : une valeur que le + // double figerait au lieu de la reprendre fait échouer l'épreuve. + // donneesApplicatives est la seule voie par laquelle il simule un + // dossier de travail sous un emplacement de données applicatives + // (§ 8.6, point 2). + const documents = '/home/exemple/Documents/Gestion table tournante Libre'; + const fs = creerFichiersSimules({ + documents, + donneesApplicatives: ['/home/exemple/.config', '/tmp'], + separateur: '/', + insensibleCasse: false, + nature: 'web', + renommageAtomique: false, + verrouDisponible: false, + hote: 'autre-poste', + }); + assert.equal(fs.nature, 'web'); + assert.equal(fs.renommageAtomique, false); + assert.equal(fs.verrouDisponible, false); + assert.equal(fs.hote, 'autre-poste'); + assert.deepEqual(await fs.emplacements(), { + executable: null, + donneesApplicatives: ['/home/exemple/.config', '/tmp'], + insensibleCasse: false, + separateur: '/', + }); + assert.deepEqual(await fs.racines(), { portable: null, documents: { id: 'documents', chemin: documents } }); + }); + + test("un exécutable publié ouvre la racine portable, data/ à côté de lui, au séparateur du système", async () => { + const cas = [ + [{ executable: 'E:\\soirees' }, 'E:\\soirees\\data'], + [{ executable: 'E:\\' }, 'E:\\data'], + [{ executable: '/media/cle/soirees', separateur: '/', insensibleCasse: false }, '/media/cle/soirees/data'], + ]; + for (const [options, chemin] of cas) { + const fs = creerFichiersSimules(options); + assert.deepEqual((await fs.racines()).portable, { id: 'portable', chemin }); + assert.equal((await fs.emplacements()).executable, options.executable); + } + }); + + test('typeSupport rend le support déclaré pour une racine, fixe par défaut', async () => { + const fs = creerFichiersSimules({ executable: 'E:\\soirees', support: { portable: 'amovible' } }); + assert.equal(await fs.typeSupport(PORTABLE), 'amovible'); + assert.equal(await fs.typeSupport(DOC), 'fixe'); + }); + + test("une racine n'existe qu'une fois créée : avant, lister lève ABSENT et écrire ECRITURE (ENOENT) ; sonder la crée", async () => { + const fs = creerFichiersSimules(); + const absent = (chemin) => ({ chemin, dossier: DOCUMENTS, cause: 'ENOENT' }); + await echoue(fs.lister(DOC, ''), 'ABSENT', { chemin: '' }); + await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', absent('soiree.gtt.json')); + await echoue(fs.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', absent('soiree.gtt-journal.jsonl')); + await echoue(fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', absent('soiree.gtt.verrou')); + assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null }); + assert.deepEqual(await fs.lister(DOC, ''), []); + }); + + test('deplacer vers un dossier absent nomme la cible et son dossier, et laisse la source', async () => { + const fs = preparer(); + fs.deposer('documents', 'soiree.gtt.json', 'état'); + await echoue(fs.deplacer(DOC, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'), 'ECRITURE', { + chemin: 'corbeille/soiree.gtt.json', + dossier: `${DOCUMENTS}\\corbeille`, + cause: 'ENOENT', + }); + assert.equal(fs.contenu('documents', 'soiree.gtt.json'), 'état'); + }); + + test('une sonde déclarée en échec rend sa cause, et sa racine refuse toute écriture', async () => { + const fs = creerFichiersSimules({ executable: 'E:\\soirees', sonde: { portable: 'EACCES' } }); + assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EACCES' }); + await echoue(fs.creerDossier(PORTABLE, ''), 'ECRITURE', { chemin: '', dossier: DATA, cause: 'EACCES' }); + assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null }); + }); + + test("un fichier qui occupe le chemin de la racine fait échouer la sonde, sans lever (EEXIST)", async () => { + // L'exécutable est publié dans E:\soirees, que la racine documents + // désigne aussi : un fichier data y occupe le chemin de la racine portable. + const fs = creerFichiersSimules({ executable: 'E:\\soirees', documents: 'E:\\soirees' }); + fs.deposer('documents', 'data', 'un fichier, pas un dossier'); + assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EEXIST' }); + assert.equal(fs.contenu('documents', 'data'), 'un fichier, pas un dossier'); + }); + + test("les dialogues natifs rendent null, comme quand l'opérateur annule ; ouvrir une racine inconnue est refusé", async () => { + const fs = preparer(); + assert.equal(await fs.choisirDossier(), null); + assert.equal(await fs.choisirFichierAImporter(), null); + assert.equal(await fs.enregistrerSous('liste.csv', new Uint8Array([0x61])), null); + assert.equal(await fs.ouvrirDansExplorateur(DOC), undefined); + await echoue(fs.ouvrirDansExplorateur(PORTABLE), 'CHEMIN_REFUSE', { chemin: '' }); + }); +}); + +describe('fichiers simulés : casse des noms', () => { + test('sans égard à la casse, une autre casse désigne le même fichier, et lister rend la casse de sa création', async () => { + const fs = preparer(); + await fs.ecrireAtomique(DOC, 'Soir\u{E9}e.gtt.json', 'état'); + assert.equal(await fs.lireTexte(DOC, 'SOIR\u{C9}E.GTT.JSON'), 'état'); + await fs.ajouterLigne(DOC, 'Soir\u{E9}e.gtt-journal.jsonl', 'a'); + await fs.ajouterLigne(DOC, 'soir\u{E9}e.GTT-JOURNAL.jsonl', 'b'); + assert.deepEqual(noms(await fs.lister(DOC, '')), [ + 'Soir\u{E9}e.gtt-journal.jsonl', + 'Soir\u{E9}e.gtt.json', + ]); + assert.equal(fs.contenu('documents', 'SOIR\u{C9}E.gtt-journal.jsonl'), 'a\nb\n'); + }); + + test('la casse se replie caractère par caractère, comme Windows : un sigma final rejoint sa majuscule, un eszett ne devient pas SS', async () => { + const fs = preparer(); + await fs.ecrireAtomique(DOC, '\u{3A3}\u{391}\u{3A3}.txt', 'majuscules'); + assert.equal(await fs.lireTexte(DOC, '\u{3C3}\u{3B1}\u{3C2}.txt'), 'majuscules'); + await fs.ecrireAtomique(DOC, 'stra\u{DF}e.txt', 'eszett'); + await fs.ecrireAtomique(DOC, 'STRASSE.txt', 'deux s'); + assert.equal(await fs.lireTexte(DOC, 'stra\u{DF}e.txt'), 'eszett'); + assert.equal(await fs.lireTexte(DOC, 'STRASSE.txt'), 'deux s'); + }); + + test('renommer par-dessus donne au fichier la casse du nouveau nom ; deplacer vers une autre casse du même nom lève EXISTE', async () => { + const fs = preparer(); + await fs.ecrireAtomique(DOC, 'Soir\u{E9}e.gtt.json', 'un'); + await fs.ecrireAtomique(DOC, 'soir\u{E9}e.gtt.json', 'deux'); + assert.deepEqual(noms(await fs.lister(DOC, '')), ['soir\u{E9}e.gtt.json']); + assert.equal(fs.contenu('documents', 'SOIR\u{C9}E.gtt.json'), 'deux'); + await echoue(fs.deplacer(DOC, 'soir\u{E9}e.gtt.json', 'SOIR\u{C9}E.gtt.json'), 'EXISTE', { + chemin: 'SOIR\u{C9}E.gtt.json', + }); + }); + + test('sensible à la casse, deux casses font deux fichiers', async () => { + const racine = '/home/exemple/Documents/Gestion table tournante Libre'; + const fs = preparer({ separateur: '/', insensibleCasse: false, documents: racine }); + const doc = { id: 'documents', chemin: racine }; + await fs.ecrireAtomique(doc, 'soir\u{E9}e.gtt.json', 'minuscule'); + await fs.ecrireAtomique(doc, 'Soir\u{E9}e.gtt.json', 'majuscule'); + assert.deepEqual(noms(await fs.lister(doc, '')), ['Soir\u{E9}e.gtt.json', 'soir\u{E9}e.gtt.json']); + await echoue(fs.lireTexte(doc, 'SOIR\u{C9}E.GTT.JSON'), 'ABSENT', { chemin: 'SOIR\u{C9}E.GTT.JSON' }); + }); + + test('un disque partagé garde la casse et le séparateur de sa première instance', () => { + const disque = new Map(); + creerFichiersSimules({ disque }); + assert.throws(() => creerFichiersSimules({ disque, insensibleCasse: false })); + assert.throws(() => creerFichiersSimules({ disque, separateur: '/' })); + creerFichiersSimules({ disque }); + }); +}); + +describe('fichiers simulés : renommage refusé (§ 8.8, § 14.10)', () => { + test("un renommage refusé laisse la cible intacte, retire le fichier d'écriture et lève ECRITURE en nommant fichier et dossier", async () => { + const fs = preparer(); + fs.deposer('documents', 'soiree.gtt.json', 'avant'); + fs.pannes.renommage('documents'); + await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'après'), 'ECRITURE', { + chemin: 'soiree.gtt.json', + dossier: DOCUMENTS, + cause: 'EBUSY', + }); + assert.equal(fs.contenu('documents', 'soiree.gtt.json'), 'avant'); + assert.equal(fs.contenu('documents', 'soiree.gtt.json.ecriture'), null); + assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.json']); + }); + + test("l'écriture réessaie le renommage : dix refus et elle passe, onze et elle échoue", async () => { + const fs = preparer(); + fs.pannes.renommage('documents', 10, 'EPERM'); + await fs.ecrireAtomique(DOC, 'etat.json', 'après dix refus'); + assert.equal(fs.contenu('documents', 'etat.json'), 'après dix refus'); + fs.pannes.renommage('documents', 11, 'EPERM'); + await echoue(fs.ecrireAtomique(DOC, 'etat.json', 'après onze refus'), 'ECRITURE', { + chemin: 'etat.json', + dossier: DOCUMENTS, + cause: 'EPERM', + }); + assert.equal(fs.contenu('documents', 'etat.json'), 'après dix refus'); + }); + + test('la panne de renommage ne touche que sa racine, et refuse aussi deplacer', async () => { + const fs = preparer({ executable: 'E:\\soirees' }); + fs.deposer('portable', 'soiree.gtt.json', 'état'); + fs.pannes.renommage('portable'); + await fs.ecrireAtomique(DOC, 'ailleurs.json', 'passe'); + assert.equal(fs.contenu('documents', 'ailleurs.json'), 'passe'); + await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'soiree-2.gtt.json'), 'ECRITURE', { + chemin: 'soiree.gtt.json', + dossier: DATA, + cause: 'EBUSY', + }); + assert.equal(fs.contenu('portable', 'soiree.gtt.json'), 'état'); + assert.equal(fs.contenu('portable', 'soiree-2.gtt.json'), null); + }); + + test("sans renommage atomique, ecrireAtomique écrit droit sur la cible : une panne de renommage ne l'arrête pas", async () => { + const fs = preparer({ renommageAtomique: false }); + fs.deposer('documents', 'etat.json', 'avant'); + fs.pannes.renommage('documents'); + await fs.ecrireAtomique(DOC, 'etat.json', 'après'); + assert.equal(fs.contenu('documents', 'etat.json'), 'après'); + assert.deepEqual(noms(await fs.lister(DOC, '')), ['etat.json']); + }); +}); + +describe("fichiers simulés : support qui refuse l'écriture", () => { + test("après pannes.ecriture, chaque primitive d'écriture de la racine lève ECRITURE avec la cause, la sonde échoue, et rien ne change", async () => { + const fs = preparer({ executable: 'E:\\soirees' }); + fs.deposer('portable', 'soiree.gtt.json', 'état'); + fs.deposer('portable', 'soiree.gtt-journal.jsonl', 'ligne\n'); + fs.deposer('portable', 'soiree.gtt.verrou', '{"seance":"seance-a"}'); + fs.pannes.ecriture('portable'); + const refus = (chemin) => ({ chemin, dossier: DATA, cause: 'EROFS' }); + await echoue(fs.ecrireAtomique(PORTABLE, 'soiree.gtt.json', 'nouveau'), 'ECRITURE', refus('soiree.gtt.json')); + await echoue( + fs.ajouterLigne(PORTABLE, 'soiree.gtt-journal.jsonl', 'suite'), + 'ECRITURE', + refus('soiree.gtt-journal.jsonl'), + ); + await echoue(fs.creerDossier(PORTABLE, 'corbeille'), 'ECRITURE', refus('corbeille')); + await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'autre.gtt.json'), 'ECRITURE', refus('soiree.gtt.json')); + await echoue(fs.supprimer(PORTABLE, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json')); + await echoue(fs.verrouiller(PORTABLE, 'neuf.gtt.verrou', 'seance-b'), 'ECRITURE', refus('neuf.gtt.verrou')); + await echoue(fs.deverrouiller(PORTABLE, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', refus('soiree.gtt.verrou')); + assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EROFS' }); + + assert.deepEqual(noms(await fs.lister(PORTABLE, '')), [ + 'soiree.gtt-journal.jsonl', + 'soiree.gtt.json', + 'soiree.gtt.verrou', + ]); + assert.equal(await fs.lireTexte(PORTABLE, 'soiree.gtt.json'), 'état'); + assert.equal(await fs.lireTexte(PORTABLE, 'soiree.gtt-journal.jsonl'), 'ligne\n'); + }); + + test("une cause donnée remplace EROFS, et l'autre racine écrit encore", async () => { + const fs = preparer({ executable: 'E:\\soirees' }); + fs.deposerDossier('portable'); + fs.pannes.ecriture('documents', 'ENOSPC'); + await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', { + chemin: 'soiree.gtt.json', + dossier: DOCUMENTS, + cause: 'ENOSPC', + }); + await fs.ecrireAtomique(PORTABLE, 'soiree.gtt.json', 'écrit ailleurs'); + assert.equal(fs.contenu('portable', 'soiree.gtt.json'), 'écrit ailleurs'); + }); +}); + +describe('fichiers simulés : processus coupé (couperApres)', () => { + test('après n écritures réussies, toute primitive lève PanneSimulee ; une nouvelle instance sur le même disque relit les n écritures et écrit à son tour', async () => { + const disque = new Map(); + const premiere = creerFichiersSimules({ disque }); + premiere.deposerDossier('documents'); + premiere.deposer('documents', 'soiree.gtt.json', 'révision 1'); + premiere.pannes.couperApres(2); + await premiere.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"revision":2}'); + await premiere.ecrireAtomique(DOC, 'soiree.gtt.json.precedent', 'révision 1'); + + const panne = await rejette(premiere.ecrireAtomique(DOC, 'soiree.gtt.json', 'révision 2')); + assert.ok(panne instanceof PanneSimulee, String(panne)); + assert.ok(!(panne instanceof ErreurStockage)); + await assert.rejects(premiere.lireTexte(DOC, 'soiree.gtt.json'), PanneSimulee); + await assert.rejects(premiere.lister(DOC, ''), PanneSimulee); + await assert.rejects(premiere.emplacements(), PanneSimulee); + await assert.rejects(premiere.sonder(DOC), PanneSimulee); + + const reouverte = creerFichiersSimules({ disque }); + assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt-journal.jsonl'), '{"revision":2}\n'); + assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt.json.precedent'), 'révision 1'); + assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt.json'), 'révision 1'); + await reouverte.ecrireAtomique(DOC, 'soiree.gtt.json', 'révision 2'); + assert.equal(premiere.contenu('documents', 'soiree.gtt.json'), 'révision 2'); + }); + + test('une écriture refusée ne compte pas parmi les n', async () => { + const fs = preparer(); + fs.pannes.renommage('documents'); + fs.pannes.couperApres(1); + const refus = await rejette(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x')); + assert.equal(refus.code, 'ECRITURE'); + await fs.creerDossier(DOC, 'corbeille'); + await assert.rejects(fs.lister(DOC, ''), PanneSimulee); + }); + + test('couperApres(0) arrête le processus sur-le-champ', async () => { + const fs = preparer(); + fs.pannes.couperApres(0); + await assert.rejects(fs.lister(DOC, ''), PanneSimulee); + }); + + test("les pannes refusent un compte qui n'est pas un entier positif ou nul", () => { + const fs = preparer(); + assert.throws(() => fs.pannes.renommage('documents', -1), TypeError); + assert.throws(() => fs.pannes.renommage('documents', 1.5), TypeError); + assert.throws(() => fs.pannes.couperApres(-1), TypeError); + assert.throws(() => fs.pannes.couperApres(Infinity), TypeError); + }); +}); + +describe('fichiers simulés : verrou', () => { + test("le fichier de verrou porte la séance, le pid et l'hôte du processus, et l'instant de sa prise", async () => { + const fs = preparer(); + assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true }); + const texte = fs.contenu('documents', 'soiree.gtt.verrou'); + assert.match( + texte, + /^\{"seance":"seance-a","pid":\d+,"hote":"poste-epreuve","depuis":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00"\}\n$/, + ); + const { pid, depuis } = JSON.parse(texte); + assert.equal(pid, fs.pid); + assert.equal(fs.hote, 'poste-epreuve'); + assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), { + pris: false, + seance: 'seance-a', + depuis, + vivant: true, + }); + }); + + test('la création est exclusive, même pour la séance qui tient le verrou', async () => { + const fs = preparer(); + await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + const second = await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + assert.deepEqual([second.pris, second.seance, second.vivant], [false, 'seance-a', true]); + }); + + test('vivant : vrai pour un processus en vie sur la même machine, faux pour un processus coupé, null pour une autre machine', async () => { + const disque = new Map(); + const a = creerFichiersSimules({ disque }); + const b = creerFichiersSimules({ disque }); + const ailleurs = creerFichiersSimules({ disque, hote: 'autre-poste' }); + a.deposerDossier('documents'); + assert.notEqual(a.pid, b.pid); + + await a.verrouiller(DOC, 'a.gtt.verrou', 'seance-a'); + const vuDeB = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b'); + assert.deepEqual([vuDeB.seance, vuDeB.vivant], ['seance-a', true]); + + await ailleurs.verrouiller(DOC, 'c.gtt.verrou', 'seance-c'); + const vuDeA = await a.verrouiller(DOC, 'c.gtt.verrou', 'seance-x'); + assert.deepEqual([vuDeA.seance, vuDeA.vivant], ['seance-c', null]); + + // Le pid d'un processus de l'autre machine ne désigne aucun processus de + // celle-ci. + const usurpe = { seance: 'seance-u', pid: ailleurs.pid, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' }; + a.deposer('documents', 'u.gtt.verrou', JSON.stringify(usurpe)); + assert.equal((await a.verrouiller(DOC, 'u.gtt.verrou', 'seance-x')).vivant, false); + + a.pannes.couperApres(0); + const apresCoupure = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b'); + assert.deepEqual([apresCoupure.seance, apresCoupure.vivant], ['seance-a', false]); + }); + + test("un verrou illisible ne se lit ni ne s'efface ; un pid absent de la machine est mort", async () => { + const fs = preparer(); + const illisible = { pris: false, seance: null, depuis: null, vivant: null }; + fs.deposer('documents', 'tronque.gtt.verrou', '{"seance":'); + assert.deepEqual(await fs.verrouiller(DOC, 'tronque.gtt.verrou', 'seance-a'), illisible); + await fs.deverrouiller(DOC, 'tronque.gtt.verrou', 'seance-a'); + assert.equal(fs.contenu('documents', 'tronque.gtt.verrou'), '{"seance":'); + + fs.deposer('documents', 'forme.gtt.verrou', '{"seance":"seance-a","pid":"7","hote":"poste-epreuve","depuis":"x"}'); + assert.deepEqual(await fs.verrouiller(DOC, 'forme.gtt.verrou', 'seance-b'), illisible); + + const ancien = { seance: 'ancienne', pid: 4242, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' }; + fs.deposer('documents', 'mort.gtt.verrou', JSON.stringify(ancien)); + assert.deepEqual(await fs.verrouiller(DOC, 'mort.gtt.verrou', 'seance-b'), { + pris: false, + seance: 'ancienne', + depuis: '2026-01-01T08:00:00+00:00', + vivant: false, + }); + }); + + test("sans verrou disponible, verrouiller rend pris sans rien écrire et deverrouiller n'efface rien", async () => { + const fs = preparer({ verrouDisponible: false }); + fs.deposer('documents', 'soiree.gtt.verrou', '{"seance":"seance-a"}'); + assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-a'), { pris: true }); + assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-b'), { pris: true }); + await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.verrou']); + }); +}); + +describe('fichiers simulés : compteurs, préparation, horloge', () => { + test("chaque appel d'une primitive de lecture compte une lecture et chaque appel d'une primitive d'écriture une écriture, refusé compris ; les autres ne comptent pas", async () => { + const fs = preparer(); + await fs.ecrireAtomique(DOC, 'a.txt', 'a'); + await fs.ajouterLigne(DOC, 'journal.jsonl', 'ligne'); + await fs.creerDossier(DOC, 'corbeille'); + await fs.deplacer(DOC, 'a.txt', 'corbeille/a.txt'); + await fs.supprimer(DOC, 'corbeille/a.txt'); + await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'); + await fs.sonder(DOC); + await assert.rejects(fs.ecrireAtomique(DOC, '../a.txt', 'a')); + await fs.lireTexte(DOC, 'journal.jsonl'); + await fs.lister(DOC, ''); + await assert.rejects(fs.lireTexte(DOC, 'absent.txt')); + await fs.emplacements(); + await fs.racines(); + await fs.typeSupport(DOC); + await fs.choisirDossier(); + await fs.ouvrirDansExplorateur(DOC); + await fs.choisirFichierAImporter(); + await fs.enregistrerSous('liste.csv', new Uint8Array([0x61])); + fs.contenu('documents', 'journal.jsonl'); + fs.deposer('documents', 'pose.txt', 'posé'); + assert.deepEqual(fs.compteurs, { ecritures: 9, lectures: 3 }); + }); + + test('deposer et deposerDossier posent sans compter, dossiers parents compris ; contenu lit sans compter et rend null hors d\u{2019}un fichier', async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents', 'corbeille/2026-01-02_03-04-05'); + fs.deposer('documents', 'archives/2025/soiree.gtt.json', 'état', 5); + assert.equal(fs.contenu('documents', 'archives/2025/soiree.gtt.json'), 'état'); + assert.equal(fs.contenu('documents', 'ARCHIVES/2025/SOIREE.GTT.JSON'), 'état'); + assert.equal(fs.contenu('documents', 'archives/2025'), null); + assert.equal(fs.contenu('documents', 'absent.txt'), null); + assert.deepEqual(fs.compteurs, { ecritures: 0, lectures: 0 }); + assert.deepEqual(await fs.lister(DOC, 'archives/2025'), [ + { nom: 'soiree.gtt.json', type: 'fichier', taille: 5, modifie: 5 }, + ]); + assert.deepEqual(noms(await fs.lister(DOC, '')), ['archives', 'corbeille']); + assert.equal(erreurDe(() => fs.deposer('inconnue', 'x.txt', 'x')).code, 'CHEMIN_REFUSE'); + assert.equal(erreurDe(() => fs.pannes.ecriture('inconnue')).code, 'CHEMIN_REFUSE'); + }); + + test("modifie suit l'horloge du disque : chaque écriture l'avance ; deposer pose l'instant courant, ou celui qu'on lui donne", async () => { + const fs = preparer(); + fs.deposer('documents', 'a.txt', 'a'); + fs.deposer('documents', 'b.txt', 'b'); + fs.deposer('documents', 'c.txt', 'c', 7); + await fs.ecrireAtomique(DOC, 'd.txt', 'd'); + await fs.ecrireAtomique(DOC, 'e.txt', 'e'); + const [a, b, c, d, e] = await fs.lister(DOC, ''); + assert.equal(a.modifie, b.modifie); + assert.equal(c.modifie, 7); + assert.ok(d.modifie > a.modifie, `${d.modifie} > ${a.modifie}`); + assert.ok(e.modifie > d.modifie, `${e.modifie} > ${d.modifie}`); + }); +}); + +describe('fichiers simulés : préconditions et dossiers', () => { + test("ajouterLigne refuse une ligne qui porte une fin de ligne ; ecrireAtomique, un texte qui n'est pas une chaîne ; verrouiller, une séance vide", async () => { + const fs = preparer(); + await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\nb'), TypeError); + await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\r'), TypeError); + await assert.rejects(fs.ecrireAtomique(DOC, 'etat.json', new Uint8Array([0x61])), TypeError); + await assert.rejects(fs.verrouiller(DOC, 'soiree.gtt.verrou', ''), TypeError); + assert.deepEqual(await fs.lister(DOC, ''), []); + }); + + test('un dossier ne se lit, ne s\u{2019}écrit, ne se déplace ni ne se supprime comme un fichier, et un fichier ne se liste pas', async () => { + const fs = preparer(); + fs.deposerDossier('documents', 'corbeille'); + fs.deposer('documents', 'soiree.gtt.json', 'état'); + const refus = (chemin, cause, dossier = DOCUMENTS) => ({ chemin, dossier, cause }); + await echoue(fs.lireTexte(DOC, 'corbeille'), 'ABSENT', { chemin: 'corbeille' }); + await echoue(fs.lister(DOC, 'soiree.gtt.json'), 'ABSENT', { chemin: 'soiree.gtt.json' }); + await echoue(fs.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR')); + await echoue(fs.ajouterLigne(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR')); + await echoue(fs.verrouiller(DOC, 'corbeille', 'seance-a'), 'ECRITURE', refus('corbeille', 'EISDIR')); + await echoue(fs.deplacer(DOC, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', 'EISDIR')); + await echoue(fs.supprimer(DOC, 'corbeille'), 'ECRITURE', refus('corbeille', 'EISDIR')); + await echoue(fs.creerDossier(DOC, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json', 'EEXIST')); + await echoue( + fs.creerDossier(DOC, 'soiree.gtt.json/sous'), + 'ECRITURE', + refus('soiree.gtt.json/sous', 'ENOTDIR', `${DOCUMENTS}\\soiree.gtt.json`), + ); + assert.deepEqual(noms(await fs.lister(DOC, '')), ['corbeille', 'soiree.gtt.json']); + }); +});