Skip to content

Commit 6e9cf92

Browse files
committed
fix: filter last maven repository_url erros
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 9f6f068 commit 6e9cf92

3 files changed

Lines changed: 257 additions & 12 deletions

File tree

services/apps/packages_worker/src/maven/__tests__/normalize.test.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ describe('parseRepoUrl', () => {
103103
expect(result?.host).toBe('other')
104104
})
105105

106+
it('keeps the full path as name (no owner) for known SVN hosts', () => {
107+
expect(parseRepoUrl('https://svn.apache.org/repos/asf/geronimo/server')).toEqual({
108+
host: 'svn',
109+
owner: null,
110+
name: 'repos/asf/geronimo/server',
111+
})
112+
})
113+
106114
it('returns null for invalid URLs', () => {
107115
expect(parseRepoUrl('not-a-url')).toBeNull()
108116
})
@@ -160,8 +168,40 @@ describe('normalizeScmUrl', () => {
160168
)
161169
})
162170

163-
it('returns null for non-https result', () => {
164-
expect(normalizeScmUrl('svn://svn.apache.org/repos/commons-lang')).toBeNull()
171+
it('returns null for non-https, non-SVN schemes', () => {
172+
expect(normalizeScmUrl('ftp://ftp.apache.org/repos/commons-lang')).toBeNull()
173+
})
174+
175+
// SVN has no owner/repo shape and is mapped even though the hosts are largely
176+
// unreachable today — see normalizeSvnUrl / normalizeSvnScmUrl docstrings.
177+
it('maps svn:// URLs on known SVN hosts to a canonical link', () => {
178+
expect(normalizeScmUrl('svn://svn.apache.org/repos/asf/commons-lang/trunk')).toBe(
179+
'https://svn.apache.org/repos/asf/commons-lang',
180+
)
181+
})
182+
183+
it('maps scm:svn: connection strings, unifying viewvc/repos-asf to the same link', () => {
184+
expect(
185+
normalizeScmUrl(
186+
'scm:svn:http://svn.apache.org/repos/asf/geronimo/server/tags/geronimo-3.0.1',
187+
),
188+
).toBe('https://svn.apache.org/repos/asf/geronimo/server')
189+
expect(
190+
normalizeScmUrl('http://svn.apache.org/viewvc/geronimo/server/tags/geronimo-3.0.1'),
191+
).toBe('https://svn.apache.org/repos/asf/geronimo/server')
192+
})
193+
194+
it('maps svn+ssh: with an embedded user and SourceForge /p/.../code paths', () => {
195+
expect(
196+
normalizeScmUrl('scm:svn:svn+ssh://albertil@svn.forge.objectweb.org/svnroot/myproj/trunk'),
197+
).toBe('https://svn.forge.objectweb.org/svnroot/myproj')
198+
expect(normalizeScmUrl('http://svn.code.sf.net/p/myproj/code/trunk/foo')).toBe(
199+
'https://svn.code.sf.net/p/myproj/code',
200+
)
201+
})
202+
203+
it('does not map dead services that incidentally match an "svn" text filter', () => {
204+
expect(normalizeScmUrl('http://specs.googlecode.com/svn/trunk')).toBeNull()
165205
})
166206

167207
// Gap B — recover repository_url from inputs that were previously dropped
@@ -480,9 +520,19 @@ describe('normalizeScmUrl', () => {
480520
expect(normalizeScmUrl('https://git.iem.at/owner/repo')).toBe('https://git.iem.at/owner/repo')
481521
})
482522

483-
it('still returns null for internal or non-allowlisted hosts', () => {
484-
expect(normalizeScmUrl('https://git.corp.adobe.com/team/project')).toBeNull()
485-
expect(normalizeScmUrl('https://gitlab.alibaba-inc.com/team/project')).toBeNull()
523+
// Internal-only hosts are mapped despite being unreachable for external
524+
// consumers — see the SCM_HOSTS docstring.
525+
it('maps internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com)', () => {
526+
expect(normalizeScmUrl('https://git.corp.adobe.com/team/project')).toBe(
527+
'https://git.corp.adobe.com/team/project',
528+
)
529+
expect(normalizeScmUrl('https://gitlab.alibaba-inc.com/team/project')).toBe(
530+
'https://gitlab.alibaba-inc.com/team/project',
531+
)
532+
})
533+
534+
it('still returns null for genuinely non-allowlisted hosts', () => {
535+
expect(normalizeScmUrl('https://git.some-random-unlisted-host.example/team/project')).toBeNull()
486536
})
487537

488538
it('preserves nested GitLab group namespaces instead of truncating to the group', () => {
@@ -515,6 +565,26 @@ describe('normalizeScmUrl', () => {
515565
it('returns null for a GitLab namespace with no project segment', () => {
516566
expect(normalizeScmUrl('https://gitlab.com/onlygroup')).toBeNull()
517567
})
568+
569+
// OpenDaylight's Gerrit: both the gitweb browse query and the SSH clone form
570+
// (port, no git@ user) unify to the same gitweb browse link.
571+
it('maps OpenDaylight Gerrit gitweb query and SSH clone URLs to the same link', () => {
572+
expect(
573+
normalizeScmUrl('https://git.opendaylight.org/gerrit/gitweb?p=netconf.git;a=summary'),
574+
).toBe('https://git.opendaylight.org/gerrit/gitweb?p=netconf.git')
575+
expect(normalizeScmUrl('scm:git:ssh://git.opendaylight.org:29418/aaa.git')).toBe(
576+
'https://git.opendaylight.org/gerrit/gitweb?p=aaa.git',
577+
)
578+
})
579+
580+
it('returns null for Gerrit shapes with no derivable repo identity', () => {
581+
// Bare Gerrit root — no path at all, nothing to extract.
582+
expect(normalizeScmUrl('http://gerrit.onosproject.org/')).toBeNull()
583+
// Gerrit admin-UI fragment path — not a clone shape.
584+
expect(
585+
normalizeScmUrl('https://gerrit.wikimedia.org/r/#/admin/projects/wikidata/query/rdf'),
586+
).toBeNull()
587+
})
518588
})
519589

520590
describe('interpolateProperties', () => {

services/apps/packages_worker/src/maven/extract.ts

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -494,8 +494,11 @@ export async function extractArtifact(groupId: string, artifactId: string, versi
494494
*
495495
* Still NOT handled (need more than a host allowlist, or are unreachable):
496496
* - eclipse.gerrithub.io: Gerrit admin-UI paths (`/admin/repos/...`), not a clone shape.
497-
* - internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com): unreachable
498-
* for consumers, so intentionally excluded.
497+
*
498+
* Internal-only hosts (git.corp.adobe.com, fit.corp.adobe.com, gitlab.alibaba-inc.com)
499+
* are included below even though they're unreachable for external consumers — per
500+
* product decision, an internal SCM URL should still resolve to a canonical link
501+
* rather than being dropped to null.
499502
*/
500503
const SCM_HOSTS = new Set([
501504
'github.com',
@@ -505,10 +508,12 @@ const SCM_HOSTS = new Set([
505508
'codeberg.org',
506509
// Self-hosted GitLab / Gitea instances seen in Maven POMs with clean
507510
// /<owner>/<repo> paths (same shape as gitlab.com — handled by the generic logic).
508-
// Internal-only hosts (git.corp.adobe.com, gitlab.alibaba-inc.com) are excluded:
509-
// their links are unreachable for consumers. The ≥2-segment owner/repo requirement
510-
// acts as a safety net so a mis-classified host yields NULL, never a junk link.
511+
// The ≥2-segment owner/repo requirement acts as a safety net so a mis-classified
512+
// host yields NULL, never a junk link.
511513
'gitlab.smartb.city',
514+
'git.corp.adobe.com',
515+
'fit.corp.adobe.com',
516+
'gitlab.alibaba-inc.com',
512517
'gitlab.ow2.org',
513518
'gitlab.nuiton.org',
514519
'gitlab.inria.fr',
@@ -769,6 +774,26 @@ function normalizeGooglesourceUrl(host: string, pathname: string): string | null
769774
return `https://${host}${path}`
770775
}
771776

777+
/**
778+
* OpenDaylight's self-hosted Gerrit serves the same flat (no-owner) repo naming as
779+
* ASF's gitweb, in two forms seen in the wild: a gitweb browse query
780+
* (`/gerrit/gitweb?p=<repo>.git;a=summary`) and an SSH clone URL with an explicit
781+
* Gerrit port and no `git@` user (`ssh://git.opendaylight.org:29418/<repo>.git`).
782+
* Both are unified to the gitweb browse form so the stored link is always
783+
* resolvable in a browser, not just via `git clone`.
784+
*/
785+
const OPENDALIGHT_GERRIT_HOST = 'git.opendaylight.org'
786+
787+
function normalizeOpendaylightGerritUrl(pathname: string, search: string): string | null {
788+
const queryRepo = new URLSearchParams(search).get('p')
789+
const segments = pathname.split('/').filter(Boolean)
790+
const rawName = queryRepo ?? (segments.length === 1 ? segments[0] : null)
791+
const name = rawName ? extractGitwebRepoName(rawName) : null
792+
if (!name) return null
793+
794+
return `https://${OPENDALIGHT_GERRIT_HOST}/gerrit/gitweb?p=${name}.git`
795+
}
796+
772797
/**
773798
* GitLab — gitlab.com and self-hosted instances — supports nested group
774799
* namespaces (group/subgroup/.../project), so the repository path is NOT limited
@@ -796,6 +821,133 @@ function normalizeGitlabUrl(host: string, pathname: string): string | null {
796821
return `https://${host}/${host === 'gitlab.com' ? path.toLowerCase() : path}`
797822
}
798823

824+
/**
825+
* Subversion hosts unambiguously identifiable by hostname alone (the "svn" in the
826+
* name), so a bare `http://svn.apache.org/...` URL is recognised as SVN even without
827+
* an `scm:svn:` connection-string prefix or `svn(+ssh)://` scheme.
828+
*/
829+
const SVN_HOSTS = new Set([
830+
'svn.apache.org',
831+
'svn.eu.apache.org',
832+
'svn.sonatype.org',
833+
'svn.forge.objectweb.org',
834+
'svn.code.sf.net',
835+
'svn.codehaus.org',
836+
'svn.java.net',
837+
'websvn.ow2.org',
838+
])
839+
840+
/**
841+
* Hosts that only count as SVN when explicitly marked (`scm:svn:` prefix or
842+
* `svn(+ssh)://` scheme) — the bare hostname is shared with non-SVN content
843+
* (java.net hosted forums, wikis, JIRA, etc. alongside its SVN forge).
844+
*/
845+
const SVN_HOSTS_REQUIRE_EXPLICIT_MARKER = new Set(['java.net'])
846+
847+
function isSvnSourceforgeHost(host: string): boolean {
848+
return host.endsWith('.svn.sourceforge.net')
849+
}
850+
851+
const APACHE_SVN_HOSTS = new Set(['svn.apache.org', 'svn.eu.apache.org'])
852+
853+
/**
854+
* Whether `host` (lowercased, no `www.`) is one of the SVN hosts this module
855+
* normalizes. Exported so callers persisting the normalized URL elsewhere
856+
* (e.g. `parseRepoUrl` in `./normalize`) can special-case SVN's owner/repo-less
857+
* path shape instead of applying a generic two-segment split.
858+
*/
859+
export function isSvnHost(host: string): boolean {
860+
return (
861+
SVN_HOSTS.has(host) || isSvnSourceforgeHost(host) || SVN_HOSTS_REQUIRE_EXPLICIT_MARKER.has(host)
862+
)
863+
}
864+
865+
/**
866+
* Subversion has no owner/repo shape — its near-universal convention is
867+
* <project>[/<module>]/{trunk|tags|branches}/... . The canonical link keeps
868+
* everything up to (but not including) that trunk/tags/branches marker, the closest
869+
* SVN equivalent of a repo root. Apache's three script fronts (viewvc, viewcvs.cgi,
870+
* repos/asf) all serve the same repos, so they're unified to the /repos/asf/ form
871+
* regardless of which one the declared URL used — mirrors wrapAsfRepo for git.
872+
*
873+
* Kept even though these hosts are largely dead/unreachable today (Apache, Sonatype
874+
* and ObjectWeb's SVN forges have moved to Git) per explicit product decision: an SVN
875+
* declared_repository_url should still resolve to a canonical link, not stay null.
876+
*/
877+
function normalizeSvnUrl(host: string, pathname: string): string | null {
878+
const rest = pathname.replace(/^\/+/, '')
879+
const markerMatch = rest.match(/^(viewvc|viewcvs\.cgi|repos\/asf|svnroot|svn|p)\/(.*)$/)
880+
const scriptPrefix = markerMatch?.[1] ?? null
881+
const afterPrefix = markerMatch ? markerMatch[2] : rest
882+
883+
const segments = afterPrefix.split('/').filter(Boolean)
884+
if (segments.some((s) => /\$\{|%7B/i.test(s))) return null
885+
886+
if (scriptPrefix === 'p') {
887+
// SourceForge /p/<project>/code/... — "code" is a fixed marker, not part of the path.
888+
return segments[0] ? `https://${host}/p/${segments[0]}/code` : null
889+
}
890+
891+
const branchMarkerIndex = segments.findIndex((s) => /^(trunk|tags|branches)$/i.test(s))
892+
const projectSegments = branchMarkerIndex === -1 ? segments : segments.slice(0, branchMarkerIndex)
893+
894+
if (projectSegments.length === 0) {
895+
// Legacy SourceForge subdomain form (<project>.svn.sourceforge.net) with no
896+
// path beyond trunk/tags/branches — the project name is the subdomain itself.
897+
if (isSvnSourceforgeHost(host)) {
898+
const project = host.replace(/\.svn\.sourceforge\.net$/, '')
899+
return project ? `https://${host}/svnroot/${project}` : null
900+
}
901+
return null
902+
}
903+
904+
if (APACHE_SVN_HOSTS.has(host)) return `https://${host}/repos/asf/${projectSegments.join('/')}`
905+
906+
const prefix = scriptPrefix && scriptPrefix !== 'repos/asf' ? `${scriptPrefix}/` : ''
907+
return `https://${host}${prefix ? `/${prefix}` : '/'}${projectSegments.join('/')}`
908+
}
909+
910+
/**
911+
* Detects and normalizes Maven `scm:svn:` connection strings and bare SVN URLs.
912+
* Returns `undefined` when the input isn't SVN at all (falls through to the
913+
* git-oriented logic below); `null` when it's SVN but not on a known SVN host or
914+
* not resolvable; otherwise the canonical link.
915+
*/
916+
function normalizeSvnScmUrl(raw: string): string | null | undefined {
917+
const hadSvnScmPrefix = /^scm:svn:/i.test(raw)
918+
let s = raw.replace(/^scm:svn:/i, '')
919+
920+
const hasSvnScheme = /^svn(\+ssh)?:\/\//i.test(s)
921+
s = s.replace(/^svn\+ssh:\/\//i, 'https://').replace(/^svn:\/\//i, 'https://')
922+
923+
if (!/^https?:\/\//i.test(s)) {
924+
if (!hadSvnScmPrefix && !hasSvnScheme) return undefined
925+
s = `https://${s.replace(/^https?:\/\//i, '')}`
926+
}
927+
928+
// Strip an embedded user@ (svn+ssh://user@host/... form)
929+
s = s.replace(/^(https?:\/\/)[^@/]+@/, '$1')
930+
931+
let parsed: URL
932+
try {
933+
parsed = new URL(s)
934+
} catch {
935+
return hadSvnScmPrefix || hasSvnScheme ? null : undefined
936+
}
937+
938+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '')
939+
const isUnambiguousSvnHost = SVN_HOSTS.has(host) || isSvnSourceforgeHost(host)
940+
941+
if (!hadSvnScmPrefix && !hasSvnScheme && !isUnambiguousSvnHost) return undefined
942+
943+
const isSvnHost =
944+
isUnambiguousSvnHost ||
945+
(SVN_HOSTS_REQUIRE_EXPLICIT_MARKER.has(host) && (hadSvnScmPrefix || hasSvnScheme))
946+
if (!isSvnHost) return null
947+
948+
return normalizeSvnUrl(host, parsed.pathname)
949+
}
950+
799951
/**
800952
* Converts the raw SCM URL from a POM (declared_repository_url) into a clean,
801953
* canonical `https://<host>/<owner>/<repo>` repository URL suitable for storage
@@ -813,14 +965,21 @@ function normalizeGitlabUrl(host: string, pathname: string): string | null {
813965
* https://github.com:owner/repo → https://github.com/owner/repo
814966
* ssh://git@github.com:owner/repo.git → https://github.com/owner/repo
815967
*
968+
* SVN connection strings (`scm:svn:...`, `svn(+ssh)://...`) are handled separately
969+
* by normalizeSvnScmUrl, which has no owner/repo shape and is dispatched before any
970+
* of the git-oriented rewriting below — see its docstring for the SVN conventions.
971+
*
816972
* Rejected (→ null): website-only URLs (https://meson.ai/), non-SCM hosts
817-
* (svn://…, http://source.android.com), placeholders (Private, ${scm-url}).
973+
* (http://source.android.com), placeholders (Private, ${scm-url}).
818974
*/
819975
export function normalizeScmUrl(raw: string | null): string | null {
820976
if (!raw) return null
821977
let s = raw.trim()
822978
if (!s) return null
823979

980+
const svnResult = normalizeSvnScmUrl(s)
981+
if (svnResult !== undefined) return svnResult
982+
824983
// Strip Maven scm:git: / scm: prefix
825984
s = s.replace(/^scm:git:/i, '').replace(/^scm:/i, '')
826985

@@ -836,6 +995,9 @@ export function normalizeScmUrl(raw: string | null): string | null {
836995
// ssh://git@host/… → https://host/…
837996
s = s.replace(/^ssh:\/\/git@([^/]+)\//, 'https://$1/')
838997

998+
// ssh://host:port/… (no git@ user, real numeric Gerrit-style port) → https://host/…
999+
s = s.replace(/^ssh:\/\/([^@/:]+):\d+\//, 'https://$1/')
1000+
8391001
// git:// → https://, and upgrade http:// → https:// — done before the SCP-colon
8401002
// rule below so that git://host:owner/repo is normalised too.
8411003
s = s.replace(/^git:\/\//, 'https://').replace(/^http:\/\//, 'https://')
@@ -879,6 +1041,10 @@ export function normalizeScmUrl(raw: string | null): string | null {
8791041
return normalizeGitwebQueryUrl(host, parsed.search)
8801042
}
8811043

1044+
if (host === OPENDALIGHT_GERRIT_HOST) {
1045+
return normalizeOpendaylightGerritUrl(parsed.pathname, parsed.search)
1046+
}
1047+
8821048
if (ECLIPSE_CGIT_HOSTS.has(host)) {
8831049
return normalizeEclipseCgitUrl(host, parsed.pathname)
8841050
}

services/apps/packages_worker/src/maven/normalize.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isSvnHost } from './extract'
2+
13
export function isPrerelease(version: string): boolean {
24
return /-(SNAPSHOT|alpha|beta|rc|cr|m\d+|dev)/i.test(version)
35
}
@@ -8,12 +10,19 @@ export function parseRepoUrl(
810
try {
911
const parsed = new URL(url)
1012
const h = parsed.hostname.toLowerCase()
13+
const parts = parsed.pathname.split('/').filter(Boolean)
14+
15+
// SVN has no owner/repo concept — its canonical path is a single project
16+
// identifier (e.g. /repos/asf/geronimo/server), so splitting it into
17+
// parts[0]/parts[1] like a git host would produce garbage (owner='repos',
18+
// name='asf'). Keep the whole path as `name` instead.
19+
if (isSvnHost(h)) return { host: 'svn', owner: null, name: parts.join('/') || null }
20+
1121
let host: string
1222
if (h === 'github.com' || h.endsWith('.github.com')) host = 'github'
1323
else if (h === 'gitlab.com' || h.includes('gitlab')) host = 'gitlab'
1424
else if (h === 'bitbucket.org') host = 'bitbucket'
1525
else host = 'other'
16-
const parts = parsed.pathname.split('/').filter(Boolean)
1726
return { host, owner: parts[0] ?? null, name: parts[1] ?? null }
1827
} catch {
1928
return null

0 commit comments

Comments
 (0)