pi-codex-marketplace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/extensions/pi/git-registration.ts +138 -0
- package/extensions/pi/index.ts +293 -0
- package/extensions/pi/installation.ts +90 -0
- package/extensions/pi/journal.ts +80 -0
- package/extensions/pi/lifecycle.ts +285 -0
- package/extensions/pi/registration.ts +143 -0
- package/extensions/pi/scope-overrides.ts +170 -0
- package/package.json +60 -0
- package/src/barrier/global-barrier.ts +105 -0
- package/src/bridge-state/atomic.ts +237 -0
- package/src/bridge-state/index.ts +5 -0
- package/src/bridge-state/migrate.ts +261 -0
- package/src/bridge-state/paths.ts +75 -0
- package/src/bridge-state/repair.ts +185 -0
- package/src/bridge-state/schema.ts +70 -0
- package/src/bridge-state/store.ts +489 -0
- package/src/bridge-state/types.ts +170 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/paths.ts +42 -0
- package/src/cache/source-cache.ts +365 -0
- package/src/compatibility/index.ts +1 -0
- package/src/compatibility/profile.ts +328 -0
- package/src/installation/flow.ts +443 -0
- package/src/installation/index.ts +1 -0
- package/src/installation/inspection.ts +129 -0
- package/src/journal/active-chains.ts +99 -0
- package/src/journal/index.ts +3 -0
- package/src/journal/journal.ts +215 -0
- package/src/journal/types.ts +49 -0
- package/src/lifecycle/index.ts +5 -0
- package/src/lifecycle/rebind.ts +290 -0
- package/src/lifecycle/refresh.ts +407 -0
- package/src/lifecycle/removal.ts +457 -0
- package/src/lifecycle/update-plan.ts +222 -0
- package/src/lifecycle/update.ts +303 -0
- package/src/projection/collision.ts +120 -0
- package/src/projection/effective-state.ts +182 -0
- package/src/projection/index.ts +4 -0
- package/src/projection/overrides.ts +230 -0
- package/src/projection/project.ts +359 -0
- package/src/reconciliation/startup.ts +144 -0
- package/src/registration/budget.ts +28 -0
- package/src/registration/catalog.ts +224 -0
- package/src/registration/contained.ts +140 -0
- package/src/registration/fence.ts +86 -0
- package/src/registration/findings.ts +188 -0
- package/src/registration/flow.ts +619 -0
- package/src/registration/git-acquisition.ts +481 -0
- package/src/registration/git-flow.ts +654 -0
- package/src/registration/git-locator.ts +380 -0
- package/src/registration/git-selector.ts +279 -0
- package/src/registration/index.ts +16 -0
- package/src/registration/receipt.ts +305 -0
- package/src/registration/registration.ts +102 -0
- package/src/registration/snapshot.ts +382 -0
- package/src/registration/source-key.ts +111 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Git Locator — credential-free HTTPS or SSH repository locator.
|
|
3
|
+
* See CONTEXT.md: Canonical Git Locator.
|
|
4
|
+
*
|
|
5
|
+
* Preserves transport/host/port/path/SSH user.
|
|
6
|
+
* Rejects plaintext/local transports, embedded credentials, query, fragment, ambiguous encoding,
|
|
7
|
+
* whitespace, control characters, backslash.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Scope } from '../bridge-state/types.js';
|
|
11
|
+
import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
|
|
12
|
+
|
|
13
|
+
export type GitTransport = 'https' | 'ssh';
|
|
14
|
+
|
|
15
|
+
export interface CanonicalGitLocator {
|
|
16
|
+
/** Original input as provided */
|
|
17
|
+
rawInput: string;
|
|
18
|
+
/** Credential-free canonical URL, e.g. https://github.com/owner/repo or ssh://git@github.com/owner/repo */
|
|
19
|
+
canonicalUrl: string;
|
|
20
|
+
transport: GitTransport;
|
|
21
|
+
host: string;
|
|
22
|
+
port?: number;
|
|
23
|
+
/** Posix path with leading slash, case-sensitive, e.g. /owner/repo.git */
|
|
24
|
+
path: string;
|
|
25
|
+
/** SSH user when transport is ssh, e.g. git */
|
|
26
|
+
user?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LocatorResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
locator?: CanonicalGitLocator;
|
|
32
|
+
findings: ValidationFinding[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Check for control characters (\x00-\x1F, \x7F) */
|
|
36
|
+
function hasControlChars(s: string): boolean {
|
|
37
|
+
// eslint-disable-next-line no-control-regex
|
|
38
|
+
return /[\x00-\x1F\x7F]/.test(s);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Detect ambiguous percent-encodings that decode to sensitive characters: / \ ? # @ : and NUL */
|
|
42
|
+
function hasAmbiguousEncoding(s: string): { bad: boolean; reason?: string } {
|
|
43
|
+
// Detect lone % not forming valid encoding first (before early return)
|
|
44
|
+
if (/%(?![0-9A-Fa-f]{2})/.test(s)) {
|
|
45
|
+
return { bad: true, reason: 'lone % without two hex digits' };
|
|
46
|
+
}
|
|
47
|
+
const pct = s.match(/%[0-9A-Fa-f]{2}/g);
|
|
48
|
+
if (!pct) return { bad: false };
|
|
49
|
+
const sensitive = new Set(['%2f', '%2F', '%5c', '%5C', '%3f', '%3F', '%23', '%00', '%40', '%3a', '%3A', '%2e', '%2E']);
|
|
50
|
+
for (const enc of pct) {
|
|
51
|
+
const low = enc.toLowerCase();
|
|
52
|
+
if (sensitive.has(low)) {
|
|
53
|
+
return { bad: true, reason: `ambiguous percent-encoding '${enc}' decodes to sensitive character` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { bad: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SCP_RE = /^(?:(?<user>[A-Za-z0-9._-]+)@)?(?<host>[A-Za-z0-9.-]+):(?<path>[^\0\s?#]+)$/;
|
|
60
|
+
|
|
61
|
+
function normalizeHost(host: string): string {
|
|
62
|
+
return host.toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizePath(path: string): string {
|
|
66
|
+
// Ensure leading slash, collapse duplicate slashes already rejected, keep case, remove trailing slash not needed
|
|
67
|
+
let p = path;
|
|
68
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
69
|
+
// Remove trailing slash except when path is just "/"
|
|
70
|
+
if (p.length > 1 && p.endsWith('/')) p = p.replace(/\/+$/, '');
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateHost(host: string): boolean {
|
|
75
|
+
if (!host || host.length === 0) return false;
|
|
76
|
+
if (host.includes('..')) return false;
|
|
77
|
+
if (!/^[A-Za-z0-9.-]+$/.test(host)) return false;
|
|
78
|
+
if (host.startsWith('.') || host.startsWith('-') || host.endsWith('.') || host.endsWith('-')) return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validatePath(path: string): { ok: boolean; reason?: string } {
|
|
83
|
+
if (!path || path.length === 0) return { ok: false, reason: 'empty path' };
|
|
84
|
+
if (path.includes('\\')) return { ok: false, reason: 'backslash in path' };
|
|
85
|
+
if (path.includes('//')) return { ok: false, reason: 'double slash in path' };
|
|
86
|
+
if (path.includes('\0')) return { ok: false, reason: 'NUL in path' };
|
|
87
|
+
// Path must be slash-separated segments, each segment non-empty, not "." or "..", no whitespace/control
|
|
88
|
+
const segments = path.split('/').filter((s) => s.length > 0);
|
|
89
|
+
if (segments.length === 0) return { ok: false, reason: 'no path segments' };
|
|
90
|
+
for (const seg of segments) {
|
|
91
|
+
if (seg === '.' || seg === '..') return { ok: false, reason: `dot segment '${seg}'` };
|
|
92
|
+
if (/\s/.test(seg)) return { ok: false, reason: `whitespace in segment '${seg}'` };
|
|
93
|
+
if (/[\x00-\x1F\x7F]/.test(seg)) return { ok: false, reason: `control character in segment '${seg}'` };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Build a blocking finding for locator */
|
|
99
|
+
function locatorFinding(scope: Scope, code: string, rule: string, outcome: string): ValidationFinding {
|
|
100
|
+
return blocking({
|
|
101
|
+
code,
|
|
102
|
+
phase: 'validation',
|
|
103
|
+
target: 'source',
|
|
104
|
+
scope,
|
|
105
|
+
pointer: '',
|
|
106
|
+
rule,
|
|
107
|
+
outcome,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a Git Locator to its credential-free canonical form.
|
|
113
|
+
* Preserves transport/host/port/path/SSH user; rejects plaintext, credentials, query/fragment, ambiguous encoding.
|
|
114
|
+
*/
|
|
115
|
+
export function normalizeGitLocator(input: string, scope: Scope): LocatorResult {
|
|
116
|
+
const findings: ValidationFinding[] = [];
|
|
117
|
+
const raw = input;
|
|
118
|
+
|
|
119
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
120
|
+
findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator is empty'));
|
|
121
|
+
return { ok: false, findings };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Control chars
|
|
125
|
+
if (hasControlChars(input)) {
|
|
126
|
+
findings.push(
|
|
127
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_CONTROL_CHARS, RULE.GIT_LOCATOR_CONTROL_CHARS, 'Git locator contains control characters'),
|
|
128
|
+
);
|
|
129
|
+
return { ok: false, findings };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Whitespace (space, tab, newline)
|
|
133
|
+
if (/\s/.test(input)) {
|
|
134
|
+
findings.push(
|
|
135
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator contains whitespace'),
|
|
136
|
+
);
|
|
137
|
+
return { ok: false, findings };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (input.includes('\\')) {
|
|
141
|
+
findings.push(
|
|
142
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator contains backslash'),
|
|
143
|
+
);
|
|
144
|
+
return { ok: false, findings };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Query / fragment — any ? or # is rejected (not accepted per spec)
|
|
148
|
+
if (input.includes('?') || input.includes('#')) {
|
|
149
|
+
findings.push(
|
|
150
|
+
locatorFinding(
|
|
151
|
+
scope,
|
|
152
|
+
CODE.GIT_LOCATOR_QUERY_FRAGMENT,
|
|
153
|
+
RULE.GIT_LOCATOR_QUERY_FRAGMENT,
|
|
154
|
+
'Git locator must not contain query (?) or fragment (#)',
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
return { ok: false, findings };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Ambiguous encoding
|
|
161
|
+
const amb = hasAmbiguousEncoding(input);
|
|
162
|
+
if (amb.bad) {
|
|
163
|
+
findings.push(
|
|
164
|
+
locatorFinding(
|
|
165
|
+
scope,
|
|
166
|
+
CODE.GIT_LOCATOR_AMBIGUOUS_ENCODING,
|
|
167
|
+
RULE.GIT_LOCATOR_AMBIGUOUS_ENCODING,
|
|
168
|
+
`Git locator has ambiguous encoding: ${amb.reason}`,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
return { ok: false, findings };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Detect plaintext/local transports early by checking scheme
|
|
175
|
+
// If input contains "://", parse scheme
|
|
176
|
+
const hasScheme = input.includes('://');
|
|
177
|
+
|
|
178
|
+
// SCP-like handling (no scheme, contains @ and : before slash)
|
|
179
|
+
if (!hasScheme) {
|
|
180
|
+
// Check if it looks like a local path or file:// without scheme
|
|
181
|
+
if (input.startsWith('/') || input.startsWith('.') || input.startsWith('file:')) {
|
|
182
|
+
findings.push(
|
|
183
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_PLAINTEXT, RULE.GIT_LOCATOR_PLAINTEXT, `Git locator transport is not allowed: local/file transport rejected — use https:// or ssh://`),
|
|
184
|
+
);
|
|
185
|
+
return { ok: false, findings };
|
|
186
|
+
}
|
|
187
|
+
const m = input.match(SCP_RE);
|
|
188
|
+
if (m && m.groups) {
|
|
189
|
+
const user = m.groups.user;
|
|
190
|
+
const host = normalizeHost(m.groups.host);
|
|
191
|
+
let path = m.groups.path;
|
|
192
|
+
// path from scp is like owner/repo.git without leading slash; ensure we treat
|
|
193
|
+
if (path.startsWith('/')) {
|
|
194
|
+
findings.push(
|
|
195
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'SCP-like path must not start with /'),
|
|
196
|
+
);
|
|
197
|
+
return { ok: false, findings };
|
|
198
|
+
}
|
|
199
|
+
// Check for embedded credentials: path should not contain @ or : beyond the initial separator
|
|
200
|
+
if (path.includes('@')) {
|
|
201
|
+
findings.push(
|
|
202
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_CREDENTIAL, RULE.GIT_LOCATOR_CREDENTIAL, 'SCP-like locator must not contain @ in path (embedded credential)'),
|
|
203
|
+
);
|
|
204
|
+
return { ok: false, findings };
|
|
205
|
+
}
|
|
206
|
+
if (!validateHost(host)) {
|
|
207
|
+
findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid host '${host}'`));
|
|
208
|
+
return { ok: false, findings };
|
|
209
|
+
}
|
|
210
|
+
const normPath = normalizePath(path);
|
|
211
|
+
const pathCheck = validatePath(normPath);
|
|
212
|
+
if (!pathCheck.ok) {
|
|
213
|
+
findings.push(
|
|
214
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid path '${path}': ${pathCheck.reason}`),
|
|
215
|
+
);
|
|
216
|
+
return { ok: false, findings };
|
|
217
|
+
}
|
|
218
|
+
const canonicalUrl = user ? `ssh://${user}@${host}${normPath}` : `ssh://${host}${normPath}`;
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
findings: [],
|
|
222
|
+
locator: {
|
|
223
|
+
rawInput: raw,
|
|
224
|
+
canonicalUrl,
|
|
225
|
+
transport: 'ssh',
|
|
226
|
+
host,
|
|
227
|
+
path: normPath,
|
|
228
|
+
user,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// No scheme and not SCP => invalid
|
|
233
|
+
findings.push(
|
|
234
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator must be https://, ssh://, or scp-like user@host:path — missing scheme'),
|
|
235
|
+
);
|
|
236
|
+
return { ok: false, findings };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Has scheme: parse via URL
|
|
240
|
+
let url: URL;
|
|
241
|
+
try {
|
|
242
|
+
url = new URL(input);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
245
|
+
findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `unable to parse locator: ${msg}`));
|
|
246
|
+
return { ok: false, findings };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const scheme = url.protocol; // includes colon, e.g. "https:"
|
|
250
|
+
if (scheme === 'http:') {
|
|
251
|
+
findings.push(
|
|
252
|
+
locatorFinding(
|
|
253
|
+
scope,
|
|
254
|
+
CODE.GIT_LOCATOR_PLAINTEXT,
|
|
255
|
+
RULE.GIT_LOCATOR_PLAINTEXT,
|
|
256
|
+
'plaintext http:// transport is not allowed — use https://',
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
return { ok: false, findings };
|
|
260
|
+
}
|
|
261
|
+
if (scheme === 'ftp:' || scheme === 'file:' || scheme === 'git:') {
|
|
262
|
+
findings.push(
|
|
263
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_PLAINTEXT, RULE.GIT_LOCATOR_PLAINTEXT, `transport '${scheme.slice(0, -1)}' is not allowed — use https:// or ssh://`),
|
|
264
|
+
);
|
|
265
|
+
return { ok: false, findings };
|
|
266
|
+
}
|
|
267
|
+
if (scheme !== 'https:' && scheme !== 'ssh:') {
|
|
268
|
+
findings.push(
|
|
269
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `unsupported transport '${scheme.slice(0, -1)}' — use https:// or ssh://`),
|
|
270
|
+
);
|
|
271
|
+
return { ok: false, findings };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Credentials check
|
|
275
|
+
if (scheme === 'https:' && (url.username.length > 0 || url.password.length > 0)) {
|
|
276
|
+
findings.push(
|
|
277
|
+
locatorFinding(
|
|
278
|
+
scope,
|
|
279
|
+
CODE.GIT_LOCATOR_CREDENTIAL,
|
|
280
|
+
RULE.GIT_LOCATOR_CREDENTIAL,
|
|
281
|
+
'https locator must not contain embedded credentials (user:pass@)',
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
return { ok: false, findings };
|
|
285
|
+
}
|
|
286
|
+
if (scheme === 'ssh:' && url.password.length > 0) {
|
|
287
|
+
findings.push(
|
|
288
|
+
locatorFinding(
|
|
289
|
+
scope,
|
|
290
|
+
CODE.GIT_LOCATOR_CREDENTIAL,
|
|
291
|
+
RULE.GIT_LOCATOR_CREDENTIAL,
|
|
292
|
+
'ssh locator must not contain password (user:pass@)',
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
return { ok: false, findings };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Query/fragment already rejected, but double-check URL search/hash
|
|
299
|
+
if (url.search.length > 0 || url.hash.length > 0) {
|
|
300
|
+
findings.push(
|
|
301
|
+
locatorFinding(
|
|
302
|
+
scope,
|
|
303
|
+
CODE.GIT_LOCATOR_QUERY_FRAGMENT,
|
|
304
|
+
RULE.GIT_LOCATOR_QUERY_FRAGMENT,
|
|
305
|
+
'Git locator must not contain query or fragment',
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
return { ok: false, findings };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const host = normalizeHost(url.hostname);
|
|
312
|
+
if (!validateHost(host)) {
|
|
313
|
+
findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid host '${host}'`));
|
|
314
|
+
return { ok: false, findings };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Port handling
|
|
318
|
+
let port: number | undefined;
|
|
319
|
+
if (url.port) {
|
|
320
|
+
const p = Number(url.port);
|
|
321
|
+
if (!Number.isInteger(p) || p <= 0 || p > 65535) {
|
|
322
|
+
findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid port '${url.port}'`));
|
|
323
|
+
return { ok: false, findings };
|
|
324
|
+
}
|
|
325
|
+
port = p;
|
|
326
|
+
// For https, default 443 may be omitted in canonical? Keep only if non-default for simplicity preserve as given
|
|
327
|
+
// For ssh, default 22 omitted
|
|
328
|
+
if ((scheme === 'https:' && port === 443) || (scheme === 'ssh:' && port === 22)) {
|
|
329
|
+
port = undefined; // omit default
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const rawPath = url.pathname; // includes leading slash
|
|
334
|
+
// For ssh/https, pathname must be at least /something
|
|
335
|
+
if (!rawPath || rawPath === '/') {
|
|
336
|
+
findings.push(locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, 'Git locator path is empty — expected /owner/repo'));
|
|
337
|
+
return { ok: false, findings };
|
|
338
|
+
}
|
|
339
|
+
const normPath = normalizePath(rawPath);
|
|
340
|
+
const pathCheck = validatePath(normPath);
|
|
341
|
+
if (!pathCheck.ok) {
|
|
342
|
+
findings.push(
|
|
343
|
+
locatorFinding(scope, CODE.GIT_LOCATOR_INVALID, RULE.GIT_LOCATOR_INVALID, `invalid path '${rawPath}': ${pathCheck.reason}`),
|
|
344
|
+
);
|
|
345
|
+
return { ok: false, findings };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Check ambiguous encoding in path already done via raw input, but also check pathname
|
|
349
|
+
// User part: for ssh, preserve
|
|
350
|
+
const user = url.username || undefined;
|
|
351
|
+
|
|
352
|
+
// Build canonical URL
|
|
353
|
+
let canonicalUrl: string;
|
|
354
|
+
const transport: GitTransport = scheme === 'https:' ? 'https' : 'ssh';
|
|
355
|
+
if (transport === 'https') {
|
|
356
|
+
canonicalUrl = `https://${host}${port ? `:${port}` : ''}${normPath}`;
|
|
357
|
+
} else {
|
|
358
|
+
canonicalUrl = `ssh://${user ? `${user}@` : ''}${host}${port ? `:${port}` : ''}${normPath}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
ok: true,
|
|
363
|
+
findings: [],
|
|
364
|
+
locator: {
|
|
365
|
+
rawInput: raw,
|
|
366
|
+
canonicalUrl,
|
|
367
|
+
transport,
|
|
368
|
+
host,
|
|
369
|
+
port,
|
|
370
|
+
path: normPath,
|
|
371
|
+
user,
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Check if a locator string is canonical (already normalized). */
|
|
377
|
+
export function isCanonicalGitLocator(canonical: string, scope: Scope): boolean {
|
|
378
|
+
const res = normalizeGitLocator(canonical, scope);
|
|
379
|
+
return res.ok && res.locator!.canonicalUrl === canonical;
|
|
380
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Selector — structured default/branch/tag/commit choice.
|
|
3
|
+
* See CONTEXT.md: Git Selector.
|
|
4
|
+
*
|
|
5
|
+
* Normalization:
|
|
6
|
+
* - default → "default"
|
|
7
|
+
* - branch → "refs/heads/<name>" (case-sensitive)
|
|
8
|
+
* - tag → "refs/tags/<name>"
|
|
9
|
+
* - commit → lowercase complete 40 or 64 hex
|
|
10
|
+
*
|
|
11
|
+
* Rejects ambiguous shorthand, abbreviated object names, generic refs, HEAD, revision/reflog expressions,
|
|
12
|
+
* option-like values, whitespace, control characters.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Scope } from '../bridge-state/types.js';
|
|
16
|
+
import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
|
|
17
|
+
|
|
18
|
+
export type GitSelectorKind = 'default' | 'branch' | 'tag' | 'commit';
|
|
19
|
+
|
|
20
|
+
export interface GitSelectorInput {
|
|
21
|
+
kind: GitSelectorKind;
|
|
22
|
+
value?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NormalizedGitSelector {
|
|
26
|
+
kind: GitSelectorKind;
|
|
27
|
+
/** Canonical form: 'default' | 'refs/heads/<name>' | 'refs/tags/<name>' | lower 40/64 hex */
|
|
28
|
+
canonical: string;
|
|
29
|
+
/** Original raw value before canonicalization (for display) */
|
|
30
|
+
raw?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SelectorResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
selector?: NormalizedGitSelector;
|
|
36
|
+
findings: ValidationFinding[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasControlOrWhitespace(s: string): { has: boolean; reason?: string } {
|
|
40
|
+
// eslint-disable-next-line no-control-regex
|
|
41
|
+
if (/[\x00-\x1F\x7F]/.test(s)) return { has: true, reason: 'control character' };
|
|
42
|
+
if (/\s/.test(s)) return { has: true, reason: 'whitespace' };
|
|
43
|
+
return { has: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isOptionLike(s: string): boolean {
|
|
47
|
+
return s.startsWith('-');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isHead(s: string): boolean {
|
|
51
|
+
return s === 'HEAD' || s === 'head';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function containsReflog(s: string): boolean {
|
|
55
|
+
return s.includes('@{');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function containsRevisionChars(s: string): boolean {
|
|
59
|
+
return /[~^:]/.test(s);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isHex(s: string): boolean {
|
|
63
|
+
return /^[0-9a-fA-F]+$/.test(s);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Git ref-name validation per git-check-ref-format (simplified, closing). */
|
|
67
|
+
function isValidRefName(name: string): { ok: boolean; reason?: string } {
|
|
68
|
+
if (name.length === 0) return { ok: false, reason: 'empty' };
|
|
69
|
+
if (hasControlOrWhitespace(name).has) return { ok: false, reason: hasControlOrWhitespace(name).reason };
|
|
70
|
+
if (name.includes('\\')) return { ok: false, reason: 'backslash' };
|
|
71
|
+
if (name.includes('..')) return { ok: false, reason: 'double dot ..' };
|
|
72
|
+
if (name.includes('//')) return { ok: false, reason: 'double slash //' };
|
|
73
|
+
if (name.includes('@{')) return { ok: false, reason: 'reflog @{ ' };
|
|
74
|
+
if (/[~^:?*\[\\]/.test(name)) return { ok: false, reason: 'invalid character ~ ^ : ? * [ \\' };
|
|
75
|
+
if (name.startsWith('.') || name.startsWith('/')) return { ok: false, reason: 'starts with . or /' };
|
|
76
|
+
if (name.endsWith('.') || name.endsWith('/') || name.endsWith('.lock')) return { ok: false, reason: 'ends with . / or .lock' };
|
|
77
|
+
// Each component between slashes must not start with . and not be empty
|
|
78
|
+
const parts = name.split('/');
|
|
79
|
+
for (const p of parts) {
|
|
80
|
+
if (p.length === 0) return { ok: false, reason: 'empty component' };
|
|
81
|
+
if (p.startsWith('.')) return { ok: false, reason: `component '${p}' starts with .` };
|
|
82
|
+
if (p === '@') return { ok: false, reason: 'single @' };
|
|
83
|
+
if (isHead(p)) {
|
|
84
|
+
// HEAD as component is not allowed in branch/tag? Spec says HEAD is rejected overall.
|
|
85
|
+
// But branch name "HEAD" itself is invalid; path component equal HEAD is also questionable.
|
|
86
|
+
// For git ref rules, HEAD is special; we reject it.
|
|
87
|
+
return { ok: false, reason: 'HEAD' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function selectorFinding(scope: Scope, code: string, rule: string, outcome: string): ValidationFinding {
|
|
94
|
+
return blocking({
|
|
95
|
+
code,
|
|
96
|
+
phase: 'validation',
|
|
97
|
+
target: 'source',
|
|
98
|
+
scope,
|
|
99
|
+
pointer: '',
|
|
100
|
+
rule,
|
|
101
|
+
outcome,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalize a Git Selector.
|
|
107
|
+
* Input is a structured { kind, value } where branch/tag value is the short name (or possibly already qualified).
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeGitSelector(input: GitSelectorInput, scope: Scope): SelectorResult {
|
|
110
|
+
const findings: ValidationFinding[] = [];
|
|
111
|
+
const kind = input.kind;
|
|
112
|
+
const rawValue = input.value ?? '';
|
|
113
|
+
|
|
114
|
+
// Common whitespace/control rejection on kind
|
|
115
|
+
if (typeof kind !== 'string' || kind.length === 0) {
|
|
116
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, 'Git selector kind is missing'));
|
|
117
|
+
return { ok: false, findings };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Normalize kind string
|
|
121
|
+
const k = String(kind).toLowerCase() as GitSelectorKind;
|
|
122
|
+
if (k !== 'default' && k !== 'branch' && k !== 'tag' && k !== 'commit') {
|
|
123
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `unknown Git selector kind '${kind}' — expected default/branch/tag/commit`));
|
|
124
|
+
return { ok: false, findings };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// default selector: value must be empty/undefined and canonical is "default"
|
|
128
|
+
if (k === 'default') {
|
|
129
|
+
if (rawValue !== undefined && String(rawValue).trim().length > 0) {
|
|
130
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, 'default selector must not have a value'));
|
|
131
|
+
return { ok: false, findings };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, findings: [], selector: { kind: 'default', canonical: 'default', raw: 'default' } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// For branch/tag/commit, value is required
|
|
137
|
+
const value = String(rawValue ?? '');
|
|
138
|
+
if (value.length === 0) {
|
|
139
|
+
const code = k === 'branch' ? CODE.GIT_SELECTOR_BRANCH_INVALID : k === 'tag' ? CODE.GIT_SELECTOR_TAG_INVALID : CODE.GIT_SELECTOR_COMMIT_INVALID;
|
|
140
|
+
const rule = k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : k === 'tag' ? RULE.GIT_SELECTOR_TAG_INVALID : RULE.GIT_SELECTOR_COMMIT_INVALID;
|
|
141
|
+
findings.push(selectorFinding(scope, code, rule, `${k} selector value is empty`));
|
|
142
|
+
return { ok: false, findings };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ws = hasControlOrWhitespace(value);
|
|
146
|
+
if (ws.has) {
|
|
147
|
+
const code = k === 'commit' ? CODE.GIT_SELECTOR_COMMIT_INVALID : CODE.GIT_SELECTOR_INVALID;
|
|
148
|
+
const rule = k === 'commit' ? RULE.GIT_SELECTOR_COMMIT_INVALID : RULE.GIT_SELECTOR_INVALID;
|
|
149
|
+
findings.push(selectorFinding(scope, code, rule, `${k} selector contains ${ws.reason}`));
|
|
150
|
+
return { ok: false, findings };
|
|
151
|
+
}
|
|
152
|
+
if (isOptionLike(value)) {
|
|
153
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not be option-like '${value}'`));
|
|
154
|
+
return { ok: false, findings };
|
|
155
|
+
}
|
|
156
|
+
if (isHead(value) || isHead(value.split('/').pop() ?? '')) {
|
|
157
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not be HEAD`));
|
|
158
|
+
return { ok: false, findings };
|
|
159
|
+
}
|
|
160
|
+
if (containsReflog(value)) {
|
|
161
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not contain reflog '@{'`));
|
|
162
|
+
return { ok: false, findings };
|
|
163
|
+
}
|
|
164
|
+
if (containsRevisionChars(value)) {
|
|
165
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `${k} selector must not contain revision characters ~ ^ :`));
|
|
166
|
+
return { ok: false, findings };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (k === 'branch' || k === 'tag') {
|
|
170
|
+
// Reject commit-like hex as branch/tag? Spec separates branch/tag vs commit; but branch named 40 hex is technically ambiguous.
|
|
171
|
+
// We will reject branch/tag values that are exactly 40/64 hex to avoid confusion with commit shorthand rejection?
|
|
172
|
+
// But per spec, branch/tag obey ref-name rules; a 40 hex string could be a valid ref name, but spec says commit is 40/64 hex.
|
|
173
|
+
// For determinism, we allow hex as branch/tag if it passes ref validation? However tests may expect branch 'abc' hex handling.
|
|
174
|
+
// We'll not reject hex for branch/tag here; commit is separate.
|
|
175
|
+
|
|
176
|
+
// If value already looks like a fully qualified ref, handle
|
|
177
|
+
const expectedPrefix = k === 'branch' ? 'refs/heads/' : 'refs/tags/';
|
|
178
|
+
let name = value;
|
|
179
|
+
let hadPrefix = false;
|
|
180
|
+
if (value.startsWith('refs/')) {
|
|
181
|
+
if (!value.startsWith(expectedPrefix)) {
|
|
182
|
+
findings.push(
|
|
183
|
+
selectorFinding(
|
|
184
|
+
scope,
|
|
185
|
+
CODE.GIT_SELECTOR_INVALID,
|
|
186
|
+
k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID,
|
|
187
|
+
`${k} selector with refs/ prefix must be ${expectedPrefix}<name>, got '${value}'`,
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
return { ok: false, findings };
|
|
191
|
+
}
|
|
192
|
+
hadPrefix = true;
|
|
193
|
+
name = value.slice(expectedPrefix.length);
|
|
194
|
+
if (name.length === 0) {
|
|
195
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID, `${k} selector ref name is empty after prefix`));
|
|
196
|
+
return { ok: false, findings };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Validate the short name per ref rules
|
|
201
|
+
const refCheck = isValidRefName(name);
|
|
202
|
+
if (!refCheck.ok) {
|
|
203
|
+
const code = k === 'branch' ? CODE.GIT_SELECTOR_BRANCH_INVALID : CODE.GIT_SELECTOR_TAG_INVALID;
|
|
204
|
+
const rule = k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID;
|
|
205
|
+
findings.push(selectorFinding(scope, code, rule, `${k} selector ref name invalid '${name}': ${refCheck.reason}`));
|
|
206
|
+
return { ok: false, findings };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Also validate full canonical ref
|
|
210
|
+
const canonical = `${expectedPrefix}${name}`;
|
|
211
|
+
const fullCheck = isValidRefName(canonical);
|
|
212
|
+
if (!fullCheck.ok) {
|
|
213
|
+
const code = k === 'branch' ? CODE.GIT_SELECTOR_BRANCH_INVALID : CODE.GIT_SELECTOR_TAG_INVALID;
|
|
214
|
+
const rule = k === 'branch' ? RULE.GIT_SELECTOR_BRANCH_INVALID : RULE.GIT_SELECTOR_TAG_INVALID;
|
|
215
|
+
findings.push(selectorFinding(scope, code, rule, `canonical ${k} ref '${canonical}' invalid: ${fullCheck.reason}`));
|
|
216
|
+
return { ok: false, findings };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { ok: true, findings: [], selector: { kind: k, canonical, raw: value } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// commit
|
|
223
|
+
if (k === 'commit') {
|
|
224
|
+
// Must be complete 40 or 64 hex, lowercased
|
|
225
|
+
// Reject abbreviations, HEAD, etc already handled
|
|
226
|
+
if (value.length !== 40 && value.length !== 64) {
|
|
227
|
+
findings.push(
|
|
228
|
+
selectorFinding(
|
|
229
|
+
scope,
|
|
230
|
+
CODE.GIT_SELECTOR_COMMIT_INVALID,
|
|
231
|
+
RULE.GIT_SELECTOR_COMMIT_INVALID,
|
|
232
|
+
`commit selector must be complete 40 or 64 hex (got ${value.length} chars) — abbreviated object names not accepted`,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
return { ok: false, findings };
|
|
236
|
+
}
|
|
237
|
+
if (!isHex(value)) {
|
|
238
|
+
findings.push(
|
|
239
|
+
selectorFinding(scope, CODE.GIT_SELECTOR_COMMIT_INVALID, RULE.GIT_SELECTOR_COMMIT_INVALID, `commit selector must be hex, got '${value}'`),
|
|
240
|
+
);
|
|
241
|
+
return { ok: false, findings };
|
|
242
|
+
}
|
|
243
|
+
// Canonical is lowercase
|
|
244
|
+
const canonical = value.toLowerCase();
|
|
245
|
+
// Validate that lowercasing didn't change length etc (already)
|
|
246
|
+
return { ok: true, findings: [], selector: { kind: 'commit', canonical, raw: value } };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Fallback
|
|
250
|
+
findings.push(selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `unhandled selector kind '${k}'`));
|
|
251
|
+
return { ok: false, findings };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Parse a freeform string like "default", "branch:main", "refs/heads/main", "abc...40hex" into a structured selector */
|
|
255
|
+
export function parseGitSelectorString(input: string, scope: Scope): SelectorResult {
|
|
256
|
+
const s = String(input ?? '').trim();
|
|
257
|
+
if (s.length === 0) {
|
|
258
|
+
return { ok: false, findings: [selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, 'empty selector string')] };
|
|
259
|
+
}
|
|
260
|
+
if (s === 'default') return normalizeGitSelector({ kind: 'default' }, scope);
|
|
261
|
+
if (s.startsWith('refs/heads/')) return normalizeGitSelector({ kind: 'branch', value: s }, scope);
|
|
262
|
+
if (s.startsWith('refs/tags/')) return normalizeGitSelector({ kind: 'tag', value: s }, scope);
|
|
263
|
+
if (/^[0-9a-fA-F]{40}$/.test(s) || /^[0-9a-fA-F]{64}$/.test(s)) return normalizeGitSelector({ kind: 'commit', value: s }, scope);
|
|
264
|
+
// branch:xxx or tag:xxx or commit:xxx syntax
|
|
265
|
+
const colon = s.indexOf(':');
|
|
266
|
+
if (colon > 0) {
|
|
267
|
+
const kind = s.slice(0, colon).toLowerCase();
|
|
268
|
+
const val = s.slice(colon + 1);
|
|
269
|
+
if ((kind === 'branch' || kind === 'tag' || kind === 'commit') && val) {
|
|
270
|
+
return normalizeGitSelector({ kind: kind as GitSelectorKind, value: val }, scope);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Default to branch short name? But we can't disambiguate. For now, if it looks like a branch name, treat as branch
|
|
274
|
+
// Safer to return invalid to force explicit kind.
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
findings: [selectorFinding(scope, CODE.GIT_SELECTOR_INVALID, RULE.GIT_SELECTOR_INVALID, `unable to parse selector string '${s}' — expected default, refs/heads/*, refs/tags/*, 40/64 hex, or kind:value`)],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './findings.js';
|
|
2
|
+
export * from './source-key.js';
|
|
3
|
+
export * from './catalog.js';
|
|
4
|
+
export * from './contained.js';
|
|
5
|
+
export * from './budget.js';
|
|
6
|
+
export * from './snapshot.js';
|
|
7
|
+
export * from './registration.js';
|
|
8
|
+
export * from './fence.js';
|
|
9
|
+
export * from './receipt.js';
|
|
10
|
+
export * from './flow.js';
|
|
11
|
+
export * from './git-locator.js';
|
|
12
|
+
export * from './git-selector.js';
|
|
13
|
+
export * from './git-acquisition.js';
|
|
14
|
+
export * from './git-flow.js';
|
|
15
|
+
export * from '../compatibility/index.js';
|
|
16
|
+
export * from '../installation/index.js';
|