feat(quick-260921-jt4): uebersetzter ZIP-Name im Zertifikat-Aufteiler mit Windows-Schutzfunktion (Restposten 1)
downloadAllAsZip liegt ausserhalb der Komponente und kann den
Uebersetzungs-Hook nicht aufrufen; der Name kommt jetzt als Parameter
herein, Aufrufstelle uebergibt t('actions.zipFilename'). Neuer
Schluessel unter certManager.actions: deutsch "Zertifikate.zip",
englisch "certificates.zip".
Der 260921-bi2-Einwand (ein uebersetzter Name koenne Umlaute auf eine
Windows-Freigabe tragen) trifft fuer diesen konkreten Text nicht zu —
das deutsche Wort enthaelt keinen Umlaut. Die Sicherheit haengt darauf
aber NICHT: neue Datei zip-filename.ts mit einer fuer sich pruefbaren
Schutzfunktion, die Windows-verbotene Zeichen, Steuerzeichen und
Nicht-ASCII ersetzt, abschliessende Punkte/Leerzeichen entfernt,
reservierte Geraetenamen abfaengt, bei leerem Ergebnis auf
certificates.zip zurueckfaellt und die .zip-Endung sicherstellt.
SplitTab.tsx schickt den uebersetzten Namen durch diese Funktion, bevor
er am Download landet. Die Dateinamen IM Archiv bleiben unangetastet.
zip-filename.test.ts deckt beide Katalogwerte (unveraendert), Umlaut,
verbotenes Zeichen, Steuerzeichen, abschliessende Punkte/Leerzeichen,
fehlende/vorhandene Endung, leeres Ergebnis und reservierte
Geraetenamen ab.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { zipSync } from 'fflate';
|
import { zipSync } from 'fflate';
|
||||||
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
|
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
|
||||||
|
import { sanitizeZipFilename } from '../zip-filename';
|
||||||
|
|
||||||
interface SplitTabProps {
|
interface SplitTabProps {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
@@ -17,7 +18,11 @@ const ROLE_STYLES: Record<CertRole, string> = {
|
|||||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
function downloadAllAsZip(certs: SplitResponse['certs']) {
|
// `downloadAllAsZip` liegt ausserhalb der Komponente und kann den
|
||||||
|
// Uebersetzungs-Hook nicht selbst aufrufen — der uebersetzte Name kommt
|
||||||
|
// deshalb als Parameter herein (Restposten 1, quick-260921-jt4). Die
|
||||||
|
// einzelnen Dateinamen IM Archiv stammen weiterhin unveraendert aus der API.
|
||||||
|
function downloadAllAsZip(certs: SplitResponse['certs'], zipFilename: string) {
|
||||||
const files: Record<string, Uint8Array> = {};
|
const files: Record<string, Uint8Array> = {};
|
||||||
for (const cert of certs) {
|
for (const cert of certs) {
|
||||||
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
|
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
|
||||||
@@ -35,7 +40,11 @@ function downloadAllAsZip(certs: SplitResponse['certs']) {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'certificates.zip';
|
// T-JT4-05: der uebersetzte Name kann Zeichen tragen, die Windows
|
||||||
|
// verbietet (Umlaute, Sonderzeichen) — sanitizeZipFilename schneidet ihn
|
||||||
|
// auf das fuer eine Windows-Freigabe Zulaessige zurueck, unabhaengig davon,
|
||||||
|
// ob der aktuelle Katalogwert zufaellig schon harmlos ist.
|
||||||
|
a.download = sanitizeZipFilename(zipFilename);
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
@@ -81,7 +90,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
|
|||||||
{result && result.certs.length > 1 && (
|
{result && result.certs.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadAllAsZip(result.certs)}
|
onClick={() => downloadAllAsZip(result.certs, t('actions.zipFilename'))}
|
||||||
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
|
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
|
||||||
>
|
>
|
||||||
{t('actions.downloadZip')}
|
{t('actions.downloadZip')}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ZIP_FILENAME_FALLBACK, sanitizeZipFilename } from './zip-filename';
|
||||||
|
|
||||||
|
describe('sanitizeZipFilename (T-JT4-05)', () => {
|
||||||
|
it('laesst den deutschen Katalogwert unveraendert durch', () => {
|
||||||
|
expect(sanitizeZipFilename('Zertifikate.zip')).toBe('Zertifikate.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst den englischen Katalogwert unveraendert durch', () => {
|
||||||
|
expect(sanitizeZipFilename('certificates.zip')).toBe('certificates.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bereinigt einen Namen mit Umlaut, statt ihn abzulehnen', () => {
|
||||||
|
expect(sanitizeZipFilename('Zertifikäte.zip')).toBe('Zertifik_te.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bereinigt einen Namen mit einem von Windows verbotenen Zeichen', () => {
|
||||||
|
expect(sanitizeZipFilename('Zertifikate:Test.zip')).toBe('Zertifikate_Test.zip');
|
||||||
|
expect(sanitizeZipFilename('a<b>c.zip')).toBe('a_b_c.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt Steuerzeichen', () => {
|
||||||
|
expect(sanitizeZipFilename(`a${String.fromCharCode(7)}b.zip`)).toBe('a_b.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt abschliessende Punkte und Leerzeichen', () => {
|
||||||
|
expect(sanitizeZipFilename('Zertifikate .zip')).toBe('Zertifikate .zip');
|
||||||
|
expect(sanitizeZipFilename('Zertifikate.zip ')).toBe('Zertifikate.zip');
|
||||||
|
expect(sanitizeZipFilename('Zertifikate...')).toBe('Zertifikate.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('faellt auf den Ersatznamen zurueck, wenn der Name ausschliesslich aus abschliessenden Punkten/Leerzeichen besteht', () => {
|
||||||
|
expect(sanitizeZipFilename('...')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
expect(sanitizeZipFilename(' ')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ergaenzt die Endung .zip, wenn sie fehlt, ohne sie zu verdoppeln', () => {
|
||||||
|
expect(sanitizeZipFilename('Zertifikate')).toBe('Zertifikate.zip');
|
||||||
|
expect(sanitizeZipFilename('Zertifikate.ZIP')).toBe('Zertifikate.ZIP');
|
||||||
|
expect(sanitizeZipFilename('Zertifikate.zip')).toBe('Zertifikate.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('faellt bei leerem Ergebnis auf den Ersatznamen zurueck', () => {
|
||||||
|
expect(sanitizeZipFilename('')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
expect(sanitizeZipFilename('***')).not.toBe(ZIP_FILENAME_FALLBACK); // sanitized, not empty
|
||||||
|
expect(sanitizeZipFilename(' ')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
expect(sanitizeZipFilename(':::')).not.toBe(ZIP_FILENAME_FALLBACK); // sanitized to "___.zip"
|
||||||
|
});
|
||||||
|
|
||||||
|
it('faellt bei einem aussichtslosen reservierten Geraetenamen auf den Ersatznamen zurueck', () => {
|
||||||
|
expect(sanitizeZipFilename('CON')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
expect(sanitizeZipFilename('con')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
expect(sanitizeZipFilename('CON.zip')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
expect(sanitizeZipFilename('LPT1')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
expect(sanitizeZipFilename('COM9.zip')).toBe(ZIP_FILENAME_FALLBACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behandelt einen reservierten Namen, der nur ein Praefix eines laengeren Namens ist, nicht als reserviert', () => {
|
||||||
|
expect(sanitizeZipFilename('CONtracts.zip')).toBe('CONtracts.zip');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* zip-filename — schneidet einen (uebersetzten) Anzeigenamen auf das fuer
|
||||||
|
* eine Windows-Freigabe Zulaessige zurueck (T-JT4-05, quick-260921-jt4).
|
||||||
|
*
|
||||||
|
* Hintergrund: `downloadAllAsZip` in `SplitTab.tsx` kann den Namen kuenftig
|
||||||
|
* ueber `t('actions.zipFilename')` aus dem Uebersetzungskatalog beziehen.
|
||||||
|
* Das deutsche Wort fuer "Zertifikate" enthaelt zufaellig keinen Umlaut und
|
||||||
|
* kein von Windows verbotenes Zeichen — darauf darf sich die
|
||||||
|
* Dateisystem-Sicherheit aber NICHT verlassen, sonst haengt sie an einer
|
||||||
|
* kuenftigen Uebersetzungsentscheidung. Diese Funktion ist deshalb fuer sich
|
||||||
|
* pruefbar und unabhaengig vom tatsaechlichen Katalogwert.
|
||||||
|
*
|
||||||
|
* Reihenfolge der Schritte:
|
||||||
|
* 1. Von Windows verbotene Zeichen (`< > : " / \ | ? *`) UND Steuerzeichen
|
||||||
|
* (0x00-0x1F) werden durch `_` ersetzt.
|
||||||
|
* 2. Nicht-ASCII-Zeichen (Umlaute, Emoji, ...) werden durch `_` ersetzt.
|
||||||
|
* 3. Abschliessende Punkte und Leerzeichen werden entfernt (Windows ignoriert
|
||||||
|
* sie beim Anlegen, ein Name, der nur daraus besteht, wuerde sonst leer).
|
||||||
|
* 4. Ist das Ergebnis leer, faellt die Funktion auf `certificates.zip`
|
||||||
|
* zurueck.
|
||||||
|
* 5. Die Endung `.zip` wird sichergestellt (case-insensitiv erkannt, nicht
|
||||||
|
* doppelt angehaengt).
|
||||||
|
* 6. Reservierte Windows-Geraetenamen (`CON`, `PRN`, `AUX`, `NUL`,
|
||||||
|
* `COM1`-`COM9`, `LPT1`-`LPT9`, ohne Ruecksicht auf Gross-/Kleinschreibung)
|
||||||
|
* fallen ebenfalls auf `certificates.zip` zurueck — ein Name, der NUR aus
|
||||||
|
* einem solchen Geraetenamen besteht, ist auf Windows aussichtslos, egal
|
||||||
|
* welche Endung er traegt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Zeichenweise statt per Regex (vermeidet lint/suspicious/noControlCharactersInRegex,
|
||||||
|
// das ein \x00-\x1F-Bereich in einem Regex-Literal ablehnt — hier aber genau die
|
||||||
|
// Absicht ist: Steuerzeichen ausfiltern, kein Tippfehler).
|
||||||
|
const WINDOWS_FORBIDDEN_PRINTABLE = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
|
||||||
|
|
||||||
|
function isControlOrForbiddenOrNonAscii(char: string): boolean {
|
||||||
|
const codePoint = char.codePointAt(0) ?? 0;
|
||||||
|
return codePoint <= 0x1f || codePoint > 0x7e || WINDOWS_FORBIDDEN_PRINTABLE.has(char);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceForbiddenCharacters(rawName: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (const char of rawName) {
|
||||||
|
result += isControlOrForbiddenOrNonAscii(char) ? '_' : char;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRAILING_DOTS_SPACES_RE = /[.\s]+$/;
|
||||||
|
|
||||||
|
const RESERVED_DEVICE_NAMES = new Set([
|
||||||
|
'CON',
|
||||||
|
'PRN',
|
||||||
|
'AUX',
|
||||||
|
'NUL',
|
||||||
|
'COM1',
|
||||||
|
'COM2',
|
||||||
|
'COM3',
|
||||||
|
'COM4',
|
||||||
|
'COM5',
|
||||||
|
'COM6',
|
||||||
|
'COM7',
|
||||||
|
'COM8',
|
||||||
|
'COM9',
|
||||||
|
'LPT1',
|
||||||
|
'LPT2',
|
||||||
|
'LPT3',
|
||||||
|
'LPT4',
|
||||||
|
'LPT5',
|
||||||
|
'LPT6',
|
||||||
|
'LPT7',
|
||||||
|
'LPT8',
|
||||||
|
'LPT9',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ZIP_FILENAME_FALLBACK = 'certificates.zip';
|
||||||
|
|
||||||
|
export function sanitizeZipFilename(rawName: string): string {
|
||||||
|
const cleaned = replaceForbiddenCharacters(rawName).replace(TRAILING_DOTS_SPACES_RE, '');
|
||||||
|
|
||||||
|
if (cleaned === '') {
|
||||||
|
return ZIP_FILENAME_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasZipExtension = cleaned.toLowerCase().endsWith('.zip');
|
||||||
|
const withExtension = hasZipExtension ? cleaned : `${cleaned}.zip`;
|
||||||
|
const base = hasZipExtension ? withExtension.slice(0, -'.zip'.length) : cleaned;
|
||||||
|
|
||||||
|
if (RESERVED_DEVICE_NAMES.has(base.toUpperCase())) {
|
||||||
|
return ZIP_FILENAME_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
return withExtension;
|
||||||
|
}
|
||||||
@@ -788,7 +788,8 @@
|
|||||||
"convert": "Konvertieren",
|
"convert": "Konvertieren",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadZip": "Alle als ZIP herunterladen",
|
"downloadZip": "Alle als ZIP herunterladen",
|
||||||
"processing": "Wird verarbeitet..."
|
"processing": "Wird verarbeitet...",
|
||||||
|
"zipFilename": "Zertifikate.zip"
|
||||||
},
|
},
|
||||||
"certRole": {
|
"certRole": {
|
||||||
"root": "Root-CA",
|
"root": "Root-CA",
|
||||||
|
|||||||
@@ -788,7 +788,8 @@
|
|||||||
"convert": "Convert",
|
"convert": "Convert",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadZip": "Download all as ZIP",
|
"downloadZip": "Download all as ZIP",
|
||||||
"processing": "Processing..."
|
"processing": "Processing...",
|
||||||
|
"zipFilename": "certificates.zip"
|
||||||
},
|
},
|
||||||
"certRole": {
|
"certRole": {
|
||||||
"root": "Root CA",
|
"root": "Root CA",
|
||||||
|
|||||||
Reference in New Issue
Block a user