fix(memo): sanitize the tag used in memo export file names

This commit is contained in:
Yun Chan 2026-09-28 00:53:45 +09:00
parent 5fef311575
commit 95aa95e986
3 changed files with 112 additions and 3 deletions

View file

@ -11,6 +11,7 @@ import type { MemoTagRow } from '../db/schema'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService' import { getCloudSyncService } from './CloudSyncService'
import { memoTagKey, normalizeMemoTag } from './sync/memo-tag-sync' import { memoTagKey, normalizeMemoTag } from './sync/memo-tag-sync'
import { buildMemoExportFileName } from './memo/memo-export-file-name'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
MemoTag, MemoTag,
@ -285,9 +286,14 @@ class MemoService {
fs.mkdirSync(exportDir, { recursive: true }) fs.mkdirSync(exportDir, { recursive: true })
} }
const timestamp = Date.now() const fileName = buildMemoExportFileName(
const tagSuffix = params.tag ? `_${params.tag}` : '' params.tag ? normalizeMemoTag(params.tag) : undefined,
const filePath = path.join(exportDir, `memo${tagSuffix}_${timestamp}.md`) 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 { try {
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8') fs.writeFileSync(filePath, lines.join('\n'), 'utf-8')

View file

@ -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[_<safe-tag>]_<timestamp>.md` 형식의 파일명을 만든다. */
export function buildMemoExportFileName(tag: string | undefined, timestamp: number): string {
const safeTag = tag ? sanitizeMemoExportTag(tag) : ''
const tagSuffix = safeTag ? `_${safeTag}` : ''
return `memo${tagSuffix}_${timestamp}.md`
}

View file

@ -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_<ts>.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"e<f>g|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()}`)
}
)
})