[FIX] web storage: refuse a name Chromium did not convert, UTF-8 launch
Under a non-UTF-8 locale, Chromium returns the parent folder for an accented folder name without raising, so a write meant for that folder replaced a file of the same name in its parent. Each step of a path walk now compares the handle with its parent and refuses NOM_NON_CONVERTI — LECTURE on reads, ECRITURE on writes — and nothing lands elsewhere. The test launcher gives Chromium LANG=C.UTF-8 when the locale is not UTF-8, as the containers do, and the matrix installs ps where an image lacks it. Checked: 1776 node and 45 browser tests; the guard holds under LC_ALL=C. --- FR --- [FIX] stockage web : refuser un nom non converti, lancement en UTF-8 Hors locale UTF-8, Chromium rend le dossier parent pour un nom de dossier accentué, sans lever : une écriture destinée à ce dossier remplaçait un fichier homonyme de son parent. Chaque pas d'un chemin compare désormais le dossier obtenu à son parent et refuse NOM_NON_CONVERTI — LECTURE en lecture, ECRITURE en écriture — : rien n'atterrit ailleurs. Le lanceur des épreuves donne LANG=C.UTF-8 à Chromium quand la locale n'est pas UTF-8, comme les conteneurs, et la matrice installe ps là où une image en manque. Vérifié : 1776 node, 45 navigateur ; la garde tient sous LC_ALL=C. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
71076afe4d
commit
82e57f47df
7 changed files with 261 additions and 28 deletions
|
|
@ -17,6 +17,12 @@
|
|||
# que celle de l'hôte, NixOS lui-même (l'image nix éprouve shell.nix, sur
|
||||
# une base qui n'est pas NixOS), une vraie session graphique.
|
||||
#
|
||||
# Les étapes tournent sous LANG=C.UTF-8, des paramètres régionaux UTF-8 que
|
||||
# la plupart des images ne posent pas, et sous lesquels Chromium nomme une
|
||||
# entrée hors ASCII de l'OPFS. La préparation installe ps (procps) là où
|
||||
# l'image ne le porte pas : une épreuve de la coquille lit par lui l'heure de
|
||||
# démarrage d'un processus.
|
||||
#
|
||||
# GTT_PARALLELES (4 par défaut) borne les conteneurs simultanés ; les
|
||||
# journaux vont dans GTT_JOURNAUX, un dossier temporaire par défaut. Les
|
||||
# téléchargements — npm, Electron, Chromium, Node — se partagent entre
|
||||
|
|
@ -103,17 +109,18 @@ rm -rf "$source_copie"
|
|||
mkdir -p "$source_copie"
|
||||
(cd "$racine" && git ls-files -co --exclude-standard -z | tar --null -cf - -T - | tar -xf - -C "$source_copie")
|
||||
|
||||
# Préparation sous root, par famille : sudo et un compte ordinaire « dev »
|
||||
# qui l'emploie sans mot de passe, comme un poste de développement. Son uid
|
||||
# est le même dans chaque conteneur — 1500, libre aussi sous Ubuntu, dont
|
||||
# l'image donne 1000 à son propre compte — : les caches partagés ne
|
||||
# changent jamais de propriétaire.
|
||||
# Préparation sous root, par famille : sudo, ps quand l'image ne le porte
|
||||
# pas — procps-ng chez Fedora, AlmaLinux, Rocky et Arch, procps ailleurs —,
|
||||
# et un compte ordinaire « dev » qui emploie sudo sans mot de passe, comme un
|
||||
# poste de développement. Son uid est le même dans chaque conteneur — 1500,
|
||||
# libre aussi sous Ubuntu, dont l'image donne 1000 à son propre compte — :
|
||||
# les caches partagés ne changent jamais de propriétaire.
|
||||
preparation() {
|
||||
case "$1" in
|
||||
ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo' ;;
|
||||
fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils' ;;
|
||||
opensuse-*) echo 'zypper --non-interactive install sudo shadow' ;;
|
||||
arch) echo 'pacman -Syu --noconfirm sudo' ;;
|
||||
ubuntu-* | debian-*) echo 'apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q sudo $(command -v ps > /dev/null || echo procps)' ;;
|
||||
fedora-* | almalinux-* | rocky-*) echo 'dnf install -y sudo shadow-utils $(command -v ps > /dev/null || echo procps-ng)' ;;
|
||||
opensuse-*) echo 'zypper --non-interactive install sudo shadow $(command -v ps > /dev/null || echo procps)' ;;
|
||||
arch) echo 'pacman -Syu --noconfirm sudo $(command -v ps > /dev/null || echo procps-ng)' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@ const EXEMPLES = new Map([
|
|||
['DONNEES_APPLICATIVES', {}],
|
||||
['SONDE_ECHOUEE', { cause: 'EROFS' }],
|
||||
['RELECTURE_DIFFERENTE', {}],
|
||||
['NOM_NON_CONVERTI', {}],
|
||||
['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }],
|
||||
['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }],
|
||||
['PLATEFORME_WEB', {}],
|
||||
|
|
|
|||
|
|
@ -271,6 +271,13 @@ export const TABLE = new Map([
|
|||
],
|
||||
// Une cause de la sonde, que les textes citent entre parenthèses.
|
||||
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
|
||||
// Une cause de l'OPFS : sous des paramètres régionaux qui ne sont pas
|
||||
// UTF-8, le navigateur ne nomme pas un dossier ou un fichier hors ASCII.
|
||||
[
|
||||
'NOM_NON_CONVERTI',
|
||||
() =>
|
||||
"le navigateur ne sait pas nommer un dossier ou un fichier aux caractères accentués sous les réglages de langue du système",
|
||||
],
|
||||
[
|
||||
'PORTABLE_NON_INSCRIPTIBLE',
|
||||
({ racine, evenements, cause }, texte) => {
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@
|
|||
//
|
||||
// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit
|
||||
// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou
|
||||
// TypeMismatchError pour une entrée d'un autre genre que celui demandé —
|
||||
// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une
|
||||
// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les
|
||||
// TypeMismatchError pour une entrée d'un autre genre que celui demandé, hors
|
||||
// le cas d'un nom non converti ci-dessous — lève ABSENT ; toute autre
|
||||
// exception d'une lecture lève LECTURE, car une panne n'est pas une
|
||||
// absence ; tout échec d'une écriture lève ECRITURE. Les
|
||||
// deux portent {chemin, dossier, cause}, dont la cause est le nom de
|
||||
// l'exception : SecurityError pour un OPFS que le navigateur refuse à
|
||||
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le
|
||||
|
|
@ -43,6 +44,17 @@
|
|||
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
|
||||
// elle-même, un dossier.
|
||||
//
|
||||
// Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium sous Linux
|
||||
// ne convertit pas un nom hors ASCII et le résout sur le dossier qui le
|
||||
// porte : un dossier de ce nom rend ce dossier sans lever, un fichier de ce
|
||||
// nom lève TypeMismatchError comme pour une entrée d'un autre genre. Chaque
|
||||
// dossier du chemin se compare donc au dossier qui le porte (isSameEntry) ;
|
||||
// une TypeMismatchError sur un fichier se départage en demandant le même nom
|
||||
// comme dossier, comparé de même. La même entrée lève, cause
|
||||
// NOM_NON_CONVERTI — LECTURE pour une lecture, ECRITURE pour une écriture —,
|
||||
// jamais ABSENT ni EXISTE : rien ne se lit ni ne s'écrit dans le dossier qui
|
||||
// le porte, et un fichier présent ne se lit pas absent.
|
||||
//
|
||||
// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et
|
||||
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
|
||||
// passe par un champ de fichier, que le navigateur n'ouvre que dans le
|
||||
|
|
@ -69,6 +81,10 @@ const TYPE_DIFFERENT = 'TypeMismatchError';
|
|||
// Cause d'une sonde dont le témoin relu diffère du texte écrit.
|
||||
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
|
||||
|
||||
// Cause d'un nom, de dossier ou de fichier, que l'OPFS résout sur le dossier
|
||||
// qui le porte.
|
||||
const NOM_NON_CONVERTI = 'NOM_NON_CONVERTI';
|
||||
|
||||
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
|
||||
// retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
|
||||
// strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
|
||||
|
|
@ -79,7 +95,9 @@ const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|||
const ignorer = () => {};
|
||||
|
||||
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
|
||||
// d'un autre genre que celui demandé.
|
||||
// d'un autre genre que celui demandé. Une TypeMismatchError de fichier passe
|
||||
// d'abord par fichierDe, qui lève NOM_NON_CONVERTI à sa place quand le nom
|
||||
// n'est pas converti.
|
||||
const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT;
|
||||
|
||||
// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son
|
||||
|
|
@ -134,17 +152,50 @@ async function ecrire(loc, travail) {
|
|||
}
|
||||
}
|
||||
|
||||
// Poignée du dossier nom sous parent. Lève une DOMException de nom
|
||||
// NOM_NON_CONVERTI quand l'OPFS rend parent lui-même pour ce nom.
|
||||
async function sousDossier(parent, nom, options) {
|
||||
const dossier = await parent.getDirectoryHandle(nom, options);
|
||||
if (await dossier.isSameEntry(parent)) throw new DOMException(nom, NOM_NON_CONVERTI);
|
||||
return dossier;
|
||||
}
|
||||
|
||||
// Vrai quand l'OPFS résout nom, demandé comme dossier, sur dossier lui-même ;
|
||||
// faux pour toute autre poignée, ou quand la demande lève.
|
||||
async function resoutSurLuiMeme(dossier, nom) {
|
||||
try {
|
||||
return await (await dossier.getDirectoryHandle(nom)).isSameEntry(dossier);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Poignée du fichier nom du dossier. Lève l'exception de l'OPFS, sauf une
|
||||
// TypeMismatchError dont le nom se résout sur le dossier lui-même, qui
|
||||
// devient une DOMException de nom NOM_NON_CONVERTI.
|
||||
async function fichierDe(dossier, nom, options) {
|
||||
try {
|
||||
return await dossier.getFileHandle(nom, options);
|
||||
} catch (erreur) {
|
||||
if (erreur?.name === TYPE_DIFFERENT && (await resoutSurLuiMeme(dossier, nom))) {
|
||||
throw new DOMException(nom, NOM_NON_CONVERTI);
|
||||
}
|
||||
throw erreur;
|
||||
}
|
||||
}
|
||||
|
||||
// Poignée du dossier que désignent les segments sous le dossier du produit ;
|
||||
// creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se
|
||||
// crée que pour des segments vides, qui le désignent : un chemin sous un
|
||||
// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de
|
||||
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
|
||||
// fichier à sa place.
|
||||
// fichier à sa place ; NOM_NON_CONVERTI pour un dossier résolu sur celui qui
|
||||
// le porte.
|
||||
async function dossierDe(segments, creer = false) {
|
||||
const options = { create: creer };
|
||||
const produit = { create: creer && segments.length === 0 };
|
||||
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit);
|
||||
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
|
||||
let dossier = await sousDossier(await navigator.storage.getDirectory(), DOSSIER_PRODUIT, produit);
|
||||
for (const segment of segments) dossier = await sousDossier(dossier, segment, options);
|
||||
return dossier;
|
||||
}
|
||||
|
||||
|
|
@ -159,10 +210,10 @@ async function dossierExistant(segments) {
|
|||
}
|
||||
|
||||
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
|
||||
// n'existe pas.
|
||||
// n'existe pas. Un nom non converti lève NOM_NON_CONVERTI.
|
||||
async function genreDe(dossier, nom) {
|
||||
try {
|
||||
await dossier.getFileHandle(nom);
|
||||
await fichierDe(dossier, nom);
|
||||
return 'fichier';
|
||||
} catch (erreur) {
|
||||
if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
|
||||
|
|
@ -200,7 +251,7 @@ async function ecrireParFlux(dossier, nom, garder, remplir) {
|
|||
let poignee;
|
||||
let cree = false;
|
||||
try {
|
||||
poignee = await dossier.getFileHandle(nom);
|
||||
poignee = await fichierDe(dossier, nom);
|
||||
} catch (erreur) {
|
||||
if (erreur?.name !== 'NotFoundError') throw erreur;
|
||||
poignee = await dossier.getFileHandle(nom, { create: true });
|
||||
|
|
@ -235,7 +286,8 @@ async function decrire(poignee) {
|
|||
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
|
||||
|
||||
// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand
|
||||
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier.
|
||||
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier ;
|
||||
// NOM_NON_CONVERTI, que genreDe lève, avant toute lecture de ses octets.
|
||||
async function lireSource(loc) {
|
||||
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
|
||||
const dossier = await dossierExistant(loc.parent);
|
||||
|
|
@ -338,7 +390,7 @@ export function creerFichiersWeb() {
|
|||
if (loc.nom === null) throw absent(chemin);
|
||||
let lu;
|
||||
try {
|
||||
lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
|
||||
lu = await lire(await fichierDe(await dossierDe(loc.parent), loc.nom));
|
||||
} catch (erreur) {
|
||||
throw echecLecture(loc, erreur);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,6 +246,92 @@ describe("fichiers web : ce que l'OPFS refuse", () => {
|
|||
expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note');
|
||||
});
|
||||
|
||||
// Sous des paramètres régionaux qui ne sont pas UTF-8, Chromium résout un
|
||||
// nom de dossier hors ASCII sur le dossier qui le porte, sans lever. La
|
||||
// doublure rend ce dossier pour le nom « é » : aucun geste ne lit ni n'écrit
|
||||
// alors dans le dossier qui le porte, où un fichier de même nom attend.
|
||||
test("un dossier dont la poignée est celle du dossier qui le porte lève NOM_NON_CONVERTI, ECRITURE à l'écriture et LECTURE à la lecture, et le dossier qui le porte reste intact", async () => {
|
||||
const fs = await preparer();
|
||||
await fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'parent');
|
||||
const poignee = FileSystemDirectoryHandle.prototype.getDirectoryHandle;
|
||||
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
|
||||
async function getDirectoryHandle(nom, options) {
|
||||
return nom === '\u{E9}' ? this : poignee.call(this, nom, options);
|
||||
},
|
||||
);
|
||||
const sous = `${CHEMIN_DOCUMENTS}/\u{E9}`;
|
||||
const refus = (chemin, dossier = sous) => ({ chemin, dossier, cause: 'NOM_NON_CONVERTI' });
|
||||
const enfant = '\u{E9}/etat.json';
|
||||
|
||||
await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}'), 'ECRITURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'creerDossier');
|
||||
await echoue(fs.creerDossier(DOCUMENTS, '\u{E9}/sous'), 'ECRITURE', refus('\u{E9}/sous'), 'creerDossier dessous');
|
||||
await echoue(fs.ecrireAtomique(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ecrireAtomique');
|
||||
await echoue(fs.ajouterLigne(DOCUMENTS, enfant, 'enfant'), 'ECRITURE', refus(enfant), 'ajouterLigne');
|
||||
await echoue(fs.supprimer(DOCUMENTS, enfant), 'ECRITURE', refus(enfant), 'supprimer');
|
||||
await echoue(fs.deplacer(DOCUMENTS, enfant, 'copie.json'), 'ECRITURE', refus(enfant), 'deplacer depuis');
|
||||
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', '\u{E9}/neuf.json'), 'ECRITURE', refus('\u{E9}/neuf.json'), 'deplacer vers');
|
||||
await echoue(fs.lireTexte(DOCUMENTS, enfant), 'LECTURE', refus(enfant), 'lireTexte');
|
||||
await echoue(fs.lister(DOCUMENTS, '\u{E9}'), 'LECTURE', refus('\u{E9}', CHEMIN_DOCUMENTS), 'lister');
|
||||
vi.restoreAllMocks();
|
||||
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([{ nom: 'etat.json', type: 'fichier' }]);
|
||||
expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('parent');
|
||||
|
||||
// Le dossier du produit se contrôle comme tout autre : une poignée qui
|
||||
// serait la racine de l'OPFS fait échouer la sonde, qui n'y écrit rien.
|
||||
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
|
||||
async function getDirectoryHandle(nom, options) {
|
||||
return nom === DOSSIER ? this : poignee.call(this, nom, options);
|
||||
},
|
||||
);
|
||||
expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NOM_NON_CONVERTI' });
|
||||
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'LECTURE', refus('etat.json', CHEMIN_DOCUMENTS), 'produit');
|
||||
vi.restoreAllMocks();
|
||||
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
|
||||
});
|
||||
|
||||
// Sous les mêmes paramètres régionaux, un nom de fichier hors ASCII se
|
||||
// résout lui aussi sur le dossier qui le porte : getFileHandle lève
|
||||
// TypeMismatchError, et getDirectoryHandle rend ce dossier. La doublure
|
||||
// reproduit les deux pour le nom « ô.txt ». Le refus n'est alors ni une
|
||||
// absence ni une entrée d'un autre genre : un état présent ne se lit pas
|
||||
// ABSENT, une cible libre ne se dit pas EXISTE.
|
||||
test("un fichier que l'OPFS résout sur le dossier qui le porte lève NOM_NON_CONVERTI, LECTURE à la lecture et ECRITURE à l'écriture, jamais ABSENT ni EXISTE", async () => {
|
||||
const fs = await preparer();
|
||||
await fs.ecrireAtomique(DOCUMENTS, 'source.txt', 'source');
|
||||
const nom = '\u{F4}.txt';
|
||||
const fichier = FileSystemDirectoryHandle.prototype.getFileHandle;
|
||||
const dossier = FileSystemDirectoryHandle.prototype.getDirectoryHandle;
|
||||
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getFileHandle').mockImplementation(
|
||||
async function getFileHandle(entree, options) {
|
||||
if (entree === nom) throw new DOMException(entree, 'TypeMismatchError');
|
||||
return fichier.call(this, entree, options);
|
||||
},
|
||||
);
|
||||
vi.spyOn(FileSystemDirectoryHandle.prototype, 'getDirectoryHandle').mockImplementation(
|
||||
async function getDirectoryHandle(entree, options) {
|
||||
return entree === nom ? this : dossier.call(this, entree, options);
|
||||
},
|
||||
);
|
||||
const refus = { chemin: nom, dossier: CHEMIN_DOCUMENTS, cause: 'NOM_NON_CONVERTI' };
|
||||
|
||||
await echoue(fs.lireTexte(DOCUMENTS, nom), 'LECTURE', refus, 'lireTexte');
|
||||
await echoue(fs.ecrireAtomique(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ecrireAtomique');
|
||||
await echoue(fs.ajouterLigne(DOCUMENTS, nom, 'x'), 'ECRITURE', refus, 'ajouterLigne');
|
||||
await echoue(fs.supprimer(DOCUMENTS, nom), 'ECRITURE', refus, 'supprimer');
|
||||
await echoue(fs.deplacer(DOCUMENTS, nom, 'copie.txt'), 'ECRITURE', refus, 'deplacer depuis');
|
||||
await echoue(fs.deplacer(DOCUMENTS, 'source.txt', nom), 'ECRITURE', refus, 'deplacer vers');
|
||||
|
||||
// Un vrai dossier reste une entrée d'un autre genre : ABSENT à la lecture.
|
||||
await fs.creerDossier(DOCUMENTS, 'corbeille');
|
||||
await echoue(fs.lireTexte(DOCUMENTS, 'corbeille'), 'ABSENT', { chemin: 'corbeille' }, 'vrai dossier');
|
||||
vi.restoreAllMocks();
|
||||
expect(await fs.lister(DOCUMENTS, '')).toMatchObject([
|
||||
{ nom: 'corbeille', type: 'dossier' },
|
||||
{ nom: 'source.txt', type: 'fichier' },
|
||||
]);
|
||||
expect(await fs.lireTexte(DOCUMENTS, 'source.txt')).toBe('source');
|
||||
});
|
||||
|
||||
// Chaque panne frappe un temps de l'écriture par flux : son ouverture,
|
||||
// l'écriture, la fermeture qui porte le contenu sur la cible.
|
||||
const PANNES = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,41 @@
|
|||
// téléchargé ; GTT_CHROMIUM désigne un autre exécutable. Sous NixOS, où les
|
||||
// binaires téléchargés ne démarrent pas, scripts/installation/shell.nix y
|
||||
// met celui de nixpkgs.
|
||||
export function optionsChromium(env) {
|
||||
const chemin = env.GTT_CHROMIUM;
|
||||
return chemin ? { launchOptions: { executablePath: chemin } } : {};
|
||||
//
|
||||
// Sous Linux, Chromium convertit le nom d'une entrée de l'OPFS par les
|
||||
// paramètres régionaux de son processus : hors UTF-8, un nom hors ASCII ne
|
||||
// se convertit pas, et les épreuves du navigateur n'éprouvent plus le
|
||||
// stockage. Les paramètres régionaux qui décident de cette conversion sont
|
||||
// ceux de LC_CTYPE : LC_ALL, puis LC_CTYPE, puis LANG, la première variable
|
||||
// non vide ; aucune, c'est « C ». Quand ils ne sont pas UTF-8, Chromium
|
||||
// reçoit une copie de l'environnement sous LANG=C.UTF-8, LC_ALL et LC_CTYPE
|
||||
// retirés puisqu'ils l'emporteraient sur LANG, comme
|
||||
// scripts/verifier_systemes.sh le lance dans ses conteneurs. Seul le nom des
|
||||
// paramètres régionaux se lit : un nom UTF-8 que l'hôte n'a pas générés
|
||||
// laisse Chromium sous « C », et l'épreuve de l'OPFS du navigateur nomme
|
||||
// alors cette cause. C.UTF-8 vient avec la glibc 2.35 et suivantes, et avec
|
||||
// les distributions qui le livrent avant ; l'environnement reçu reste tel
|
||||
// quel.
|
||||
|
||||
// Un nom de paramètres régionaux dont le jeu de caractères est UTF-8 :
|
||||
// « fr_CA.UTF-8 », « C.utf8 », « de_DE.UTF-8@euro ».
|
||||
const NOM_UTF8 = /\.utf-?8(@[^.]*)?$/i;
|
||||
|
||||
// Paramètres régionaux de LC_CTYPE que env donne, « C » sans aucun.
|
||||
function caracteresDe(env) {
|
||||
for (const variable of ['LC_ALL', 'LC_CTYPE', 'LANG']) {
|
||||
if (env[variable]) return env[variable];
|
||||
}
|
||||
return 'C';
|
||||
}
|
||||
|
||||
export function optionsChromium(env) {
|
||||
const lancement = {};
|
||||
if (env.GTT_CHROMIUM) lancement.executablePath = env.GTT_CHROMIUM;
|
||||
if (!NOM_UTF8.test(caracteresDe(env))) {
|
||||
lancement.env = { ...env, LANG: 'C.UTF-8' };
|
||||
delete lancement.env.LC_ALL;
|
||||
delete lancement.env.LC_CTYPE;
|
||||
}
|
||||
return Object.keys(lancement).length > 0 ? { launchOptions: lancement } : {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,25 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le Chromium des épreuves du navigateur : celui de Playwright par défaut,
|
||||
// celui que GTT_CHROMIUM désigne sinon, et vitest.config.js applique la règle.
|
||||
// celui que GTT_CHROMIUM désigne sinon ; sous des paramètres régionaux qui ne
|
||||
// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. vitest.config.js
|
||||
// applique la règle.
|
||||
import assert from 'node:assert/strict';
|
||||
import configuration from '../vitest.config.js';
|
||||
import { optionsChromium } from './chromium.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
// Un environnement d'épreuve, sous des paramètres régionaux UTF-8.
|
||||
const UTF8 = { PATH: '/usr/bin', LANG: 'fr_CA.UTF-8' };
|
||||
|
||||
describe('chromium : l’exécutable des épreuves du navigateur', () => {
|
||||
test('sans GTT_CHROMIUM, Playwright lance le Chromium qu’il a téléchargé', () => {
|
||||
assert.deepEqual(optionsChromium({}), {});
|
||||
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '' }), {});
|
||||
assert.deepEqual(optionsChromium(UTF8), {});
|
||||
assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '' }), {});
|
||||
});
|
||||
|
||||
test('GTT_CHROMIUM désigne l’exécutable que Playwright lance', () => {
|
||||
assert.deepEqual(optionsChromium({ GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
|
||||
assert.deepEqual(optionsChromium({ ...UTF8, GTT_CHROMIUM: '/nix/store/abc-chromium/bin/chromium' }), {
|
||||
launchOptions: { executablePath: '/nix/store/abc-chromium/bin/chromium' },
|
||||
});
|
||||
});
|
||||
|
|
@ -25,3 +30,44 @@ describe('chromium : l’exécutable des épreuves du navigateur', () => {
|
|||
assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env));
|
||||
});
|
||||
});
|
||||
|
||||
describe('chromium : les paramètres régionaux du navigateur des épreuves', () => {
|
||||
test('des paramètres régionaux UTF-8 laissent Chromium hériter de l’environnement : LC_ALL, puis LC_CTYPE, puis LANG décident', () => {
|
||||
const utf8 = [
|
||||
{ LANG: 'C.UTF-8' },
|
||||
{ LANG: 'C.utf8' },
|
||||
{ LANG: 'de_DE.UTF-8@euro' },
|
||||
{ LANG: 'C', LC_CTYPE: 'fr_CA.utf8' },
|
||||
{ LANG: 'C', LC_CTYPE: 'C', LC_ALL: 'fr_CA.UTF-8' },
|
||||
{ LANG: 'fr_CA.UTF-8', LC_ALL: '', LC_CTYPE: '' },
|
||||
];
|
||||
for (const env of utf8) assert.deepEqual(optionsChromium(env), {}, JSON.stringify(env));
|
||||
});
|
||||
|
||||
test('hors UTF-8, Chromium reçoit une copie de l’environnement sous LANG=C.UTF-8, sans LC_ALL ni LC_CTYPE', () => {
|
||||
const cas = [
|
||||
[{}, {}],
|
||||
[{ PATH: '/usr/bin', LC_ALL: 'C' }, { PATH: '/usr/bin' }],
|
||||
[{ PATH: '/usr/bin', LANG: 'POSIX', LC_MESSAGES: 'fr_CA.UTF-8' }, { PATH: '/usr/bin', LC_MESSAGES: 'fr_CA.UTF-8' }],
|
||||
[{ LANG: 'fr_CA.UTF-8', LC_CTYPE: 'C' }, {}],
|
||||
[{ LANG: 'fr_CA.UTF-8', LC_ALL: 'fr_CA.ISO-8859-1' }, {}],
|
||||
[{ LANG: 'C.UTF-8-ancien' }, {}],
|
||||
];
|
||||
for (const [env, reste] of cas) {
|
||||
const avant = { ...env };
|
||||
assert.deepEqual(
|
||||
optionsChromium(env),
|
||||
{ launchOptions: { env: { ...reste, LANG: 'C.UTF-8' } } },
|
||||
JSON.stringify(env),
|
||||
);
|
||||
assert.deepEqual(env, avant, `${JSON.stringify(env)} : l'environnement reçu reste tel quel`);
|
||||
}
|
||||
});
|
||||
|
||||
test('hors UTF-8, GTT_CHROMIUM désigne toujours l’exécutable, que l’environnement accompagne', () => {
|
||||
const env = { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' };
|
||||
assert.deepEqual(optionsChromium(env), {
|
||||
launchOptions: { executablePath: '/opt/essai/chromium', env: { GTT_CHROMIUM: '/opt/essai/chromium', LANG: 'C.UTF-8' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue