gestion_table_tournante_libre/src/stockage/systeme_fichiers.js

179 lines
10 KiB
JavaScript
Raw Normal View History

// © 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
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
// LECTURE {chemin, dossier, cause} une lecture refusée autrement que
// sur un absent ; cause
// UTF8_INVALIDE pour un fichier dont
// les octets ne sont pas de l'UTF-8,
// que lireTexte ne lit jamais en
// U+FFFD ; les détails portent
// alors aussi octets, une copie
// des octets du fichier
//
// 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
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
* processus tourne sur cette machine avec l'heure de démarrage inscrite ;
* faux quand il est mort à coup sûr — aucun processus de ce pid, ou deux
* heures de démarrage connues qui diffèrent ; null sinon — verrou d'une autre
* machine, ou vie qui ne se prouve pas. 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
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
* ou un dossier. Le décodage est strict : des octets qui ne sont
* pas de l'UTF-8 lèvent LECTURE {chemin, dossier, cause:
* 'UTF8_INVALIDE', octets}, octets étant un Uint8Array, copie des
* octets du fichier, que le dépôt décode ligne par ligne
* @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
[ADD] storage: copy, rename, import, delete events; local settings § 8.7 operations reuse the event store's pairing and alignment instead of copying them. Renaming writes the complete pair under the new name — the store now writes a gesture with the pair it writes elsewhere — before deleting the old one, so no cut leaves zero pairs; an unchanged name writes nothing. Deleting moves state, .precedent, journal, lock and leftovers into a dated trash, matching names as the case-insensitive disk does. creerDossier creates only the root it is aimed at, so a vanished working folder never comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings; its deferred writer always keeps the last demand. Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %. --- FR --- [ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom — le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et restes dans une corbeille datée, en comparant les noms comme le disque insensible à la casse. creerDossier ne crée que la racine visée : un dossier de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux réglages du § 8.5 ; son écrivain différé garde la dernière demande. Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:05 -04:00
* '' crée la racine elle-même, parents compris ; un autre chemin
* crée ce qui lui manque sous la racine, qui doit exister : une
* racine absente lève ECRITURE, sans rien créer, car un dossier de
* travail disparu ne renaît pas en silence (§ 8.6). 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
[FIX] electron: lock liveness only when certain, retries on every write A lock judged by its pid alone took a reused pid for a live session after a reboot, and the event stayed locked. The lock now records the process start time; a session is dead only when that is certain — no process with the pid, or two known start times that differ — and alive only when they match. Otherwise liveness is unknown and the session is not taken over, so two sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY and EACCES that guarded only the rename now guards appends, moves and deletes too, through one helper. Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC. --- FR --- [FIX] electron : vie du verrou sûre, réessais sur chaque écriture Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance vivante après un redémarrage, et l'événement restait verrouillé. Le verrou inscrit désormais l'heure de démarrage du processus ; une séance n'est morte que si c'est certain — aucun processus de ce pid, ou deux heures connues qui diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble (§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne gardaient que le renommage, gardent aussi ajouts, déplacements et suppressions, par une seule aide. Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC. Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00
* création exclusive de {seance, pid, hote, depuis, demarrage} :
* 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;
}