diff --git a/__tests__/buildkit/git.test.ts b/__tests__/buildkit/git.test.ts new file mode 100644 index 0000000..b820566 --- /dev/null +++ b/__tests__/buildkit/git.test.ts @@ -0,0 +1,344 @@ +/** + * Copyright 2024 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {beforeEach, describe, expect, jest, test} from '@jest/globals'; + +import {Git} from '../../src/buildkit/git'; + +import {GitRef, GitURL} from '../../src/types/buildkit/git'; + +beforeEach(() => { + jest.restoreAllMocks(); +}); + +describe('parseURL', () => { + // prettier-ignore + test.each([ + [ + 'http://github.com/moby/buildkit', + { + scheme: 'http', + host: 'github.com', + path: '/moby/buildkit' + } as GitURL, + false + ], + [ + 'https://github.com/moby/buildkit', + { + scheme: 'https', + host: 'github.com', + path: '/moby/buildkit' + } as GitURL, + false + ], + [ + 'http://github.com/moby/buildkit#v1.0.0', + { + scheme: 'http', + host: 'github.com', + path: '/moby/buildkit', + fragment: { + ref: 'v1.0.0', + } + } as GitURL, + false + ], + [ + 'http://github.com/moby/buildkit#v1.0.0:subdir', + { + scheme: 'http', + host: 'github.com', + path: '/moby/buildkit', + fragment: { + ref: 'v1.0.0', + subdir: 'subdir' + } + } as GitURL, + false + ], + [ + 'http://foo:bar@github.com/moby/buildkit#v1.0.0', + { + scheme: 'http', + host: 'github.com', + path: '/moby/buildkit', + fragment: { + ref: 'v1.0.0', + }, + user: { + username: 'foo', + password: 'bar', + passwordSet: true + } + } as GitURL, + false + ], + [ + 'ssh://git@github.com/moby/buildkit.git', + { + scheme: 'ssh', + host: 'github.com', + path: '/moby/buildkit.git', + user: { + username: 'git', + password: '', + passwordSet: false + } + } as GitURL, + false + ], + [ + 'ssh://git@github.com:22/moby/buildkit.git', + { + scheme: 'ssh', + host: 'github.com:22', + path: '/moby/buildkit.git', + user: { + username: 'git', + password: '', + passwordSet: false + } + } as GitURL, + false + ], + // TODO: handle SCP-style URLs + // [ + // 'git@github.com:moby/buildkit.git', + // { + // scheme: 'ssh', + // host: 'github.com:22', + // path: 'moby/buildkit.git', + // user: { + // username: 'git', + // password: '', + // passwordSet: false + // } + // } as GitURL, + // false + // ], + [ + 'ssh://root@subdomain.example.hostname:2222/root/my/really/weird/path/foo.git', + { + scheme: 'ssh', + host: 'subdomain.example.hostname:2222', + path: '/root/my/really/weird/path/foo.git', + user: { + username: 'root', + password: '', + passwordSet: false + } + } as GitURL, + false + ], + [ + 'git://host.xz:1234/path/to/repo.git', + { + scheme: 'git', + host: 'host.xz:1234', + path: '/path/to/repo.git', + } as GitURL, + false + ], + [ + 'ssh://someuser@192.168.0.123:456/~/repo-in-my-home-dir.git', + { + scheme: 'ssh', + host: '192.168.0.123:456', + path: '/~/repo-in-my-home-dir.git', + user: { + username: 'someuser', + password: '', + passwordSet: false + } + } as GitURL, + false + ], + [ + 'httpx://github.com/moby/buildkit', + {} as GitURL, + true + ], + [ + 'HTTP://github.com/moby/buildkit', + { + scheme: 'http', + host: 'github.com', + path: '/moby/buildkit' + } as GitURL, + false + ], + ])('given %p', async (ref: string, expected: GitURL, expectedErr: boolean) => { + try { + const got = Git.parseURL(ref); + expect(got.scheme).toEqual(expected.scheme); + expect(got.host).toEqual(expected.host); + expect(got.path).toEqual(expected.path); + expect(got.fragment).toEqual(expected.fragment); + expect(got.user?.username).toEqual(expected.user?.username); + expect(got.user?.password).toEqual(expected.user?.password); + expect(got.user?.passwordSet).toEqual(expected.user?.passwordSet); + } catch (err) { + if (!expectedErr) { + console.log(err); + } + // eslint-disable-next-line jest/no-conditional-expect + expect(expectedErr).toBeTruthy(); + } + }); +}); + +describe('parseRef', () => { + // prettier-ignore + test.each([ + [ + 'https://example.com/', + undefined + ], + [ + 'https://example.com/foo', + undefined + ], + [ + 'https://example.com/foo.git', + { + remote: 'https://example.com/foo.git', + shortName: 'foo' + } as GitRef + ], + [ + 'https://example.com/foo.git#deadbeef', + { + remote: 'https://example.com/foo.git', + shortName: 'foo', + commit: 'deadbeef' + } as GitRef + ], + [ + 'https://example.com/foo.git#release/1.2', + { + remote: 'https://example.com/foo.git', + shortName: 'foo', + commit: 'release/1.2' + } as GitRef + ], + [ + 'https://example.com/foo.git/', + undefined + ], + [ + 'https://example.com/foo.git.bar', + undefined + ], + [ + 'git://example.com/foo', + { + remote: 'git://example.com/foo', + shortName: 'foo', + unencryptedTCP: true + } as GitRef + ], + [ + 'github.com/moby/buildkit', + { + remote: 'github.com/moby/buildkit', + shortName: 'buildkit', + indistinguishableFromLocal: true + } as GitRef + ], + [ + 'custom.xyz/moby/buildkit.git', + undefined + ], + [ + 'https://github.com/moby/buildkit', + undefined + ], + [ + 'https://github.com/moby/buildkit.git', + { + remote: 'https://github.com/moby/buildkit.git', + shortName: 'buildkit', + } as GitRef + ], + [ + 'https://foo:bar@github.com/moby/buildkit.git', + { + remote: 'https://foo:bar@github.com/moby/buildkit.git', + shortName: 'buildkit', + } as GitRef + ], + // TODO handle SCP-style URLs + // [ + // 'git@github.com:moby/buildkit', + // { + // remote: 'git@github.com:moby/buildkit', + // shortName: 'buildkit', + // } as GitRef + // ], + // [ + // 'git@github.com:moby/buildkit.git', + // { + // remote: 'git@github.com:moby/buildkit', + // shortName: 'buildkit', + // } as GitRef + // ], + // [ + // 'git@bitbucket.org:atlassianlabs/atlassian-docker.git', + // { + // remote: 'git@bitbucket.org:atlassianlabs/atlassian-docker.git', + // shortName: 'atlassian-docker', + // } as GitRef + // ], + [ + 'https://github.com/foo/bar.git#baz/qux:quux/quuz', + { + remote: 'https://github.com/foo/bar.git', + shortName: 'bar', + commit: 'baz/qux', + subDir: 'quux/quuz', + } as GitRef + ], + [ + 'https://github.com/docker/docker.git#:myfolder', + { + remote: 'https://github.com/docker/docker.git', + shortName: 'docker', + subDir: 'myfolder', + commit: '' + } as GitRef + ], + [ + './.git', + undefined + ], + [ + '.git', + undefined + ], + ])('given %p', async (ref: string, expected: GitRef | undefined) => { + try { + const got = Git.parseRef(ref); + expect(got).toEqual(expected); + } catch (err) { + if (expected) { + console.log(err); + } + // eslint-disable-next-line jest/no-conditional-expect + expect(expected).toBeUndefined(); + } + }); +}); diff --git a/src/buildkit/git.ts b/src/buildkit/git.ts new file mode 100644 index 0000000..2415b59 --- /dev/null +++ b/src/buildkit/git.ts @@ -0,0 +1,113 @@ +/** + * Copyright 2024 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {GitRef, GitURL, GitURLFragment, URLUserInfo} from '../types/buildkit/git'; + +export class Git { + private static protoRegexp = new RegExp('^[a-zA-Z0-9]+://'); + private static supportedProtos = { + http: {}, + https: {}, + ssh: {}, + git: {} + }; + + // https://github.com/moby/buildkit/blob/2ec1338fc13f73b43f0b1b4f4678d7cd654bc86c/util/gitutil/git_url.go#L79 + public static parseURL(remote: string): GitURL { + const match = remote.match(Git.protoRegexp); + if (match && match.length > 0) { + let proto = match[0].toLowerCase(); + proto = proto.slice(0, proto.lastIndexOf('://')); + if (!(proto in Git.supportedProtos)) { + throw new Error(`Invalid protocol: ${proto}`); + } + return Git.fromURL(new URL(remote)); + } + + throw new Error('Unknown protocol'); + } + + // https://github.com/moby/buildkit/blob/2ec1338fc13f73b43f0b1b4f4678d7cd654bc86c/util/gitutil/git_url.go#L108 + private static fromURL(url: URL): GitURL { + const withoutFragment = new URL(url.toString()); + withoutFragment.hash = ''; + + let user: URLUserInfo | undefined; + if (url.username || url.password) { + user = { + username: url.username, + password: url.password, + passwordSet: url.password !== '' + }; + } + + // TODO: handle SCP-style URLs + + return { + scheme: url.protocol.slice(0, -1), + user: user, + host: `${url.hostname}${url.port ? ':' + url.port : ''}`, + path: url.pathname, + fragment: Git.splitGitFragment(url.hash), + remote: withoutFragment.toString() + }; + } + + // https://github.com/moby/buildkit/blob/2ec1338fc13f73b43f0b1b4f4678d7cd654bc86c/util/gitutil/git_url.go#L69 + private static splitGitFragment(fragment: string): GitURLFragment | undefined { + if (fragment === '') { + return undefined; + } + const [ref, subdir] = fragment.slice(1).split(':'); + return { + ref: ref, + subdir: subdir + }; + } + + // https://github.com/moby/buildkit/blob/2ec1338fc13f73b43f0b1b4f4678d7cd654bc86c/util/gitutil/git_ref.go#L52 + public static parseRef(ref: string): GitRef | undefined { + const res: GitRef = {}; + let remote: GitURL; + if (ref.startsWith('./') || ref.startsWith('../')) { + throw new Error('Invalid argument'); + } else if (ref.startsWith('github.com/')) { + res.indistinguishableFromLocal = true; // Deprecated + remote = Git.fromURL(new URL('https://' + ref)); + } else { + remote = Git.parseURL(ref); + if (['http', 'git'].includes(remote.scheme)) { + res.unencryptedTCP = true; // Discouraged, but not deprecated + } + if (['http', 'https'].includes(remote.scheme) && !remote.path.endsWith('.git')) { + throw new Error('Invalid argument'); + } + } + res.remote = remote.remote; + + if (res.indistinguishableFromLocal) { + res.remote = res.remote.split('://')[1]; + } + if (remote.fragment) { + res.commit = remote.fragment.ref; + res.subDir = remote.fragment.subdir; + } + + const repoSplitBySlash = res.remote.split('/'); + res.shortName = repoSplitBySlash[repoSplitBySlash.length - 1].replace('.git', ''); + return res; + } +} diff --git a/src/types/buildkit/git.ts b/src/types/buildkit/git.ts new file mode 100644 index 0000000..bdae0ea --- /dev/null +++ b/src/types/buildkit/git.ts @@ -0,0 +1,44 @@ +/** + * Copyright 2024 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface GitURL { + scheme: string; + host: string; + path: string; + user?: URLUserInfo; + fragment?: GitURLFragment; + remote: string; +} + +export interface GitURLFragment { + ref: string; + subdir: string; +} + +export interface GitRef { + remote?: string; + shortName?: string; + commit?: string; + subDir?: string; + indistinguishableFromLocal?: boolean; + unencryptedTCP?: boolean; +} + +export interface URLUserInfo { + username: string; + password: string; + passwordSet: boolean; +}