Skip to content

Commit fec1f58

Browse files
fix(cli): a connection label printed by the CLI removes a password or a credential query pair wherever the URL rules read one (#30171)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:CLI 打印的数据库连接标签(`os dev` / `os start` / `os serve` 启动横幅与迁移确认)改为通过 spec 的共享 URL 凭据函数 `redactUrlCredentials` 去掉密码与凭据查询参数,不再自带一套规则或参数名单。 - 为什么改:凡是平台 URL 规则读出凭据(userinfo 密码或凭据查询参数)的值,终端标签都必须去掉它;标签与数据源读门共用同一套规则,规则扩展时标签自动跟随。 - 风险与回滚:仅影响打印文本;共享规则读不出凭据的值输出逐字节不变(已用对照用例钉住)。回滚即还原单个函数。 - 先看:`packages/cli/src/utils/connection-display.ts` 的 `redactConnectionUrl` 前两行;`connection-display.test.ts` 新增的两个 describe 块。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/utils/connection-display.test.ts`,预期 33 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - `redactConnectionUrl` (the database label the CLI prints) first removes credentials through `redactUrlCredentials` from `@objectstack/spec/data`, the composite the datasource read path applies. That covers the userinfo password and every credential query pair judged by the shared query-pair judge and its one list of names. Rule: a connection label printed by the CLI removes a password or a credential query pair wherever the URL rules read one, on every path the label takes. - The CLI keeps no second grammar and no second list of names. The existing display shaping runs unchanged after that step: the URL parser drops the username and query string, and when the parser rejects the value the userinfo segment is dropped by hand. - `@objectstack/cli` already depends on `@objectstack/spec` (`dependencies`, `workspace:*`), so no dependency is added. - Changeset: `@objectstack/cli` patch. ## Tests Two new top-level describes in the existing harness file `packages/cli/src/utils/connection-display.test.ts`, named for the rule. Password rule: - the password is removed wherever the shared grammar reads one, including a special scheme's authority; - the whole password is removed up to the boundary the shared grammar draws; - property pin: across a corpus of URL shapes, the label never contains a password that `urlUserinfoPassword` reads, and every shape in the corpus is judged; - controls, byte for byte against today's output. Query rule: - a credential query pair is removed on each of the three paths: a value with no `://`, the URL parser path, and the hand fallback; - property pin: the label never contains a value that `urlCredentialQueryParams` judges a credential; - controls, byte for byte, for values with no credential pair: a non-credential query on the fallback path and on a value with no `://`, and an empty-valued pair. Expected outputs for the controls were computed from the pre-change function before they were written into the pins. ## Acceptance notes - One-shot ablations (not kept as files), each restored to the HEAD blob with `git diff HEAD` empty: - the password step replaced by the identity: the three password-rule pins go red and every control stays green; - the query half removed (the import aliased to the password-only function): 3 failed, 30 passed. The no-`://` and hand-fallback pins and the query property pin go red. The URL-parser path pin stays green as expected, because that path already drops the whole query string. - The label follows any future widening of the shared grammar with no edit here; the property pins cover new shapes through their oracles. ## Verification - `connection-display.test.ts`: 33/33 passed. - Query-token coverage: a credential query pair is pinned removed on the no-`://` path, the hand-fallback path and the URL-parser path. - `pnpm --filter @objectstack/cli typecheck`: exit 0. - Full `@objectstack/cli` suite, both tiers, under the verify lock at 1e01266: Test Files 577 passed, 1 skipped (578); Tests 7074 passed, 9 skipped (7083); VERDICT command-exit 0. - Derived gate families (`dispatch-gates.mjs --commands`): 59 commands run locally, 56 exited 0. Three answered `PREREQUISITE NOT MET` (exit 3) because they need a whole-repo build: `check:dual-build-cjs-loads`, `check:i18n-coverage`, `check:type-check-debt`. Those three are NOT MEASURED locally and are left to `Lint & Repo Gates` in CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59e6463 commit fec1f58

3 files changed

Lines changed: 177 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): a connection label printed by the CLI removes a password or a credential query pair wherever the URL rules read one
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **One credential grammar.** The database label that `os dev`, `os start` and `os serve` print, and the migrate and resync confirms, now remove URL credentials through the same shared function the datasource read door applies (`redactUrlCredentials` from `@objectstack/spec/data`): the userinfo password, and every credential query pair judged by the shared query-pair judge and its one list of names. Wherever those rules read a credential, on every path the label takes, the label drops it. When the shared rules read more, the label follows with no edit of its own.
11+
- **Unchanged.** Every value the shared rules read no credential in prints exactly as before: `postgres://`, `libsql://` and `file:` values, values with no userinfo, a username-only userinfo, and everything the URL parser already cleaned.

‎packages/cli/src/utils/connection-display.test.ts‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
// shape leaks the credentials the DSN carries.
1313

1414
import { describe, it, expect } from 'vitest';
15+
import {
16+
CREDENTIAL_URL_QUERY_PARAM_NAMES,
17+
urlCredentialQueryParams,
18+
urlUserinfoPassword,
19+
} from '@objectstack/spec/data';
1520
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from './connection-display.js';
1621

1722
describe('redactConnectionUrl', () => {
@@ -56,6 +61,145 @@ describe('redactConnectionUrl', () => {
5661
});
5762
});
5863

64+
// The label is a print-time reader of the same question the datasource doors
65+
// answer: where does a URL carry a password? It asks the platform's ONE
66+
// userinfo grammar (`@objectstack/spec/data`) rather than keeping its own, so
67+
// the oracle below is that grammar's own password accessor.
68+
describe('redactConnectionUrl — the password rule is the shared URL grammar', () => {
69+
const PASSWORD = 'S3CRET-PW';
70+
71+
it('removes the password wherever the shared grammar reads one, with or without a literal `://`', () => {
72+
// A scheme-relative authority.
73+
expect(redactConnectionUrl(`//admin:${PASSWORD}@db.example.com:5432/app`))
74+
.toBe('//admin@db.example.com:5432/app');
75+
// A tab inside the `//`, which a URL parser deletes before it reads.
76+
expect(redactConnectionUrl(`postgres:/\t/admin:${PASSWORD}@db.example.com/app`))
77+
.toBe('postgres:/\t/admin@db.example.com/app');
78+
});
79+
80+
it('removes the password where the shared grammar reads a special scheme\'s authority without a `//`', () => {
81+
expect(redactConnectionUrl(`https:admin:${PASSWORD}@db.example.com/app`))
82+
.toBe('https:admin@db.example.com/app');
83+
expect(redactConnectionUrl(`http:/admin:${PASSWORD}@db.example.com/app`))
84+
.toBe('http:/admin@db.example.com/app');
85+
expect(redactConnectionUrl(`wss:\\admin:${PASSWORD}@db.example.com/app`))
86+
.toBe('wss:\\admin@db.example.com/app');
87+
});
88+
89+
it('removes the whole password up to the last `@` of the authority, as the shared grammar bounds it', () => {
90+
expect(redactConnectionUrl('postgres://admin:S3C@RET-PW@h1:5432,h2:5432/app'))
91+
.toBe('postgres://h1:5432,h2:5432/app');
92+
});
93+
94+
it('never prints a password the shared grammar reads, in any URL shape', () => {
95+
const shapes = [
96+
`postgres://admin:${PASSWORD}@db.example.com:5432/app`,
97+
`postgres://:${PASSWORD}@db.example.com/app`,
98+
`mysql://admin:${PASSWORD}@db.example.com:3306/app`,
99+
`libsql://admin:${PASSWORD}@app-org.turso.io?authToken=jwt`,
100+
`postgres://admin:${PASSWORD}@h1:5432,h2:5432/app`,
101+
`//admin:${PASSWORD}@db.example.com:5432/app`,
102+
`postgres:/\t/admin:${PASSWORD}@db.example.com/app`,
103+
` \npostgres://admin:${PASSWORD}@db.example.com/app`,
104+
// Special schemes without the `//`, read as a URL parser reads them.
105+
`https:admin:${PASSWORD}@db.example.com/app`,
106+
`http:/admin:${PASSWORD}@db.example.com/app`,
107+
`wss:\\admin:${PASSWORD}@db.example.com/app`,
108+
];
109+
const judged = shapes.filter((value) => urlUserinfoPassword(value) !== undefined);
110+
// Non-vacuity floor: the shared grammar reads a password in every shape
111+
// listed, so the property below is never checked over nothing.
112+
expect(judged.length).toBe(shapes.length);
113+
for (const value of judged) {
114+
expect(urlUserinfoPassword(value)).toBe(PASSWORD);
115+
expect(redactConnectionUrl(value), value).not.toContain(PASSWORD);
116+
}
117+
});
118+
119+
it('keeps its output byte for byte for every value the shared grammar reads no password in', () => {
120+
const controls: ReadonlyArray<readonly [string, string]> = [
121+
['postgres://admin@db.example.com:5432/app', 'postgres://db.example.com:5432/app'],
122+
['postgres://admin:@db.example.com/app', 'postgres://db.example.com/app'],
123+
['postgres://db.example.com:5432/app?sslmode=require', 'postgres://db.example.com:5432/app'],
124+
['postgres://admin@h1:5432,h2:5432/app', 'postgres://h1:5432,h2:5432/app'],
125+
['libsql://app-org.turso.io?authToken=secret-jwt', 'libsql://app-org.turso.io'],
126+
['https://db.example.com/app', 'https://db.example.com/app'],
127+
['file:./data/app.db', 'file:./data/app.db'],
128+
['file:///var/lib/app.db', 'file:///var/lib/app.db'],
129+
['sqlite:/var/lib/app.db', 'sqlite:/var/lib/app.db'],
130+
['//admin@db.example.com/app', '//admin@db.example.com/app'],
131+
[':memory:', ':memory:'],
132+
['(in-memory)', '(in-memory)'],
133+
];
134+
for (const [value, printed] of controls) {
135+
expect(urlUserinfoPassword(value), value).toBeUndefined();
136+
expect(redactConnectionUrl(value), value).toBe(printed);
137+
}
138+
});
139+
140+
it('keeps its output byte for byte for a password the URL parser already removed', () => {
141+
expect(redactConnectionUrl(`postgres://admin:${PASSWORD}@db.example.com:5432/app`))
142+
.toBe('postgres://db.example.com:5432/app');
143+
expect(redactConnectionUrl(`libsql://admin:${PASSWORD}@app-org.turso.io?authToken=jwt`))
144+
.toBe('libsql://app-org.turso.io');
145+
});
146+
});
147+
148+
// The query half of the same rule: a credential query pair is judged by the
149+
// shared query-pair judge and its one list of names, on every path the label
150+
// takes — a value with no `://`, the URL parser's, and the hand fallback for a
151+
// value the parser rejects.
152+
describe('redactConnectionUrl — the credential query rule is the shared query-pair judge', () => {
153+
const TOKEN = 'T0KEN-VALUE';
154+
155+
it('removes a credential query pair from a value with no `://`', () => {
156+
expect(redactConnectionUrl(`libsql:app-org.turso.io?authToken=${TOKEN}`))
157+
.toBe('libsql:app-org.turso.io');
158+
expect(redactConnectionUrl(`file:local.db?authToken=${TOKEN}&mode=ro`))
159+
.toBe('file:local.db?mode=ro');
160+
});
161+
162+
it('removes a credential query pair on the hand fallback for a value the URL parser rejects', () => {
163+
expect(redactConnectionUrl(`postgres://admin@h1:5432,h2:5432/app?password=${TOKEN}&sslmode=require`))
164+
.toBe('postgres://h1:5432,h2:5432/app?sslmode=require');
165+
});
166+
167+
it('removes a credential query pair on the URL parser path', () => {
168+
expect(redactConnectionUrl(`postgres://db.example.com/app?PASSWORD=${TOKEN}`))
169+
.toBe('postgres://db.example.com/app');
170+
expect(redactConnectionUrl(`libsql://app-org.turso.io?authToken=${TOKEN}`))
171+
.toBe('libsql://app-org.turso.io');
172+
});
173+
174+
it('never prints a credential query value the shared judge reads, on any path', () => {
175+
const shapes = [
176+
`libsql:app-org.turso.io?authToken=${TOKEN}`,
177+
`file:local.db?mode=ro&AUTHTOKEN=${TOKEN}`,
178+
`file:local.db?auth%54oken=${TOKEN}`,
179+
`postgres://admin@h1:5432,h2:5432/app?password=${TOKEN}`,
180+
`postgres://db.example.com/app?password=${TOKEN}`,
181+
`libsql://app-org.turso.io?authToken=${TOKEN}#frag`,
182+
];
183+
for (const value of shapes) {
184+
expect(urlCredentialQueryParams(value, CREDENTIAL_URL_QUERY_PARAM_NAMES), value).not.toEqual([]);
185+
expect(redactConnectionUrl(value), value).not.toContain(TOKEN);
186+
}
187+
});
188+
189+
it('keeps its output byte for byte for every value with no credential query pair', () => {
190+
const controls: ReadonlyArray<readonly [string, string]> = [
191+
['postgres://admin@h1:5432,h2:5432/app?sslmode=require', 'postgres://h1:5432,h2:5432/app?sslmode=require'],
192+
['file:local.db?mode=ro', 'file:local.db?mode=ro'],
193+
['libsql:app-org.turso.io?authToken=', 'libsql:app-org.turso.io?authToken='],
194+
['postgres://db.example.com:5432/app?sslmode=require', 'postgres://db.example.com:5432/app'],
195+
];
196+
for (const [value, printed] of controls) {
197+
expect(urlCredentialQueryParams(value, CREDENTIAL_URL_QUERY_PARAM_NAMES), value).toEqual([]);
198+
expect(redactConnectionUrl(value), value).toBe(printed);
199+
}
200+
});
201+
});
202+
59203
describe('describeDriverConnection', () => {
60204
it('reads a DSN string', () => {
61205
expect(describeDriverConnection({ client: 'pg', connection: 'postgres://u:p@host:5432/app' }))

‎packages/cli/src/utils/connection-display.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,33 @@
2424
* that the driver label and the host don't already say.
2525
*/
2626

27+
import { redactUrlCredentials } from '@objectstack/spec/data';
28+
2729
/**
2830
* Strip credentials from a connection string.
2931
*
30-
* Values that aren't URLs — a sqlite path, `:memory:`, the `(in-memory)`
31-
* marker — pass through untouched, as does anything the URL parser rejects
32-
* (minus its userinfo segment). Idempotent, so a call site that can't tell
33-
* whether its input was already redacted may apply it defensively.
32+
* Credentials go first, and by the platform's ONE URL credential grammar
33+
* (`redactUrlCredentials` from `@objectstack/spec/data`, the composite the
34+
* datasource read door applies): wherever that grammar reads a userinfo
35+
* password or a credential query pair — the string judged as a URL parser
36+
* cleans it, the pair judged by the shared query-pair judge and its one list
37+
* of credential names — the label drops it, so the label cannot print a
38+
* credential the doors would refuse or redact. No second grammar and no second
39+
* name list live here; when the shared ones read more, this label follows
40+
* without an edit.
41+
*
42+
* Then the display shaping, unchanged: a value carrying `://` loses its
43+
* username and query string through the URL parser, or its userinfo segment
44+
* by hand when the parser rejects it. Values that aren't URLs — a sqlite path,
45+
* `:memory:`, the `(in-memory)` marker — pass through untouched unless the
46+
* shared grammar read a credential in them. Idempotent, so a call site that
47+
* can't tell whether its input was already redacted may apply it defensively.
3448
*/
3549
export function redactConnectionUrl(value: string): string {
36-
if (!value.includes('://')) return value;
50+
const withoutCredentials = redactUrlCredentials(value);
51+
if (!withoutCredentials.includes('://')) return withoutCredentials;
3752
try {
38-
const url = new URL(value);
53+
const url = new URL(withoutCredentials);
3954
url.username = '';
4055
url.password = '';
4156
// Query params are where tokens hide (`libsql://….turso.io?authToken=…`),
@@ -45,7 +60,7 @@ export function redactConnectionUrl(value: string): string {
4560
} catch {
4661
// Not parseable (a driver-specific dialect, a malformed URL). Drop the
4762
// userinfo segment by hand rather than printing the whole thing.
48-
return value.replace(/:\/\/[^@/]*@/, '://');
63+
return withoutCredentials.replace(/:\/\/[^@/]*@/, '://');
4964
}
5065
}
5166

0 commit comments

Comments
 (0)