diff --git a/src/commands/newArticles.ts b/src/commands/newArticles.ts index fa6b0eb..c9a5f66 100644 --- a/src/commands/newArticles.ts +++ b/src/commands/newArticles.ts @@ -1,18 +1,28 @@ import arg from "arg"; import { getFileSystemRepo } from "../lib/get-file-system-repo"; +import { validateFilename } from "../lib/filename-validator"; export const newArticles = async (argv: string[]) => { const args = arg({}, { argv }); const fileSystemRepo = await getFileSystemRepo(); + let hasErrors = false; if (args._.length > 0) { for (const basename of args._) { + const validation = validateFilename(basename); + if (!validation.isValid) { + console.error(`Error: ${validation.error}`); + hasErrors = true; + continue; + } + const createdFileName = await fileSystemRepo.createItem(basename); if (createdFileName) { console.log(`created: ${createdFileName}.md`); } else { console.error(`Error: '${basename}.md' is already exist`); + hasErrors = true; } } } else { @@ -21,6 +31,11 @@ export const newArticles = async (argv: string[]) => { console.log(`created: ${createdFileName}.md`); } else { console.error("Error: failed to create"); + hasErrors = true; } } + + if (hasErrors) { + process.exitCode = 1; + } }; diff --git a/src/lib/filename-validator.test.ts b/src/lib/filename-validator.test.ts new file mode 100644 index 0000000..e72e2e9 --- /dev/null +++ b/src/lib/filename-validator.test.ts @@ -0,0 +1,58 @@ +import { + validateFilename, + ERROR_FILENAME_EMPTY, + ERROR_INVALID_CHARACTERS, + ERROR_INVALID_START_END, +} from "./filename-validator"; + +describe("validateFilename", () => { + it.each([ + "test", + "test-article", + "test_article", + "test123", + "記事テスト", + "article.backup", + "my-awesome-article", + "test file", + ])("should accept valid filename '%s'", (name) => { + const result = validateFilename(name); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it.each(["", " ", " ", "\t", "\n"])( + "should reject empty or whitespace-only filename '%s'", + (name) => { + const result = validateFilename(name); + expect(result.isValid).toBe(false); + expect(result.error).toBe(ERROR_FILENAME_EMPTY); + }, + ); + + it.each([ + "testfile", + "test:file", + 'test"file', + "test/file", + "test\\file", + "test|file", + "test?file", + "test*file", + "test\x00file", + ])("should reject filename with invalid characters '%s'", (name) => { + const result = validateFilename(name); + expect(result.isValid).toBe(false); + expect(result.error).toBe(ERROR_INVALID_CHARACTERS); + }); + + it.each([".test", "test.", " test", "test ", "..test", "test.."])( + "should reject filename starting or ending with dots or spaces '%s'", + (name) => { + const result = validateFilename(name); + expect(result.isValid).toBe(false); + expect(result.error).toBe(ERROR_INVALID_START_END); + }, + ); +}); diff --git a/src/lib/filename-validator.ts b/src/lib/filename-validator.ts new file mode 100644 index 0000000..0336b9f --- /dev/null +++ b/src/lib/filename-validator.ts @@ -0,0 +1,40 @@ +// eslint-disable-next-line no-control-regex -- include control characters +const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1f]/; + +export const ERROR_FILENAME_EMPTY = "Filename is empty"; +export const ERROR_INVALID_CHARACTERS = + 'Filename contains invalid characters: < > : " / \\ | ? * and control characters'; +export const ERROR_INVALID_START_END = + "Filename cannot start or end with a dot or space"; + +export interface FilenameValidationResult { + isValid: boolean; + error?: string; +} + +export function validateFilename(filename: string): FilenameValidationResult { + if (!filename || filename.trim().length === 0) { + return { + isValid: false, + error: ERROR_FILENAME_EMPTY, + }; + } + + if (INVALID_FILENAME_CHARS.test(filename)) { + return { + isValid: false, + error: ERROR_INVALID_CHARACTERS, + }; + } + + if (/^[. ]|[. ]$/.test(filename)) { + return { + isValid: false, + error: ERROR_INVALID_START_END, + }; + } + + return { + isValid: true, + }; +}