+
๐
Processed locally
โข
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
index 10bdbcd..1f099ef 100644
--- a/src/components/SearchBar.tsx
+++ b/src/components/SearchBar.tsx
@@ -50,7 +50,7 @@ const SearchBar = ({ onSearch }: SearchBarProps) => {
id='tool-search'
value={searchTerm}
onChange={handleInputChange}
- className='block w-full pl-10 pr-3 py-3 border border-slate-300 rounded-lg leading-5 bg-white text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 sm:text-sm transition-shadow duration-150 shadow-sm hover:shadow-md'
+ className='block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-700 rounded-lg leading-5 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 sm:text-sm transition-shadow duration-150 shadow-sm hover:shadow-md'
placeholder='Search for a tool (e.g., image compressor, regex)'
/>
diff --git a/src/components/SourceLink.tsx b/src/components/SourceLink.tsx
index d829725..38e4bb1 100644
--- a/src/components/SourceLink.tsx
+++ b/src/components/SourceLink.tsx
@@ -1,3 +1,3 @@
'use client';
import { usePathname } from 'next/navigation';
-export default function SourceLink() { const pathname = usePathname(); if (!pathname || pathname === '/') return null; return
View this tool source ; }
+export default function SourceLink() { const pathname = usePathname(); if (!pathname || pathname === '/') return null; return
View this tool source ; }
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..18d5436
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,25 @@
+'use client';
+import { useSyncExternalStore } from 'react';
+
+const readTheme = () => document.documentElement.dataset.theme === 'dark';
+const subscribe = (callback: () => void) => {
+ const media = matchMedia('(prefers-color-scheme: dark)');
+ window.addEventListener('toolstack-theme', callback); window.addEventListener('storage', callback); media.addEventListener('change', callback);
+ return () => { window.removeEventListener('toolstack-theme', callback); window.removeEventListener('storage', callback); media.removeEventListener('change', callback); };
+};
+
+export default function ThemeToggle() {
+ const dark = useSyncExternalStore(subscribe, readTheme, () => false);
+ const label = dark ? 'Switch to light mode' : 'Switch to dark mode';
+ return
{
+ const value = dark ? 'light' : 'dark';
+ document.documentElement.dataset.appearance = value;
+ try { localStorage.setItem('toolstack-theme', value); } catch { /* Keep the selected theme for this page if storage is blocked. */ }
+ document.documentElement.dataset.theme = value;
+ window.dispatchEvent(new Event('toolstack-theme'));
+ }}>
+
+ {dark ? : <> >}
+ {dark ? 'Dark' : 'Light'}
+ ;
+}
diff --git a/src/components/ToolCard.tsx b/src/components/ToolCard.tsx
index c8bb25e..77797f4 100644
--- a/src/components/ToolCard.tsx
+++ b/src/components/ToolCard.tsx
@@ -1,19 +1,10 @@
import Link from 'next/link';
import type { Tool } from '@/types/tool';
-interface ToolCardProps { tool: Tool }
-
-export default function ToolCard({ tool }: ToolCardProps) {
- return
-
-
{tool.name}
-
- {tool.new && New }
- Local
-
-
-
{tool.description}
-
{tool.category}
-
{tool.tags.slice(0,5).map(tag => {tag} )}
+export default function ToolCard({ tool }: { tool: Tool }) {
+ return
+
{tool.name}
+
{tool.description}
+
{tool.category} โ
;
}
diff --git a/src/components/ToolPage.tsx b/src/components/ToolPage.tsx
index 913f67d..788c8c5 100644
--- a/src/components/ToolPage.tsx
+++ b/src/components/ToolPage.tsx
@@ -2,5 +2,5 @@ import Link from 'next/link';
import PrivacyBadge from './PrivacyBadge';
import SourceLink from './SourceLink';
export default function ToolPage({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
- return
{children} ← Back to all tools
;
+ return
{children} ← Back to all tools
;
}
diff --git a/src/config/tools.ts b/src/config/tools.ts
index 6607d32..d5c02ec 100644
--- a/src/config/tools.ts
+++ b/src/config/tools.ts
@@ -7,7 +7,7 @@ const tool = (slug: string, name: string, description: string, category: ToolCat
export const TOOLS: Tool[] = [
tool('image-compressor','Image Compressor','Compress image batches locally with browser-side processing.','Images',['image','compress','optimize','media']),
tool('regex-tester','Regex Tester & Explainer','Test regular expressions in a disposable worker with a timeout.','Developer',['text','developer','regex','code']),
- tool('text-diff','Text Diff Checker','Compare two pieces of text and highlight differences.','Text & Data',['text','compare','diff']),
+ tool('text-diff','Text & File Diff','Compare and merge files inline, match records and export reviewed, redacted changes.','Text & Data',['text','file','compare','diff','merge','csv','json','env','redact','privacy'],{new:true}),
tool('color-converter','Color Code Converter','Convert HEX, RGB and HSL color values.','Converters',['color','design','developer','converter']),
tool('placeholder-generator','Placeholder Image Generator','Create custom placeholder images locally.','Images',['image','design','placeholder']),
tool('text-analyzer','Text Statistics / Analyzer','Count words, characters, sentences and more.','Text & Data',['text','stats','analyzer','counter']),
@@ -20,14 +20,14 @@ export const TOOLS: Tool[] = [
tool('lorem-ipsum-generator','Lorem Ipsum Generator','Generate placeholder text.','Generators',['text','generator','placeholder','design']),
tool('regex-generator','Easy Regex Generator','Build common regular expressions without memorizing syntax.','Developer',['regex','generator','developer','text','code']),
tool('find-replace','Find & Replace in Text','Find and replace text with optional regex support.','Text & Data',['text','find','replace','regex']),
- tool('data-anonymizer','Data Anonymizer','Redact common sensitive-looking values locally.','Privacy & Security',['text','privacy','security','anonymize','redact'],{featured:true}),
+ tool('data-anonymizer','Data Anonymizer','Scrub sensitive text or mask linked CSV / JSON datasets with consistent aliases.','Privacy & Security',['text','privacy','security','anonymize','redact','csv','dataset','pseudonymize'],{featured:true}),
tool('css-specificity-calculator','CSS Specificity Calculator','Calculate modern CSS selector specificity with an AST parser.','Developer',['css','developer','specificity','design']),
tool('unit-converter','Unit Converter','Convert common measurement units.','Converters',['converter','units','measurement','math']),
tool('http-status-codes','HTTP Status Code Explainer','Look up HTTP status codes and meanings.','Developer',['http','developer','reference','network']),
tool('list-cleaner','List Cleaner & Formatter','Sort, deduplicate and format text lists.','Text & Data',['text','list','format','cleaner','sort']),
tool('html-tag-explainer','HTML Tag Explainer','Reference standard HTML tags and their uses.','Developer',['html','developer','reference','code']),
tool('file-hash','File Hash & Checksum','Generate SHA-256, SHA-384 and SHA-512 checksums without uploading files.','Privacy & Security',['privacy','hash','checksum','file','security'],{featured:true,new:true}),
- tool('json-toolbox','JSON Toolbox','Format, minify, validate, sort and structurally compare JSON locally.','Developer',['json','format','validate','diff','developer','data'],{new:true}),
+ tool('json-toolbox','JSON Toolbox','Format, compare and turn JSON samples into TypeScript or JSON Schema.','Developer',['json','format','validate','diff','developer','data','typescript','schema'],{new:true}),
tool('url-cleaner','Tracking URL Cleaner','Remove common tracking parameters and inspect URL parts locally.','Privacy & Security',['privacy','url','tracking','utm','cleaner'],{featured:true,new:true}),
tool('password-generator','Password & Passphrase Generator','Generate cryptographically secure passwords and passphrases locally.','Privacy & Security',['password','security','privacy','generator'],{featured:true,new:true}),
tool('encoder-decoder','Encoder / Decoder','Encode and decode Base64, URLs and HTML entities.','Developer',['base64','url','html','unicode','encode','decode'],{new:true}),
@@ -46,6 +46,12 @@ export const TOOLS: Tool[] = [
tool('csp-builder','CSP Builder & Analyzer','Draft and inspect Content Security Policy headers locally.','Developer',['csp','security','headers','developer','web'],{new:true}),
tool('hmac-tool','HMAC Generator & Verifier','Generate and verify HMAC-SHA signatures using Web Crypto.','Privacy & Security',['hmac','hash','signature','crypto','security'],{new:true}),
tool('cron-explainer','Cron Expression Explainer','Explain standard five-field cron expressions.','Developer',['cron','schedule','developer','automation'],{new:true}),
+ tool('support-sanitizer','Support File Sanitizer','Clean logs, JSON and HAR files, or convert cURL to redacted JavaScript.','Privacy & Security',['privacy','har','logs','support','secrets','redact','curl','fetch'],{featured:true,new:true}),
+ tool('screenshot-redactor','Screenshot Redactor','Replace sensitive pixels with solid areas and export a new, flattened PNG.','Privacy & Security',['privacy','screenshot','image','redact','sharing'],{featured:true,new:true}),
+ tool('document-privacy','Document Privacy Inspector','Inspect Office files for hidden information and clear supported document properties.','Privacy & Security',['privacy','office','docx','xlsx','pptx','metadata','documents'],{featured:true,new:true}),
+ tool('calendar-privacy','Calendar Privacy Exporter','Share a finite busy-time snapshot without meeting details or account connections.','Privacy & Security',['privacy','calendar','ics','availability','busy','sharing'],{new:true}),
+ tool('synthetic-data','Synthetic Dataset Generator','Generate repeatable fake CSV / JSON datasets with linked tables.','Generators',['synthetic','dataset','csv','json','test','fixtures','generator'],{new:true}),
+ tool('qr-toolkit','QR Toolkit','Create text, Wi-Fi and contact QR codes, or read QR images locally.','Generators',['qr','qrcode','wifi','contact','image','decode','generator'],{new:true}),
];
export const TOOL_BY_SLUG = new Map(TOOLS.map(item => [item.slug, item]));
diff --git a/src/lib/calendar-privacy.test.ts b/src/lib/calendar-privacy.test.ts
new file mode 100644
index 0000000..c8b2b7b
--- /dev/null
+++ b/src/lib/calendar-privacy.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+import { createPrivateCalendar } from './calendar-privacy';
+
+const wrap = (...events: string[]) => ['BEGIN:VCALENDAR','VERSION:2.0',...events,'END:VCALENDAR'].join('\r\n');
+const event = (extra = '') => ['BEGIN:VEVENT','UID:private@example.com','DTSTART:20260105T090000Z','DTEND:20260105T100000Z','SUMMARY:Secret meeting','ATTENDEE:mailto:private@example.com','DESCRIPTION:https://private.invalid/meeting','LOCATION:Private office',extra,'END:VEVENT'].filter(Boolean).join('\r\n');
+const options = { from: '2026-01-01', through: '2026-01-31', floating: 'utc' as const };
+describe('private calendar snapshots', () => {
+ it('expands recurrence, excludes EXDATE and strips original identifiers and content', () => {
+ const result = createPrivateCalendar(wrap(event('RRULE:FREQ=WEEKLY;COUNT=4\r\nEXDATE:20260112T090000Z')), options);
+ expect(result.blocks).toHaveLength(3); expect(result.ics).not.toMatch(/Secret|private@example|private.invalid|Private office|ATTENDEE|DESCRIPTION|LOCATION|RRULE/);
+ expect(result.ics.match(/SUMMARY:Busy/g)).toHaveLength(3);
+ });
+ it('handles moved and cancelled recurrence exceptions', () => {
+ const moved = 'BEGIN:VEVENT\r\nUID:private@example.com\r\nRECURRENCE-ID:20260112T090000Z\r\nDTSTART:20260113T110000Z\r\nDTEND:20260113T120000Z\r\nEND:VEVENT';
+ const cancelled = 'BEGIN:VEVENT\r\nUID:private@example.com\r\nRECURRENCE-ID:20260119T090000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT';
+ const result = createPrivateCalendar(wrap(event('RRULE:FREQ=WEEKLY;COUNT=3'), moved, cancelled), options);
+ expect(result.blocks.map(block => block.start)).toEqual(['2026-01-05T09:00:00.000Z','2026-01-13T11:00:00.000Z']);
+ });
+ it('retains all-day exclusive end dates and excludes transparent events', () => {
+ const allDay = 'BEGIN:VEVENT\r\nUID:day\r\nDTSTART;VALUE=DATE:20260107\r\nDTEND;VALUE=DATE:20260109\r\nEND:VEVENT';
+ const result = createPrivateCalendar(wrap(allDay, event('TRANSP:TRANSPARENT')), options);
+ expect(result.blocks).toEqual([{ start: '2026-01-07', end: '2026-01-09', allDay: true }]);
+ expect(result.ics).toContain('DTEND;VALUE=DATE:20260109');
+ });
+ it('uses embedded timezone definitions instead of guessing UTC', () => {
+ const zone = 'BEGIN:VTIMEZONE\r\nTZID:Asia/Kolkata\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530\r\nEND:STANDARD\r\nEND:VTIMEZONE';
+ const timed = event().replace('DTSTART:20260105T090000Z', 'DTSTART;TZID=Asia/Kolkata:20260105T090000').replace('DTEND:20260105T100000Z','DTEND;TZID=Asia/Kolkata:20260105T100000');
+ expect(createPrivateCalendar(wrap(zone, timed), options).blocks[0].start).toBe('2026-01-05T03:30:00.000Z');
+ expect(() => createPrivateCalendar(wrap(timed), options)).toThrow(/VTIMEZONE/);
+ });
+ it('stops on unsupported range exceptions and oversized date windows', () => {
+ expect(() => createPrivateCalendar(wrap(event('RECURRENCE-ID;RANGE=THISANDFUTURE:20260105T090000Z')), options)).toThrow(/range-based/);
+ expect(() => createPrivateCalendar(wrap(event()), { ...options, through: '2028-01-01' })).toThrow(/366/);
+ });
+ it('matches UTC recurrence exceptions against events in an embedded local timezone', () => {
+ const zone = 'BEGIN:VTIMEZONE\r\nTZID:Asia/Kolkata\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530\r\nEND:STANDARD\r\nEND:VTIMEZONE';
+ const master = event('RRULE:FREQ=WEEKLY;COUNT=2').replace('DTSTART:20260105T090000Z','DTSTART;TZID=Asia/Kolkata:20260105T090000').replace('DTEND:20260105T100000Z','DTEND;TZID=Asia/Kolkata:20260105T100000');
+ const exception = 'BEGIN:VEVENT\r\nUID:private@example.com\r\nRECURRENCE-ID:20260112T033000Z\r\nDTSTART:20260113T033000Z\r\nDTEND:20260113T043000Z\r\nEND:VEVENT';
+ expect(createPrivateCalendar(wrap(zone, master, exception), options).blocks.map(block => block.start)).toEqual(['2026-01-05T03:30:00.000Z','2026-01-13T03:30:00.000Z']);
+ });
+});
diff --git a/src/lib/calendar-privacy.ts b/src/lib/calendar-privacy.ts
new file mode 100644
index 0000000..ddcc227
--- /dev/null
+++ b/src/lib/calendar-privacy.ts
@@ -0,0 +1,79 @@
+import ICAL from 'ical.js';
+
+export type BusyBlock = { start: string; end: string; allDay: boolean };
+export type CalendarResult = { ics: string; summary: string; blocks: BusyBlock[]; skipped: number };
+export type CalendarOptions = { from: string; through: string; floating: 'utc' | 'local' };
+const compact = (date: Date) => date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
+
+export function createPrivateCalendar(text: string, options: CalendarOptions): CalendarResult {
+ const dateStart = new Date(`${options.from}T00:00:00Z`); const dateEnd = new Date(`${options.through}T00:00:00Z`);
+ if (![dateStart, dateEnd].every(date => Number.isFinite(date.getTime())) || dateStart.toISOString().slice(0,10) !== options.from || dateEnd.toISOString().slice(0,10) !== options.through) throw new Error('Choose valid start and end dates.');
+ dateEnd.setUTCDate(dateEnd.getUTCDate() + 1);
+ if (dateEnd <= dateStart || dateEnd.getTime() - dateStart.getTime() > 366 * 86400000) throw new Error('Choose a date range of up to 366 days, with the end on or after the start.');
+ const root = new ICAL.Component(ICAL.parse(text));
+ if (root.name !== 'vcalendar') throw new Error('This is not an iCalendar VCALENDAR file.');
+ const components = root.getAllSubcomponents('vevent');
+ if (!components.length) throw new Error('No calendar events were found. Tasks and journals are not exported.');
+ if (components.length > 5000) throw new Error('Use a calendar with up to 5,000 event records.');
+ const events = components.map(component => {
+ for (const property of component.getAllProperties()) {
+ const zone = property.getParameter('tzid');
+ if (zone && zone !== 'UTC' && !root.getTimeZoneByID(String(zone))) throw new Error(`An event uses a time zone without an embedded VTIMEZONE definition. Re-export the calendar with time zone definitions included.`);
+ if (property.name === 'rdate' && property.type === 'period') throw new Error('RDATE periods are not supported. Export this calendar as individual events first.');
+ }
+ if (component.getFirstProperty('recurrence-id')?.getParameter('range')) throw new Error('This calendar uses range-based recurrence exceptions, which this exporter cannot safely expand.');
+ if (!component.hasProperty('dtstart') && String(component.getFirstPropertyValue('status')).toUpperCase() !== 'CANCELLED') throw new Error('An event has no start time. Repair it before exporting.');
+ return new ICAL.Event(component, { exceptions: [] });
+ });
+ const masters = events.filter(event => !event.isRecurrenceException());
+ const masterIds = new Set(masters.map(event => event.uid));
+ if (masterIds.size !== masters.length || masters.some(event => !event.uid)) throw new Error('Event IDs are missing or duplicated. Re-export a single current calendar.');
+ const exceptions = events.filter(event => event.isRecurrenceException());
+ if (exceptions.some(event => !masterIds.has(event.uid))) throw new Error('A recurrence exception has no parent event in this file. Export the full calendar first.');
+ const toDate = (time: InstanceType
) => {
+ if (time.isDate || (time.zone === ICAL.Timezone.localTimezone && options.floating === 'utc')) return new Date(Date.UTC(time.year, time.month - 1, time.day, time.hour, time.minute, time.second));
+ return time.toJSDate();
+ };
+ const occurrenceKey = (uid: string, time: InstanceType) => `${uid}:${time.isDate ? 'date' : 'time'}:${toDate(time).getTime()}`;
+ const overrides = new Set(exceptions.map(event => occurrenceKey(event.uid, event.recurrenceId)));
+ if (overrides.size !== exceptions.length) throw new Error('Duplicate recurrence exceptions were found. Re-export a single current calendar.');
+ const blocks: BusyBlock[] = []; let skipped = root.getAllSubcomponents().filter(component => component.name !== 'vevent' && component.name !== 'vtimezone').length; let expanded = 0;
+ const add = (event: InstanceType, start: InstanceType, end: InstanceType) => {
+ const status = String(event.component.getFirstPropertyValue('status') ?? '').toUpperCase();
+ const transparency = String(event.component.getFirstPropertyValue('transp') ?? '').toUpperCase();
+ if (status === 'CANCELLED' || transparency === 'TRANSPARENT') { skipped++; return; }
+ const first = toDate(start); const last = toDate(end);
+ if (!Number.isFinite(first.getTime()) || !Number.isFinite(last.getTime()) || last < first) throw new Error('An event has invalid or inverted dates.');
+ if (last.getTime() === first.getTime()) { skipped++; return; }
+ if (first >= dateEnd || last <= dateStart) return;
+ const from = new Date(Math.max(first.getTime(), dateStart.getTime())); const through = new Date(Math.min(last.getTime(), dateEnd.getTime()));
+ blocks.push({ start: start.isDate ? from.toISOString().slice(0,10) : from.toISOString(), end: start.isDate ? through.toISOString().slice(0,10) : through.toISOString(), allDay: start.isDate });
+ if (blocks.length > 10000) throw new Error('This range contains more than 10,000 busy blocks. Choose a smaller range.');
+ };
+ for (const event of masters) {
+ if (String(event.component.getFirstPropertyValue('status') ?? '').toUpperCase() === 'CANCELLED') { skipped++; continue; }
+ if (!event.isRecurring()) { add(event, event.startDate, event.endDate); continue; }
+ const iterator = event.iterator(); let occurrence;
+ while ((occurrence = iterator.next())) {
+ if (++expanded > 25000) throw new Error('Recurrence expansion exceeded 25,000 occurrences. Use a more recent calendar export or a smaller source calendar.');
+ if (toDate(occurrence) >= dateEnd) break;
+ if (overrides.has(occurrenceKey(event.uid, occurrence))) continue;
+ const end = occurrence.clone(); end.addDuration(event.duration); add(event, occurrence, end);
+ }
+ }
+ for (const event of exceptions) {
+ if (String(event.component.getFirstPropertyValue('status') ?? '').toUpperCase() === 'CANCELLED') { skipped++; continue; }
+ add(event, event.startDate, event.endDate);
+ }
+ blocks.sort((a,b) => a.start.localeCompare(b.start));
+ const stamp = compact(new Date());
+ const lines = ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//ToolStack//Private Busy Snapshot//EN','CALSCALE:GREGORIAN'];
+ for (const block of blocks) {
+ lines.push('BEGIN:VEVENT', `UID:${crypto.randomUUID()}@toolstack.invalid`, `DTSTAMP:${stamp}`, 'SUMMARY:Busy', 'TRANSP:OPAQUE');
+ if (block.allDay) lines.push(`DTSTART;VALUE=DATE:${block.start.replaceAll('-','')}`, `DTEND;VALUE=DATE:${block.end.replaceAll('-','')}`);
+ else lines.push(`DTSTART:${compact(new Date(block.start))}`, `DTEND:${compact(new Date(block.end))}`);
+ lines.push('END:VEVENT');
+ }
+ lines.push('END:VCALENDAR');
+ return { ics: lines.join('\r\n') + '\r\n', blocks, skipped, summary: `Busy time snapshot: ${options.from} through ${options.through} (UTC date range)\n${blocks.length} blocks. All times below are UTC; all-day end dates are exclusive.\n` + blocks.map(block => block.allDay ? `${block.start} โ ${block.end} (all day, exclusive end)` : `${block.start} โ ${block.end}`).join('\n') };
+}
diff --git a/src/lib/calendar.worker.ts b/src/lib/calendar.worker.ts
new file mode 100644
index 0000000..64c7645
--- /dev/null
+++ b/src/lib/calendar.worker.ts
@@ -0,0 +1,5 @@
+import { createPrivateCalendar, type CalendarOptions } from './calendar-privacy';
+self.onmessage = (event: MessageEvent<{ text: string; options: CalendarOptions }>) => {
+ try { self.postMessage({ result: createPrivateCalendar(event.data.text, event.data.options) }); }
+ catch (err) { self.postMessage({ error: err instanceof Error ? err.message : 'Calendar processing failed.' }); }
+};
diff --git a/src/lib/curl-converter.test.ts b/src/lib/curl-converter.test.ts
new file mode 100644
index 0000000..5eea1a1
--- /dev/null
+++ b/src/lib/curl-converter.test.ts
@@ -0,0 +1,18 @@
+import { describe,expect,it } from 'vitest';
+import { curlToFetch,parseCurl,tokenizeCurl } from './curl-converter';
+describe('cURL conversion',() => {
+ it('parses POSIX continuations, headers, JSON and query values and defaults to redaction',() => {
+ const command = ["curl 'https://example.invalid/orders?token=secret-query&token=another'", "-H 'Authorization: Bearer private-key'", `--json '{"email":"person@example.invalid","nested":[123,true,null]}'`].join(' \\\n');
+ const request = parseCurl(command); expect(request.method).toBe('POST'); expect(request.headers.authorization).toBe('Bearer private-key');
+ const result = curlToFetch(command); expect(result.code).toContain(['fetch','('].join('')); expect(result.code).toContain('[REDACTED]');
+ for (const secret of ['secret-query','private-key','person@example.invalid','another']) expect(result.code).not.toContain(secret);
+ expect(result.code).toContain('"credentials": "omit"'); expect(curlToFetch(command,false).code).toContain('private-key');
+ });
+ it('converts GET query data and basic auth, omits browser headers and supports literal escaped values',() => {
+ const request = parseCurl("curl -G 'https://example.invalid' -d 'q=hello' -u 'test:password' -H 'Cookie: private' -H '__proto__: literal'");
+ expect(request.url).toBe('https://example.invalid/?q=hello'); expect(request.body).toBeUndefined(); expect(request.headers.authorization).toContain('Basic '); expect(Object.hasOwn(request.headers,'cookie')).toBe(false); expect(Object.hasOwn(request.headers,'__proto__')).toBe(true); expect(request.warnings.length).toBe(1);
+ expect(tokenizeCurl(`curl 'https://example.invalid' --data-raw 'a=$literal'`)[3]).toBe('a=$literal');
+ expect(curlToFetch("curl 'https://example.invalid' --data-raw 'private=secret'").code).not.toContain('private=secret');
+ });
+ it.each(["curl $(whoami)","curl https://example.invalid; echo secret","curl 'https://example.invalid' --data @private.txt","curl 'https://example.invalid' --insecure","curl 'file:///private'","curl 'https://user:password@example.invalid'","curl 'https://example.invalid' -X GET -d 'body'","curl 'https://example.invalid' --json 'broken'","curl 'https://example.invalid' | sh",'curl "https://example.invalid/$TOKEN"'])('rejects unsupported or unsafe input %s',command => { expect(() => curlToFetch(command)).toThrow(); });
+});
diff --git a/src/lib/curl-converter.ts b/src/lib/curl-converter.ts
new file mode 100644
index 0000000..99d232c
--- /dev/null
+++ b/src/lib/curl-converter.ts
@@ -0,0 +1,104 @@
+export type CurlRequest = { url: string; method: string; headers: Record; body?: string; warnings: string[] };
+const FORBIDDEN = /^(?:accept-charset|accept-encoding|access-control-request-headers|access-control-request-method|connection|content-length|cookie|cookie2|date|dnt|expect|host|keep-alive|origin|permissions-policy|referer|set-cookie|te|trailer|transfer-encoding|upgrade|user-agent|via|proxy-.*|sec-.*)$/i;
+
+/** Tokenize a limited POSIX command as data. Nothing is evaluated or executed. */
+export function tokenizeCurl(input: string): string[] {
+ if (input.length > 100000) throw new Error('Use a cURL command up to 100 KB.');
+ const tokens: string[] = []; let word = ''; let started = false; let quote = '';
+ const flush = () => { if (started) tokens.push(word); word = ''; started = false; };
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i];
+ if (quote === "'") { if (char === "'") quote = ''; else word += char; continue; }
+ if (char === '\\') {
+ if (i + 1 >= input.length) throw new Error('The command ends with an incomplete escape.');
+ if (input[i + 1] === '\n') { i++; continue; }
+ if (input[i + 1] === '\r' && input[i + 2] === '\n') { i += 2; continue; }
+ const next = input[++i];
+ if (quote === '"' && !['"','\\','$','`'].includes(next)) word += '\\';
+ word += next; started = true; continue;
+ }
+ if (char === '$' || char === '`') throw new Error('Shell variables and command substitutions are unsupported. Paste literal values in single quotes.');
+ if (quote === '"') { if (char === '"') quote = ''; else word += char; continue; }
+ if (char === "'" || char === '"') { quote = char; started = true; continue; }
+ if (/\s/.test(char)) { flush(); continue; }
+ if (/[|&;<>()[\]^]/.test(char) || (!started && char === '#')) throw new Error('Use one POSIX cURL command without shell operators. Quote URLs containing &, brackets or parentheses.');
+ word += char; started = true;
+ }
+ if (quote) throw new Error('A quoted argument is not closed.');
+ flush(); return tokens;
+}
+
+export function parseCurl(input: string): CurlRequest {
+ const tokens = tokenizeCurl(input);
+ if (!/^(?:curl|curl\.exe)$/.test(tokens.shift() ?? '')) throw new Error('Start with curl followed by an HTTP or HTTPS URL.');
+ let url = ''; let method = ''; let get = false; let head = false; let json = false; const bodies: string[] = []; const headers: Record = Object.create(null); const warnings: string[] = [];
+ const addHeader = (name: string, value: string) => {
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[\r\n]/.test(value)) throw new Error('A header has an invalid name or a line break.');
+ const key = name.toLowerCase();
+ if (FORBIDDEN.test(key)) { warnings.push(`${name} is controlled by the browser and is omitted.`); return; }
+ headers[key] = Object.hasOwn(headers,key) ? `${headers[key]}, ${value}` : value;
+ };
+ for (let i = 0; i < tokens.length; i++) {
+ let token = tokens[i]; let inline: string | undefined;
+ if (token.startsWith('--') && token.includes('=')) { const split = token.indexOf('='); inline = token.slice(split + 1); token = token.slice(0,split); }
+ else if (/^-[XHdubAe].+/.test(token)) { inline = token.slice(2); token = token.slice(0,2); }
+ const argument = () => { const value = inline ?? tokens[++i]; if (value === undefined) throw new Error(`${token} needs a value.`); return value; };
+ if (token === '-X' || token === '--request') { method = argument().toUpperCase(); if (!/^[A-Z]+$/.test(method) || ['CONNECT','TRACE','TRACK'].includes(method)) throw new Error('Use a method supported by browser requests, such as GET, POST, PUT, PATCH or DELETE.'); }
+ else if (token === '--url') { if (url) throw new Error('Use one URL per conversion.'); url = argument(); }
+ else if (token === '-H' || token === '--header') { const value = argument(); const split = value.indexOf(':'); if (split <= 0) throw new Error('Headers must use Name: value. Header-file references are unsupported.'); addHeader(value.slice(0,split).trim(),value.slice(split + 1).trim()); }
+ else if (['-d','--data','--data-raw','--data-binary','--json'].includes(token)) {
+ const value = argument(); if (value.startsWith('@') && token !== '--data-raw') throw new Error('File references are unsupported. Paste the request body directly.');
+ if (token === '--json') { if (bodies.length && !json) throw new Error('Do not mix --json and other body flags.'); json = true; }
+ else if (json) throw new Error('Do not mix --json and other body flags.');
+ bodies.push(value);
+ } else if (token === '-u' || token === '--user') {
+ const auth = argument(); if (!auth.includes(':')) throw new Error('Provide user:password as a literal value; interactive password prompts are unsupported.');
+ const bytes = new TextEncoder().encode(auth); let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); addHeader('Authorization',`Basic ${btoa(binary)}`);
+ } else if (token === '-b' || token === '--cookie' || token === '-A' || token === '--user-agent' || token === '-e' || token === '--referer') { argument(); warnings.push(`${token} is omitted because browsers control cookies, user agents and referrers.`); }
+ else if (token === '-G' || token === '--get') get = true;
+ else if (token === '-I' || token === '--head') head = true;
+ else if (token === '-L' || token === '--location') { /* Browser requests follow redirects by default. */ }
+ else if (['--compressed','--silent','--show-error','--fail','--http1.1','--http2','--http3'].includes(token) || /^-[sSfL]+$/.test(token)) warnings.push(`${token} is a cURL transport/output option; it has no direct effect in the generated code.`);
+ else if (token.startsWith('-')) throw new Error(`Unsupported option: ${token}. File uploads, shell expansion, TLS overrides and output flags are not converted.`);
+ else { if (url) throw new Error('Use one URL per conversion.'); url = token; }
+ if (inline !== undefined && !['-X','--request','--url','-H','--header','-d','--data','--data-raw','--data-binary','--json','-u','--user','-b','--cookie','-A','--user-agent','-e','--referer'].includes(token)) throw new Error(`${token} does not accept an inline value.`);
+ }
+ let parsed: URL;
+ try { parsed = new URL(url); } catch { throw new Error('Provide a valid HTTP or HTTPS URL.'); }
+ if (!['http:','https:'].includes(parsed.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
+ if (parsed.username || parsed.password) throw new Error('URLs with embedded credentials are unsupported. Use an Authorization header instead.');
+ if (/[{}]/.test(parsed.pathname)) throw new Error('cURL URL globbing is unsupported. Use a single expanded URL.');
+ parsed.hash = '';
+ const body = bodies.length ? bodies.join(json ? '' : '&') : undefined;
+ if (get && json) throw new Error('Do not combine --get with --json.');
+ if (get && body !== undefined) parsed.search += `${parsed.search ? '&' : '?'}${body}`;
+ const resolvedMethod = method || (head ? 'HEAD' : get ? 'GET' : body !== undefined ? 'POST' : 'GET');
+ if (body !== undefined && !get && ['GET','HEAD'].includes(resolvedMethod)) throw new Error('Browser GET/HEAD requests cannot have a body. Use --get for query parameters or a body-capable method.');
+ if (body !== undefined && !get) {
+ if (!Object.hasOwn(headers,'content-type')) headers['content-type'] = json ? 'application/json' : 'application/x-www-form-urlencoded';
+ if (json && !Object.hasOwn(headers,'accept')) headers.accept = 'application/json';
+ }
+ return { url: parsed.toString(), method: resolvedMethod, headers, ...(body !== undefined && !get ? { body } : {}), warnings: [...new Set(warnings)] };
+}
+
+export function curlToFetch(input: string, redact = true) {
+ const request = parseCurl(input); const url = new URL(request.url);
+ const hideValues = (value: unknown, depth = 0): unknown => {
+ if (depth > 35) throw new Error('The JSON body is nested too deeply.');
+ if (Array.isArray(value)) return value.map(item => hideValues(item,depth + 1));
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key,item]) => [key,hideValues(item,depth + 1)]));
+ return '[REDACTED]';
+ };
+ if (redact) for (const name of [...new Set(url.searchParams.keys())]) { const values = url.searchParams.getAll(name); url.searchParams.delete(name); values.forEach(() => url.searchParams.append(name,'[REDACTED]')); }
+ const headers = redact ? Object.fromEntries(Object.entries(request.headers).map(([name,value]) => [name, ['content-type','accept'].includes(name) ? value : '[REDACTED]'])) : request.headers;
+ let body = request.body;
+ if (redact && body !== undefined) {
+ if (/\bapplication\/(?:[\w.-]+\+)?json\b/i.test(request.headers['content-type'] ?? '')) {
+ let value: unknown; try { value = JSON.parse(body); } catch { throw new Error('The body is marked as JSON but is invalid. Fix it before producing a redacted example.'); }
+ body = JSON.stringify(hideValues(value));
+ } else body = '[REDACTED_BODY]';
+ }
+ const init = { method: request.method, ...(Object.keys(headers).length ? { headers } : {}), ...(body !== undefined ? { body } : {}), credentials: 'omit' };
+ const call = ['fetch','('].join('');
+ return { code: `const response = await ${call}${JSON.stringify(url.toString())}, ${JSON.stringify(init,null,2)});\n`, warnings: request.warnings };
+}
diff --git a/src/lib/dataset-mask.test.ts b/src/lib/dataset-mask.test.ts
new file mode 100644
index 0000000..55cb3a7
--- /dev/null
+++ b/src/lib/dataset-mask.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { maskDatasets, parseDataset } from './dataset-mask';
+
+describe('dataset pseudonymization', () => {
+ it('preserves repeated identifiers across different files and column names with the same group', () => {
+ const source = [parseDataset('customers.json', '[{"id":42,"email":"private@example.com"}]'), parseDataset('orders.json', '[{"customer_id":42,"total":5},{"customer_id":42,"total":6}]')];
+ const result = maskDatasets(source, { id: { mode: 'alias', group: 'customer' }, customer_id: { mode: 'alias', group: 'customer' }, email: { mode: 'remove', group: '' } }, 0);
+ const customer = JSON.parse(result[0].text); const orders = JSON.parse(result[1].text);
+ expect(customer[0].id).toBe(orders[0].customer_id); expect(orders[1].customer_id).toBe(customer[0].id);
+ expect(customer[0].email).toBeUndefined(); expect(orders[0].total).toBe(5); expect(result[1].rows).toBe(2);
+ });
+ it('retains blank/null values and shifts dates consistently through a leap day', () => {
+ const source = parseDataset('dates.json', '[{"date":"2024-02-28","id":null},{"date":"2024-03-01","id":""}]');
+ const rows = JSON.parse(maskDatasets([source], { date: { mode: 'date', group: '' }, id: { mode: 'alias', group: 'id' } }, 1)[0].text);
+ expect(rows[0]).toEqual({ date: '2024-02-29', id: null }); expect(rows[1]).toEqual({ date: '2024-03-02', id: '' });
+ });
+ it('rejects invalid dates and dates without explicit time zones', () => {
+ const rules = { date: { mode: 'date' as const, group: '' } };
+ for (const value of ['2025-02-30', 'not a date', '2026-01-01T10:00:00']) expect(() => maskDatasets([parseDataset('a.json', JSON.stringify([{ date: value }]))], rules, 1)).toThrow();
+ });
+ it('rejects duplicate CSV headers and invalid JSON row structures', () => {
+ expect(() => parseDataset('a.csv', 'id,id\n1,2')).toThrow(/duplicate/);
+ expect(() => parseDataset('a.json', '[1,2]')).toThrow(/row objects/);
+ });
+ it('escapes spreadsheet formulas on CSV export', () => {
+ expect(maskDatasets([parseDataset('a.csv', 'value\n=1+2')], {}, 0)[0].text).toContain("'=1+2");
+ });
+});
diff --git a/src/lib/dataset-mask.ts b/src/lib/dataset-mask.ts
new file mode 100644
index 0000000..bec624b
--- /dev/null
+++ b/src/lib/dataset-mask.ts
@@ -0,0 +1,68 @@
+import Papa from 'papaparse';
+
+export type Dataset = { name: string; format: 'csv' | 'json'; rows: Record[]; columns: string[] };
+export type ColumnRule = { mode: 'keep' | 'remove' | 'mask' | 'alias' | 'date' | 'band'; group: string };
+export type DatasetResult = { name: string; text: string; rows: number; columns: number };
+
+export function parseDataset(name: string, text: string): Dataset {
+ let rows: Record[];
+ let columns: string[];
+ const format = name.toLowerCase().endsWith('.json') ? 'json' : 'csv';
+ if (format === 'json') {
+ const data: unknown = JSON.parse(text);
+ if (!Array.isArray(data) || data.some(row => !row || typeof row !== 'object' || Array.isArray(row))) throw new Error('JSON must be an array of row objects.');
+ rows = data as Record[];
+ columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
+ } else {
+ const parsed = Papa.parse>(text, { header: true, skipEmptyLines: 'greedy', dynamicTyping: false });
+ const parseError = parsed.errors.find(error => error.code !== 'UndetectableDelimiter');
+ if (parseError) throw new Error(`CSV could not be read: ${parseError.message}`);
+ const renamed = (parsed.meta as typeof parsed.meta & { renamedHeaders?: Record }).renamedHeaders;
+ if (renamed && Object.keys(renamed).length) throw new Error('CSV has duplicate column names. Rename them before masking.');
+ rows = parsed.data; columns = parsed.meta.fields ?? [];
+ }
+ if (!rows.length || !columns.length) throw new Error('The dataset needs a header and at least one row.');
+ if (rows.length > 50000 || columns.length > 200) throw new Error('Use up to 50,000 rows and 200 columns per file.');
+ return { name, format, rows, columns };
+}
+
+export function maskDatasets(datasets: Dataset[], rules: Record, shiftDays: number): DatasetResult[] {
+ if (!Number.isInteger(shiftDays) || Math.abs(shiftDays) > 36500) throw new Error('Date shift must be a whole number between -36500 and 36500 days.');
+ const maps = new Map>();
+ let totalRows = 0;
+ const results = datasets.map((dataset, fileIndex) => {
+ totalRows += dataset.rows.length;
+ if (totalRows > 100000) throw new Error('Use up to 100,000 rows across the batch.');
+ const columns = dataset.columns.filter(column => rules[column]?.mode !== 'remove');
+ if (!columns.length) throw new Error(`File ${fileIndex + 1}: at least one column must remain.`);
+ const rows = dataset.rows.map((row, rowIndex) => Object.fromEntries(columns.filter(column => Object.hasOwn(row, column)).map(column => {
+ const value = row[column]; const rule = rules[column] ?? { mode: 'keep', group: column };
+ if (rule.mode === 'keep' || value === null || value === '') return [column, value];
+ if (rule.mode === 'mask') return [column, '[REDACTED]'];
+ if (rule.mode === 'alias') {
+ const group = rule.group.trim() || column;
+ let mapping = maps.get(group); if (!mapping) { mapping = new Map(); maps.set(group, mapping); }
+ const key = typeof value === 'object' ? JSON.stringify(value) : String(value);
+ if (!mapping.has(key)) mapping.set(key, `VALUE_${String(mapping.size + 1).padStart(4, '0')}`);
+ return [column, mapping.get(key)];
+ }
+ const fail = (message: string): never => { throw new Error(`File ${fileIndex + 1}, row ${rowIndex + 1}, column "${column}": ${message}`); };
+ if (rule.mode === 'band') {
+ if (!['number', 'string'].includes(typeof value) || String(value).trim() === '' || !Number.isFinite(Number(value))) return fail('10-value bands require numeric values.');
+ const low = Math.floor(Number(value) / 10) * 10;
+ return [column, `${low}โ${low + 9}`];
+ }
+ if (rule.mode === 'date') {
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/.test(value)) return fail('use ISO dates (YYYY-MM-DD) or timestamps with an explicit time zone.');
+ const time = new Date(value.length === 10 ? `${value}T00:00:00Z` : value);
+ const datePart = value.slice(0,10); const day = new Date(`${datePart}T00:00:00Z`);
+ if (!Number.isFinite(time.getTime()) || !Number.isFinite(day.getTime()) || day.toISOString().slice(0,10) !== datePart) return fail('invalid ISO date.');
+ time.setUTCDate(time.getUTCDate() + shiftDays);
+ return [column, value.length === 10 ? time.toISOString().slice(0, 10) : time.toISOString()];
+ }
+ return [column, value];
+ })));
+ return { name: `dataset-${fileIndex + 1}.${dataset.format}`, text: dataset.format === 'csv' ? Papa.unparse({ fields: columns, data: rows }, { escapeFormulae: true }) : JSON.stringify(rows, null, 2), rows: rows.length, columns: columns.length };
+ });
+ return results;
+}
diff --git a/src/lib/diff-redaction.ts b/src/lib/diff-redaction.ts
new file mode 100644
index 0000000..f3c8ead
--- /dev/null
+++ b/src/lib/diff-redaction.ts
@@ -0,0 +1,54 @@
+const SECRET_KEY = /password|passwd|(?:^|_)pwd(?:$|_)|secret|token|authorization|cookie|api[-_]?key|private[-_]?key|session[-_]?id|credential/i;
+export function sensitiveDiffKey(key: string) { return SECRET_KEY.test(key); }
+
+/** Per-comparison aliases preserve equality, without exporting originals or an alias map. */
+export function createDiffRedactor(literals: string[]) {
+ const aliases = new Map(); let count = 0;
+ const hide = (value: string) => {
+ if (!aliases.has(value)) aliases.set(value,`[PRIVATE_${++count}]`);
+ return aliases.get(value)! + (value.match(/\r\n|\r|\n/g) ?? []).map(eol => eol + '[PRIVATE_LINE]').join('');
+ };
+ const text = (value: string): string => {
+ const source = value.replace(/\[PRIVATE_(\d+)\]/g,'[LITERAL_PRIVATE_$1]');
+ let output = source;
+ // Use source offsets for valid JSON/JSONC, including entire secret objects/arrays.
+ // Replacing from the end retains source positions, and hide() retains line endings.
+ if (/^\s*[\[{]/.test(source) && source.length <= 1024 * 1024) {
+ try {
+ const errors: ParseError[] = []; const tree = parseTree(source.replace(/^\uFEFF/,' '),errors); const ranges: { from: number; to: number }[] = []; let budget = 0;
+ const scan = (node: Node,depth: number) => {
+ if (depth > 50 || ++budget > 50000) throw new Error('Masking budget reached');
+ if (node.type === 'object') for (const property of node.children ?? []) { const [key,child] = property.children!; if (SECRET_KEY.test(String(key.value))) ranges.push({ from: child.offset,to: child.offset + child.length }); else scan(child,depth + 1); }
+ else if (node.type === 'array') for (const child of node.children ?? []) scan(child,depth + 1);
+ };
+ if (tree && !errors.length) { scan(tree,0); for (const range of ranges.sort((a,b) => b.from - a.from)) output = output.slice(0,range.from) + hide(source.slice(range.from,range.to)) + output.slice(range.to); }
+ } catch { /* Non-JSON text falls through to the literal and pattern rules. */ }
+ }
+ output = output.replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/g,hide);
+ output = output.replace(/((?:["']?)[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie|credential)[\w.-]*["']?\s*[:=]\s*)("(?:\\.|[^"\\])*"|'[^']*'|[^\s,;\r\n]+)/gi,(_,prefix: string,secret: string) => prefix + hide(secret));
+ output = output.replace(/^(\s*(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*)[^\r\n]*/gim,(_,prefix: string) => prefix + hide(_.slice(prefix.length)));
+ output = output.replace(/\b(Bearer|Basic)\s+[^\s"',;]+/gi,(_,scheme: string) => scheme + ' ' + hide(_));
+ output = output.replace(/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b|\bsk-[A-Za-z0-9_-]{20,}\b/g,hide);
+ output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,hide);
+ output = output.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g,hide);
+ output = output.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,hide);
+ output = output.replace(/([A-Z]:\\Users\\|\/Users\/|\/home\/)([^\s/\\]+)/gi,(_,prefix: string,name: string) => prefix + hide(name));
+ // Local URLs are never opened. Mask login information and every query value.
+ output = output.replace(/https?:\/\/[^\s<>"']+/gi,match => {
+ try { const url = new URL(match); if (url.username || url.password) { url.username = hide(url.username + ':' + url.password); url.password = ''; } for (const key of [...new Set(url.searchParams.keys())]) { const values = url.searchParams.getAll(key); url.searchParams.delete(key); values.forEach(value => url.searchParams.append(key,hide(value))); } url.hash = url.hash ? hide(url.hash) : ''; return url.toString(); } catch { return match; }
+ });
+ // Run literal rules last, so masking a field name cannot disable its automatic value rule.
+ for (const literal of [...new Set(literals)].filter(Boolean).sort((a,b) => b.length - a.length)) output = output.split(literal).join(hide(literal));
+ return output;
+ };
+ const value = (input: unknown,key = '',depth = 0): unknown => {
+ if (depth > 50) throw new Error('Use data with at most 50 nesting levels.');
+ if (SECRET_KEY.test(key)) return hide(JSON.stringify(input));
+ if (typeof input === 'string') return text(input);
+ if (Array.isArray(input)) return input.map(item => value(item,'',depth + 1));
+ if (input && typeof input === 'object') return Object.fromEntries(Object.entries(input).map(([name,item]) => [text(name),value(item,name,depth + 1)]));
+ return input;
+ };
+ return { text,value,hide,count: () => count };
+}
+import { parseTree,type ParseError,type Node } from 'jsonc-parser';
diff --git a/src/lib/diff-report.ts b/src/lib/diff-report.ts
new file mode 100644
index 0000000..c1f6071
--- /dev/null
+++ b/src/lib/diff-report.ts
@@ -0,0 +1,12 @@
+import type { CompareResult } from './file-diff-types';
+import { visibleDiffText } from './file-diff';
+
+const escapeHtml = (value: string) => visibleDiffText(value).replace(/[&<>"']/g,char => ({ '&': '&','<': '<','>': '>','"': '"',"'": ''' })[char]!);
+/** Only selected, already-redacted fields are serialized. No source documents, filenames or alias maps. */
+export function buildDiffReport(result: CompareResult,ids: string[],includeContext: boolean) {
+ const selected = new Set(ids); const changes = result.entries.filter(entry => selected.has(entry.id));
+ if (!changes.length) throw new Error('Select at least one change for the report.');
+ const text = ['ToolStack โ reviewed diff',`Mode: ${result.mode}`,`${changes.length} selected change blocks. Detected values masked; field names and other unrecognized text may remain.`,...(result.rules.length ? ['Comparison rules:',...result.rules.map(rule => '- ' + rule)] : []),'',...changes.flatMap(entry => [visibleDiffText(entry.path) + ` ยท ${entry.kind}${entry.ignored ? ' ยท ignored by rules' : ''}${entry.expected === false ? ' ยท unexpected' : entry.expected === true ? ' ยท expected' : ''}`,...entry.reasons,'LEFT',visibleDiffText(includeContext ? entry.contextBefore : entry.before) || '(absent)','RIGHT',visibleDiffText(includeContext ? entry.contextAfter : entry.after) || '(absent)',''])].join('\n');
+ const html = `Reviewed diff โ ToolStack Reviewed diff ${changes.length} selected change blocks ยท ${escapeHtml(result.mode)} ยท processed locally with ToolStack
Detected values are masked. Field names, paths and unrecognized information may remain. This is a reviewed excerpt, not a complete comparison or a patch.
${result.rules.length ? `Comparison rules ${result.rules.map(rule => `${escapeHtml(rule)} `).join('')} ` : ''}${changes.map(entry => `${escapeHtml(entry.path)} ยท ${entry.kind}${entry.ignored ? ' ยท ignored by rules' : ''}${entry.expected === false ? ' ยท unexpected' : entry.expected === true ? ' ยท expected' : ''} ${entry.reasons.map(reason => `${escapeHtml(reason)}
`).join('')}Left ${escapeHtml(includeContext ? entry.contextBefore : entry.before) || '(absent)'} Right ${escapeHtml(includeContext ? entry.contextAfter : entry.after) || '(absent)'} `).join('')}`;
+ return { text,html };
+}
diff --git a/src/lib/file-diff-types.ts b/src/lib/file-diff-types.ts
new file mode 100644
index 0000000..74afe3e
--- /dev/null
+++ b/src/lib/file-diff-types.ts
@@ -0,0 +1,40 @@
+export type CompareMode = 'text' | 'json' | 'csv' | 'env';
+export type CompareOptions = {
+ mode: CompareMode; whitespace: 'exact' | 'trim' | 'all'; ignoreCase: boolean; ignoreEol: boolean;
+ timestamps: boolean; uuids: boolean; ignoreLiterals: string[];
+ ignorePaths: string[]; expectedPaths: string[]; keys: string[]; numericTolerance: number;
+ redactions: string[]; context: number;
+};
+export const DEFAULT_COMPARE: CompareOptions = { mode: 'text',whitespace: 'exact',ignoreCase: false,ignoreEol: false,timestamps: false,uuids: false,ignoreLiterals: [],ignorePaths: [],expectedPaths: [],keys: [],numericTolerance: 0,redactions: [],context: 1 };
+export type CompareEntry = {
+ id: string; path: string; kind: 'added' | 'removed' | 'changed' | 'moved'; ignored: boolean; expected: boolean | null;
+ before: string; after: string; contextBefore: string; contextAfter: string; lineA?: number; lineB?: number; reasons: string[];
+};
+export type TextInfo = { characters: number; lines: number; lf: number; crlf: number; cr: number; finalNewline: boolean; bom: boolean; unusual: { code: string; name: string; count: number; line: number; column: number }[] };
+export type CompareResult = {
+ entries: CompareEntry[]; meaningful: number; ignored: number; expected: number; unexpected: number; reordered: number;
+ left: string; right: string; exactLeft: string; exactRight: string; infoA: TextInfo; infoB: TextInfo;
+ warnings: string[]; rules: string[]; maskedValues: number; mode: CompareMode;
+};
+export const MAX_DIFF_BYTES = 1024 * 1024;
+export function checkDiffInput(text: string) {
+ if (text.length > MAX_DIFF_BYTES || new TextEncoder().encode(text).length > MAX_DIFF_BYTES) throw new Error('Use at most 1 MB of text per side.');
+ const lines = (text.match(/\r\n|\r|\n/g)?.length ?? 0) + (text && !/[\r\n]$/.test(text) ? 1 : 0);
+ if (lines > 20000) throw new Error('Use at most 20,000 lines per side.');
+ if (text.includes('\0')) throw new Error('This looks like binary data. Compare text files, or decode a UTF-16 file with a BOM.');
+}
+
+export function decodeDiffFile(bytes: Uint8Array): string {
+ if (bytes.length > MAX_DIFF_BYTES) throw new Error('Use text files up to 1 MB each.');
+ const utf16 = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8';
+ let text: string;
+ try { text = new TextDecoder(utf16,{ fatal: true,ignoreBOM: true }).decode(bytes); } catch { throw new Error('Use UTF-8, or UTF-16 with a byte-order mark. This file could not be decoded losslessly.'); }
+ checkDiffInput(text); return text;
+}
+
+export function encodeDiffFile(text: string,encoding: 'utf-8' | 'utf-16le' | 'utf-16be') {
+ if (encoding === 'utf-8') return new TextEncoder().encode(text);
+ const bytes = new Uint8Array(text.length * 2); const view = new DataView(bytes.buffer);
+ for (let i = 0; i < text.length; i++) view.setUint16(i * 2,text.charCodeAt(i),encoding === 'utf-16le');
+ return bytes;
+}
diff --git a/src/lib/file-diff.test.ts b/src/lib/file-diff.test.ts
new file mode 100644
index 0000000..8bdc689
--- /dev/null
+++ b/src/lib/file-diff.test.ts
@@ -0,0 +1,98 @@
+import { describe,expect,it } from 'vitest';
+import { applyPatch } from 'diff';
+import { compareFiles,diffTextInfo,matchesDiffPath,rawDiffPatch,visibleDiffText } from './file-diff';
+import { DEFAULT_COMPARE,checkDiffInput,decodeDiffFile,encodeDiffFile,type CompareOptions } from './file-diff-types';
+import { buildDiffReport } from './diff-report';
+
+const options = (update: Partial = {}): CompareOptions => ({ ...DEFAULT_COMPARE,...update });
+describe('private file comparisons',() => {
+ it('compares original secrets before masking, preserving changed and unchanged values',() => {
+ const result = compareFiles('API_KEY=private-old-token\nPASSWORD=same-private-password\n','API_KEY=private-new-token\nPASSWORD=same-private-password\n',options({ mode: 'env' }));
+ expect(result.meaningful).toBe(1); expect(result.entries[0].path).toBe('/API_KEY'); expect(result.entries[0].before).not.toBe(result.entries[0].after);
+ for (const secret of ['private-old-token','private-new-token','same-private-password']) expect(JSON.stringify(result)).not.toContain(secret);
+ const raw = compareFiles('password=old-secret\n','password=new-secret\n',options()); expect(raw.meaningful).toBe(1); expect(raw.entries[0].before).not.toEqual(raw.entries[0].after);
+ const literalKey = compareFiles('password=must-remain-private\n','password=another-private-value\n',options({ redactions: ['password'] })); expect(JSON.stringify(literalKey)).not.toContain('must-remain-private'); expect(JSON.stringify(literalKey)).not.toContain('another-private-value');
+ const marker = compareFiles('person@example.invalid','[PRIVATE_1]',options()); expect(marker.left).not.toBe(marker.right);
+ });
+ it('sanitizes full multiline secrets before extracting changes or nearby context',() => {
+ const before = 'start\n-----BEGIN PRIVATE KEY-----\nVERY_PRIVATE_BASE64_ONE\nVERY_PRIVATE_BASE64_TWO\n-----END PRIVATE KEY-----\nvalue=old\n';
+ const after = before.replace('value=old','value=new'); const result = compareFiles(before,after,options({ context: 5 }));
+ const report = buildDiffReport(result,result.entries.map(entry => entry.id),true);
+ for (const value of [result.left,result.exactLeft,report.html,report.text]) { expect(value).not.toContain('VERY_PRIVATE'); expect(value).not.toContain('BEGIN PRIVATE KEY'); }
+ expect(result.entries[0].lineA).toBe(6); expect(report.text).toContain('value=old');
+ const normalized = compareFiles(before,after,options({ whitespace: 'all',ignoreCase: true }));
+ expect(normalized.left.toLowerCase()).not.toContain('very_private_base64'); expect(normalized.right.toLowerCase()).not.toContain('very_private_base64');
+ });
+ it('audits excluded differences and preserves exact source metadata',() => {
+ const result = compareFiles('time=2026-01-01T09:00:00Z name=Alice\r\n','time=2026-01-02T09:00:00Z name=alice\n',options({ timestamps: true,ignoreCase: true,ignoreEol: true }));
+ expect(result.meaningful).toBe(0); expect(result.ignored).toBe(1); expect(result.entries[0].ignored).toBe(true); expect(result.exactLeft).toContain('Alice'); expect(result.left).toBe(result.right);
+ expect(result.infoA.crlf).toBe(1); expect(result.infoB.lf).toBe(1); expect(result.rules).toHaveLength(3);
+ });
+ it('does not silently ignore whitespace or final newlines by default',() => {
+ expect(compareFiles('same\n','same',options()).meaningful).toBe(1);
+ expect(compareFiles('a b\n','ab\n',options()).meaningful).toBe(1);
+ const ignored = compareFiles('a b\n','ab\n',options({ whitespace: 'all' })); expect(ignored.meaningful).toBe(0); expect(ignored.ignored).toBe(1);
+ expect(compareFiles('a\u00a0b','ab',options({ whitespace: 'all' })).meaningful).toBe(1);
+ });
+ it('uses exact and composite record keys, surfaces real fields and counts reordered records separately',() => {
+ const before = '[{"region":"us","id":1,"value":"a"},{"region":"eu","id":1,"value":"b"}]';
+ const after = '[{"id":1,"region":"eu","value":"b"},{"id":1,"region":"us","value":"updated"}]';
+ const result = compareFiles(before,after,options({ mode: 'json',keys: ['region','id'],expectedPaths: ['/*/value'] }));
+ expect(result.meaningful).toBe(1); expect(result.reordered).toBe(1); expect(result.expected).toBe(1); expect(result.unexpected).toBe(0);
+ expect(compareFiles(before,after.replace('updated','a'),options({ mode: 'json',keys: ['region','id'] })).meaningful).toBe(0);
+ expect(() => compareFiles(before,after,options({ mode: 'json',keys: ['id'] }))).toThrow(/Duplicate record/);
+ expect(() => compareFiles(before,after,options({ mode: 'json',keys: ['missing'] }))).toThrow(/nonempty/);
+ });
+ it('compares CSV cells as exact strings and handles quoting, column order and leading zeros',() => {
+ const before = 'id,note,code\r\n1,"a,b",001\r\n2,hello,002\r\n';
+ const after = 'code,note,id\n002,hello,2\n001,"a,b",1\n';
+ const result = compareFiles(before,after,options({ mode: 'csv',keys: ['id'] })); expect(result.meaningful).toBe(0); expect(result.reordered).toBe(1);
+ expect(compareFiles(before,after.replace('001','1'),options({ mode: 'csv',keys: ['id'] })).meaningful).toBe(1);
+ expect(() => compareFiles('id,id\n1,2','id,id\n1,2',options({ mode: 'csv' }))).toThrow(/unique/);
+ expect(() => compareFiles('id,a\n1','id,a\n1,2',options({ mode: 'csv' }))).toThrow(/field count/);
+ });
+ it('checks expectations only after explicit ignore rules and supports numeric tolerances',() => {
+ const result = compareFiles('{"timeout":30,"auth":true,"stamp":"a","value":1.001}','{"timeout":60,"auth":false,"stamp":"b","value":1.002}',options({ mode: 'json',expectedPaths: ['/timeout'],ignorePaths: ['/stamp'],numericTolerance: 0.01 }));
+ expect(result.meaningful).toBe(2); expect(result.ignored).toBe(2); expect(result.expected).toBe(1); expect(result.unexpected).toBe(1); expect(result.entries.find(entry => entry.path === '/auth')?.expected).toBe(false);
+ expect(matchesDiffPath('/records/a/email','/records/*/email')).toBe(true); expect(matchesDiffPath('/records/a/b/email','/records/*/email')).toBe(false); expect(matchesDiffPath('/records/a/b/email','/records/**')).toBe(true);
+ expect(result.entries.find(entry => entry.path === '/stamp')?.expected).toBeNull();
+ expect(compareFiles('1','1.01',options({ mode: 'json',numericTolerance: 0.01 })).meaningful).toBe(0);
+ expect(compareFiles('1000000000','1000000000.0000001',options({ mode: 'json',numericTolerance: 1e-8 })).meaningful).toBe(1);
+ });
+ it('rejects ambiguous structured input and keeps __proto__ as data',() => {
+ expect(() => compareFiles('{"a":1,"a":2}','{}',options({ mode: 'json' }))).toThrow(/Duplicate JSON/);
+ expect(() => compareFiles('{"id":9007199254740993}','{}',options({ mode: 'json' }))).toThrow(/precision/);
+ expect(() => compareFiles('TOKEN=a\nTOKEN=b','TOKEN=b',options({ mode: 'env' }))).toThrow(/Duplicate/);
+ expect(() => compareFiles('KEY="one"junk"two"','KEY=one',options({ mode: 'env' }))).toThrow(/quoted/);
+ const result = compareFiles('{"__proto__":{"polluted":1}}','{"__proto__":{"polluted":2}}',options({ mode: 'json' })); expect(result.entries[0].path).toBe('/__proto__/polluted'); expect(Object.hasOwn({},'polluted')).toBe(false);
+ });
+ it('does not expose sensitive matching keys or manually marked values in reports',() => {
+ const result = compareFiles('[{"token":"secret-record-id","name":"Private Customer","status":1}]','[{"token":"secret-record-id","name":"Private Customer","status":2}]',options({ mode: 'json',keys: ['token'],redactions: ['Private Customer'] }));
+ const report = buildDiffReport(result,result.entries.map(entry => entry.id),true);
+ expect(report.html).not.toContain('secret-record-id'); expect(report.text).not.toContain('Private Customer'); expect(JSON.stringify(result)).not.toContain('secret-record-id');
+ });
+ it('masks secret containers and CSV credential columns in every review representation',() => {
+ const jsonA = '{"secret":{"first":"do-not-leak-one","second":["do-not-leak-two"]},"ok":1}';
+ for (const mode of ['text','json'] as const) { const result = compareFiles(jsonA,jsonA.replace('"ok":1','"ok":2'),options({ mode })); expect(JSON.stringify(result)).not.toContain('do-not-leak'); }
+ const csv = compareFiles('id,password\n1,unrecognized-credential-one\n','id,password\n1,unrecognized-credential-two\n',options({ mode: 'csv',keys: ['id'] }));
+ expect(JSON.stringify(csv)).not.toContain('unrecognized-credential'); expect(csv.meaningful).toBe(1); expect(csv.entries[0].before).not.toBe(csv.entries[0].after);
+ });
+ it('exports only selected changes, escapes active HTML and never embeds hidden originals',() => {
+ const result = compareFiles('first=one\nstable\nsecond=old\n','first= \nstable\nsecond=NEW_PRIVATE\n',options({ redactions: ['NEW_PRIVATE'],context: 0 }));
+ expect(result.entries).toHaveLength(2); const report = buildDiffReport(result,[result.entries[0].id],false);
+ expect(report.html).toContain('<img'); expect(report.html).not.toContain(' buildDiffReport(result,[],false)).toThrow(/Select/);
+ });
+ it('creates exact applicable patches including CRLF and missing final newlines',() => {
+ for (const [before,after] of [['one\r\ntwo\r\n','one\r\nchanged\r\n'],['same\n','same'],['','new\n']]) expect(applyPatch(before,rawDiffPatch(before,after))).toBe(after);
+ });
+ it('round-trips supported file encodings without silently replacing bad bytes',() => {
+ const text = '\uFEFFHello ๐\r\n';
+ for (const encoding of ['utf-8','utf-16le','utf-16be'] as const) expect(decodeDiffFile(encodeDiffFile(text,encoding))).toBe(text);
+ expect(() => decodeDiffFile(new Uint8Array([0xc3,0x28]))).toThrow(/losslessly/); expect(() => checkDiffInput('a\0b')).toThrow(/binary/); expect(() => checkDiffInput('x'.repeat(1048577))).toThrow(/1 MB/);
+ });
+ it('explains hidden Unicode and line-ending differences without normalizing them away',() => {
+ const info = diffTextInfo('\uFEFFa\u200bb\u00a0\tะฐ\r\n');
+ expect(info.bom).toBe(true); expect(info.crlf).toBe(1); expect(info.unusual.some(item => item.name === 'Cyrillic a')).toBe(true); expect(visibleDiffText('\u202E