From 2375b25542d5926473051dbc3b7e82cdc5926c4a Mon Sep 17 00:00:00 2001 From: srfsh Date: Sun, 15 Oct 2023 16:38:56 +0300 Subject: [PATCH] feat(git): introduce the git module --- pkg/git/.npmignore | 1 + pkg/git/package.json | 38 ++++++++++++ pkg/git/src/index.ts | 1 + pkg/git/src/plugins.ts | 131 ++++++++++++++++++++++++++++++++++++++++ pkg/git/test/plugins.ts | 66 ++++++++++++++++++++ pkg/git/tsconfig.json | 1 + pnpm-lock.yaml | 103 ++++++++++++++++++++++++++++--- 7 files changed, 331 insertions(+), 10 deletions(-) create mode 120000 pkg/git/.npmignore create mode 100644 pkg/git/package.json create mode 100644 pkg/git/src/index.ts create mode 100644 pkg/git/src/plugins.ts create mode 100644 pkg/git/test/plugins.ts create mode 120000 pkg/git/tsconfig.json diff --git a/pkg/git/.npmignore b/pkg/git/.npmignore new file mode 120000 index 00000000..b4359f69 --- /dev/null +++ b/pkg/git/.npmignore @@ -0,0 +1 @@ +../../.npmignore \ No newline at end of file diff --git a/pkg/git/package.json b/pkg/git/package.json new file mode 100644 index 00000000..7e521447 --- /dev/null +++ b/pkg/git/package.json @@ -0,0 +1,38 @@ +{ + "name": "@slangroom/git", + "version": "1.0.0", + "dependencies": { + "@slangroom/core": "workspace:*", + "isomorphic-git": "^1.24.5" + }, + "repository": "https://github.com/dyne/slangroom", + "license": "AGPL-3.0-only", + "type": "module", + "main": "./build/cjs/src/index.js", + "types": "./build/cjs/src/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./build/esm/src/index.d.ts", + "default": "./build/esm/src/index.js" + }, + "require": { + "types": "./build/cjs/src/index.d.ts", + "default": "./build/cjs/src/index.js" + } + }, + "./*": { + "import": { + "types": "./build/esm/src/*.d.ts", + "default": "./build/esm/src/*.js" + }, + "require": { + "types": "./build/cjs/src/*.d.ts", + "default": "./build/cjs/src/*.js" + } + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/pkg/git/src/index.ts b/pkg/git/src/index.ts new file mode 100644 index 00000000..b74b213d --- /dev/null +++ b/pkg/git/src/index.ts @@ -0,0 +1 @@ +export * from '@slangroom/git/plugins'; diff --git a/pkg/git/src/plugins.ts b/pkg/git/src/plugins.ts new file mode 100644 index 00000000..9749d07c --- /dev/null +++ b/pkg/git/src/plugins.ts @@ -0,0 +1,131 @@ +import type { PluginContext, PluginResult } from '@slangroom/core'; +import git from 'isomorphic-git'; +// TODO: why does this require index.js? +import http from 'isomorphic-git/http/node/index.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +/** + * @internal + */ +export const sandboxDir = () => { + // TODO: sanitize sandboxDir + const ret = process.env['FILES_DIR']; + if (!ret) throw new Error('$FILES_DIR must be provided'); + return ret; +}; + +const sandboxizeDir = (unsafe: string) => { + const normalized = path.normalize(unsafe); + // `/` and `..` prevent directory traversal + const doesDirectoryTraversal = normalized.startsWith('/') || normalized.startsWith('..'); + // Unlike `resolveFilepath`, we allow `.` to be used here, obviously. + if (doesDirectoryTraversal) return { error: `dirpath is unsafe: ${unsafe}` }; + return { dirpath: path.join(sandboxDir(), normalized) }; +}; + +const sandboxizeFile = (sandboxdir: string, unsafe: string) => { + const normalized = path.normalize(unsafe); + // `/` and `..` prevent directory traversal + const doesDirectoryTraversal = normalized.startsWith('/') || normalized.startsWith('..'); + // `.` ensures that `foo/bar` or `./foo` is valid, while `.` isn't + // (that is, no "real" filepath is provided) + const DoesntProvideFile = normalized.startsWith('.'); + if (doesDirectoryTraversal || DoesntProvideFile) + return { error: `filepath is unsafe: ${unsafe}` }; + return { filepath: path.join(sandboxdir, normalized) }; +}; + +/** + * @internal + */ +export const executeVerifyGitRepository = async (ctx: PluginContext): Promise => { + const unsafe = ctx.fetchOpen()[0]; + const { dirpath, error } = sandboxizeDir(unsafe); + if (!dirpath) return ctx.fail(error); + + try { + await git.findRoot({ fs: fs, filepath: dirpath }); + return ctx.pass(null); + } catch (e) { + return ctx.fail(e); + } +}; + +/* + * @internal + */ +export const executeCloneRepository = async (ctx: PluginContext): Promise => { + const repoUrl = ctx.fetchConnect()[0]; + const unsafe = ctx.fetch('path'); + if (typeof unsafe !== 'string') return ctx.fail('path must be string'); + + const { dirpath, error } = sandboxizeDir(unsafe); + if (!dirpath) return ctx.fail(error); + + await git.clone({ fs: fs, http: http, dir: dirpath, url: repoUrl }); + return ctx.pass(null); +}; + +/* + * @internal + */ +export const executeCreateNewGitCommit = async (ctx: PluginContext): Promise => { + const unsafe = ctx.fetchOpen()[0]; + const { dirpath, error } = sandboxizeDir(unsafe); + if (!dirpath) return ctx.fail(error); + + const commit = ctx.fetch('commit') as { + message: string; + author: string; + email: string; + files: string[]; + }; + + try { + commit.files.map((unsafe) => { + const { filepath, error: ferror } = sandboxizeFile(dirpath, unsafe); + if (!filepath) throw ferror; + return filepath; + }); + } catch (e) { + return ctx.fail(e); + } + + await Promise.all( + commit.files.map((safe) => { + return git.add({ + fs: fs, + dir: dirpath, + filepath: safe, + }); + }) + ); + + const hash = await git.commit({ + fs: fs, + dir: dirpath, + message: commit.message, + author: { + name: commit.author, + email: commit.email, + }, + }); + + return ctx.pass(hash); +}; + +const gitPlugin = async (ctx: PluginContext): Promise => { + switch (ctx.phrase) { + case 'verify git repository': + return await executeVerifyGitRepository(ctx); + case 'clone repository': + return await executeCloneRepository(ctx); + case 'create new git commit': + return await executeCreateNewGitCommit(ctx); + default: + return ctx.fail('no match'); + } +}; + +export const gitPlugins = new Set([gitPlugin]); diff --git a/pkg/git/test/plugins.ts b/pkg/git/test/plugins.ts new file mode 100644 index 00000000..5e649fce --- /dev/null +++ b/pkg/git/test/plugins.ts @@ -0,0 +1,66 @@ +import ava, { type TestFn } from 'ava'; +import * as fs from 'node:fs/promises'; +import { join } from 'node:path'; +import * as os from 'node:os'; +import git from 'isomorphic-git'; +import { PluginContextTest } from '@slangroom/core'; +import { + executeCloneRepository, + executeCreateNewGitCommit, + executeVerifyGitRepository, +} from '@slangroom/git'; + +const test = ava as TestFn; + +test.beforeEach(async (t) => { + const tmpdir = await fs.mkdtemp(join(os.tmpdir(), 'slangroom-test-')); + process.env['FILES_DIR'] = tmpdir; + t.context = tmpdir; +}); + +test.afterEach(async (t) => await fs.rm(t.context, { recursive: true })); + +test.serial('verifyGitRepository works', async (t) => { + const path = join('foo', 'bar'); + const dir = join(t.context, path); + await git.init({ fs: fs, dir: dir }); + const ctx = PluginContextTest.openconnect(path); + const res = await executeVerifyGitRepository(ctx); + t.deepEqual(res, { ok: true, value: null }); +}); + +// TODO: somehow make this work with nock using dumb http +test.serial('cloneRepository works', async (t) => { + const path = join('foo', 'bar'); + const dir = join(t.context, path); + const ctx = new PluginContextTest('https://github.com/srfsh/dumb', { + path: path, + }); + const res = await executeCloneRepository(ctx); + t.deepEqual(res, { ok: true, value: null }); + const content = await fs.readFile(join(dir, 'README.md')); + t.is(content.toString(), '# dumb\nA repo only for testing. It shall never change.\n'); +}); + +test.serial('createNewCommit works', async (t) => { + const path = join('foo', 'bar'); + const dir = join(t.context, path); + await git.init({ fs: fs, dir: dir }); + const files = ['file0.txt', 'file1.txt']; + files.forEach(async (f) => await fs.appendFile(join(dir, f), `test data ${f}`)); + const commitParams = { + message: 'my message', + author: 'my author', + email: 'email@example.com', + files: files, + }; + const ctx = new PluginContextTest(path, { commit: commitParams }); + const res = await executeCreateNewGitCommit(ctx); + const hash = await git.resolveRef({ fs: fs, dir: dir, ref: 'HEAD' }); + t.deepEqual(res, { ok: true, value: hash }); + const { commit } = await git.readCommit({ fs: fs, dir: dir, oid: hash }); + t.is(commit.author.name, commitParams.author); + t.is(commit.author.email, commitParams.email); + // newline is required + t.is(commit.message, `${commitParams.message}\n`); +}); diff --git a/pkg/git/tsconfig.json b/pkg/git/tsconfig.json new file mode 120000 index 00000000..fd0e4743 --- /dev/null +++ b/pkg/git/tsconfig.json @@ -0,0 +1 @@ +../../tsconfig.json \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 247699bb..8adf8a94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -75,6 +75,15 @@ importers: specifier: ^3.10.0 version: 3.10.0 + pkg/git: + dependencies: + '@slangroom/core': + specifier: workspace:* + version: link:../core + isomorphic-git: + specifier: ^1.24.5 + version: 1.24.5 + pkg/http: dependencies: '@slangroom/core': @@ -1410,6 +1419,10 @@ packages: engines: {node: '>=12'} dev: true + /async-lock@1.4.0: + resolution: {integrity: sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==} + dev: false + /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false @@ -1725,6 +1738,10 @@ packages: resolution: {integrity: sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==} dev: true + /clean-git-ref@2.0.1: + resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} + dev: false + /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -2021,6 +2038,12 @@ packages: typescript: 4.9.5 dev: true + /crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + dev: false + /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true @@ -2093,6 +2116,13 @@ packages: engines: {node: '>=0.10.0'} dev: true + /decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dependencies: + mimic-response: 3.1.0 + dev: false + /dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} peerDependencies: @@ -2135,6 +2165,10 @@ packages: engines: {node: '>=12.20'} dev: true + /diff3@0.0.3: + resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + dev: false + /diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -2953,7 +2987,6 @@ packages: /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} - dev: true /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} @@ -2996,7 +3029,6 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true /ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} @@ -3189,6 +3221,24 @@ packages: engines: {node: '>=0.10.0'} dev: true + /isomorphic-git@1.24.5: + resolution: {integrity: sha512-07M4YscftHZJIuw7xZhgWkdFvVjHSBJBsIwWXkxgFCivhb0l8mGNchM7nO2hU27EKSIf0sT4gJivEgLGohWbzA==} + engines: {node: '>=12'} + hasBin: true + dependencies: + async-lock: 1.4.0 + clean-git-ref: 2.0.1 + crc-32: 1.2.2 + diff3: 0.0.3 + ignore: 5.2.4 + minimisted: 2.0.1 + pako: 1.0.11 + pify: 4.0.1 + readable-stream: 3.6.2 + sha.js: 2.4.11 + simple-get: 4.0.1 + dev: false + /istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} engines: {node: '>=8'} @@ -3595,6 +3645,11 @@ packages: engines: {node: '>=12'} dev: true + /mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + dev: false + /min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -3631,7 +3686,12 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true + + /minimisted@2.0.1: + resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} + dependencies: + minimist: 1.2.8 + dev: false /minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} @@ -3957,7 +4017,6 @@ packages: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 - dev: true /onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} @@ -4153,6 +4212,10 @@ packages: - supports-color dev: true + /pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + dev: false + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4284,6 +4347,11 @@ packages: engines: {node: '>=4'} dev: true + /pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + dev: false + /pify@6.1.0: resolution: {integrity: sha512-KocF8ve28eFjjuBKKGvzOBGzG8ew2OqOOSxTTZhirkzH7h3BI1vyzqlR0qbfcDBve1Yzo3FVlWUAtCRrbVN8Fw==} engines: {node: '>=14.16'} @@ -4495,7 +4563,6 @@ packages: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - dev: true /readable-stream@4.4.2: resolution: {integrity: sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==} @@ -4606,7 +4673,6 @@ packages: /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - dev: true /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -4644,6 +4710,14 @@ packages: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: true + /sha.js@2.4.11: + resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} + hasBin: true + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + /shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} @@ -4693,6 +4767,18 @@ packages: - supports-color dev: true + /simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + dev: false + + /simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + dev: false + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4848,7 +4934,6 @@ packages: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: safe-buffer: 5.2.1 - dev: true /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} @@ -5190,7 +5275,6 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} @@ -5323,7 +5407,6 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true /write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==}