refactor(ids): co-locate ID generation and validation in ids.ts to prevent contract drift

This commit is contained in:
avi 2026-08-24 21:47:37 -05:00
commit 90b7f2cf76
4 changed files with 63 additions and 29 deletions

View file

@ -9,6 +9,7 @@ import {
makeChapterId, makeChapterId,
saveMeta, saveMeta,
} from "./project.js"; } from "./project.js";
import { isSafeChapterId } from "./ids.js";
import { import {
getChapterDoc, getChapterDoc,
setChapterDoc, setChapterDoc,
@ -164,16 +165,14 @@ export interface ChapterSnapshot {
index: number; index: number;
} }
// Chapter ids are always slugified lowercase ids; enforcing that shape here
// keeps renderer-supplied snapshots from escaping the book directory.
const SAFE_ID = /^[a-z0-9][a-z0-9-]*$/;
// Re-create a previously deleted chapter at its original position, restoring // Re-create a previously deleted chapter at its original position, restoring
// its title, color, and content. The chapter is stored as Markdown so it reads // its title, color, and content. The chapter is stored as Markdown so it reads
// correctly regardless of the format it originally used. Idempotent: restoring // correctly regardless of the format it originally used. Idempotent: restoring
// an id that still exists is a no-op instead of a duplicate. // an id that still exists is a no-op instead of a duplicate.
export function restoreChapter(bookPath: string, snap: ChapterSnapshot): ChapterEntry { export function restoreChapter(bookPath: string, snap: ChapterSnapshot): ChapterEntry {
if (!snap || typeof snap.id !== "string" || !SAFE_ID.test(snap.id)) { // Enforcing the shared id grammar (see ids.ts) keeps renderer-supplied
// snapshots from escaping the book directory.
if (!isSafeChapterId(snap?.id)) {
throw new Error("Invalid chapter id"); throw new Error("Invalid chapter id");
} }
const meta = loadBook(bookPath); const meta = loadBook(bookPath);

48
src/main/ids.ts Normal file
View file

@ -0,0 +1,48 @@
// The chapter-id grammar and book-folder naming, in one place.
//
// Three concerns used to live scattered across project.ts (slug + id
// generation), chapters.ts (the SAFE_ID validator) and main/index.ts (folder
// sanitization). Splitting the grammar from its validator meant a future edit
// to slugify could silently produce ids that isSafeChapterId rejects (e.g.
// allowing underscores), breaking undo/restore for every new chapter.
// Co-locating them makes the contract impossible to miss.
/** Human-readable book folder name under ~/Documents. Keeps spaces and case;
* strips characters hostile to filesystems, collapses whitespace, caps at
* 100 chars. Callers fall back to "Untitled" when the result is empty. */
export function sanitizeBookFolderName(name: string): string {
return name
.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 100);
}
/** Lowercase ASCII slug of a title; never empty ("chapter" fallback). */
export function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "chapter";
}
/** Fresh chapter id: `<slug>-<4 random chars>`. Guaranteed to satisfy
* isSafeChapterId — keep it that way if you ever touch either half. */
export function makeChapterId(title: string): string {
const rand = Math.random().toString(36).slice(2, 6);
return `${slugify(title)}-${rand}`;
}
/** The id contract every persisted chapter must satisfy: starts with an
* alphanumeric, continues with alphanumerics or dashes. This is also what
* keeps renderer-supplied snapshots from escaping the book directory. */
const CHAPTER_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
export function isSafeChapterId(id: unknown): id is string {
return typeof id === "string" && CHAPTER_ID_RE.test(id);
}
/** Markdown filename for a chapter id (legacy/native Markdown storage). */
export function chapterFileName(id: string): string {
return `${id}.md`;
}

View file

@ -37,6 +37,7 @@ import {
getRecoveryRoot, getRecoveryRoot,
setRecoveryRoot, setRecoveryRoot,
} from "./backup.js"; } from "./backup.js";
import { sanitizeBookFolderName } from "./ids.js";
import { buildDocx, buildOdt } from "./office-export.js"; import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js"; import { getSetting, setSetting } from "./settings.js";
import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
@ -187,14 +188,6 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
} }
} }
function sanitizeName(name: string): string {
return name
.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 100);
}
// Create a new book as a dedicated folder <name>/ inside the user's Documents // Create a new book as a dedicated folder <name>/ inside the user's Documents
// directory, then open it. The folder name is the sanitized book title; if a // directory, then open it. The folder name is the sanitized book title; if a
// folder with that name already exists we append a numeric suffix. // folder with that name already exists we append a numeric suffix.
@ -203,7 +196,7 @@ ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
if (!title) return { error: "Please provide a book name." }; if (!title) return { error: "Please provide a book name." };
try { try {
const base = app.getPath("documents"); const base = app.getPath("documents");
const folder = sanitizeName(title) || "Untitled"; const folder = sanitizeBookFolderName(title) || "Untitled";
let target = path.join(base, folder); let target = path.join(base, folder);
let n = 2; let n = 2;
while (hasBook(target) || fs.existsSync(target)) { while (hasBook(target) || fs.existsSync(target)) {

View file

@ -53,21 +53,15 @@ export const META_FILE = "folio.json";
export const CHAPTERS_DIR = "chapters"; export const CHAPTERS_DIR = "chapters";
export const CURRENT_VERSION = 1; export const CURRENT_VERSION = 1;
export function slugify(s: string): string { // The id grammar moved to ids.ts (with its validator, so the contract cannot
return s // drift). Re-exported here for existing importers and test bundles.
.toLowerCase() export {
.replace(/[^a-z0-9]+/g, "-") slugify,
.replace(/^-+|-+$/g, "") || "chapter"; makeChapterId,
} chapterFileName,
isSafeChapterId,
export function makeChapterId(title: string): string { sanitizeBookFolderName,
const rand = Math.random().toString(36).slice(2, 6); } from "./ids.js";
return `${slugify(title) || "chapter"}-${rand}`;
}
export function chapterFileName(id: string): string {
return `${id}.md`;
}
export function defaultMeta(title: string): BookMeta { export function defaultMeta(title: string): BookMeta {
return { return {