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 { 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -788,7 +788,8 @@
|
||||
"convert": "Convert",
|
||||
"download": "Download",
|
||||
"downloadZip": "Download all as ZIP",
|
||||
"processing": "Processing..."
|
||||
"processing": "Processing...",
|
||||
"zipFilename": "certificates.zip"
|
||||
},
|
||||
"certRole": {
|
||||
"root": "Root CA",
|
||||
|
||||
Reference in New Issue
Block a user