diff --git a/Dockerfile b/Dockerfile index 174abec..9f88cfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,4 +11,5 @@ WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist +USER node ENTRYPOINT ["node", "dist/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0bb26ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 smartass + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d9b36d8..0074c77 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,11 @@ Connections are merged from three layers. Higher layers win on name collisions. Rules: -- When the same name appears in more than one layer, the higher layer wins. +- Reads resolve name collisions by layer priority: env > config > store, so a + higher layer shadows a lower one with the same name. `list_connections` + reports the source layer that won. +- Writes never create a new shadow: `add_connection` refuses a name that already + exists in any layer (env, config, or store), even one it cannot edit. - `add_connection`, `update_connection`, and `remove_connection` only touch the store layer. Editing a connection that came from the env or a config file returns an error naming its source. @@ -51,7 +55,7 @@ rejected (the schemas are strict). | Field | Type | Notes | | --- | --- | --- | -| `name` | string | matches `[a-zA-Z0-9_-]+`; unique across all layers | +| `name` | string | matches `[a-zA-Z0-9_-]+`; `add_connection` refuses a name already present in any layer | | `type` | `postgres` \| `mysql` | the database engine | | `host` | string | database host | | `port` | number | defaults to `5432` (postgres) or `3306` (mysql) | @@ -158,15 +162,27 @@ Run it from your client, persisting the store in a named volume: "mcpServers": { "dbmole": { "command": "docker", - "args": ["run", "-i", "--rm", "-v", "dbmole-store:/root/.config/dbmole", "dbmole-mcp"] + "args": [ + "run", + "-i", + "--rm", + "-v", + "dbmole-store:/home/node/.config/dbmole", + "dbmole-mcp" + ] } } } ``` +The image runs as the non-root `node` user, so the store lives at +`/home/node/.config/dbmole` — mount the volume there (as above). + Note: when an SSH tunnel targets a bastion that is only reachable from the host, the usual Docker networking caveats apply. Use `host.docker.internal` (or host -networking) so the container can reach it. +networking) so the container can reach it. On Docker Desktop that name resolves +automatically; on plain Linux Docker Engine you must add +`--add-host=host.docker.internal:host-gateway` to the `docker run` args. ## Development diff --git a/src/db/sqlGuard.ts b/src/db/sqlGuard.ts index 1328dd5..84cb574 100644 --- a/src/db/sqlGuard.ts +++ b/src/db/sqlGuard.ts @@ -141,11 +141,29 @@ const skipBlockComment = (sql: string, start: number, engine: Engine): number => return i } +// True when the single quote at `start` opens a postgres E'...' / e'...' +// escape-string literal: the immediately preceding char is a standalone E/e +// prefix (the char before it must be a non-identifier char or the string start, +// so that the E is not the tail of a longer identifier). +const isPostgresEscapeString = (sql: string, start: number, engine: Engine): boolean => { + if (engine !== 'postgres' || start === 0) { + return false + } + const prev = sql[start - 1] + if (prev !== 'E' && prev !== 'e') { + return false + } + return start - 1 === 0 || !isWordChar(sql[start - 2]) +} + const skipSingleQuoted = (sql: string, start: number, engine: Engine): number => { + // mysql strings honor backslash escapes; postgres only does so for the + // E'...' escape-string syntax, where '\'' does not terminate the string. + const backslashEscapes = engine === 'mysql' || isPostgresEscapeString(sql, start, engine) let i = start + 1 while (i < sql.length) { const ch = sql[i] - if (engine === 'mysql' && ch === '\\') { + if (backslashEscapes && ch === '\\') { i += 2 continue } diff --git a/src/tools/schema.ts b/src/tools/schema.ts index dc58b1e..c902163 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -28,7 +28,7 @@ export const registerSchemaTools = (server: McpServer, manager: Manager): void = 'list_databases', { description: - 'List databases on a connection with their size in bytes (system databases hidden).', + 'List databases on a connection with size in bytes. Hides postgres template databases and mysql system schemas.', inputSchema: { connection: z.string().describe('connection name, see list_connections') } diff --git a/test/integration/mysql.test.ts b/test/integration/mysql.test.ts index a7e0702..7bf340d 100644 --- a/test/integration/mysql.test.ts +++ b/test/integration/mysql.test.ts @@ -8,11 +8,14 @@ import { createRegistry } from '../../src/config/registry.js' import type { Manager } from '../../src/db/manager.js' import { createManager } from '../../src/db/manager.js' +// Ordering contract: vitest runs in-file `it` blocks sequentially in +// declaration order, and these tests share tables across blocks by design +// (e.g. `users` created early is read by later cases). Do not reorder. describe('mysql integration', () => { - let container: StartedMySqlContainer - let dir: string + let container: StartedMySqlContainer | undefined + let dir: string | undefined let registry: Registry - let manager: Manager + let manager: Manager | undefined beforeAll(async () => { container = await new MySqlContainer('mysql:8.4').start() @@ -42,13 +45,29 @@ describe('mysql integration', () => { }) afterAll(async () => { - await manager.disposeAll() - await container.stop() - rmSync(dir, { recursive: true, force: true }) + await manager?.disposeAll().catch(() => {}) + await container?.stop().catch(() => {}) + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } }) + const db = (): Manager => { + if (!manager) { + throw new Error('setup failed: manager not initialized') + } + return manager + } + + const my = (): StartedMySqlContainer => { + if (!container) { + throw new Error('setup failed: container not initialized') + } + return container + } + it('runs DDL, DML with lastInsertId and SELECT with params', async () => { - const { driver } = await manager.get('my') + const { driver } = await db().get('my') await driver.query({ sql: 'CREATE TABLE users (id int AUTO_INCREMENT PRIMARY KEY, name varchar(64) NOT NULL)', rowLimit: 10 @@ -69,7 +88,7 @@ describe('mysql integration', () => { }) it('preserves BIGINT precision beyond 2^53', async () => { - const { driver } = await manager.get('my') + const { driver } = await db().get('my') await driver.query({ sql: 'CREATE TABLE big (v bigint)', rowLimit: 10 @@ -83,7 +102,7 @@ describe('mysql integration', () => { }) it('rejects multi-statement and session-level sql', async () => { - const { driver } = await manager.get('my') + const { driver } = await db().get('my') await expect(driver.query({ sql: 'SELECT 1; SELECT 2', rowLimit: 10 })).rejects.toThrow( /one SQL statement/ ) @@ -93,9 +112,9 @@ describe('mysql integration', () => { }) it('lists databases and tables, describes a table', async () => { - const { driver } = await manager.get('my') + const { driver } = await db().get('my') const databases = await driver.listDatabases() - expect(databases.map((d) => d.name)).toContain(container.getDatabase()) + expect(databases.map((d) => d.name)).toContain(my().getDatabase()) await driver.query({ sql: `CREATE TABLE orders ( @@ -127,12 +146,12 @@ describe('mysql integration', () => { }) it('describe_table of a missing table fails clearly', async () => { - const { driver } = await manager.get('my') + const { driver } = await db().get('my') await expect(driver.describeTable({ table: 'ghost' })).rejects.toThrow(/not found/) }) it('enforces readonly: INSERT and DDL fail, SELECT works', async () => { - const { driver } = await manager.get('my-ro') + const { driver } = await db().get('my-ro') const select = await driver.query({ sql: 'SELECT count(*) FROM users', rowLimit: 10 }) expect(Number(select.rows[0][0])).toBe(2) await expect( @@ -144,7 +163,7 @@ describe('mysql integration', () => { }) it('reports server version', async () => { - const { driver } = await manager.get('my') + const { driver } = await db().get('my') expect(await driver.serverVersion()).toMatch(/^8\./) }) }) diff --git a/test/integration/postgres.test.ts b/test/integration/postgres.test.ts index b4900b7..e582280 100644 --- a/test/integration/postgres.test.ts +++ b/test/integration/postgres.test.ts @@ -8,11 +8,14 @@ import { createRegistry } from '../../src/config/registry.js' import type { Manager } from '../../src/db/manager.js' import { createManager } from '../../src/db/manager.js' +// Ordering contract: vitest runs in-file `it` blocks sequentially in +// declaration order, and these tests share tables across blocks by design +// (e.g. `users` created early is read by later cases). Do not reorder. describe('postgres integration', () => { - let container: StartedPostgreSqlContainer - let dir: string + let container: StartedPostgreSqlContainer | undefined + let dir: string | undefined let registry: Registry - let manager: Manager + let manager: Manager | undefined beforeAll(async () => { container = await new PostgreSqlContainer('postgres:17-alpine').start() @@ -42,13 +45,29 @@ describe('postgres integration', () => { }) afterAll(async () => { - await manager.disposeAll() - await container.stop() - rmSync(dir, { recursive: true, force: true }) + await manager?.disposeAll().catch(() => {}) + await container?.stop().catch(() => {}) + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } }) + const db = (): Manager => { + if (!manager) { + throw new Error('setup failed: manager not initialized') + } + return manager + } + + const pg = (): StartedPostgreSqlContainer => { + if (!container) { + throw new Error('setup failed: container not initialized') + } + return container + } + it('runs DDL, DML and SELECT with params', async () => { - const { driver } = await manager.get('pg') + const { driver } = await db().get('pg') await driver.query({ sql: 'CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL)', rowLimit: 10 @@ -68,7 +87,7 @@ describe('postgres integration', () => { }) it('truncates SELECT results at rowLimit', async () => { - const { driver } = await manager.get('pg') + const { driver } = await db().get('pg') const result = await driver.query({ sql: 'SELECT generate_series(1, 10)', rowLimit: 3 @@ -78,7 +97,7 @@ describe('postgres integration', () => { }) it('rejects multi-statement and session-level sql', async () => { - const { driver } = await manager.get('pg') + const { driver } = await db().get('pg') await expect(driver.query({ sql: 'SELECT 1; SELECT 2', rowLimit: 10 })).rejects.toThrow( /one SQL statement/ ) @@ -86,9 +105,9 @@ describe('postgres integration', () => { }) it('lists databases, tables and describes a table', async () => { - const { driver } = await manager.get('pg') + const { driver } = await db().get('pg') const databases = await driver.listDatabases() - expect(databases.map((d) => d.name)).toContain(container.getDatabase()) + expect(databases.map((d) => d.name)).toContain(pg().getDatabase()) const tables = await driver.listTables({}) expect(tables).toContainEqual(expect.objectContaining({ schema: 'public', name: 'users' })) @@ -123,7 +142,7 @@ describe('postgres integration', () => { }) it('describes composite foreign keys with paired columns', async () => { - const { driver } = await manager.get('pg') + const { driver } = await db().get('pg') await driver.query({ sql: `CREATE TABLE parents ( a integer NOT NULL, @@ -149,12 +168,12 @@ describe('postgres integration', () => { }) it('describe_table of a missing table fails clearly', async () => { - const { driver } = await manager.get('pg') + const { driver } = await db().get('pg') await expect(driver.describeTable({ table: 'ghost' })).rejects.toThrow(/not found/) }) it('enforces readonly: INSERT and DDL fail, SELECT works', async () => { - const { driver } = await manager.get('pg-ro') + const { driver } = await db().get('pg-ro') const select = await driver.query({ sql: 'SELECT count(*) FROM users', rowLimit: 10 }) expect(select.rows[0][0]).toBe('2') await expect( @@ -166,7 +185,7 @@ describe('postgres integration', () => { }) it('reports server version', async () => { - const { driver } = await manager.get('pg') + const { driver } = await db().get('pg') expect(await driver.serverVersion()).toMatch(/^17\./) }) }) diff --git a/test/integration/tunnel.test.ts b/test/integration/tunnel.test.ts index f316dc1..97fdd38 100644 --- a/test/integration/tunnel.test.ts +++ b/test/integration/tunnel.test.ts @@ -14,12 +14,17 @@ import { createRegistry } from '../../src/config/registry.js' import type { Manager } from '../../src/db/manager.js' import { createManager } from '../../src/db/manager.js' +// Pinned by digest for wait-strategy stability: the wait below depends on this +// image's '[ls.io-init] done.' log line. Bump deliberately if the log changes. +const SSHD_IMAGE = + 'lscr.io/linuxserver/openssh-server@sha256:5b8550a3b703eb4e5efb14a1f491370b7f765febfac5b0b2ed0321cdc74b1476' + describe('ssh tunnel integration', () => { - let network: StartedNetwork - let postgres: StartedPostgreSqlContainer - let sshd: StartedTestContainer - let dir: string - let manager: Manager + let network: StartedNetwork | undefined + let postgres: StartedPostgreSqlContainer | undefined + let sshd: StartedTestContainer | undefined + let dir: string | undefined + let manager: Manager | undefined beforeAll(async () => { network = await new Network().start() @@ -29,7 +34,7 @@ describe('ssh tunnel integration', () => { .withNetworkAliases('db') .start() - sshd = await new GenericContainer('lscr.io/linuxserver/openssh-server:latest') + sshd = await new GenericContainer(SSHD_IMAGE) .withNetwork(network) .withEnvironment({ PUID: '1000', @@ -70,21 +75,30 @@ describe('ssh tunnel integration', () => { }) afterAll(async () => { - await manager.disposeAll() - await sshd.stop() - await postgres.stop() - await network.stop() - rmSync(dir, { recursive: true, force: true }) + await manager?.disposeAll().catch(() => {}) + await sshd?.stop().catch(() => {}) + await postgres?.stop().catch(() => {}) + await network?.stop().catch(() => {}) + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } }) + const db = (): Manager => { + if (!manager) { + throw new Error('setup failed: manager not initialized') + } + return manager + } + it('queries postgres through the ssh tunnel', async () => { - const { driver } = await manager.get('pg-tunneled') + const { driver } = await db().get('pg-tunneled') const result = await driver.query({ sql: 'SELECT 1 + 1', rowLimit: 10 }) expect(result.rows).toEqual([[2]]) }) it('reports server version through the tunnel', async () => { - const { driver } = await manager.get('pg-tunneled') + const { driver } = await db().get('pg-tunneled') expect(await driver.serverVersion()).toMatch(/^17\./) }) }) diff --git a/test/unit/db/sqlGuard.test.ts b/test/unit/db/sqlGuard.test.ts index a43c586..54dfab2 100644 --- a/test/unit/db/sqlGuard.test.ts +++ b/test/unit/db/sqlGuard.test.ts @@ -93,6 +93,30 @@ describe('assertSafeStatement', () => { // \' does not end the string for mysql, so the ; stays inside expect(() => assertSafeStatement("select 'a\\'; select 2'", 'mysql')).not.toThrow() }) + + it('treats postgres E-strings as backslash-escaped (single statement)', () => { + // E'it\'s; fine' is one literal: \' is an escaped quote, so the ; + // stays inside the string and this is a single statement. + expect(() => assertSafeStatement("SELECT E'it\\'s; fine'", 'postgres')).not.toThrow() + expect(() => assertSafeStatement("SELECT e'it\\'s; fine'", 'postgres')).not.toThrow() + }) + + it('does not treat plain postgres strings as backslash-escaped', () => { + // In a plain '...' literal \ is a literal char, so 'a\' closes at the + // second quote, leaving '; SELECT 1' as a second statement. + expect(() => assertSafeStatement("SELECT 'a\\'; SELECT 1", 'postgres')).toThrow( + /one SQL statement/ + ) + }) + + it('does not treat a trailing-E identifier as an E-string prefix', () => { + // The E here is the tail of the identifier `somE`, not a standalone + // prefix, so backslash escapes stay off: 'a\' closes at the second + // quote and '; SELECT 1' is a second statement. + expect(() => assertSafeStatement("SELECT somE'a\\'; SELECT 1", 'postgres')).toThrow( + /one SQL statement/ + ) + }) }) describe('session-statement guard', () => {