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:
2026-09-21 15:02:08 +02:00
parent 5a03b75f1e
commit 6c10c9bbe0
5 changed files with 170 additions and 5 deletions
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { zipSync } from 'fflate';
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
import { sanitizeZipFilename } from '../zip-filename';
interface SplitTabProps {
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',
};
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> = {};
for (const cert of certs) {
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 a = document.createElement('a');
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();
URL.revokeObjectURL(url);
}
@@ -81,7 +90,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
{result && result.certs.length > 1 && (
<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"
>
{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;
}
+2 -1
View File
@@ -788,7 +788,8 @@
"convert": "Konvertieren",
"download": "Herunterladen",
"downloadZip": "Alle als ZIP herunterladen",
"processing": "Wird verarbeitet..."
"processing": "Wird verarbeitet...",
"zipFilename": "Zertifikate.zip"
},
"certRole": {
"root": "Root-CA",
+2 -1
View File
@@ -788,7 +788,8 @@
"convert": "Convert",
"download": "Download",
"downloadZip": "Download all as ZIP",
"processing": "Processing..."
"processing": "Processing...",
"zipFilename": "certificates.zip"
},
"certRole": {
"root": "Root CA",