gestion_table_tournante_libre/src/stockage/systeme_fichiers.js
Mathieu Benoit fdaa79ed32 [ADD] storage: file-system interface, failing test double, contract
Storage code receives its file system as a parameter (§ 13.4): one
interface, and here the implementation tests inject. It keeps files in
memory, shares a disk between two instances to replay a restart, and
fails on demand: a refused rename that leaves the target intact, writes
refused from a moment on, a process killed after n writes, a failing
probe, a removable medium. One contract suite, its assertions passed in,
will run unchanged on the Electron and web implementations.

Checked: 87 tests, the contract suite run on the test double.

--- FR ---

[ADD] stockage : interface du système de fichiers, double, contrat

Le code du stockage reçoit son système de fichiers en paramètre
(§ 13.4) : une interface, et ici l'implémentation que les épreuves
injectent. Elle garde les fichiers en mémoire, partage un disque entre
deux instances pour rejouer un redémarrage, et tombe en panne à la
demande : renommage refusé qui laisse la cible intacte, écritures
refusées à partir d'un instant, processus tué après n écritures, sonde
qui échoue, support amovible. Une suite de contrat, ses assertions
reçues en paramètre, servira telle quelle sous Electron et sous web.

Vérifié : 87 épreuves, la suite de contrat jouée sur le double.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 13:12:25 -04:00

161 lines
8.8 KiB
JavaScript

// © 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>} 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<Racine|null>} 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<string>} 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<void>} ecrireAtomique
* <chemin>.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<void>} 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<Entree[]>} 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<void>} creerDossier
* parents compris ; un dossier existant n'est pas une faute
* @property {(r: Racine, de: string, vers: string) => Promise<void>} 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<void>} supprimer
* un fichier ; un fichier absent n'est pas une faute
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} 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<void>} deverrouiller
* n'efface que le verrou de cette séance ; un verrou absent n'est
* pas une faute
* @property {(r: Racine) => Promise<void>} ouvrirDansExplorateur
* @property {() => Promise<{nom: string, octets: Uint8Array}|null>} choisirFichierAImporter
* null quand l'opérateur annule
* @property {(nomPropose: string, octets: Uint8Array) => Promise<string|null>} 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;
}