diff --git a/apps/desktop/src/main/services/MemoService.ts b/apps/desktop/src/main/services/MemoService.ts index d576a7d..1c7aa69 100644 --- a/apps/desktop/src/main/services/MemoService.ts +++ b/apps/desktop/src/main/services/MemoService.ts @@ -11,6 +11,7 @@ import type { MemoTagRow } from '../db/schema' import { getLogger } from './LoggerService' import { getCloudSyncService } from './CloudSyncService' import { memoTagKey, normalizeMemoTag } from './sync/memo-tag-sync' +import { buildMemoExportFileName } from './memo/memo-export-file-name' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { MemoTag, @@ -285,9 +286,14 @@ class MemoService { fs.mkdirSync(exportDir, { recursive: true }) } - const timestamp = Date.now() - const tagSuffix = params.tag ? `_${params.tag}` : '' - const filePath = path.join(exportDir, `memo${tagSuffix}_${timestamp}.md`) + const fileName = buildMemoExportFileName( + params.tag ? normalizeMemoTag(params.tag) : undefined, + Date.now() + ) + const filePath = path.join(exportDir, fileName) + if (path.dirname(path.resolve(filePath)) !== path.resolve(exportDir)) { + throw new D3ROError(ErrorCode.MemoExportFailed, 'Memo export path escapes the exports folder') + } try { fs.writeFileSync(filePath, lines.join('\n'), 'utf-8') diff --git a/apps/desktop/src/main/services/memo/memo-export-file-name.ts b/apps/desktop/src/main/services/memo/memo-export-file-name.ts new file mode 100644 index 0000000..d94871f --- /dev/null +++ b/apps/desktop/src/main/services/memo/memo-export-file-name.ts @@ -0,0 +1,35 @@ +// src/main/services/memo/memo-export-file-name.ts +// 메모 내보내기 파일명 정책 (순수 함수). 태그는 자유 텍스트이고 폰 동기화로도 들어오므로 +// 경로 구분자·NTFS 예약 문자·제어 문자를 그대로 파일명에 넣으면 하위 폴더 ENOENT, +// NTFS 대체 데이터 스트림(':'), exports 폴더 이탈('..')이 생긴다. 여기서 한 번에 막는다. + +/** 파일명에 들어갈 태그 조각의 최대 길이(코드 포인트 기준). */ +export const MEMO_EXPORT_TAG_MAX_LENGTH = 64 + +// Windows 예약 문자 + 경로 구분자 + C0/DEL 제어 문자 +// eslint-disable-next-line no-control-regex +const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g + +/** + * 태그를 단일 파일명 세그먼트로 안전하게 바꾼다. + * 결과가 비면 빈 문자열을 돌려주고, 호출자는 태그 접미사를 생략한다. + */ +export function sanitizeMemoExportTag(tag: string): string { + const replaced = tag + .normalize('NFC') + .replace(UNSAFE_FILE_NAME_CHARS, '_') + .replace(/\s+/g, '_') + .replace(/\.{2,}/g, '.') + .replace(/_+/g, '_') + .replace(/^[._]+|[._]+$/g, '') + + const capped = Array.from(replaced).slice(0, MEMO_EXPORT_TAG_MAX_LENGTH).join('') + return capped.replace(/[._]+$/g, '') +} + +/** `memo[_]_.md` 형식의 파일명을 만든다. */ +export function buildMemoExportFileName(tag: string | undefined, timestamp: number): string { + const safeTag = tag ? sanitizeMemoExportTag(tag) : '' + const tagSuffix = safeTag ? `_${safeTag}` : '' + return `memo${tagSuffix}_${timestamp}.md` +} diff --git a/apps/desktop/tests/red/memo-export-redteam-r1-10.test.ts b/apps/desktop/tests/red/memo-export-redteam-r1-10.test.ts new file mode 100644 index 0000000..f1c2858 --- /dev/null +++ b/apps/desktop/tests/red/memo-export-redteam-r1-10.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import fs from 'fs' +import path from 'path' +import { getHistoryService } from '../../src/main/services/HistoryService' +import { getMemoService } from '../../src/main/services/MemoService' +import { + buildMemoExportFileName, + sanitizeMemoExportTag, + MEMO_EXPORT_TAG_MAX_LENGTH +} from '../../src/main/services/memo/memo-export-file-name' +import { historyInput, useRedHarness } from './harness' + +useRedHarness() + +describe('메모 내보내기 파일명 정책 (순수)', () => { + it('태그가 없으면 memo_.md', () => { + expect(buildMemoExportFileName(undefined, 123)).toBe('memo_123.md') + expect(buildMemoExportFileName('', 123)).toBe('memo_123.md') + }) + + it('경로 구분자·NTFS 예약 문자·제어 문자는 _ 로 바뀐다', () => { + expect(sanitizeMemoExportTag('c/c++')).toBe('c_c++') + expect(sanitizeMemoExportTag('q:a')).toBe('q_a') + expect(sanitizeMemoExportTag('a\\b*c?d"eg|h')).toBe('a_b_c_d_e_f_g_h') + expect(sanitizeMemoExportTag('x\u0001y\u007fz')).toBe('x_y_z') + }) + + it('.. 탐색 조각은 남지 않는다', () => { + const name = buildMemoExportFileName('a/../../../x', 1) + expect(name).not.toMatch(/[\\/]/) + expect(name).not.toContain('..') + expect(path.basename(name)).toBe(name) + }) + + it('한글 태그는 유지되고 공백은 _ 로 바뀐다', () => { + expect(sanitizeMemoExportTag('회의 메모')).toBe('회의_메모') + }) + + it('길이가 제한된다', () => { + expect(Array.from(sanitizeMemoExportTag('가'.repeat(500))).length).toBe(MEMO_EXPORT_TAG_MAX_LENGTH) + }) + + it('안전한 문자가 하나도 없으면 접미사를 생략한다', () => { + expect(buildMemoExportFileName('///', 7)).toBe('memo_7.md') + expect(buildMemoExportFileName('..', 7)).toBe('memo_7.md') + }) +}) + +describe('유스케이스: 특수 문자 태그로 메모 내보내기', () => { + function exportWithTag(tag: string): string { + const id = getHistoryService().create(historyInput()).id + getMemoService().addTag(id, tag) + return getMemoService().exportMarkdown({ tag }) + } + + it.each(['c/c++', 'q:a', 'a/../../../x', 'what?*|<>"'])( + '태그 %s 도 exports 폴더 바로 아래에 보이는 파일로 저장된다', + (tag) => { + const filePath = exportWithTag(tag) + expect(path.basename(path.dirname(filePath))).toBe('exports') + expect(path.basename(filePath)).not.toMatch(/[\\/:*?"<>|]/) + expect(fs.existsSync(filePath)).toBe(true) + const listed = fs.readdirSync(path.dirname(filePath)) + expect(listed).toContain(path.basename(filePath)) + expect(fs.readFileSync(filePath, 'utf-8')).toContain(`#${tag.toLowerCase()}`) + } + ) +})