diff --git a/README.md b/README.md index a894f28..68467e3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,16 @@ # Starter Kitty -Common app components that are safe-by-default. +Starter Kitty is a collection of common utilities and packages for JavaScript projects. It is designed to provide sensible defaults for common tasks, such as file system operations and input validation. + +## Why `starter-kitty`? + +Application security is hard. There are often many ways to get it wrong, and it's easy to make mistakes when you're trying to ship features quickly. This package provides a set of components that are safe-by-default, so you can focus on building your app without worrying about common security footguns. + +## Documentation + +Please refer to the [documentation website](https://kit.open.gov.sg/) for detailed API documentation and usage examples. ## Packages +- [`@opengovsg/starter-kitty-fs`](./packages/safe-fs/): Safe file system operations. - [`@opengovsg/starter-kitty-validators`](./packages/validators/): Common input validators. diff --git a/api-extractor.json b/api-extractor.json index 1f16c4a..c1ee910 100644 --- a/api-extractor.json +++ b/api-extractor.json @@ -168,7 +168,7 @@ * SUPPORTED TOKENS: , , * DEFAULT VALUE: "/temp/" */ - "reportFolder": "./etc/" + "reportFolder": "./etc/", /** * Specifies the folder where the temporary report file is written. The file name portion is determined by @@ -183,7 +183,7 @@ * SUPPORTED TOKENS: , , * DEFAULT VALUE: "/temp/" */ - // "reportTempFolder": "/temp/", + "reportTempFolder": "./temp/" /** * Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations @@ -202,7 +202,7 @@ /** * (REQUIRED) Whether to generate a doc model file. */ - "enabled": true + "enabled": true, /** * The output path for the doc model file. The file extension should be ".api.json". @@ -213,7 +213,7 @@ * SUPPORTED TOKENS: , , * DEFAULT VALUE: "/temp/.api.json" */ - // "apiJsonFilePath": "/temp/.api.json", + "apiJsonFilePath": "./temp/.api.json" /** * Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations diff --git a/apps/docs/.vitepress/utils.ts b/apps/docs/.vitepress/utils.ts index 18204ac..865ff5d 100644 --- a/apps/docs/.vitepress/utils.ts +++ b/apps/docs/.vitepress/utils.ts @@ -1,10 +1,10 @@ -import fs from "fs"; -import path from "path"; +import fs from "node:fs"; +import path from "node:path"; export const scanDir = (dir: string) => { let res = fs .readdirSync(path.resolve(__dirname, `../${dir}`)) - .filter((item) => !item.startsWith(".")); + .filter((item) => !item.startsWith(".")) as string[]; if (res) { const arr = []; for (let item of res) { diff --git a/apps/docs/examples/index.md b/apps/docs/examples/index.md index 714bc9f..6035238 100644 --- a/apps/docs/examples/index.md +++ b/apps/docs/examples/index.md @@ -1,3 +1,4 @@ # Examples - [`@opengovsg/starter-kitty-validators`](./validators.md): Common input validators. +- [`@opengovsg/starter-kitty-fs`](./safe-fs.md): Safe-by-default `fs` wrapper. diff --git a/apps/docs/examples/safe-fs.md b/apps/docs/examples/safe-fs.md new file mode 100644 index 0000000..0d92c3c --- /dev/null +++ b/apps/docs/examples/safe-fs.md @@ -0,0 +1,32 @@ +# @opengovsg/starter-kitty-fs + +## Installation + +```bash +npm i --save @opengovsg/starter-kitty-fs +``` + +## Usage + +```javascript +import safeFs from '@opengovsg/starter-kitty-fs' + +const fs = safeFs('/app/content') + +// Writes to /app/content/hello.txt +fs.writeFileSync('hello.txt', 'Hello, world!') + +// Tries to read from /app/content/etc/passwd +fs.readFileSync('../../etc/passwd') +``` + +The interfaces for all `fs` methods are the exact same as the built-in `fs` module, but if a `PathLike` parameter is given, +it will be normalized, stripped of leading traversal characters, then resolved relative to the base directory passed to `safeFs`. + +This guarantees that the resolved path will always be within the base directory or its subdirectories. + +For example, if the base directory is `/app/content`: + +- `hello.txt` resolves to `/app/content/hello.txt` +- `../../etc/passwd` resolves to `/app/content/etc/passwd` +- `/etc/passwd` resolves to `/app/content/etc/passwd` diff --git a/apps/docs/examples/validators.md b/apps/docs/examples/validators.md index 941c7f1..f74847a 100644 --- a/apps/docs/examples/validators.md +++ b/apps/docs/examples/validators.md @@ -6,6 +6,45 @@ npm i --save @opengovsg/starter-kitty-validators ``` +## Path Validation + +```javascript +import { createPathSchema } from '@opengovsg/starter-kitty-validators' + +const pathSchema = createPathSchema({ + basePath: '/app/content', +}) + +const contentSubmissionSchema = z.object({ + fullPermalink: pathSchema, + title: z.string(), + content: z.string(), +}) + +type ContentSubmission = z.infer +``` + +`fullPermalink`, when resolved relative to the working directory of the Node process, must lie within `/app/content`. + +## Email Validation + +```javascript +import { createEmailSchema } from '@opengovsg/starter-kitty-validators' + +const emailSchema = createEmailSchema({ + domains: [{ domain: 'gov.sg', includeSubdomains: true }], +}) + +const formSchema = z.object({ + name: z.string(), + email: emailSchema, +}) + +type FormValues = z.infer +``` + +`email` must be a valid email address and have a domain that is `gov.sg` or a subdomain of `gov.sg`. + ## URL Validation ```javascript @@ -59,20 +98,3 @@ export const callbackUrlSchema = z }) .catch(new URL(HOME, baseUrl)) ``` - -## Email Validation - -```javascript -import { createEmailSchema } from '@opengovsg/starter-kitty-validators' - -const emailSchema = createEmailSchema({ - domains: [{ domain: 'gov.sg', includeSubdomains: true }], -}) - -const formSchema = z.object({ - name: z.string(), - email: emailSchema, -}) - -type FormValues = z.infer -``` diff --git a/etc/starter-kitty-fs.api.md b/etc/starter-kitty-fs.api.md new file mode 100644 index 0000000..2a96f3e --- /dev/null +++ b/etc/starter-kitty-fs.api.md @@ -0,0 +1,15 @@ +## API Report File for "@opengovsg/starter-kitty-fs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +/// + +import * as fs from 'node:fs'; + +// @public +const safeFs: (basePath?: string) => typeof fs; +export default safeFs; + +``` diff --git a/etc/starter-kitty-validators.api.md b/etc/starter-kitty-validators.api.md index cd2a5a5..57f9e70 100644 --- a/etc/starter-kitty-validators.api.md +++ b/etc/starter-kitty-validators.api.md @@ -12,6 +12,9 @@ import { ZodSchema } from 'zod'; // @public export const createEmailSchema: (options?: EmailValidatorOptions) => ZodSchema; +// @public +export const createPathSchema: (options: PathValidatorOptions) => ZodSchema; + // @public export interface EmailValidatorOptions { domains?: { @@ -25,6 +28,11 @@ export class OptionsError extends Error { constructor(message: string); } +// @public +export interface PathValidatorOptions { + basePath: string; +} + // @public export class UrlValidationError extends Error { constructor(message: string); diff --git a/packages/safe-fs/.eslintrc b/packages/safe-fs/.eslintrc new file mode 100644 index 0000000..7d495d3 --- /dev/null +++ b/packages/safe-fs/.eslintrc @@ -0,0 +1,24 @@ +{ + "extends": ["opengovsg"], + "ignorePatterns": ["dist/**/*", "vitest.config.ts", "vitest.setup.ts"], + "plugins": ["import", "eslint-plugin-tsdoc"], + "rules": { + "import/no-unresolved": "error", + "tsdoc/syntax": "error" + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "**/tsconfig.json" + }, + "settings": { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + }, + "import/resolver": { + "typescript": { + "alwaysTryTypes": true, + "project": "**/tsconfig.json" + } + } + } +} diff --git a/packages/safe-fs/.prettierignore b/packages/safe-fs/.prettierignore new file mode 100644 index 0000000..bfe3b8d --- /dev/null +++ b/packages/safe-fs/.prettierignore @@ -0,0 +1 @@ +tsconfig.json \ No newline at end of file diff --git a/packages/safe-fs/.prettierrc b/packages/safe-fs/.prettierrc new file mode 100644 index 0000000..d50a919 --- /dev/null +++ b/packages/safe-fs/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true +} diff --git a/packages/safe-fs/api-extractor.json b/packages/safe-fs/api-extractor.json new file mode 100644 index 0000000..549515e --- /dev/null +++ b/packages/safe-fs/api-extractor.json @@ -0,0 +1,21 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "extends": "../../api-extractor.json", + + /** + * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor + * analyzes the symbols exported by this module. + * + * The file extension must be ".d.ts" and not ".ts". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + */ + "mainEntryPointFilePath": "/dist/index.d.ts" +} diff --git a/packages/safe-fs/package.json b/packages/safe-fs/package.json new file mode 100644 index 0000000..c2fc509 --- /dev/null +++ b/packages/safe-fs/package.json @@ -0,0 +1,36 @@ +{ + "name": "@opengovsg/starter-kitty-fs", + "version": "1.2.3", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc && tsc-alias", + "build:report": "api-extractor run --local --verbose", + "build:docs": "api-documenter markdown --input-folder ../../temp/ --output-folder ../../apps/docs/api/", + "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" --cache", + "test": "vitest", + "ci:report": "api-extractor run --verbose" + }, + "devDependencies": { + "@swc/core": "^1.6.13", + "@types/node": "^18.19.47", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.56.0", + "eslint-config-opengovsg": "^3.0.0", + "eslint-config-prettier": "^8.6.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-simple-import-sort": "^10.0.0", + "eslint-plugin-tsdoc": "^0.3.0", + "memfs": "^4.11.1", + "prettier": "^2.8.4", + "tsc-alias": "^1.8.10", + "tsup": "^8.1.0", + "typescript": "^5.4.5", + "vitest": "^2.0.2" + } +} diff --git a/packages/safe-fs/src/__tests__/fs.test.ts b/packages/safe-fs/src/__tests__/fs.test.ts new file mode 100644 index 0000000..47fee24 --- /dev/null +++ b/packages/safe-fs/src/__tests__/fs.test.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { vol } from 'memfs' +import { beforeEach, describe, expect, it } from 'vitest' + +import { createGetter } from '@/getter' + +describe('getter', () => { + const testDir = '/tmp/test-fs-sfs' + const getter = createGetter(testDir) + const sfs = new Proxy(fs, { get: getter }) + + beforeEach(() => { + // reset the state of in-memory fs + vol.reset() + sfs.mkdirSync(testDir, { recursive: true }) + }) + + describe('synchronous API', () => { + it('should write and read files correctly', () => { + const filePath = 'test.txt' + const content = 'Hello, World!' + + sfs.writeFileSync(filePath, content) + const readContent = sfs.readFileSync(filePath, 'utf8') + + expect(readContent).toBe(content) + }) + + it('should append to files correctly', () => { + const filePath = 'append-test.txt' + const initialContent = 'Initial content\n' + const appendedContent = 'Appended content' + + sfs.writeFileSync(filePath, initialContent) + sfs.appendFileSync(filePath, appendedContent) + + const finalContent = sfs.readFileSync(filePath, 'utf8') + expect(finalContent).toBe(initialContent + appendedContent) + }) + + it('should create and remove directories correctly', () => { + const dirPath = 'test-dir' + + sfs.mkdirSync(dirPath) + expect(sfs.existsSync(path.join(testDir, dirPath))).toBe(true) + + sfs.rmdirSync(dirPath) + expect(sfs.existsSync(path.join(testDir, dirPath))).toBe(false) + }) + + it('should rename files correctly', () => { + const oldPath = 'old.txt' + const newPath = 'new.txt' + const content = 'Rename test' + + sfs.writeFileSync(oldPath, content) + sfs.renameSync(oldPath, newPath) + + expect(sfs.existsSync(path.join(testDir, oldPath))).toBe(false) + expect(sfs.existsSync(path.join(testDir, newPath))).toBe(true) + + const readContent = sfs.readFileSync(newPath, 'utf8') + expect(readContent).toBe(content) + }) + + it('should get file stats correctly', () => { + const filePath = 'stat-test.txt' + const content = 'Stat test' + + sfs.writeFileSync(filePath, content) + const stats = sfs.statSync(filePath) + + expect(stats.isFile()).toBe(true) + expect(stats.size).toBe(content.length) + }) + }) + + describe('asynchronous API', () => { + it('should write and read files correctly', async () => { + const filePath = 'async-test.txt' + const content = 'Async Hello, World!' + + await new Promise((resolve, reject) => { + sfs.writeFile(filePath, content, (err) => { + if (err) reject(err) + else resolve() + }) + }) + + const readContent = await new Promise((resolve, reject) => { + sfs.readFile(filePath, 'utf8', (err, data) => { + if (err) reject(err) + else resolve(data) + }) + }) + + expect(readContent).toBe(content) + }) + + it('should append to files correctly', async () => { + const filePath = 'async-append-test.txt' + const initialContent = 'Initial async content\n' + const appendedContent = 'Appended async content' + + await new Promise((resolve, reject) => { + sfs.writeFile(filePath, initialContent, (err) => { + if (err) reject(err) + else resolve() + }) + }) + + await new Promise((resolve, reject) => { + sfs.appendFile(filePath, appendedContent, (err) => { + if (err) reject(err) + else resolve() + }) + }) + + const finalContent = await new Promise((resolve, reject) => { + sfs.readFile(filePath, 'utf8', (err, data) => { + if (err) reject(err) + else resolve(data) + }) + }) + + expect(finalContent).toBe(initialContent + appendedContent) + }) + }) + + describe('security tests', () => { + beforeEach(() => { + const sensitiveDir = '/etc' + + vol.reset() + vol.mkdirSync(sensitiveDir, { recursive: true }) + }) + + it('should prevent path traversal attempts', () => { + const maliciousPath = '../../../etc/passwd' + const content = 'Malicious content' + + expect(() => sfs.writeFileSync(maliciousPath, content)).toThrow() + expect(() => sfs.readFileSync(maliciousPath)).toThrow() + expect(() => sfs.mkdirSync(maliciousPath)).toThrow() + expect(() => sfs.rmdirSync(maliciousPath)).toThrow() + expect(() => sfs.unlinkSync(maliciousPath)).toThrow() + expect(() => sfs.renameSync(maliciousPath, 'new.txt')).toThrow() + expect(() => sfs.renameSync('old.txt', maliciousPath)).toThrow() + expect(() => sfs.statSync(maliciousPath)).toThrow() + }) + + it('should prevent absolute path usage', () => { + const absolutePath = '/etc/passwd' + const content = 'Absolute path content' + + expect(() => sfs.writeFileSync(absolutePath, content)).toThrow() + expect(() => sfs.readFileSync(absolutePath)).toThrow() + expect(() => sfs.mkdirSync(absolutePath)).toThrow() + expect(() => sfs.rmdirSync(absolutePath)).toThrow() + expect(() => sfs.unlinkSync(absolutePath)).toThrow() + expect(() => sfs.renameSync(absolutePath, 'new.txt')).toThrow() + expect(() => sfs.renameSync('old.txt', absolutePath)).toThrow() + expect(() => sfs.statSync(absolutePath)).toThrow() + }) + + it('should allow operations within the base path', () => { + const sfs2 = new Proxy(fs, { get: createGetter('/etc') }) // unsafe usage of the library + const maliciousPath = 'passwd' + const content = 'Valid content' + + expect(() => sfs2.writeFileSync(maliciousPath, content)).not.toThrow() + expect(() => sfs2.readFileSync(maliciousPath)).not.toThrow() + expect(() => sfs2.renameSync(maliciousPath, 'new.txt')).not.toThrow() + expect(() => sfs2.statSync('new.txt')).not.toThrow() + expect(() => sfs2.unlinkSync('new.txt')).not.toThrow() + + const validPath = 'valid/nested/path.txt' + const newPath = 'valid/new.txt' + + expect(() => + sfs.mkdirSync('valid/nested', { recursive: true }), + ).not.toThrow() + expect(() => sfs.writeFileSync(validPath, content)).not.toThrow() + expect(() => sfs.readFileSync(validPath)).not.toThrow() + expect(() => sfs.renameSync(validPath, newPath)).not.toThrow() + expect(() => sfs.statSync(newPath)).not.toThrow() + expect(() => sfs.unlinkSync(newPath)).not.toThrow() + expect(() => sfs.rmdirSync('valid/nested')).not.toThrow() + }) + }) +}) diff --git a/packages/safe-fs/src/getter.ts b/packages/safe-fs/src/getter.ts new file mode 100644 index 0000000..a55fa34 --- /dev/null +++ b/packages/safe-fs/src/getter.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs' + +import PARAMS_TO_SANITIZE from '@/params' +import { sanitizePath } from '@/sanitizers' + +type ReturnType = F extends ( + ...args: infer A +) => infer R + ? R + : never + +type FsFunction = Extract<(typeof fs)[keyof typeof fs], CallableFunction> + +export const createGetter: ( + basePath: string, +) => ProxyHandler['get'] = + (basePath: string) => (target: typeof fs, p: keyof typeof fs, receiver) => { + if (typeof target[p] === 'function') { + const func = Reflect.get(target, p, receiver) as FsFunction + const paramsToSanitize = PARAMS_TO_SANITIZE[p] + + if (paramsToSanitize) { + return (...args: Parameters) => { + const sanitizedArgs = args.map((arg, i) => { + // the argument could be a file descriptor + if (paramsToSanitize.includes(i) && typeof arg !== 'number') { + return sanitizePath(arg as fs.PathLike, basePath) + } + return arg + }) + return (func as CallableFunction)(...sanitizedArgs) as ReturnType< + typeof func + > + } + } + return func + } + return Reflect.get(target, p, receiver) + } diff --git a/packages/safe-fs/src/index.ts b/packages/safe-fs/src/index.ts new file mode 100644 index 0000000..e5e5779 --- /dev/null +++ b/packages/safe-fs/src/index.ts @@ -0,0 +1,33 @@ +/** + * A safe-by-default wrapper around the `fs` module. + * + * @packageDocumentation + */ + +import * as fs from 'node:fs' + +import { createGetter } from '@/getter' + +/** + * Creates a safe-by-default version of the Node.js `fs` module. + * + * @public + * @param basePath - The base path to use for all file system operations. + * @returns + * A safe version of the Node.js `fs` module, guarded against path traversal + * attacks and absolute path usage. + * + * All interfaces are exactly the same as the original `fs` module, + * but all file paths are resolved relative to the provided `basePath` + * and are guaranteed to fall within the `basePath` when the `fs` + * operations are executed. + * + * The use of file descriptors in place of a file path is not affected. + */ +const safeFs = (basePath: string = process.cwd()) => { + return new Proxy(fs, { + get: createGetter(basePath), + }) +} + +export default safeFs diff --git a/packages/safe-fs/src/params.ts b/packages/safe-fs/src/params.ts new file mode 100644 index 0000000..5f82618 --- /dev/null +++ b/packages/safe-fs/src/params.ts @@ -0,0 +1,68 @@ +interface ParamsToSanitize { + [key: string]: number[] +} + +const PARAMS_TO_SANITIZE: ParamsToSanitize = { + access: [0], // path + appendFile: [0], // path + chmod: [0], // path + chown: [0], // path + copyFile: [0, 1], // src, dest + cp: [0, 1], // src, dest + glob: [0], // pattern + lchmod: [0], // path + lchown: [0], // path + lutimes: [0], // path + link: [0, 1], // existingPath, newPath + lstat: [0], // path + mkdir: [0], // path + mkdtemp: [0], // prefix + open: [0], // path + opendir: [0], // path + readdir: [0], // path + readFile: [0], // path + readlink: [0], // path + realpath: [0], // path + rename: [0, 1], // oldPath, newPath + rmdir: [0], // path + rm: [0], // path + stat: [0], // path + statfs: [0], // path + symlink: [0, 1], // target, path + truncate: [0], // path + unlink: [0], // path + utimes: [0], // path + writeFile: [0], // path + accessSync: [0], // path + appendFileSync: [0], // path + chmodSync: [0], // path + chownSync: [0], // path + copyFileSync: [0, 1], // src, dest + cpSync: [0, 1], // src, dest + globSync: [0], // pattern + lchmodSync: [0], // path + lchownSync: [0], // path + lutimesSync: [0], // path + linkSync: [0, 1], // existingPath, newPath + lstatSync: [0], // path + mkdirSync: [0], // path + mkdtempSync: [0], // prefix + openSync: [0], // path + opendirSync: [0], // path + readdirSync: [0], // path + readFileSync: [0], // path + readlinkSync: [0], // path + realpathSync: [0], // path + renameSync: [0, 1], // oldPath, newPath + rmdirSync: [0], // path + rmSync: [0], // path + statSync: [0], // path + statfsSync: [0], // path + symlinkSync: [0, 1], // target, path + truncateSync: [0], // path + unlinkSync: [0], // path + utimesSync: [0], // path + writeFileSync: [0], // path +} + +export default PARAMS_TO_SANITIZE diff --git a/packages/safe-fs/src/sanitizers.ts b/packages/safe-fs/src/sanitizers.ts new file mode 100644 index 0000000..58ba779 --- /dev/null +++ b/packages/safe-fs/src/sanitizers.ts @@ -0,0 +1,16 @@ +import { PathLike } from 'node:fs' +import path from 'node:path' + +const LEADING_DOT_SLASH_REGEX = /^(\.\.(\/|\\|$))+/ + +export const sanitizePath = ( + dangerousPath: PathLike, + rootPath: string, +): string => { + return path.join( + rootPath, + path + .normalize(dangerousPath.toString()) + .replace(LEADING_DOT_SLASH_REGEX, ''), + ) +} diff --git a/packages/safe-fs/tsconfig.json b/packages/safe-fs/tsconfig.json new file mode 100644 index 0000000..0460d8a --- /dev/null +++ b/packages/safe-fs/tsconfig.json @@ -0,0 +1,108 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "lib": [ + "esnext" + ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + "paths": { + "@/*": ["./src/*"] + } /* Specify a set of entries that re-map imports to additional lookup locations. */, + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + "resolveJsonModule": true /* Enable importing .json files. */, + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "sourceMap": true /* Create source map files for emitted JavaScript files. */, + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, + "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */, + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + "skipDefaultLibCheck": true /* Skip type checking .d.ts files that are included with TypeScript. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/safe-fs/vitest.config.ts b/packages/safe-fs/vitest.config.ts new file mode 100644 index 0000000..9ff6101 --- /dev/null +++ b/packages/safe-fs/vitest.config.ts @@ -0,0 +1,12 @@ +import path from 'node:path' + +export default { + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + setupFiles: ['./vitest.setup.ts'], + }, +} diff --git a/packages/safe-fs/vitest.setup.ts b/packages/safe-fs/vitest.setup.ts new file mode 100644 index 0000000..6319d9d --- /dev/null +++ b/packages/safe-fs/vitest.setup.ts @@ -0,0 +1,21 @@ +import { vi } from 'vitest' + +vi.mock('node:fs', async () => { + const memfs: { fs: typeof fs } = await vi.importActual('memfs') + + return { + default: { + ...memfs.fs, + }, + } +}) + +vi.mock('node:fs/promises', async () => { + const memfs: { fs: typeof fs } = await vi.importActual('memfs') + + return { + default: { + ...memfs.fs.promises, + }, + } +}) diff --git a/packages/validators/package.json b/packages/validators/package.json index 3081646..ad97f3d 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -1,6 +1,6 @@ { "name": "@opengovsg/starter-kitty-validators", - "version": "1.1.3", + "version": "1.2.3", "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ @@ -9,7 +9,7 @@ "scripts": { "build": "tsc && tsc-alias", "build:report": "api-extractor run --local --verbose", - "build:docs": "api-documenter markdown --input-folder ./temp/ --output-folder ../../apps/docs/api/", + "build:docs": "api-documenter markdown --input-folder ../../temp/ --output-folder ../../apps/docs/api/", "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" --cache", "test": "vitest", "ci:report": "api-extractor run --verbose" diff --git a/packages/validators/src/__tests__/path.test.ts b/packages/validators/src/__tests__/path.test.ts new file mode 100644 index 0000000..ec81b91 --- /dev/null +++ b/packages/validators/src/__tests__/path.test.ts @@ -0,0 +1,95 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' +import { ZodError } from 'zod' + +import { OptionsError } from '@/common/errors' +import { createPathSchema } from '@/index' + +describe('Path validator with current working directory', () => { + const schema = createPathSchema({ basePath: process.cwd() }) + + it('should allow a valid path', () => { + expect(() => schema.parse('valid/path')).not.toThrow() + expect(() => schema.parse('valid/nested/path')).not.toThrow() + expect(() => schema.parse('.')).not.toThrow(ZodError) + }) + + it('should trim the path', () => { + expect(schema.parse(' valid/path ')).toBe( + path.join(process.cwd(), 'valid/path'), + ) + }) + + it('should not allow directory traversal', () => { + expect(() => schema.parse('../etc/passwd')).toThrow(ZodError) + expect(() => schema.parse('..')).toThrow(ZodError) + expect(() => schema.parse('../')).toThrow(ZodError) + expect(() => schema.parse('..\\')).toThrow(ZodError) + expect(() => schema.parse('..././')).toThrow(ZodError) + }) + + it('should handle paths with special characters', () => { + expect(() => schema.parse('path with spaces')).not.toThrow() + expect(() => schema.parse('path_with_underscores')).not.toThrow() + expect(() => schema.parse('path-with-hyphens')).not.toThrow() + expect(() => schema.parse('path.with.dots')).not.toThrow() + }) + + it('should handle paths with non-ASCII characters', () => { + expect(() => schema.parse('パス')).not.toThrow() + expect(() => schema.parse('путь')).not.toThrow() + expect(() => schema.parse('路径')).not.toThrow() + }) + + it('should handle absolute paths', () => { + const absolutePath = path.resolve('/absolute/path') + expect(() => schema.parse(absolutePath)).toThrow(ZodError) + + const cwd = process.cwd() + expect(path.isAbsolute(cwd)).toBe(true) + expect(() => schema.parse(cwd)).not.toThrow(ZodError) + }) +}) + +describe('Path validator with different directory', () => { + const schema = createPathSchema({ basePath: '/var/www' }) + + it('should allow a valid path within the base path', () => { + expect(() => + schema.parse('../'.repeat(process.cwd().split('/').length) + 'var/www'), + ).not.toThrow() + expect(() => schema.parse('/var/www')).not.toThrow() + expect(() => schema.parse('/var/www/valid/path')).not.toThrow() + expect(() => schema.parse('/var/www/valid/nested/path')).not.toThrow() + }) + + it('should not allow paths outside the base path', () => { + expect(() => schema.parse('/etc/passwd')).toThrow(ZodError) + expect(() => schema.parse('/var/log/app.log')).toThrow(ZodError) + expect(() => schema.parse('/var/www/../etc/passwd')).toThrow(ZodError) + }) +}) + +describe('Path validator with invalid options', () => { + it('should throw an error for missing options', () => { + // @ts-expect-error Testing invalid options + expect(() => createPathSchema()).toThrow(OptionsError) + }) + it('should throw an error for an invalid base path', () => { + expect(() => createPathSchema({ basePath: 'relative/path' })).toThrow( + OptionsError, + ) + expect(() => createPathSchema({ basePath: '' })).toThrow(OptionsError) + expect(() => createPathSchema({ basePath: '.' })).toThrow(OptionsError) + }) + + it('should throw an error for non-string base paths', () => { + // @ts-expect-error Testing invalid options + expect(() => createPathSchema({ basePath: 123 })).toThrow(OptionsError) + // @ts-expect-error Testing invalid options + expect(() => createPathSchema({ basePath: null })).toThrow(OptionsError) + // @ts-expect-error Testing invalid options + expect(() => createPathSchema({ basePath: {} })).toThrow(OptionsError) + }) +}) diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index e04e6cb..0bf0db0 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -7,6 +7,8 @@ export type * from '@/common/errors' export * from '@/email' export type { EmailValidatorOptions } from '@/email/options' +export * from '@/path' +export type { PathValidatorOptions } from '@/path/options' export * from '@/url' export type * from '@/url/errors' export type { UrlValidatorOptions } from '@/url/options' diff --git a/packages/validators/src/path/index.ts b/packages/validators/src/path/index.ts new file mode 100644 index 0000000..6f2699f --- /dev/null +++ b/packages/validators/src/path/index.ts @@ -0,0 +1,25 @@ +import { ZodSchema } from 'zod' +import { fromError } from 'zod-validation-error' + +import { OptionsError } from '@/common/errors' +import { optionsSchema, PathValidatorOptions } from '@/path/options' +import { toSchema } from '@/path/schema' + +/** + * Create a schema that validates user-supplied pathnames for filesystem operations. + * + * @param options - The options to use for validation + * @throws {@link OptionsError} If the options are invalid + * @returns A Zod schema that validates paths. + * + * @public + */ +export const createPathSchema = ( + options: PathValidatorOptions, +): ZodSchema => { + const result = optionsSchema.safeParse(options) + if (result.success) { + return toSchema(result.data) + } + throw new OptionsError(fromError(result.error).toString()) +} diff --git a/packages/validators/src/path/options.ts b/packages/validators/src/path/options.ts new file mode 100644 index 0000000..37c6e59 --- /dev/null +++ b/packages/validators/src/path/options.ts @@ -0,0 +1,29 @@ +import path from 'node:path' + +import { z } from 'zod' + +/** + * The options to use for path validation. + * + * @public + */ +export interface PathValidatorOptions { + /** + * The base path to use for validation. This must be an absolute path. + * + * All provided paths, resolved relative to the working directory of the Node process, + * must be within this directory (or its subdirectories), or they will be considered unsafe. + * You should provide a safe base path that does not contain sensitive files or directories. + * + * @example `'/var/www'` + */ + basePath: string +} + +export const optionsSchema = z.object({ + basePath: z.string().refine((basePath) => { + return basePath === path.resolve(basePath) && path.isAbsolute(basePath) + }, 'The base path must be an absolute path'), +}) + +export type ParsedPathValidatorOptions = z.infer diff --git a/packages/validators/src/path/schema.ts b/packages/validators/src/path/schema.ts new file mode 100644 index 0000000..ce6cec5 --- /dev/null +++ b/packages/validators/src/path/schema.ts @@ -0,0 +1,21 @@ +import path from 'node:path' + +import { z } from 'zod' + +import { ParsedPathValidatorOptions } from '@/path/options' +import { isSafePath } from '@/path/utils' + +const createValidationSchema = (options: ParsedPathValidatorOptions) => + z + .string() + // resolve the path relative to the Node process's current working directory + // since that's what fs operations will be relative to + .transform((untrustedPath) => path.resolve(untrustedPath)) + // resolvedPath is now an absolute path + .refine((resolvedPath) => isSafePath(resolvedPath, options.basePath), { + message: 'The provided path is unsafe.', + }) + +export const toSchema = (options: ParsedPathValidatorOptions) => { + return z.string().trim().pipe(createValidationSchema(options)) +} diff --git a/packages/validators/src/path/utils.ts b/packages/validators/src/path/utils.ts new file mode 100644 index 0000000..4b21f18 --- /dev/null +++ b/packages/validators/src/path/utils.ts @@ -0,0 +1,26 @@ +import path from 'node:path' + +export const isSafePath = (absPath: string, basePath: string): boolean => { + // check for poison null bytes + if (absPath.indexOf('\0') !== -1) { + return false + } + // check for backslashes + if (absPath.indexOf('\\') !== -1) { + return false + } + + // check for dot segments, even if they don't normalize to anything + if (absPath.includes('..')) { + return false + } + + // check if the normalized path is within the provided 'safe' base path + if (path.resolve(basePath, path.relative(basePath, absPath)) !== absPath) { + return false + } + if (absPath.indexOf(basePath) !== 0) { + return false + } + return true +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 481080e..e179f8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,11 +25,65 @@ importers: version: link:../../packages/validators vitepress: specifier: ^1.3.1 - version: 1.3.1(@algolia/client-search@4.24.0)(@types/node@18.19.39)(postcss@8.4.39)(search-insights@2.15.0)(typescript@5.4.5) + version: 1.3.1(@algolia/client-search@4.24.0)(@types/node@18.19.47)(postcss@8.4.39)(search-insights@2.15.0)(typescript@5.4.5) vue: specifier: ~3.4.31 version: 3.4.33(typescript@5.4.5) + packages/safe-fs: + devDependencies: + '@swc/core': + specifier: ^1.6.13 + version: 1.6.13 + '@types/node': + specifier: ^18.19.47 + version: 18.19.47 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': + specifier: ^6.0.0 + version: 6.21.0(eslint@8.57.0)(typescript@5.4.5) + eslint: + specifier: ^8.56.0 + version: 8.57.0 + eslint-config-opengovsg: + specifier: ^3.0.0 + version: 3.0.0(@pulumi/eslint-plugin@0.2.0(eslint@8.57.0))(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5))(eslint-config-prettier@8.10.0(eslint@8.57.0))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.10.0(eslint@8.57.0))(eslint@8.57.0)(prettier@2.8.8))(eslint-plugin-react-hooks@4.6.0(eslint@8.57.0))(eslint-plugin-react@7.33.2(eslint@8.57.0))(eslint-plugin-simple-import-sort@10.0.0(eslint@8.57.0))(eslint@8.57.0)(prettier@2.8.8) + eslint-config-prettier: + specifier: ^8.6.0 + version: 8.10.0(eslint@8.57.0) + eslint-import-resolver-typescript: + specifier: ^3.6.1 + version: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-plugin-import: + specifier: ^2.29.1 + version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-simple-import-sort: + specifier: ^10.0.0 + version: 10.0.0(eslint@8.57.0) + eslint-plugin-tsdoc: + specifier: ^0.3.0 + version: 0.3.0 + memfs: + specifier: ^4.11.1 + version: 4.11.1 + prettier: + specifier: ^2.8.4 + version: 2.8.8 + tsc-alias: + specifier: ^1.8.10 + version: 1.8.10 + tsup: + specifier: ^8.1.0 + version: 8.1.0(@swc/core@1.6.13)(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.6.13)(@types/node@18.19.47)(typescript@5.4.5))(typescript@5.4.5) + typescript: + specifier: ^5.4.5 + version: 5.4.5 + vitest: + specifier: ^2.0.2 + version: 2.0.2(@types/node@18.19.47) + packages/validators: dependencies: email-addresses: @@ -415,6 +469,24 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.1.0': + resolution: {integrity: sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.3.0': + resolution: {integrity: sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@microsoft/tsdoc-config@0.17.0': resolution: {integrity: sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg==} @@ -637,6 +709,9 @@ packages: '@types/node@18.19.39': resolution: {integrity: sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==} + '@types/node@18.19.47': + resolution: {integrity: sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==} + '@types/semver@7.5.0': resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} @@ -1450,6 +1525,10 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + ignore@5.3.1: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} @@ -1678,6 +1757,10 @@ packages: mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + memfs@4.11.1: + resolution: {integrity: sha512-LZcMTBAgqUUKNXZagcZxvXXfgF1bHX7Y7nQ0QyEiNbRJgE29GhgPd8Yna1VQcLlPiHt/5RFJMWYN9Uv/VPNvjQ==} + engines: {node: '>= 4.0.0'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2120,6 +2203,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@1.21.0: + resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + tinybench@2.8.0: resolution: {integrity: sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==} @@ -2146,6 +2235,12 @@ packages: tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tree-dump@1.0.2: + resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2183,6 +2278,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tsup@8.1.0: resolution: {integrity: sha512-UFdfCAXukax+U6KzeTNO2kAARHcWxmKsnvSPXUcfA1D+kU05XDccCrkffCQpFaWDsZfV0jMyTsxU39VfCp6EOg==} engines: {node: '>=18'} @@ -2674,7 +2772,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.4 + debug: 4.3.5 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.1 @@ -2744,6 +2842,22 @@ snapshots: '@jridgewell/sourcemap-codec': 1.4.15 optional: true + '@jsonjoy.com/base64@1.1.2(tslib@2.7.0)': + dependencies: + tslib: 2.7.0 + + '@jsonjoy.com/json-pack@1.1.0(tslib@2.7.0)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.7.0) + '@jsonjoy.com/util': 1.3.0(tslib@2.7.0) + hyperdyperid: 1.2.0 + thingies: 1.21.0(tslib@2.7.0) + tslib: 2.7.0 + + '@jsonjoy.com/util@1.3.0(tslib@2.7.0)': + dependencies: + tslib: 2.7.0 + '@microsoft/tsdoc-config@0.17.0': dependencies: '@microsoft/tsdoc': 0.15.0 @@ -2922,6 +3036,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@18.19.47': + dependencies: + undici-types: 5.26.5 + '@types/semver@7.5.0': {} '@types/unist@3.0.2': {} @@ -3069,9 +3187,9 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-vue@5.0.5(vite@5.3.3(@types/node@18.19.39))(vue@3.4.33(typescript@5.4.5))': + '@vitejs/plugin-vue@5.0.5(vite@5.3.3(@types/node@18.19.47))(vue@3.4.33(typescript@5.4.5))': dependencies: - vite: 5.3.3(@types/node@18.19.39) + vite: 5.3.3(@types/node@18.19.47) vue: 3.4.33(typescript@5.4.5) '@vitest/expect@2.0.2': @@ -3731,7 +3849,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4 + debug: 4.3.5 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -3979,6 +4097,8 @@ snapshots: human-signals@5.0.0: {} + hyperdyperid@1.2.0: {} + ignore@5.3.1: {} import-fresh@3.3.0: @@ -4191,6 +4311,13 @@ snapshots: mark.js@8.11.1: {} + memfs@4.11.1: + dependencies: + '@jsonjoy.com/json-pack': 1.1.0(tslib@2.7.0) + '@jsonjoy.com/util': 1.3.0(tslib@2.7.0) + tree-dump: 1.0.2(tslib@2.7.0) + tslib: 2.7.0 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -4361,6 +4488,14 @@ snapshots: postcss: 8.4.39 ts-node: 10.9.1(@swc/core@1.6.13)(@types/node@18.19.39)(typescript@5.4.5) + postcss-load-config@4.0.2(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.6.13)(@types/node@18.19.47)(typescript@5.4.5)): + dependencies: + lilconfig: 3.1.2 + yaml: 2.4.5 + optionalDependencies: + postcss: 8.4.39 + ts-node: 10.9.1(@swc/core@1.6.13)(@types/node@18.19.47)(typescript@5.4.5) + postcss@8.4.39: dependencies: nanoid: 3.3.7 @@ -4627,6 +4762,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thingies@1.21.0(tslib@2.7.0): + dependencies: + tslib: 2.7.0 + tinybench@2.8.0: {} tinypool@1.0.0: {} @@ -4645,6 +4784,10 @@ snapshots: dependencies: punycode: 2.3.0 + tree-dump@1.0.2(tslib@2.7.0): + dependencies: + tslib: 2.7.0 + tree-kill@1.2.2: {} ts-api-utils@1.0.2(typescript@5.4.5): @@ -4674,6 +4817,27 @@ snapshots: '@swc/core': 1.6.13 optional: true + ts-node@10.9.1(@swc/core@1.6.13)(@types/node@18.19.47)(typescript@5.4.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 18.19.47 + acorn: 8.12.1 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.4.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.6.13 + optional: true + tsc-alias@1.8.10: dependencies: chokidar: 3.6.0 @@ -4692,6 +4856,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.7.0: {} + tsup@8.1.0(@swc/core@1.6.13)(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.6.13)(@types/node@18.19.39)(typescript@5.4.5))(typescript@5.4.5): dependencies: bundle-require: 4.2.1(esbuild@0.21.5) @@ -4716,6 +4882,30 @@ snapshots: - supports-color - ts-node + tsup@8.1.0(@swc/core@1.6.13)(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.6.13)(@types/node@18.19.47)(typescript@5.4.5))(typescript@5.4.5): + dependencies: + bundle-require: 4.2.1(esbuild@0.21.5) + cac: 6.7.14 + chokidar: 3.6.0 + debug: 4.3.4 + esbuild: 0.21.5 + execa: 5.1.1 + globby: 11.1.0 + joycon: 3.1.1 + postcss-load-config: 4.0.2(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.6.13)(@types/node@18.19.47)(typescript@5.4.5)) + resolve-from: 5.0.0 + rollup: 4.18.1 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.6.13 + postcss: 8.4.39 + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + - ts-node + tsutils@3.21.0(typescript@4.9.5): dependencies: tslib: 1.14.1 @@ -4818,6 +5008,23 @@ snapshots: - supports-color - terser + vite-node@2.0.2(@types/node@18.19.47): + dependencies: + cac: 6.7.14 + debug: 4.3.5 + pathe: 1.1.2 + tinyrainbow: 1.2.0 + vite: 5.3.3(@types/node@18.19.47) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + vite@5.3.3(@types/node@18.19.39): dependencies: esbuild: 0.21.5 @@ -4827,14 +5034,23 @@ snapshots: '@types/node': 18.19.39 fsevents: 2.3.3 - vitepress@1.3.1(@algolia/client-search@4.24.0)(@types/node@18.19.39)(postcss@8.4.39)(search-insights@2.15.0)(typescript@5.4.5): + vite@5.3.3(@types/node@18.19.47): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.39 + rollup: 4.18.1 + optionalDependencies: + '@types/node': 18.19.47 + fsevents: 2.3.3 + + vitepress@1.3.1(@algolia/client-search@4.24.0)(@types/node@18.19.47)(postcss@8.4.39)(search-insights@2.15.0)(typescript@5.4.5): dependencies: '@docsearch/css': 3.6.1 '@docsearch/js': 3.6.1(@algolia/client-search@4.24.0)(search-insights@2.15.0) '@shikijs/core': 1.11.0 '@shikijs/transformers': 1.11.0 '@types/markdown-it': 14.1.1 - '@vitejs/plugin-vue': 5.0.5(vite@5.3.3(@types/node@18.19.39))(vue@3.4.33(typescript@5.4.5)) + '@vitejs/plugin-vue': 5.0.5(vite@5.3.3(@types/node@18.19.47))(vue@3.4.33(typescript@5.4.5)) '@vue/devtools-api': 7.3.6 '@vue/shared': 3.4.33 '@vueuse/core': 10.11.0(vue@3.4.33(typescript@5.4.5)) @@ -4843,7 +5059,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.0.2 shiki: 1.11.0 - vite: 5.3.3(@types/node@18.19.39) + vite: 5.3.3(@types/node@18.19.47) vue: 3.4.33(typescript@5.4.5) optionalDependencies: postcss: 8.4.39 @@ -4906,6 +5122,38 @@ snapshots: - supports-color - terser + vitest@2.0.2(@types/node@18.19.47): + dependencies: + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.2 + '@vitest/pretty-format': 2.0.2 + '@vitest/runner': 2.0.2 + '@vitest/snapshot': 2.0.2 + '@vitest/spy': 2.0.2 + '@vitest/utils': 2.0.2 + chai: 5.1.1 + debug: 4.3.5 + execa: 8.0.1 + magic-string: 0.30.10 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.8.0 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 + vite: 5.3.3(@types/node@18.19.47) + vite-node: 2.0.2(@types/node@18.19.47) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 18.19.47 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + vue-demi@0.14.8(vue@3.4.33(typescript@5.4.5)): dependencies: vue: 3.4.33(typescript@5.4.5)