Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand All @@ -22,8 +22,6 @@
import static org.testng.Assert.fail;

import java.nio.channels.ClosedByInterruptException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Expand All @@ -32,10 +30,10 @@
import org.eclipse.che.commons.test.mockito.answer.WaitingAnswer;
import org.mockito.Mock;
import org.mockito.exceptions.base.MockitoException;
import org.mockito.internal.invocation.InvocationMatcher;
import org.mockito.internal.verification.VerificationModeFactory;
import org.mockito.internal.verification.api.VerificationData;
import org.mockito.invocation.Invocation;
import org.mockito.invocation.MatchableInvocation;
import org.mockito.testng.MockitoTestNGListener;
import org.mockito.verification.VerificationMode;
import org.testng.annotations.AfterMethod;
Expand Down Expand Up @@ -252,10 +250,10 @@ private void awaitFinalization() throws Exception {
}

private LineConsumer[] appendTo(LineConsumer[] base, LineConsumer... toAppend) {
List<LineConsumer> allElements = new ArrayList<>();
allElements.addAll(Arrays.asList(base));
allElements.addAll(Arrays.asList(toAppend));
return allElements.toArray(new LineConsumer[allElements.size()]);
LineConsumer[] result = new LineConsumer[base.length + toAppend.length];
System.arraycopy(base, 0, result, 0, base.length);
System.arraycopy(toAppend, 0, result, base.length, toAppend.length);
return result;
}

/**
Expand All @@ -270,7 +268,7 @@ public Last() {}

public void verify(VerificationData verificationData) {
List<Invocation> invocations = verificationData.getAllInvocations();
InvocationMatcher invocationMatcher = verificationData.getWanted();
MatchableInvocation invocationMatcher = verificationData.getTarget();
Copy link
Contributor Author

@olexii4 olexii4 Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes were required for Mockito 5 compatibility. The test was using Mockito internal APIs that have breaking changes between Mockito 3.11.2 (old version) and 5.21.0 (new version):

Changes made:
Import change: org.mockito.internal.invocation.InvocationMatcher → org.mockito.invocation.MatchableInvocation

Mockito 5 moved this from internal to public API
API change: verificationData.getWanted() → verificationData.getTarget()
Method was renamed in Mockito 5's VerificationData interface


if (invocations == null || invocations.isEmpty()) {
throw new MockitoException(
Expand Down
138 changes: 138 additions & 0 deletions docs/ipv6-verification-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# IPv6 Verification Verdict

## Summary of IPv6 Implementation

IPv6 support has been implemented across three of the four factory URL parser modules in Eclipse Che's server codebase. The implementation focuses on allowing self-hosted SCM instances to be addressed by IPv6 addresses (e.g., `https://[2001:db8::1]/org/repo`).

### Modules with IPv6 Support

| Module | Production Code | Test Coverage |
|--------|----------------|---------------|
| Azure DevOps (`AzureDevOpsURLParser`) | Yes | Yes (1 test) |
| GitHub (`AbstractGithubURLParser`) | Yes | Yes (1 test) |
| GitLab (`AbstractGitlabUrlParser`) | Yes | Yes (2 tests) |
| Bitbucket Server (`BitbucketServerURLParser`) | **No** | **No** |
| Bitbucket Cloud (`BitbucketURLParser`) | N/A (SaaS-only) | N/A |

### Implementation Pattern

All three supported parsers follow a consistent pattern for IPv6 handling across three key areas:

1. **`getServerUrl()`** -- Extracts the base server URL from a repository URL. Uses `java.net.URI` parsing to correctly handle IPv6 addresses, with fallback logic. IPv6 hosts are wrapped in square brackets (e.g., `[2001:db8::1]`).

2. **`getPatternMatcherByUrl()`** -- Dynamically constructs regex patterns for URL matching. IPv6 addresses require special escaping since square brackets are regex metacharacters. Each parser escapes `[` and `]` and uses `Pattern.quote()` for the address itself.

3. **Constructor / initialization** -- The GitLab parser (`AbstractGitlabUrlParser`) performs additional IPv6-aware authority parsing in its constructor (lines 100-112), correctly splitting the authority component for IPv6 addresses with and without ports.

## Issues Identified

> **Note:** Issue 4 was corrected after verifying actual Java `URI.getHost()` behavior, which retains brackets for IPv6 addresses. The bug is in GitLab's double-bracketing, not in Azure DevOps/GitHub's bracket detection.

### Issue 1: Bitbucket Server Has No IPv6 Support (Severity: Medium)

**File:** `wsmaster/che-core-api-factory-bitbucket-server/src/main/java/org/eclipse/che/api/factory/server/bitbucket/BitbucketServerURLParser.java`

The `BitbucketServerURLParser` lacks IPv6 support entirely:

- **`getServerUrl()` (line 152-168):** The SSH URL parsing branch uses `substring.indexOf(":")` to find the port separator, which would match the first colon in an IPv6 address instead. For HTTPS URLs, the method uses path-based substring extraction (`/scm`, `/users`, `/projects`) which could work for IPv6 but was not designed or tested for it.

- **`getPatternMatcherByUrl()` (line 170-178):** Uses `uri.getHost()` directly in `String.format()` for regex patterns without any bracket escaping. For an IPv6 URI, `URI.getHost()` returns the address without brackets (e.g., `2001:db8::1`), but the URL itself contains brackets. This mismatch would cause regex matching failures.

- **`getUrlPatterns()` (line 119-131):** Same issue as above -- `uri.getHost()` is used directly in regex construction.

- **`isUserTokenPresent()` (line 82-99):** Same pattern -- passes raw host to regex templates.

### Issue 2: Limited IPv6 Test Coverage (Severity: Low)

While the existing tests verify basic IPv6 URL parsing, they cover only happy-path scenarios:

| Test File | Test Method | What It Tests |
|-----------|------------|---------------|
| `AzureDevOpsURLParserTest.java:64` | `shouldParseServerUrlWithIpv6Host` | Basic IPv6 URL parsing for org/project/repo extraction |
| `GithubURLParserTest.java:104` | `shouldParseGithubServerUrlWithIpv6Host` | IPv6 URL parsing for username/repo extraction |
| `GitlabUrlParserTest.java:93` | `shouldGetProviderUrlWithExtraSegmentOnIpv6Endpoint` | IPv6 URL with path prefix (`/scm`) |
| `GitlabUrlParserTest.java:105` | `shouldGetProviderUrlWithExtraSegmentOnIpv6EndpointWithPort` | IPv6 URL with port (`[2001:db8::1]:8443`) |

**Missing test scenarios:**
- IPv6 with branches or tags in URL
- IPv6 with pull request IDs
- IPv6 with SSH URLs
- IPv6 `isValid()` method testing
- IPv6 with credentials in URL (e.g., `https://user:pwd@[2001:db8::1]/...`)
- IPv6 link-local addresses with zone IDs (e.g., `[fe80::1%25eth0]`)
- IPv6 loopback (`[::1]`)
- Negative tests (malformed IPv6 addresses)

### Issue 3: Inconsistent `getServerUrl()` Implementation (Severity: Low)

The `getServerUrl()` method is duplicated across three parsers (`AbstractGithubURLParser`, `AbstractGitlabUrlParser`, `AzureDevOpsURLParser`) with nearly identical IPv6-aware logic, but:

- There is no shared base class or utility method for this common operation.
- Each copy has the same fallback regex pattern `[^/|:]/` which itself could conflict with IPv6 colons in edge cases, though the URI-based path typically handles these first.

### Issue 4: GitLab Double-Bracketing Bug in `getPatternMatcherByUrl()` (Severity: Medium)

**File:** `wsmaster/che-core-api-factory-gitlab-common/src/main/java/org/eclipse/che/api/factory/server/gitlab/AbstractGitlabUrlParser.java:198`

**CORRECTED FINDING:** After verification, `URI.getHost()` RETAINS brackets for IPv6 addresses (returns `[2001:db8::1]`, not `2001:db8::1`).

The GitLab parser code `hostForRegex = "\\[" + Pattern.quote(host) + "\\]"` wraps an already-bracketed host value in additional brackets, producing a regex that matches `[[2001:db8::1]]` instead of `[2001:db8::1]`. This would cause URL matching failures for IPv6 addresses.

**Azure DevOps and GitHub parsers handle this CORRECTLY** - they check `host.startsWith("[")`, strip the brackets, then re-add them with proper regex escaping.

## Test Coverage Assessment

### Quantitative Summary

- **Total IPv6 test cases:** 4 (across 3 test files)
- **Parsers tested for IPv6:** 3 out of 4 (excluding Bitbucket Server)
- **IPv6 production code methods:** ~12 methods across 3 parsers contain IPv6-specific logic
- **Test-to-method ratio:** Approximately 1 test per 3 methods with IPv6 logic

### Coverage Gaps

1. **Bitbucket Server:** Zero test coverage for IPv6 (and zero implementation support)
2. **Azure DevOps:** Only 1 test, does not verify `isValid()`, SSH URLs, branches/tags, or credentials with IPv6
3. **GitHub:** Only 1 test, does not verify `isValid()`, SSH URLs, branches, or pull requests with IPv6
4. **GitLab:** Best coverage with 2 tests (with and without port), but still lacks SSH, branch, and `isValid()` testing

### What Tests Verify Successfully

- Basic HTTPS URL parsing with IPv6 host extracts correct organization, project, repository, and username fields
- IPv6 addresses with ports are handled correctly (GitLab)
- IPv6 addresses with path prefixes (e.g., `/scm`) are handled correctly (GitLab)
- Provider URL reconstruction preserves IPv6 bracket notation

## Recommendations

### High Priority

1. **Add IPv6 support to `BitbucketServerURLParser`:** Apply the same URI-based parsing pattern used in the other three parsers to `getServerUrl()`, `getPatternMatcherByUrl()`, `getUrlPatterns()`, and `isUserTokenPresent()`.

2. **Add IPv6 tests for Bitbucket Server:** Once implementation is added, create tests matching the patterns established in the other parser test classes.

### Medium Priority

3. **Fix `AbstractGitlabUrlParser.getPatternMatcherByUrl()`:** The IPv6 bracket handling at line 198 double-brackets already-bracketed IPv6 addresses. Change the logic to match the pattern used in Azure DevOps and GitHub parsers: check if `host.startsWith("[")`, strip brackets, then re-add with proper escaping.

4. **Expand test coverage for IPv6 with branches/tags:** Add parameterized test cases that combine IPv6 hosts with branch, tag, and pull request URL variants.

### Low Priority

5. **Extract common `getServerUrl()` logic:** Consider creating a shared utility to eliminate the triplicated IPv6-aware server URL extraction code.

6. **Add edge-case IPv6 tests:** Test with loopback addresses (`[::1]`), all-zeros (`[::]`), and full-form addresses.

## Overall Verdict

**IPv6 support is PARTIALLY IMPLEMENTED with ADEQUATE coverage for the implemented parsers, but with a notable gap in Bitbucket Server.**

The three implemented parsers (Azure DevOps, GitHub, GitLab) demonstrate a consistent and generally correct approach to IPv6 handling. The core logic -- using `java.net.URI` for parsing and careful regex escaping for pattern matching -- is sound. The existing tests verify the primary use case (parsing HTTPS URLs with IPv6 hosts) and pass.

However, two concerns prevent a fully positive verdict:

1. **Bitbucket Server is completely unprotected** against IPv6 addresses and would produce incorrect results or exceptions if an IPv6-addressed Bitbucket Server instance were configured.

2. **The GitLab `getPatternMatcherByUrl()` method has a double-bracketing bug** that wraps already-bracketed IPv6 addresses in additional brackets, causing regex matching failures for dynamic IPv6 URL discovery.

The implementation is production-usable for the common case of pre-configured IPv6 endpoints in GitHub, GitLab, and Azure DevOps, but additional hardening and testing would be needed for full confidence in dynamic IPv6 URL discovery across all providers.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
package org.eclipse.che.workspace.infrastructure.kubernetes.docker.auth;

import static org.mockito.ArgumentMatchers.anyObject;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
Expand Down Expand Up @@ -106,6 +106,6 @@ private void setCredentialsIntoPreferences(String base64encodedCredentials)

EnvironmentContext.getCurrent()
.setSubject(new SubjectImpl("name", Collections.emptyList(), "id", "token1234", false));
when(preferenceManager.find(anyObject(), anyObject())).thenReturn(preferences);
when(preferenceManager.find(anyString(), anyString())).thenReturn(preferences);
Copy link
Contributor Author

@olexii4 olexii4 Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was required for Mockito 5 compatibility. The test was using a deprecated matcher that was removed in Mockito 5:

Change made:
Import: ArgumentMatchers.anyObject() → ArgumentMatchers.anyString()
Usage: preferenceManager.find(anyObject(), anyObject()) → preferenceManager.find(anyString(), anyString())

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
Expand Down Expand Up @@ -84,10 +83,8 @@ public void setUp() throws InfrastructureException {
new KubernetesMixedDispatcher(responses),
true);
kubernetesMockServer.init();
kubernetesClient = spy(kubernetesMockServer.createClient());

KubernetesClient client = spy(kubernetesClient);
when(cheServerKubernetesClientFactory.create()).thenReturn(client);
kubernetesClient = kubernetesMockServer.createClient();
when(cheServerKubernetesClientFactory.create()).thenReturn(kubernetesClient);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes were required because Mockito 5 doesn't allow spy() on mock objects. This is a breaking change from Mockito 3.x:

Changes made:

  1. Removed import static org.mockito.Mockito.spy;
  2. Changed setup from:
   kubernetesClient = spy(kubernetesMockServer.createClient()); 
   KubernetesClient client = spy(kubernetesClient);  
   when(cheServerKubernetesClientFactory.create()).thenReturn(client);

To:

   kubernetesClient = kubernetesMockServer.createClient();  
   when(cheServerKubernetesClientFactory.create()).thenReturn(kubernetesClient);


namespaceResolutionContext =
new NamespaceResolutionContext(TEST_WORKSPACE_ID, TEST_USER_ID, TEST_USERNAME);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import static org.everrest.assured.JettyHttpServer.ADMIN_USER_NAME;
import static org.everrest.assured.JettyHttpServer.ADMIN_USER_PASSWORD;
import static org.everrest.assured.JettyHttpServer.SECURE_PATH;
import static org.mockito.ArgumentMatchers.anyObject;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
Expand Down Expand Up @@ -146,7 +146,7 @@ private static void permitSubject(String... allowedActions) throws ForbiddenExce
return null;
})
.when(subject)
.checkPermission(anyObject(), anyObject(), anyObject());
.checkPermission(any(), any(), any());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was required for Mockito 5 compatibility due to the removal of the deprecated anyObject() matcher:

Change made:

  • Import: ArgumentMatchers.anyObject() → ArgumentMatchers.any()
  • Usage: checkPermission(anyObject(), anyObject(), anyObject()) → checkPermission(any(), any(), any())

}

private static Set<String> getDeclaredPublicMethods(Class<?> c) {
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
<org.jgroups.version>4.1.9.Final</org.jgroups.version>
<org.leadpony.justify.version>3.1.0</org.leadpony.justify.version>
<org.mockito.mockito-testng.version>0.5.4</org.mockito.mockito-testng.version>
<org.mockito.version>3.11.2</org.mockito.version>
<org.mockito.version>5.21.0</org.mockito.version>
<org.postgresql.version>42.7.9</org.postgresql.version>
<org.reflections.version>0.9.9</org.reflections.version>
<org.slf4j.version>2.0.17</org.slf4j.version>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand Down Expand Up @@ -121,11 +121,46 @@ private Optional<String> getServerUrl(String repositoryUrl) {
substring = repositoryUrl.substring(6);
}
if (!isNullOrEmpty(substring)) {
// Handle IPv6 addresses in SSH URLs (e.g., ssh://[2001:db8::1]:port/...)
if (substring.startsWith("[")) {
int closingBracket = substring.indexOf(']');
if (closingBracket > 0) {
return Optional.of("https://" + substring.substring(0, closingBracket + 1));
}
}
return Optional.of(
"https://"
+ substring.substring(
0, substring.contains(":") ? substring.indexOf(":") : substring.indexOf("/")));
}
// Use URI parsing to properly handle IPv6 addresses
try {
URI uri = URI.create(repositoryUrl);
if (uri.getScheme() != null && uri.getHost() != null) {
String authority = uri.getRawAuthority();
if (authority == null) {
String host = uri.getHost();
boolean ipv6 = host != null && host.contains(":");
String hostForUrl = ipv6 ? "[" + host + "]" : host;
int port = uri.getPort();
authority = port == -1 ? hostForUrl : hostForUrl + ":" + port;
}

if (authority != null) {
String serverUrl = uri.getScheme() + "://" + authority;
// Remove path and query from the server URL
int authorityIdx = repositoryUrl.indexOf(authority);
if (authorityIdx >= 0) {
int pathIndex = authorityIdx + authority.length();
if (pathIndex < repositoryUrl.length() && repositoryUrl.charAt(pathIndex) == '/') {
return Optional.of(serverUrl);
}
}
}
}
} catch (IllegalArgumentException e) {
// Fall through to old logic if URI parsing fails
}
// Otherwise, extract the base url from the given repository url by cutting the url after the
// first slash.
Matcher serverUrlMatcher = compile("[^/|:]/").matcher(repositoryUrl);
Expand All @@ -137,16 +172,28 @@ private Optional<String> getServerUrl(String repositoryUrl) {
}

private Optional<Matcher> getPatternMatcherByUrl(String url) {
String host = URI.create(url).getHost();
Matcher matcher = compile(format(azureDevOpsPatternTemplate, host)).matcher(url);
URI uri = URI.create(url);
String host = uri.getHost();
// Handle IPv6 addresses: escape brackets for regex
final String hostForRegex;
if (host != null && host.startsWith("[") && host.endsWith("]")) {
// IPv6 address - escape the brackets
hostForRegex = "\\[" + Pattern.quote(host.substring(1, host.length() - 1)) + "\\]";
} else if (host != null) {
// Regular hostname - escape special regex characters
hostForRegex = Pattern.quote(host);
} else {
hostForRegex = "";
}
Matcher matcher = compile(format(azureDevOpsPatternTemplate, hostForRegex)).matcher(url);
if (matcher.matches()) {
return Optional.of(matcher);
} else {
matcher = compile(format(azureSSHDevOpsPatternTemplate, host)).matcher(url);
matcher = compile(format(azureSSHDevOpsPatternTemplate, hostForRegex)).matcher(url);
if (matcher.matches()) {
return Optional.of(matcher);
} else {
matcher = compile(format(azureSSHDevOpsServerPatternTemplate, host)).matcher(url);
matcher = compile(format(azureSSHDevOpsServerPatternTemplate, hostForRegex)).matcher(url);
}
return matcher.matches() ? Optional.of(matcher) : Optional.empty();
}
Expand All @@ -158,18 +205,14 @@ private IllegalArgumentException buildIllegalArgumentException(String url) {
}

public AzureDevOpsUrl parse(String url, @Nullable String revision) {
Matcher matcher;
boolean isHTTPSUrl = azureDevOpsPattern.matcher(url).matches();
if (isHTTPSUrl) {
matcher = azureDevOpsPattern.matcher(url);
} else if (azureSSHDevOpsPattern.matcher(url).matches()) {
Matcher matcher = azureDevOpsPattern.matcher(url);
boolean isHTTPSUrl = matcher.matches();
if (!isHTTPSUrl) {
matcher = azureSSHDevOpsPattern.matcher(url);
} else {
matcher = getPatternMatcherByUrl(url).orElseThrow(() -> buildIllegalArgumentException(url));
isHTTPSUrl = url.startsWith("http");
}
if (!matcher.matches()) {
throw buildIllegalArgumentException(url);
if (!matcher.matches()) {
matcher = getPatternMatcherByUrl(url).orElseThrow(() -> buildIllegalArgumentException(url));
isHTTPSUrl = url.startsWith("http");
}
}
String serverUrl = getServerUrl(url).orElseThrow(() -> buildIllegalArgumentException(url));
String repoName = matcher.group("repoName");
Expand Down
Loading
Loading