diff --git a/src/__tests__/utils/output.test.ts b/src/__tests__/utils/output.test.ts index 97c4deb..c8c39e3 100644 --- a/src/__tests__/utils/output.test.ts +++ b/src/__tests__/utils/output.test.ts @@ -2,9 +2,22 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import {serialize, format_from_ext, print} from '../../utils/output'; +const mocks = vi.hoisted(()=>({ + get_config: vi.fn(), +})); +vi.mock('../../utils/config', ()=>({ + get: mocks.get_config, +})); + +import {serialize, format_from_ext, print} from '../../utils/output'; describe('utils/output.serialize csv', ()=>{ + beforeEach(()=>{ + mocks.get_config.mockReturnValue(true); + }); + afterEach(()=>{ + mocks.get_config.mockReset(); + }); it('serializes array of flat objects as RFC 4180 CSV with header row', ()=>{ const rows = [ {url: 'https://a.test/1', title: 'A', price: 1.5}, @@ -46,6 +59,42 @@ describe('utils/output.serialize csv', ()=>{ const lines = out.trim().split('\n'); expect(lines[1]).toBe('1,"{""tag"":""x""}"'); }); + it('sanitizes spreadsheet formula prefixes in CSV string cells', ()=>{ + const rows = [{ + equals: '=1+1', + space: ' =1+1', + multi_space: ' =1+1', + tab: '\t=1+1', + nbsp: '\u00a0=1+1', + plus: '+cmd', + minus: '-SUM(A1:A2)', + at: '@SUM(A1:A2)', + }]; + const out = serialize(rows, 'csv'); + expect(out).toContain("'=1+1"); + expect(out).toContain("' =1+1"); + expect(out).toContain("' =1+1"); + expect(out).toContain("'\t=1+1"); + expect(out).toContain("'\u00a0=1+1"); + expect(out).toContain("'+cmd"); + expect(out).toContain("'-SUM(A1:A2)"); + expect(out).toContain("'@SUM(A1:A2)"); + }); + it('does not sanitize numeric values', ()=>{ + const out = serialize([{value: -100}], 'csv'); + const lines = out.trim().split('\n'); + expect(lines[1]).toBe('-100'); + }); + it('preserves numeric-looking CSV strings', ()=>{ + const out = serialize([{negative: '-100', positive: '+15'}], 'csv'); + expect(out).toContain('-100,+15'); + }); + it('does not sanitize CSV cells when sanitize_csv is false', ()=>{ + mocks.get_config.mockReturnValue(false); + const out = serialize([{value: '=1+1'}], 'csv'); + expect(out).toContain('=1+1'); + expect(out).not.toContain("'=1+1"); + }); }); describe('utils/output.serialize markdown', ()=>{ diff --git a/src/commands/config.ts b/src/commands/config.ts index 6a5a6d0..f4c3aa3 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -14,6 +14,7 @@ const CONFIG_KEYS: Config_key[] = [ 'default_zone_serp', 'default_format', 'api_url', + 'sanitize_csv', ]; const format_keys = ()=>CONFIG_KEYS.join(', '); @@ -50,12 +51,22 @@ const handle_get_config = (key: string)=>{ ); return; } - process.stdout.write(value+'\n'); + process.stdout.write(String(value)+'\n'); }; const handle_set_config = (key: string, value: string)=>{ const valid_key = ensure_valid_key(key); - set_config(valid_key, value); + if (valid_key == 'sanitize_csv') + { + if (value != 'true' && value != 'false') + { + fail('sanitize_csv must be true or false'); + return; + } + set_config(valid_key, value == 'true'); + } + else + set_config(valid_key, value); success(`Config updated: ${valid_key}=${value}`); }; diff --git a/src/utils/config.ts b/src/utils/config.ts index 0efa59b..6323bd5 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -9,11 +9,17 @@ type Config = { default_zone_serp?: string; default_format?: string; api_url?: string; + sanitize_csv?: boolean; }; +type String_config_key = { + [K in keyof Config]-?: Config[K] extends string|undefined ? K : never +}[keyof Config]; + const DEFAULTS: Config = { default_format: 'markdown', api_url: 'https://api.brightdata.com', + sanitize_csv: true, }; const load = (): Config=>{ @@ -35,12 +41,12 @@ const save = (config: Config)=>{ fs.writeFileSync(get_config_path(), JSON.stringify(config, null, 4)); }; -const get = (key: keyof Config): string|undefined=>{ +const get = (key: K): Config[K]=>{ const config = load(); return config[key]; }; -const set = (key: keyof Config, value: string)=>{ +const set = (key: K, value: Config[K])=>{ const config = load(); config[key] = value; save(config); @@ -50,7 +56,7 @@ const set = (key: keyof Config, value: string)=>{ const resolve = ( cli_val: string|undefined, env_key: string, - config_key: keyof Config + config_key: String_config_key ): string|undefined=>{ if (cli_val) return cli_val; diff --git a/src/utils/output.ts b/src/utils/output.ts index 7869d02..a669e4a 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; +import {get as get_config} from './config'; + const is_tty = process.stdout.isTTY === true; const ansi = (code: string, text: string)=> @@ -84,10 +86,21 @@ const cell_to_string = (val: unknown): string=>{ return JSON.stringify(val); }; +const sanitize_csv_cell = (s: string): string=>{ + const trimmed = s.trim(); + if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(trimmed)) + return s; + if (/^[\s\u00a0]*[=+\-@]/.test(s)) + return "'" + s; + return s; +}; + const csv_escape = (val: unknown): string=>{ - const s = cell_to_string(val); + let s = cell_to_string(val); + if (typeof val == 'string' && get_config('sanitize_csv') !== false) + s = sanitize_csv_cell(s); if (/[",\r\n]/.test(s)) - return '"'+s.replace(/"/g, '""')+'"'; + return '"' + s.replace(/"/g, '""') + '"'; return s; };