diff --git a/.changeset/dotdir.md b/.changeset/dotdir.md new file mode 100644 index 0000000..4d08ab7 --- /dev/null +++ b/.changeset/dotdir.md @@ -0,0 +1,5 @@ +--- +'@kidd-cli/core': minor +--- + +Add `ctx.dotdir` — a `DotDirectoryClient` for scoped filesystem operations in CLI dot directories with file protection registry. diff --git a/packages/core/src/context/create-context.ts b/packages/core/src/context/create-context.ts index 7045db4..0de0880 100644 --- a/packages/core/src/context/create-context.ts +++ b/packages/core/src/context/create-context.ts @@ -2,6 +2,7 @@ import * as clack from '@clack/prompts' import pc from 'picocolors' import type { Colors } from 'picocolors/types' +import { createDotDirectory } from '@/lib/dotdir/index.js' import { createLog } from '@/lib/log.js' import type { AnyRecord, KiddStore, Merge, ResolvedDirs } from '@/types/index.js' @@ -67,12 +68,15 @@ export function createContext['args'], colors: Object.freeze({ ...pc }) as Colors, config: options.config as CommandContext['config'], + dotdir: ctxDotdir, fail(message: string, failOptions?: { code?: string; exitCode?: number }): never { // Accepted exception: ctx.fail() is typed `never` and caught by the CLI boundary. // This is the framework's halt mechanism — the runner catches the thrown ContextError. diff --git a/packages/core/src/context/types.ts b/packages/core/src/context/types.ts index 331c1c8..d5a0987 100644 --- a/packages/core/src/context/types.ts +++ b/packages/core/src/context/types.ts @@ -1,5 +1,6 @@ import type { Colors } from 'picocolors/types' +import type { DotDirectory } from '@/lib/dotdir/types.js' import type { AnyRecord, DeepReadonly, @@ -296,6 +297,12 @@ export interface CommandContext< */ readonly config: DeepReadonly> + /** + * Dot directory manager for reading/writing files in the CLI's + * dot directories (e.g. `~/.myapp/`, `/.myapp/`). + */ + readonly dotdir: DotDirectory + /** * Pure string formatters for data serialization (no I/O). */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5bbc5b..26b7326 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,4 +16,10 @@ export type { } from './types/index.js' export type { Colors } from 'picocolors/types' export type { CommandContext, Log, Prompts, Spinner } from './context/types.js' +export type { + DotDirectory, + DotDirectoryClient, + DotDirectoryError, + ProtectedFileEntry, +} from './lib/dotdir/types.js' export type { Report } from './middleware/report/types.js' diff --git a/packages/core/src/lib/dotdir/create-dot-directory-client.test.ts b/packages/core/src/lib/dotdir/create-dot-directory-client.test.ts new file mode 100644 index 0000000..0960ac6 --- /dev/null +++ b/packages/core/src/lib/dotdir/create-dot-directory-client.test.ts @@ -0,0 +1,363 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' + +import { createDotDirectoryClient } from './create-dot-directory-client.js' +import { createProtectionRegistry } from './protection.js' + +describe('createDotDirectoryClient()', () => { + let tmpDir: string + let registry: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kidd-dotdir-')) + registry = createProtectionRegistry() + }) + + afterEach(() => { + rmSync(tmpDir, { force: true, recursive: true }) + }) + + // ------------------------------------------------------------------------- + // Dir + // ------------------------------------------------------------------------- + + describe('dir', () => { + it('should expose the resolved directory path', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + expect(dotdir.dir).toBe(tmpDir) + }) + }) + + // ------------------------------------------------------------------------- + // Ensure + // ------------------------------------------------------------------------- + + describe('ensure', () => { + it('should create the directory if it does not exist', () => { + const dir = join(tmpDir, 'nested', 'dir') + const dotdir = createDotDirectoryClient({ dir, location: 'global', registry }) + + const [error, result] = dotdir.ensure() + + expect(error).toBeNull() + expect(result).toBe(dir) + expect(existsSync(dir)).toBeTruthy() + }) + + it('should succeed when the directory already exists', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, result] = dotdir.ensure() + + expect(error).toBeNull() + expect(result).toBe(tmpDir) + }) + }) + + // ------------------------------------------------------------------------- + // Write / Read + // ------------------------------------------------------------------------- + + describe('write', () => { + it('should write a file and return the file path', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, filePath] = dotdir.write('config.json', '{"key":"value"}') + + expect(error).toBeNull() + expect(filePath).toBe(join(tmpDir, 'config.json')) + expect(readFileSync(filePath as string, 'utf8')).toBe('{"key":"value"}') + }) + + it('should create parent directory if it does not exist', () => { + const dir = join(tmpDir, 'new-dir') + const dotdir = createDotDirectoryClient({ dir, location: 'global', registry }) + + const [error] = dotdir.write('file.txt', 'content') + + expect(error).toBeNull() + expect(existsSync(join(dir, 'file.txt'))).toBeTruthy() + }) + }) + + describe('read', () => { + it('should read a file and return its content', () => { + writeFileSync(join(tmpDir, 'data.txt'), 'hello') + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, content] = dotdir.read('data.txt') + + expect(error).toBeNull() + expect(content).toBe('hello') + }) + + it('should return fs_error when the file does not exist', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.read('missing.txt') + + expect(error).toMatchObject({ type: 'fs_error' }) + }) + }) + + // ------------------------------------------------------------------------- + // WriteJson / ReadJson + // ------------------------------------------------------------------------- + + describe('writeJson', () => { + it('should serialize and write JSON', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, filePath] = dotdir.writeJson('data.json', { key: 'value' }) + + expect(error).toBeNull() + const raw = readFileSync(filePath as string, 'utf8') + expect(JSON.parse(raw)).toEqual({ key: 'value' }) + }) + }) + + describe('readJson', () => { + it('should read and parse a JSON file', () => { + writeFileSync(join(tmpDir, 'data.json'), '{"key":"value"}') + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, data] = dotdir.readJson('data.json') + + expect(error).toBeNull() + expect(data).toEqual({ key: 'value' }) + }) + + it('should return parse_error for invalid JSON', () => { + writeFileSync(join(tmpDir, 'bad.json'), '{not valid}') + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.readJson('bad.json') + + expect(error).toMatchObject({ type: 'parse_error' }) + }) + + it('should validate with a Zod schema when provided', () => { + const schema = z.object({ name: z.string(), version: z.number() }) + writeFileSync(join(tmpDir, 'valid.json'), '{"name":"test","version":1}') + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, data] = dotdir.readJson('valid.json', { schema }) + + expect(error).toBeNull() + expect(data).toEqual({ name: 'test', version: 1 }) + }) + + it('should return parse_error when Zod validation fails', () => { + const schema = z.object({ name: z.string(), version: z.number() }) + writeFileSync(join(tmpDir, 'invalid.json'), '{"name":123}') + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.readJson('invalid.json', { schema }) + + expect(error).toMatchObject({ type: 'parse_error' }) + }) + }) + + // ------------------------------------------------------------------------- + // Exists + // ------------------------------------------------------------------------- + + describe('exists', () => { + it('should return true when the file exists', () => { + writeFileSync(join(tmpDir, 'test.txt'), 'x') + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + expect(dotdir.exists('test.txt')).toBeTruthy() + }) + + it('should return false when the file does not exist', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + expect(dotdir.exists('missing.txt')).toBeFalsy() + }) + }) + + // ------------------------------------------------------------------------- + // Remove + // ------------------------------------------------------------------------- + + describe('remove', () => { + it('should remove an existing file', () => { + writeFileSync(join(tmpDir, 'temp.txt'), 'data') + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, filePath] = dotdir.remove('temp.txt') + + expect(error).toBeNull() + expect(filePath).toBe(join(tmpDir, 'temp.txt')) + expect(existsSync(join(tmpDir, 'temp.txt'))).toBeFalsy() + }) + + it('should succeed when the file does not exist', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, filePath] = dotdir.remove('nonexistent.txt') + + expect(error).toBeNull() + expect(filePath).toBe(join(tmpDir, 'nonexistent.txt')) + }) + }) + + // ------------------------------------------------------------------------- + // Path + // ------------------------------------------------------------------------- + + describe('path', () => { + it('should return the full absolute path for a filename', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, filePath] = dotdir.path('config.json') + + expect(error).toBeNull() + expect(filePath).toBe(join(tmpDir, 'config.json')) + }) + }) + + // ------------------------------------------------------------------------- + // Path traversal + // ------------------------------------------------------------------------- + + describe('path traversal', () => { + it('should reject read with a traversal filename', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.read('../../../etc/passwd') + + expect(error).toMatchObject({ type: 'path_traversal' }) + }) + + it('should reject write with a traversal filename', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.write('../escape.txt', 'malicious') + + expect(error).toMatchObject({ type: 'path_traversal' }) + }) + + it('should reject remove with a traversal filename', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.remove('../escape.txt') + + expect(error).toMatchObject({ type: 'path_traversal' }) + }) + + it('should reject readJson with a traversal filename', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.readJson('../../../etc/shadow') + + expect(error).toMatchObject({ type: 'path_traversal' }) + }) + + it('should return false for exists with a traversal filename', () => { + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + expect(dotdir.exists('../../../etc/passwd')).toBeFalsy() + }) + }) + + // ------------------------------------------------------------------------- + // Protection + // ------------------------------------------------------------------------- + + describe('protection', () => { + it('should block read on a protected file', () => { + writeFileSync(join(tmpDir, 'auth.json'), '{"token":"secret"}') + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.read('auth.json') + + expect(error).toMatchObject({ type: 'protected_file' }) + }) + + it('should allow read on a protected file with dangerouslyAccessProtectedFile', () => { + writeFileSync(join(tmpDir, 'auth.json'), '{"token":"secret"}') + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, content] = dotdir.read('auth.json', { dangerouslyAccessProtectedFile: true }) + + expect(error).toBeNull() + expect(content).toBe('{"token":"secret"}') + }) + + it('should block write on a protected file', () => { + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.write('auth.json', 'overwrite') + + expect(error).toMatchObject({ type: 'protected_file' }) + }) + + it('should block remove on a protected file', () => { + writeFileSync(join(tmpDir, 'auth.json'), '{}') + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.remove('auth.json') + + expect(error).toMatchObject({ type: 'protected_file' }) + }) + + it('should block readJson on a protected file', () => { + writeFileSync(join(tmpDir, 'auth.json'), '{}') + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.readJson('auth.json') + + expect(error).toMatchObject({ type: 'protected_file' }) + }) + + it('should block writeJson on a protected file', () => { + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error] = dotdir.writeJson('auth.json', { token: 'new' }) + + expect(error).toMatchObject({ type: 'protected_file' }) + }) + + it('should not block exists on a protected file', () => { + writeFileSync(join(tmpDir, 'auth.json'), '{}') + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + expect(dotdir.exists('auth.json')).toBeTruthy() + }) + + it('should not block path on a protected file', () => { + registry.add({ filename: 'auth.json', location: 'global' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, filePath] = dotdir.path('auth.json') + + expect(error).toBeNull() + expect(filePath).toBe(join(tmpDir, 'auth.json')) + }) + + it('should not block files in a different location', () => { + writeFileSync(join(tmpDir, 'auth.json'), '{"token":"secret"}') + registry.add({ filename: 'auth.json', location: 'local' }) + const dotdir = createDotDirectoryClient({ dir: tmpDir, location: 'global', registry }) + + const [error, content] = dotdir.read('auth.json') + + expect(error).toBeNull() + expect(content).toBe('{"token":"secret"}') + }) + }) +}) diff --git a/packages/core/src/lib/dotdir/create-dot-directory-client.ts b/packages/core/src/lib/dotdir/create-dot-directory-client.ts new file mode 100644 index 0000000..f76507d --- /dev/null +++ b/packages/core/src/lib/dotdir/create-dot-directory-client.ts @@ -0,0 +1,343 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' + +import { attempt } from '@kidd-cli/utils/fp' +import type { Result } from '@kidd-cli/utils/fp' +import { jsonParse, jsonStringify } from '@kidd-cli/utils/json' + +import type { + AccessOptions, + DotDirectoryClient, + DotDirectoryError, + DotDirectoryLocation, + ProtectionRegistry, + ReadJsonOptions, + WriteOptions, +} from './types.js' + +/** + * Create a scoped {@link DotDirectoryClient} for a single resolved directory. + * + * All filesystem operations are synchronous (consistent with {@link FileStore}). + * Protected files are checked against the shared {@link ProtectionRegistry}. + * Filenames are validated to prevent path traversal outside the scoped directory. + * + * @param options - Directory path, location scope, and protection registry. + * @returns A frozen DotDirectoryClient instance. + */ +export function createDotDirectoryClient(options: { + readonly dir: string + readonly location: DotDirectoryLocation + readonly registry: ProtectionRegistry +}): DotDirectoryClient { + const { dir, location, registry } = options + const resolvedDir = resolve(dir) + + /** + * Resolve a filename and validate it stays within the scoped directory. + * + * @private + * @param filename - The filename to resolve. + * @returns A Result with the resolved path on success, or a path_traversal error. + */ + function safePath(filename: string): Result { + const resolved = resolve(resolvedDir, filename) + if (!resolved.startsWith(resolvedDir + sep) && resolved !== resolvedDir) { + return [ + { + message: `Path "${filename}" escapes the dot directory boundary.`, + type: 'path_traversal', + }, + null, + ] + } + + const [, stat] = attempt(() => lstatSync(resolved)) + if (stat && stat.isSymbolicLink()) { + return [ + { + message: `Path "${filename}" is a symbolic link, which is not allowed.`, + type: 'path_traversal', + }, + null, + ] + } + + return [null, resolved] + } + + /** + * Check whether a file is protected and access was not explicitly opted-in. + * + * @private + * @param filename - The filename to check. + * @param accessOptions - Access options that may include the bypass flag. + * @returns A DotDirectoryError if the file is protected, or null. + */ + function checkProtection( + filename: string, + accessOptions?: AccessOptions + ): DotDirectoryError | null { + if (accessOptions !== undefined && accessOptions.dangerouslyAccessProtectedFile === true) { + return null + } + + if (registry.has(location, filename)) { + return { + message: `File "${filename}" is protected in ${location} scope. Pass { dangerouslyAccessProtectedFile: true } to access it.`, + type: 'protected_file', + } + } + + return null + } + + /** + * Ensure the directory exists, creating it with mode 0o700 if needed. + * + * @private + * @returns A Result with the directory path on success. + */ + function ensure(): Result { + const [error] = attempt(() => { + mkdirSync(resolvedDir, { mode: 0o700, recursive: true }) + }) + + if (error) { + return [ + { + message: `Failed to create directory "${resolvedDir}": ${error.message}`, + type: 'fs_error', + }, + null, + ] + } + + return [null, resolvedDir] + } + + /** + * Read a file as a raw string. + * + * @private + * @param filename - The filename to read. + * @param accessOptions - Access options. + * @returns A Result with the file content on success. + */ + function read( + filename: string, + accessOptions?: AccessOptions + ): Result { + const protectionError = checkProtection(filename, accessOptions) + if (protectionError) { + return [protectionError, null] + } + + const [pathError, filePath] = safePath(filename) + if (pathError) { + return [pathError, null] + } + + const [error, content] = attempt(() => readFileSync(filePath, 'utf8')) + + if (error) { + return [{ message: `Failed to read "${filePath}": ${error.message}`, type: 'fs_error' }, null] + } + + return [null, content] as const + } + + /** + * Write a raw string to a file (mode 0o600). Creates the directory if needed. + * + * @private + * @param filename - The filename to write. + * @param content - The string content. + * @param writeOptions - Write options. + * @returns A Result with the written file path on success. + */ + function write( + filename: string, + content: string, + writeOptions?: WriteOptions + ): Result { + const protectionError = checkProtection(filename, writeOptions) + if (protectionError) { + return [protectionError, null] + } + + const [pathError, filePath] = safePath(filename) + if (pathError) { + return [pathError, null] + } + + const [ensureError] = ensure() + if (ensureError) { + return [ensureError, null] + } + + const [error] = attempt(() => { + writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o600 }) + }) + + if (error) { + return [ + { message: `Failed to write "${filePath}": ${error.message}`, type: 'fs_error' }, + null, + ] + } + + return [null, filePath] + } + + /** + * Read and parse a JSON file, optionally validating with a Zod schema. + * + * @private + * @param filename - The filename to read. + * @param readOptions - Read options with optional schema. + * @returns A Result with the parsed (and optionally validated) data. + */ + function readJson( + filename: string, + readOptions?: ReadJsonOptions + ): Result { + const [readError, content] = read(filename, readOptions) + if (readError) { + return [readError, null] + } + + const [parseError, parsed] = jsonParse(content) + if (parseError) { + return [ + { message: `Failed to parse "${filename}": ${parseError.message}`, type: 'parse_error' }, + null, + ] + } + + if (readOptions !== undefined && readOptions.schema !== undefined) { + const result = readOptions.schema.safeParse(parsed) + if (!result.success) { + return [ + { + message: `Validation failed for "${filename}": ${result.error.message}`, + type: 'parse_error', + }, + null, + ] + } + return [null, result.data] + } + + return [null, parsed as T] + } + + /** + * Serialize data to JSON and write it to a file. + * + * @private + * @param filename - The filename to write. + * @param data - The data to serialize. + * @param writeOptions - Write options. + * @returns A Result with the written file path on success. + */ + function writeJson( + filename: string, + data: unknown, + writeOptions?: WriteOptions + ): Result { + const [stringifyError, json] = jsonStringify(data, { pretty: true }) + if (stringifyError) { + return [ + { + message: `Failed to serialize "${filename}": ${stringifyError.message}`, + type: 'parse_error', + }, + null, + ] + } + + return write(filename, json, writeOptions) + } + + /** + * Check whether a file exists in the directory. + * + * @private + * @param filename - The filename to check. + * @returns True if the file exists and the path is within the directory boundary. + */ + function fileExists(filename: string): boolean { + const [pathError, filePath] = safePath(filename) + if (pathError) { + return false + } + return existsSync(filePath) + } + + /** + * Remove a file from the directory. Idempotent — succeeds if the file does not exist. + * + * @private + * @param filename - The filename to remove. + * @param accessOptions - Access options. + * @returns A Result with the file path on success. + */ + function remove( + filename: string, + accessOptions?: AccessOptions + ): Result { + const protectionError = checkProtection(filename, accessOptions) + if (protectionError) { + return [protectionError, null] + } + + const [pathError, filePath] = safePath(filename) + if (pathError) { + return [pathError, null] + } + + if (!existsSync(filePath)) { + return [null, filePath] + } + + const [error] = attempt(() => { + unlinkSync(filePath) + }) + + if (error) { + return [ + { message: `Failed to remove "${filePath}": ${error.message}`, type: 'fs_error' }, + null, + ] + } + + return [null, filePath] + } + + /** + * Resolve the full absolute path for a filename within the directory. + * + * Validates the filename against path traversal and symlink checks + * via {@link safePath}. + * + * @private + * @param filename - The filename to resolve. + * @returns A Result with the absolute path on success, or a path_traversal error. + */ + function resolvePath(filename: string): Result { + return safePath(filename) + } + + return Object.freeze({ + dir: resolvedDir, + ensure, + exists: fileExists, + path: resolvePath, + read, + readJson, + remove, + write, + writeJson, + }) +} diff --git a/packages/core/src/lib/dotdir/create-dot-directory.test.ts b/packages/core/src/lib/dotdir/create-dot-directory.test.ts new file mode 100644 index 0000000..3179f8f --- /dev/null +++ b/packages/core/src/lib/dotdir/create-dot-directory.test.ts @@ -0,0 +1,104 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createDotDirectory } from './create-dot-directory.js' + +/** + * Shared mutable state for the homedir mock. + * + * `vi.mock` is hoisted and must reference module-scoped state. + * The `let` binding is required by the mock closure and is reassigned + * per-test in `beforeEach`. This is an accepted exception to the + * no-`let` rule for test infrastructure. + */ +let globalHome: string +let tmpDir: string + +vi.mock(import('node:os'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: () => globalHome, + } +}) + +describe('createDotDirectory()', () => { + const DIR_NAME = '.myapp' + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kidd-dotdir-client-')) + mkdirSync(join(tmpDir, '.git'), { recursive: true }) + globalHome = mkdtempSync(join(tmpdir(), 'kidd-dotdir-client-global-')) + }) + + afterEach(() => { + rmSync(tmpDir, { force: true, recursive: true }) + rmSync(globalHome, { force: true, recursive: true }) + }) + + // ------------------------------------------------------------------------- + // Global + // ------------------------------------------------------------------------- + + describe('global', () => { + it('should return a DotDirectoryClient with the global path', () => { + const client = createDotDirectory({ dirs: { global: DIR_NAME, local: DIR_NAME } }) + + const dotdir = client.global() + + expect(dotdir.dir).toBe(join(globalHome, DIR_NAME)) + }) + }) + + // ------------------------------------------------------------------------- + // Local + // ------------------------------------------------------------------------- + + describe('local', () => { + it('should return a DotDirectoryClient when inside a project root', () => { + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir) + const client = createDotDirectory({ dirs: { global: DIR_NAME, local: DIR_NAME } }) + + const [error, dotdir] = client.local() + + expect(error).toBeNull() + expect(dotdir.dir).toBe(join(tmpDir, DIR_NAME)) + }) + }) + + // ------------------------------------------------------------------------- + // Protect + // ------------------------------------------------------------------------- + + describe('protect', () => { + it('should protect a file across DotDirectoryClient instances from the same DotDirectory', () => { + const client = createDotDirectory({ dirs: { global: DIR_NAME, local: DIR_NAME } }) + const globalDir = join(globalHome, DIR_NAME) + mkdirSync(globalDir, { recursive: true }) + writeFileSync(join(globalDir, 'auth.json'), '{"token":"secret"}') + + client.protect({ filename: 'auth.json', location: 'global' }) + const dotdir = client.global() + + const [error] = dotdir.read('auth.json') + expect(error).toMatchObject({ type: 'protected_file' }) + }) + + it('should allow access to protected files with dangerouslyAccessProtectedFile', () => { + const client = createDotDirectory({ dirs: { global: DIR_NAME, local: DIR_NAME } }) + const globalDir = join(globalHome, DIR_NAME) + mkdirSync(globalDir, { recursive: true }) + writeFileSync(join(globalDir, 'auth.json'), '{"token":"secret"}') + + client.protect({ filename: 'auth.json', location: 'global' }) + const dotdir = client.global() + + const [error, content] = dotdir.read('auth.json', { dangerouslyAccessProtectedFile: true }) + expect(error).toBeNull() + expect(content).toBe('{"token":"secret"}') + }) + }) +}) diff --git a/packages/core/src/lib/dotdir/create-dot-directory.ts b/packages/core/src/lib/dotdir/create-dot-directory.ts new file mode 100644 index 0000000..29fd2d8 --- /dev/null +++ b/packages/core/src/lib/dotdir/create-dot-directory.ts @@ -0,0 +1,66 @@ +import type { Result } from '@kidd-cli/utils/fp' + +import { resolveGlobalPath, resolveLocalPath } from '@/lib/project/index.js' +import type { ResolvedDirs } from '@/types/index.js' + +import { createDotDirectoryClient } from './create-dot-directory-client.js' +import { createProtectionRegistry } from './protection.js' +import type { + DotDirectory, + DotDirectoryClient, + DotDirectoryError, + ProtectedFileEntry, +} from './types.js' + +/** + * Create a {@link DotDirectory} that returns scoped {@link DotDirectoryClient} + * handles and manages a shared protection registry. + * + * @param options - The resolved directory names from `ctx.meta.dirs`. + * @returns A frozen DotDirectory instance. + */ +export function createDotDirectory(options: { readonly dirs: ResolvedDirs }): DotDirectory { + const { dirs } = options + const registry = createProtectionRegistry() + + /** + * Get a DotDirectoryClient scoped to the global home directory. + * + * @private + * @returns A DotDirectoryClient for the global scope. + */ + function global(): DotDirectoryClient { + const dir = resolveGlobalPath({ dirName: dirs.global }) + return createDotDirectoryClient({ dir, location: 'global', registry }) + } + + /** + * Get a DotDirectoryClient scoped to the project-local directory. + * + * @private + * @returns A Result with a DotDirectoryClient on success, or a no_project_root error. + */ + function local(): Result { + const dir = resolveLocalPath({ dirName: dirs.local }) + if (dir === null) { + return [{ message: 'No project root found', type: 'no_project_root' }, null] + } + return [null, createDotDirectoryClient({ dir, location: 'local', registry })] + } + + /** + * Register a file as protected in the shared registry. + * + * @private + * @param entry - The file entry to protect. + */ + function protect(entry: ProtectedFileEntry): void { + registry.add(entry) + } + + return Object.freeze({ + global, + local, + protect, + }) +} diff --git a/packages/core/src/lib/dotdir/index.ts b/packages/core/src/lib/dotdir/index.ts new file mode 100644 index 0000000..30738e8 --- /dev/null +++ b/packages/core/src/lib/dotdir/index.ts @@ -0,0 +1,13 @@ +export { createDotDirectoryClient } from './create-dot-directory-client.js' +export { createDotDirectory } from './create-dot-directory.js' + +export type { + AccessOptions, + DotDirectory, + DotDirectoryClient, + DotDirectoryError, + DotDirectoryLocation, + ProtectedFileEntry, + ReadJsonOptions, + WriteOptions, +} from './types.js' diff --git a/packages/core/src/lib/dotdir/protection.test.ts b/packages/core/src/lib/dotdir/protection.test.ts new file mode 100644 index 0000000..4fe0da2 --- /dev/null +++ b/packages/core/src/lib/dotdir/protection.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import { createProtectionRegistry } from './protection.js' + +describe('createProtectionRegistry()', () => { + it('should return false for an entry that was not added', () => { + const registry = createProtectionRegistry() + + expect(registry.has('global', 'auth.json')).toBeFalsy() + }) + + it('should return true after adding an entry', () => { + const registry = createProtectionRegistry() + + registry.add({ filename: 'auth.json', location: 'global' }) + + expect(registry.has('global', 'auth.json')).toBeTruthy() + }) + + it('should distinguish between locations for the same filename', () => { + const registry = createProtectionRegistry() + + registry.add({ filename: 'auth.json', location: 'global' }) + + expect(registry.has('global', 'auth.json')).toBeTruthy() + expect(registry.has('local', 'auth.json')).toBeFalsy() + }) + + it('should distinguish between filenames for the same location', () => { + const registry = createProtectionRegistry() + + registry.add({ filename: 'auth.json', location: 'global' }) + + expect(registry.has('global', 'auth.json')).toBeTruthy() + expect(registry.has('global', 'config.json')).toBeFalsy() + }) + + it('should handle multiple entries', () => { + const registry = createProtectionRegistry() + + registry.add({ filename: 'auth.json', location: 'global' }) + registry.add({ filename: 'secrets.json', location: 'local' }) + + expect(registry.has('global', 'auth.json')).toBeTruthy() + expect(registry.has('local', 'secrets.json')).toBeTruthy() + expect(registry.has('global', 'secrets.json')).toBeFalsy() + expect(registry.has('local', 'auth.json')).toBeFalsy() + }) + + it('should be idempotent when adding the same entry twice', () => { + const registry = createProtectionRegistry() + + registry.add({ filename: 'auth.json', location: 'global' }) + registry.add({ filename: 'auth.json', location: 'global' }) + + expect(registry.has('global', 'auth.json')).toBeTruthy() + }) +}) diff --git a/packages/core/src/lib/dotdir/protection.ts b/packages/core/src/lib/dotdir/protection.ts new file mode 100644 index 0000000..3d44658 --- /dev/null +++ b/packages/core/src/lib/dotdir/protection.ts @@ -0,0 +1,38 @@ +import type { DotDirectoryLocation, ProtectedFileEntry, ProtectionRegistry } from './types.js' + +/** + * Create a {@link ProtectionRegistry} backed by a `Set`. + * + * Keys are serialized as `"location:filename"`. The registry is shared + * by reference across all `DotDirectoryClient` instances from the same `DotDirectory`. + * + * @returns A frozen ProtectionRegistry instance. + */ +export function createProtectionRegistry(): ProtectionRegistry { + const entries = new Set() + + return Object.freeze({ + add(entry: ProtectedFileEntry): void { + entries.add(toKey(entry.location, entry.filename)) + }, + has(location: DotDirectoryLocation, filename: string): boolean { + return entries.has(toKey(location, filename)) + }, + }) +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/** + * Serialize a location and filename into a registry key. + * + * @private + * @param location - The directory location. + * @param filename - The filename. + * @returns A colon-delimited key string. + */ +function toKey(location: DotDirectoryLocation, filename: string): string { + return `${location}:${filename}` +} diff --git a/packages/core/src/lib/dotdir/types.ts b/packages/core/src/lib/dotdir/types.ts new file mode 100644 index 0000000..544f49f --- /dev/null +++ b/packages/core/src/lib/dotdir/types.ts @@ -0,0 +1,132 @@ +import type { Result } from '@kidd-cli/utils/fp' +import type { z } from 'zod' + +// --------------------------------------------------------------------------- +// DotDirectoryLocation +// --------------------------------------------------------------------------- + +/** + * The scope of a dot directory — either project-local or user-global. + */ +export type DotDirectoryLocation = 'local' | 'global' + +// --------------------------------------------------------------------------- +// DotDirectoryError +// --------------------------------------------------------------------------- + +/** + * Error returned by dot directory operations. + */ +export interface DotDirectoryError { + readonly type: + | 'no_project_root' + | 'protected_file' + | 'path_traversal' + | 'fs_error' + | 'parse_error' + readonly message: string +} + +// --------------------------------------------------------------------------- +// ProtectedFileEntry +// --------------------------------------------------------------------------- + +/** + * A file registered as protected by middleware. + */ +export interface ProtectedFileEntry { + readonly location: DotDirectoryLocation + readonly filename: string +} + +// --------------------------------------------------------------------------- +// AccessOptions +// --------------------------------------------------------------------------- + +/** + * Base options for any operation that touches file contents. + */ +export interface AccessOptions { + readonly dangerouslyAccessProtectedFile?: boolean +} + +// --------------------------------------------------------------------------- +// WriteOptions +// --------------------------------------------------------------------------- + +/** + * Options for write operations. Extends {@link AccessOptions}. + */ +export interface WriteOptions extends AccessOptions {} + +// --------------------------------------------------------------------------- +// ReadJsonOptions +// --------------------------------------------------------------------------- + +/** + * Options for reading and parsing JSON files, with optional Zod validation. + * + * @typeParam T - The expected parsed type. + */ +export interface ReadJsonOptions extends AccessOptions { + readonly schema?: z.ZodType +} + +// --------------------------------------------------------------------------- +// DotDirectoryClient +// --------------------------------------------------------------------------- + +/** + * A scoped filesystem handle for a single dot directory. + * + * Provides read, write, exists, remove, and path resolution operations + * that respect the shared protection registry. + */ +export interface DotDirectoryClient { + readonly dir: string + readonly ensure: () => Result + readonly read: (filename: string, options?: AccessOptions) => Result + readonly write: ( + filename: string, + content: string, + options?: WriteOptions + ) => Result + readonly readJson: ( + filename: string, + options?: ReadJsonOptions + ) => Result + readonly writeJson: ( + filename: string, + data: unknown, + options?: WriteOptions + ) => Result + readonly exists: (filename: string) => boolean + readonly remove: (filename: string, options?: AccessOptions) => Result + readonly path: (filename: string) => Result +} + +// --------------------------------------------------------------------------- +// DotDirectory +// --------------------------------------------------------------------------- + +/** + * Root dot directory manager for obtaining scoped {@link DotDirectoryClient} + * handles and managing the protection registry. + */ +export interface DotDirectory { + readonly global: () => DotDirectoryClient + readonly local: () => Result + readonly protect: (entry: ProtectedFileEntry) => void +} + +// --------------------------------------------------------------------------- +// ProtectionRegistry (internal) +// --------------------------------------------------------------------------- + +/** + * Internal registry tracking files that middleware has marked as protected. + */ +export interface ProtectionRegistry { + readonly add: (entry: ProtectedFileEntry) => void + readonly has: (location: DotDirectoryLocation, filename: string) => boolean +} diff --git a/packages/core/src/middleware/auth/auth.test.ts b/packages/core/src/middleware/auth/auth.test.ts index f4e4eb9..781d724 100644 --- a/packages/core/src/middleware/auth/auth.test.ts +++ b/packages/core/src/middleware/auth/auth.test.ts @@ -45,6 +45,11 @@ function createMockCtx(options?: { readonly envToken?: string }) { text: vi.fn(), }, spinner: { message: vi.fn(), start: vi.fn(), stop: vi.fn() }, + dotdir: { + global: vi.fn(), + local: vi.fn(), + protect: vi.fn(), + }, store: { clear: () => store.clear(), delete: (key: string) => store.delete(key), diff --git a/packages/core/src/middleware/auth/auth.ts b/packages/core/src/middleware/auth/auth.ts index 5bd42e2..37913fc 100644 --- a/packages/core/src/middleware/auth/auth.ts +++ b/packages/core/src/middleware/auth/auth.ts @@ -85,6 +85,7 @@ function createAuth(options: AuthOptions): Middleware { }) decorateContext(ctx, 'auth', authContext) + ctx.dotdir.protect({ filename: DEFAULT_AUTH_FILENAME, location: 'global' }) return next() })