fix(memo): sanitize the tag used in memo export file names
This commit is contained in:
parent
5fef311575
commit
95aa95e986
3 changed files with 112 additions and 3 deletions
|
|
@ -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')
|
||||||
|
|
|
||||||
35
apps/desktop/src/main/services/memo/memo-export-file-name.ts
Normal file
35
apps/desktop/src/main/services/memo/memo-export-file-name.ts
Normal 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`
|
||||||
|
}
|
||||||
68
apps/desktop/tests/red/memo-export-redteam-r1-10.test.ts
Normal file
68
apps/desktop/tests/red/memo-export-redteam-r1-10.test.ts
Normal 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()}`)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue