Skip to content

Commit 1f42bd0

Browse files
authored
Allow user env vars for GitLab subgroups (#17831)
* [server] Move from owner/repo to potentially longer env var patterns * review suggestions * fix and double-wildcard * Adjust TestsEnvVarService for double wild card
1 parent 9fddc74 commit 1f42bd0

File tree

5 files changed

+351
-57
lines changed

5 files changed

+351
-57
lines changed

components/gitpod-protocol/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"clean": "rimraf lib",
3939
"build": "tsc",
4040
"test": "mocha --opts mocha.opts './**/*.spec.ts' --exclude './node_modules/**'",
41+
"test-debug": "mocha --opts mocha.opts --inspect-brk './**/*.spec.ts' --exclude './node_modules/**'",
4142
"watch": "leeway exec --package .:lib --transitive-dependencies --filter-type yarn --components --parallel -- tsc -w --preserveWatchOutput"
4243
},
4344
"dependencies": {

components/gitpod-protocol/src/protocol.ts

Lines changed: 108 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,17 @@ export interface UserEnvVar extends UserEnvVarValue {
427427
}
428428

429429
export namespace UserEnvVar {
430+
export const DELIMITER = "/";
431+
export const WILDCARD_ASTERISK = "*";
432+
// This wildcard is only allowed on the last segment, and matches arbitrary segments to the right
433+
export const WILDCARD_DOUBLE_ASTERISK = "**";
434+
const WILDCARD_SHARP = "#"; // TODO(gpl) Where does this come from? Bc we have/had patterns as part of URLs somewhere, maybe...?
435+
const MIN_PATTERN_SEGMENTS = 2;
436+
437+
function isWildcard(c: string): boolean {
438+
return c === WILDCARD_ASTERISK || c === WILDCARD_SHARP;
439+
}
440+
430441
/**
431442
* @param variable
432443
* @returns Either a string containing an error message or undefined.
@@ -452,8 +463,8 @@ export namespace UserEnvVar {
452463
if (pattern.trim() === "") {
453464
return "Scope must not be empty.";
454465
}
455-
const split = pattern.split("/");
456-
if (split.length < 2) {
466+
const split = splitRepositoryPattern(pattern);
467+
if (split.length < MIN_PATTERN_SEGMENTS) {
457468
return "A scope must use the form 'organization/repo'.";
458469
}
459470
for (const name of split) {
@@ -466,55 +477,111 @@ export namespace UserEnvVar {
466477
return undefined;
467478
}
468479

469-
// DEPRECATED: Use ProjectEnvVar instead of repositoryPattern - https://github.com/gitpod-com/gitpod/issues/5322
470480
export function normalizeRepoPattern(pattern: string) {
471481
return pattern.toLocaleLowerCase();
472482
}
473483

474-
// DEPRECATED: Use ProjectEnvVar instead of repositoryPattern - https://github.com/gitpod-com/gitpod/issues/5322
475-
export function score(value: UserEnvVarValue): number {
476-
// We use a score to enforce precedence:
477-
// value/value = 0
478-
// value/* = 1
479-
// */value = 2
480-
// */* = 3
481-
// #/# = 4 (used for env vars passed through the URL)
482-
// the lower the score, the higher the precedence.
483-
const [ownerPattern, repoPattern] = splitRepositoryPattern(value.repositoryPattern);
484-
let score = 0;
485-
if (repoPattern == "*") {
486-
score += 1;
487-
}
488-
if (ownerPattern == "*") {
489-
score += 2;
490-
}
491-
if (ownerPattern == "#" || repoPattern == "#") {
492-
score = 4;
493-
}
494-
return score;
484+
function splitRepositoryPattern(pattern: string): string[] {
485+
return pattern.split(DELIMITER);
495486
}
496487

497-
// DEPRECATED: Use ProjectEnvVar instead of repositoryPattern - https://github.com/gitpod-com/gitpod/issues/5322
498-
export function filter<T extends UserEnvVarValue>(vars: T[], owner: string, repo: string): T[] {
499-
let result = vars.filter((e) => {
500-
const [ownerPattern, repoPattern] = splitRepositoryPattern(e.repositoryPattern);
501-
if (ownerPattern !== "*" && ownerPattern !== "#" && !!owner && ownerPattern !== owner.toLocaleLowerCase()) {
488+
function joinRepositoryPattern(...parts: string[]): string {
489+
return parts.join(DELIMITER);
490+
}
491+
492+
/**
493+
* Matches the given EnvVar pattern against the fully qualified name of the repository:
494+
* - GitHub: "repo/owner"
495+
* - GitLab: "some/nested/repo" (up to MAX_PATTERN_SEGMENTS deep)
496+
* @param pattern
497+
* @param repoFqn
498+
* @returns True if the pattern matches that fully qualified name
499+
*/
500+
export function matchEnvVarPattern(pattern: string, repoFqn: string): boolean {
501+
const fqnSegments = splitRepositoryPattern(repoFqn);
502+
const patternSegments = splitRepositoryPattern(pattern);
503+
if (patternSegments.length < MIN_PATTERN_SEGMENTS) {
504+
// Technically not a problem, but we should not allow this for safety reasons.
505+
// And because we hisotrically never allowed this (GitHub would always require at least "*/*") we are just keeping old constraints.
506+
// Note: Most importantly this guards against arbitrary wildcard matches
507+
return false;
508+
}
509+
510+
function isWildcardMatch(patternSegment: string, isLastSegment: boolean): boolean {
511+
if (isWildcard(patternSegment)) {
512+
return true;
513+
}
514+
return isLastSegment && patternSegment === WILDCARD_DOUBLE_ASTERISK;
515+
}
516+
let i = 0;
517+
for (; i < patternSegments.length; i++) {
518+
if (i >= fqnSegments.length) {
502519
return false;
503520
}
504-
if (repoPattern !== "*" && repoPattern !== "#" && !!repo && repoPattern !== repo.toLocaleLowerCase()) {
521+
const fqnSegment = fqnSegments[i];
522+
const patternSegment = patternSegments[i];
523+
const isLastSegment = patternSegments.length === i + 1;
524+
if (!isWildcardMatch(patternSegment, isLastSegment) && patternSegment !== fqnSegment.toLocaleLowerCase()) {
505525
return false;
506526
}
507-
return true;
508-
});
509-
527+
}
528+
if (fqnSegments.length > i) {
529+
// Special case: "**" as last segment matches arbitrary # of segments to the right
530+
if (patternSegments[i - 1] === WILDCARD_DOUBLE_ASTERISK) {
531+
return true;
532+
}
533+
return false;
534+
}
535+
return true;
536+
}
537+
538+
// Matches the following patterns for "some/nested/repo", ordered by highest score:
539+
// - exact: some/nested/repo ()
540+
// - partial:
541+
// - some/nested/*
542+
// - some/*
543+
// - generic:
544+
// - */*/*
545+
// - */*
546+
// Does NOT match:
547+
// - */repo (number of parts does not match)
548+
// cmp. test cases in env-var-service.spec.ts
549+
export function filter<T extends UserEnvVarValue>(vars: T[], owner: string, repo: string): T[] {
550+
const matchedEnvVars = vars.filter((e) =>
551+
matchEnvVarPattern(e.repositoryPattern, joinRepositoryPattern(owner, repo)),
552+
);
510553
const resmap = new Map<string, T[]>();
511-
result.forEach((e) => {
554+
matchedEnvVars.forEach((e) => {
512555
const l = resmap.get(e.name) || [];
513556
l.push(e);
514557
resmap.set(e.name, l);
515558
});
516559

517-
result = [];
560+
// In cases of multiple matches per env var: find the best match
561+
// Best match is the most specific pattern, where the left-most segment is most significant
562+
function scoreMatchedEnvVar(e: T): number {
563+
function valueSegment(segment: string): number {
564+
switch (segment) {
565+
case WILDCARD_ASTERISK:
566+
return 2;
567+
case WILDCARD_SHARP:
568+
return 2;
569+
case WILDCARD_DOUBLE_ASTERISK:
570+
return 1;
571+
default:
572+
return 3;
573+
}
574+
}
575+
const patternSegments = splitRepositoryPattern(e.repositoryPattern);
576+
let result = 0;
577+
for (const segment of patternSegments) {
578+
// We can assume the pattern matches from context, so we just need to check whether it's a wildcard or not
579+
const val = valueSegment(segment);
580+
result = result * 10 + val;
581+
}
582+
return result;
583+
}
584+
const result = [];
518585
for (const name of resmap.keys()) {
519586
const candidates = resmap.get(name);
520587
if (!candidates) {
@@ -527,12 +594,12 @@ export namespace UserEnvVar {
527594
continue;
528595
}
529596

530-
let minscore = 10;
531-
let bestCandidate: T | undefined;
532-
for (const e of candidates) {
533-
const score = UserEnvVar.score(e);
534-
if (!bestCandidate || score < minscore) {
535-
minscore = score;
597+
let bestCandidate = candidates[0];
598+
let bestScore = scoreMatchedEnvVar(bestCandidate);
599+
for (const e of candidates.slice(1)) {
600+
const score = scoreMatchedEnvVar(e);
601+
if (score > bestScore) {
602+
bestScore = score;
536603
bestCandidate = e;
537604
}
538605
}
@@ -541,14 +608,6 @@ export namespace UserEnvVar {
541608

542609
return result;
543610
}
544-
545-
// DEPRECATED: Use ProjectEnvVar instead of repositoryPattern - https://github.com/gitpod-com/gitpod/issues/5322
546-
export function splitRepositoryPattern(repositoryPattern: string): string[] {
547-
const patterns = repositoryPattern.split("/");
548-
const repoPattern = patterns.slice(1).join("/");
549-
const ownerPattern = patterns[0];
550-
return [ownerPattern, repoPattern];
551-
}
552611
}
553612

554613
export interface SSHPublicKeyValue {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { suite, test } from "mocha-typescript";
8+
import * as chai from "chai";
9+
10+
import { UserEnvVar } from "./protocol";
11+
12+
const expect = chai.expect;
13+
14+
@suite
15+
class TestUserEnvVar {
16+
public before() {}
17+
18+
@test public testMatchEnvVarPattern() {
19+
interface Test {
20+
pattern: string;
21+
repository: string;
22+
result: boolean;
23+
}
24+
const tests: Test[] = [
25+
{
26+
pattern: "*/*",
27+
repository: "some/repo",
28+
result: true,
29+
},
30+
{
31+
pattern: "*/*",
32+
repository: "some/nested/repo",
33+
result: false,
34+
},
35+
{
36+
pattern: "*/*",
37+
repository: "some/nested/deeply/repo",
38+
result: false,
39+
},
40+
{
41+
pattern: "some/*",
42+
repository: "some/repo",
43+
result: true,
44+
},
45+
{
46+
pattern: "some/*",
47+
repository: "some/nested/repo",
48+
result: false,
49+
},
50+
{
51+
pattern: "some/*",
52+
repository: "some/nested/deeply/repo",
53+
result: false,
54+
},
55+
{
56+
pattern: "some/**",
57+
repository: "some/repo",
58+
result: true,
59+
},
60+
{
61+
pattern: "some/**",
62+
repository: "some/nested/repo",
63+
result: true,
64+
},
65+
{
66+
pattern: "some/**",
67+
repository: "some/nested/deeply/repo",
68+
result: true,
69+
},
70+
{
71+
pattern: "some/nested/*",
72+
repository: "some/repo",
73+
result: false,
74+
},
75+
{
76+
pattern: "some/nested/*",
77+
repository: "some/nested/repo",
78+
result: true,
79+
},
80+
{
81+
pattern: "some/nested/*",
82+
repository: "some/nested/deeply/repo",
83+
result: false,
84+
},
85+
{
86+
pattern: "some/nested/*",
87+
repository: "another/repo",
88+
result: false,
89+
},
90+
{
91+
pattern: "some/nested/**",
92+
repository: "some/nested/repo",
93+
result: true,
94+
},
95+
{
96+
pattern: "some/nested/**",
97+
repository: "some/nested/deeply/repo",
98+
result: true,
99+
},
100+
{
101+
pattern: "some/nested/**",
102+
repository: "another/repo",
103+
result: false,
104+
},
105+
{
106+
pattern: "*/repo",
107+
repository: "some/repo",
108+
result: true,
109+
},
110+
{
111+
pattern: "*/repo",
112+
repository: "some/nested/repo",
113+
result: false,
114+
},
115+
{
116+
pattern: "*/repo",
117+
repository: "some/nested/deeply/repo",
118+
result: false,
119+
},
120+
{
121+
pattern: "*/repo",
122+
repository: "another/repo",
123+
result: true,
124+
},
125+
{
126+
pattern: "*/*/repo",
127+
repository: "some/repo",
128+
result: false,
129+
},
130+
{
131+
pattern: "*/*/repo",
132+
repository: "some/nested/repo",
133+
result: true,
134+
},
135+
{
136+
pattern: "*/*/repo",
137+
repository: "some/nested/deeply/repo",
138+
result: false,
139+
},
140+
{
141+
pattern: "*/*/repo",
142+
repository: "another/repo",
143+
result: false,
144+
},
145+
];
146+
147+
for (const test of tests) {
148+
const actual = UserEnvVar.matchEnvVarPattern(test.pattern, test.repository);
149+
expect(actual, `'${test.repository}' % '${test.pattern}' -> ${test.result}`).to.eq(test.result);
150+
}
151+
}
152+
}
153+
154+
module.exports = new TestUserEnvVar();

0 commit comments

Comments
 (0)