Skip to content

Commit c355317

Browse files
authoredMar 28, 2024··
Feature/array custom binary (#987)
* Feature: enable the use of two commands for the `customBinary` Example: ```typescript simpleGit({ binary: 'git' }); // default simpleGit({ binary: ['./local/script/git'] }); // single item array simpleGit({ binary: ['wsl', 'git'] }); // string tuple ``` To avoid accidentally merging dangerous content into the binary - both commands are limited to alphanumeric with a restricted set of special characters `./\-_`. With `allowUnsafeCustomBinary` to ignore the validation of `binary` param inputs
1 parent 2575c48 commit c355317

20 files changed

+288
-31
lines changed
 

‎.changeset/forty-trains-float.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"simple-git": minor
3+
---
4+
5+
Enable the use of a two part custom binary

‎docs/PLUGIN-CUSTOM-BINARY.md

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
## Custom Binary
2+
3+
The `simple-git` library relies on `git` being available on the `$PATH` when spawning the child processes
4+
to handle each `git` command.
5+
6+
```typescript
7+
simpleGit().init();
8+
```
9+
10+
Is equivalent to opening a terminal prompt and typing
11+
12+
```shell
13+
git init
14+
```
15+
16+
### Configuring the binary for a new instance
17+
18+
When `git` isn't available on the `$PATH`, which can often be the case if you're running in a custom
19+
or virtualised container, the `git` binary can be replaced using the configuration object:
20+
21+
```typescript
22+
simpleGit({ binary: 'my-custom-git' });
23+
```
24+
25+
For environments where you need even further customisation of the path (for example flatpak or WSL),
26+
the `binary` configuration property can be supplied as an array of up to two strings which will become
27+
the command and first argument of the spawned child processes:
28+
29+
```typescript
30+
simpleGit({ binary: ['wsl', 'git'] }).init();
31+
```
32+
33+
Is equivalent to:
34+
35+
```shell
36+
wsl git init
37+
```
38+
39+
### Changing the binary on an existing instance
40+
41+
From v3.24.0 and above, the `simpleGit.customBinary` method supports the same parameter type and can be
42+
used to change the `binary` configuration on an existing `simple-git` instance:
43+
44+
```typescript
45+
const git = await simpleGit().init();
46+
git.customBinary('./custom/git').raw('add', '.');
47+
```
48+
49+
Is equivalent to:
50+
51+
```shell
52+
git init
53+
./custom/git add .
54+
```
55+
56+
### Caveats / Security
57+
58+
To prevent accidentally merging arbitrary code into the spawned child processes, the strings supplied
59+
in the `binary` config are limited to alphanumeric, slashes, dot, hyphen and underscore. Colon is also
60+
permitted when part of a valid windows path (ie: after one letter at the start of the string).
61+
62+
This protection can be overridden by passing an additional unsafe configuration setting:
63+
64+
```typescript
65+
// this would normally throw because of the invalid value for `binary`
66+
simpleGit({
67+
unsafe: {
68+
allowUnsafeCustomBinary: true
69+
},
70+
binary: '!'
71+
});
72+
```

‎simple-git/readme.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ await git.pull();
9090
- [AbortController](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-ABORT-CONTROLLER.md)
9191
Terminate pending and future tasks in a `simple-git` instance (requires node >= 16).
9292

93+
- [Custom Binary](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-CUSTOM-BINARY.md)
94+
Customise the `git` binary `simple-git` uses when spawning `git` child processes.
95+
9396
- [Completion Detection](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-COMPLETION-DETECTION.md)
9497
Customise how `simple-git` detects the end of a `git` process.
9598

@@ -195,7 +198,7 @@ in v2 (deprecation notices were logged to `stdout` as `console.warn` in v2).
195198
| `.clearQueue()` | immediately clears the queue of pending tasks (note: any command currently in progress will still call its completion callback) |
196199
| `.commit(message, handlerFn)` | commits changes in the current working directory with the supplied message where the message can be either a single string or array of strings to be passed as separate arguments (the `git` command line interface converts these to be separated by double line breaks) |
197200
| `.commit(message, [fileA, ...], options, handlerFn)` | commits changes on the named files with the supplied message, when supplied, the optional options object can contain any other parameters to pass to the commit command, setting the value of the property to be a string will add `name=value` to the command string, setting any other type of value will result in just the key from the object being passed (ie: just `name`), an example of setting the author is below |
198-
| `.customBinary(gitPath)` | sets the command to use to reference git, allows for using a git binary not available on the path environment variable |
201+
| `.customBinary(gitPath)` | sets the command to use to reference git, allows for using a git binary not available on the path environment variable [docs](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-CUSTOM-BINARY.md) |
199202
| `.env(name, value)` | Set environment variables to be passed to the spawned child processes, [see usage in detail below](#environment-variables). |
200203
| `.exec(handlerFn)` | calls a simple function in the current step |
201204
| `.fetch([options, ] handlerFn)` | update the local working copy database with changes from the default remote repo and branch, when supplied the options argument can be a standard [options object](#how-to-specify-options) either an array of string commands as supported by the [git fetch](https://git-scm.com/docs/git-fetch). |

‎simple-git/src/git.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ const { addAnnotatedTagTask, addTagTask, tagListTask } = require('./lib/tasks/ta
4949
const { straightThroughBufferTask, straightThroughStringTask } = require('./lib/tasks/task');
5050

5151
function Git(options, plugins) {
52+
this._plugins = plugins;
5253
this._executor = new GitExecutor(
53-
options.binary,
5454
options.baseDir,
5555
new Scheduler(options.maxConcurrentProcesses),
5656
plugins
@@ -64,12 +64,9 @@ function Git(options, plugins) {
6464
/**
6565
* Sets the path to a custom git binary, should either be `git` when there is an installation of git available on
6666
* the system path, or a fully qualified path to the executable.
67-
*
68-
* @param {string} command
69-
* @returns {Git}
7067
*/
7168
Git.prototype.customBinary = function (command) {
72-
this._executor.binary = command;
69+
this._plugins.reconfigure('binary', command);
7370
return this;
7471
};
7572

‎simple-git/src/lib/git-factory.ts

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
blockUnsafeOperationsPlugin,
77
commandConfigPrefixingPlugin,
88
completionDetectionPlugin,
9+
customBinaryPlugin,
910
errorDetectionHandler,
1011
errorDetectionPlugin,
1112
PluginStore,
@@ -68,5 +69,7 @@ export function gitInstanceFactory(
6869
plugins.add(errorDetectionPlugin(errorDetectionHandler(true)));
6970
config.errors && plugins.add(errorDetectionPlugin(config.errors));
7071

72+
customBinaryPlugin(plugins, config.binary, config.unsafe?.allowUnsafeCustomBinary);
73+
7174
return new Git(config, plugins);
7275
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { SimpleGitOptions } from '../types';
2+
3+
import { GitPluginError } from '../errors/git-plugin-error';
4+
import { asArray } from '../utils';
5+
import { PluginStore } from './plugin-store';
6+
7+
const WRONG_NUMBER_ERR = `Invalid value supplied for custom binary, requires a single string or an array containing either one or two strings`;
8+
const WRONG_CHARS_ERR = `Invalid value supplied for custom binary, restricted characters must be removed or supply the unsafe.allowUnsafeCustomBinary option`;
9+
10+
function isBadArgument(arg: string) {
11+
return !arg || !/^([a-z]:)?([a-z0-9/.\\_-]+)$/i.test(arg);
12+
}
13+
14+
function toBinaryConfig(
15+
input: string[],
16+
allowUnsafe: boolean
17+
): { binary: string; prefix?: string } {
18+
if (input.length < 1 || input.length > 2) {
19+
throw new GitPluginError(undefined, 'binary', WRONG_NUMBER_ERR);
20+
}
21+
22+
const isBad = input.some(isBadArgument);
23+
if (isBad) {
24+
if (allowUnsafe) {
25+
console.warn(WRONG_CHARS_ERR);
26+
} else {
27+
throw new GitPluginError(undefined, 'binary', WRONG_CHARS_ERR);
28+
}
29+
}
30+
31+
const [binary, prefix] = input;
32+
return {
33+
binary,
34+
prefix,
35+
};
36+
}
37+
38+
export function customBinaryPlugin(
39+
plugins: PluginStore,
40+
input: SimpleGitOptions['binary'] = ['git'],
41+
allowUnsafe = false
42+
) {
43+
let config = toBinaryConfig(asArray(input), allowUnsafe);
44+
45+
plugins.on('binary', (input) => {
46+
config = toBinaryConfig(asArray(input), allowUnsafe);
47+
});
48+
49+
plugins.append('spawn.binary', () => {
50+
return config.binary;
51+
});
52+
53+
plugins.append('spawn.args', (data) => {
54+
return config.prefix ? [config.prefix, ...data] : data;
55+
});
56+
}

‎simple-git/src/lib/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './abort-plugin';
22
export * from './block-unsafe-operations-plugin';
33
export * from './command-config-prefixing-plugin';
44
export * from './completion-detection.plugin';
5+
export * from './custom-binary.plugin';
56
export * from './error-detection.plugin';
67
export * from './plugin-store';
78
export * from './progress-monitor-plugin';

‎simple-git/src/lib/plugins/plugin-store.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
1-
import { SimpleGitPlugin, SimpleGitPluginType, SimpleGitPluginTypes } from './simple-git-plugin';
1+
import { EventEmitter } from 'node:events';
2+
3+
import type {
4+
SimpleGitPlugin,
5+
SimpleGitPluginType,
6+
SimpleGitPluginTypes,
7+
} from './simple-git-plugin';
28
import { append, asArray } from '../utils';
9+
import type { SimpleGitPluginConfig } from '../types';
310

411
export class PluginStore {
512
private plugins: Set<SimpleGitPlugin<SimpleGitPluginType>> = new Set();
13+
private events = new EventEmitter();
14+
15+
on<K extends keyof SimpleGitPluginConfig>(
16+
type: K,
17+
listener: (data: SimpleGitPluginConfig[K]) => void
18+
) {
19+
this.events.on(type, listener);
20+
}
21+
22+
reconfigure<K extends keyof SimpleGitPluginConfig>(type: K, data: SimpleGitPluginConfig[K]) {
23+
this.events.emit(type, data);
24+
}
25+
26+
public append<T extends SimpleGitPluginType>(type: T, action: SimpleGitPlugin<T>['action']) {
27+
const plugin = append(this.plugins, { type, action });
28+
29+
return () => this.plugins.delete(plugin);
30+
}
631

732
public add<T extends SimpleGitPluginType>(
833
plugin: void | SimpleGitPlugin<T> | SimpleGitPlugin<T>[]

‎simple-git/src/lib/plugins/simple-git-plugin.ts

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface SimpleGitPluginTypes {
1111
data: string[];
1212
context: SimpleGitTaskPluginContext & {};
1313
};
14+
'spawn.binary': {
15+
data: string;
16+
context: SimpleGitTaskPluginContext & {};
17+
};
1418
'spawn.options': {
1519
data: Partial<SpawnOptions>;
1620
context: SimpleGitTaskPluginContext & {};

‎simple-git/src/lib/runners/git-executor-chain.ts

+2-5
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ export class GitExecutorChain implements SimpleGitExecutor {
2020
private _queue = new TasksPendingQueue();
2121
private _cwd: string | undefined;
2222

23-
public get binary() {
24-
return this._executor.binary;
25-
}
26-
2723
public get cwd() {
2824
return this._cwd || this._executor.cwd;
2925
}
@@ -84,6 +80,7 @@ export class GitExecutorChain implements SimpleGitExecutor {
8480
}
8581

8682
private async attemptRemoteTask<R>(task: RunnableTask<R>, logger: OutputLogger) {
83+
const binary = this._plugins.exec('spawn.binary', '', pluginContext(task, task.commands));
8784
const args = this._plugins.exec(
8885
'spawn.args',
8986
[...task.commands],
@@ -92,7 +89,7 @@ export class GitExecutorChain implements SimpleGitExecutor {
9289

9390
const raw = await this.gitResponse(
9491
task,
95-
this.binary,
92+
binary,
9693
args,
9794
this.outputHandler,
9895
logger.step('SPAWN')

‎simple-git/src/lib/runners/git-executor.ts

-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ export class GitExecutor implements SimpleGitExecutor {
1111
public outputHandler?: outputHandler;
1212

1313
constructor(
14-
public binary: string = 'git',
1514
public cwd: string,
1615
private _scheduler: Scheduler,
1716
private _plugins: PluginStore

‎simple-git/src/lib/types/index.ts

+15-5
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export type GitExecutorEnv = NodeJS.ProcessEnv | undefined;
4545
export interface SimpleGitExecutor {
4646
env: GitExecutorEnv;
4747
outputHandler?: outputHandler;
48-
binary: string;
4948
cwd: string;
5049

5150
chain(): SimpleGitExecutor;
@@ -66,6 +65,15 @@ export interface GitExecutorResult {
6665
export interface SimpleGitPluginConfig {
6766
abort: AbortSignal;
6867

68+
/**
69+
* Name of the binary the child processes will spawn - defaults to `git`,
70+
* supply as a tuple to enable the use of platforms that require `git` to be
71+
* called through an alternative binary (eg: `wsl git ...`).
72+
* Note: commands supplied in this way support a restricted set of characters
73+
* and should not be used as a way to supply arbitrary config arguments etc.
74+
*/
75+
binary: string | [string] | [string, string];
76+
6977
/**
7078
* Configures the events that should be used to determine when the unederlying child process has
7179
* been terminated.
@@ -122,6 +130,12 @@ export interface SimpleGitPluginConfig {
122130
spawnOptions: Pick<SpawnOptions, 'uid' | 'gid'>;
123131

124132
unsafe: {
133+
/**
134+
* Allows potentially unsafe values to be supplied in the `binary` configuration option and
135+
* `git.customBinary()` method call.
136+
*/
137+
allowUnsafeCustomBinary?: boolean;
138+
125139
/**
126140
* By default `simple-git` prevents the use of inline configuration
127141
* options to override the protocols available for the `git` child
@@ -154,10 +168,6 @@ export interface SimpleGitOptions extends Partial<SimpleGitPluginConfig> {
154168
* Base directory for all tasks run through this `simple-git` instance
155169
*/
156170
baseDir: string;
157-
/**
158-
* Name of the binary the child processes will spawn - defaults to `git`
159-
*/
160-
binary: string;
161171
/**
162172
* Limit for the number of child processes that will be spawned concurrently from a `simple-git` instance
163173
*/

‎simple-git/test/unit/plugin.abort.spec.ts renamed to ‎simple-git/test/unit/plugins/plugin.abort.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
createAbortController,
66
newSimpleGit,
77
wait,
8-
} from './__fixtures__';
9-
import { GitPluginError } from '../..';
8+
} from '../__fixtures__';
9+
import { GitPluginError } from '../../..';
1010

1111
describe('plugin.abort', function () {
1212
it('aborts an active child process', async () => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { promiseError } from '@kwsites/promise-result';
2+
import { assertGitError, closeWithSuccess, newSimpleGit } from '../__fixtures__';
3+
import { mockChildProcessModule } from '../__mocks__/mock-child-process';
4+
5+
describe('binaryPlugin', () => {
6+
it.each<[string, undefined | string | [string] | [string, string], string[]]>([
7+
['undefined', undefined, ['git']],
8+
['string', 'simple', ['simple']],
9+
['string array', ['array'], ['array']],
10+
['strings array', ['array', 'tuple'], ['array', 'tuple']],
11+
])('allows binary set to %s', async (_, binary, command) => {
12+
newSimpleGit({ binary }).raw('hello');
13+
14+
expect(await expected()).toEqual([...command, 'hello']);
15+
});
16+
17+
each(
18+
'valid',
19+
'./bin/git',
20+
'c:\\path\\to\\git.exe',
21+
'custom-git',
22+
'GIT'
23+
)('allows valid syntax "%s"', async (binary) => {
24+
newSimpleGit({ binary }).raw('hello');
25+
expect(await expected()).toEqual([binary, 'hello']);
26+
});
27+
28+
each(
29+
'long:\\path\\git.exe',
30+
'space fail',
31+
'"dquote fail"',
32+
"'squote fail'",
33+
'$',
34+
'!'
35+
)('rejects invalid syntax "%s"', async (binary) => {
36+
assertGitError(
37+
await promiseError((async () => newSimpleGit({ binary }).raw('hello'))()),
38+
'Invalid value supplied for custom binary'
39+
);
40+
});
41+
42+
it('works with config plugin', async () => {
43+
newSimpleGit({ binary: ['alpha', 'beta'], config: ['gamma'] }).raw('hello');
44+
expect(await expected()).toEqual(['alpha', 'beta', '-c', 'gamma', 'hello']);
45+
});
46+
47+
it('allows reconfiguring binary', async () => {
48+
const git = newSimpleGit().raw('a');
49+
expect(await expected()).toEqual(['git', 'a']);
50+
51+
git.customBinary('next').raw('b');
52+
expect(await expected()).toEqual(['next', 'b']);
53+
54+
git.customBinary(['abc', 'def']).raw('g');
55+
expect(await expected()).toEqual(['abc', 'def', 'g']);
56+
});
57+
58+
it('rejects reconfiguring to an invalid binary', async () => {
59+
const git = newSimpleGit().raw('a');
60+
expect(await expected()).toEqual(['git', 'a']);
61+
62+
assertGitError(
63+
await promiseError((async () => git.customBinary('not valid'))()),
64+
'Invalid value supplied for custom binary'
65+
);
66+
});
67+
68+
it('allows configuring to bad values when overridden', async () => {
69+
const git = newSimpleGit({ unsafe: { allowUnsafeCustomBinary: true }, binary: '$' }).raw('a');
70+
expect(await expected()).toEqual(['$', 'a']);
71+
72+
git.customBinary('!').raw('b');
73+
expect(await expected()).toEqual(['!', 'b']);
74+
});
75+
});
76+
77+
function each(...things: string[]) {
78+
return it.each(things.map((thing) => [thing]));
79+
}
80+
81+
async function expected() {
82+
await closeWithSuccess();
83+
const recent = mockChildProcessModule.$mostRecent();
84+
return [recent.$command, ...recent.$args];
85+
}

‎simple-git/test/unit/plugin.completion-detection.spec.ts renamed to ‎simple-git/test/unit/plugins/plugin.completion-detection.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { newSimpleGit, theChildProcessMatching, wait } from './__fixtures__';
2-
import { MockChildProcess } from './__mocks__/mock-child-process';
1+
import { newSimpleGit, theChildProcessMatching, wait } from '../__fixtures__';
2+
import { MockChildProcess } from '../__mocks__/mock-child-process';
33

44
describe('completionDetectionPlugin', () => {
55
function process(proc: MockChildProcess, data: string, close = false, exit = false) {

‎simple-git/test/unit/plugin.error.spec.ts renamed to ‎simple-git/test/unit/plugins/plugin.error.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { promiseError } from '@kwsites/promise-result';
2-
import { assertGitError, closeWithError, closeWithSuccess, newSimpleGit } from './__fixtures__';
2+
import { assertGitError, closeWithError, closeWithSuccess, newSimpleGit } from '../__fixtures__';
33

4-
import { GitError } from '../..';
4+
import { GitError } from '../../..';
55

66
describe('errorDetectionPlugin', () => {
77
it('can throw with custom content', async () => {

‎simple-git/test/unit/plugin.pathspec.spec.ts renamed to ‎simple-git/test/unit/plugins/plugin.pathspec.spec.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { SimpleGit } from '../../typings';
2-
import { assertExecutedCommands, closeWithSuccess, newSimpleGit } from './__fixtures__';
3-
import { pathspec } from '../../src/lib/args/pathspec';
1+
import { SimpleGit } from '../../../typings';
2+
import { assertExecutedCommands, closeWithSuccess, newSimpleGit } from '../__fixtures__';
3+
import { pathspec } from '../../../src/lib/args/pathspec';
44

55
describe('suffixPathsPlugin', function () {
66
let git: SimpleGit;

‎simple-git/test/unit/plugin.unsafe.spec.ts renamed to ‎simple-git/test/unit/plugins/plugin.unsafe.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
assertGitError,
55
closeWithSuccess,
66
newSimpleGit,
7-
} from './__fixtures__';
7+
} from '../__fixtures__';
88

99
describe('blockUnsafeOperationsPlugin', () => {
1010
it.each([

‎simple-git/test/unit/plugins.spec.ts renamed to ‎simple-git/test/unit/plugins/plugins.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SimpleGit } from '../../typings';
1+
import { SimpleGit } from '../../../typings';
22
import {
33
assertChildProcessSpawnOptions,
44
assertExecutedCommands,
@@ -8,7 +8,7 @@ import {
88
theChildProcess,
99
writeToStdErr,
1010
writeToStdOut,
11-
} from './__fixtures__';
11+
} from '../__fixtures__';
1212

1313
describe('plugins', () => {
1414
let git: SimpleGit;

‎simple-git/typings/simple-git.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ export interface SimpleGit extends SimpleGitBase {
452452
* Sets the path to a custom git binary, should either be `git` when there is an installation of git available on
453453
* the system path, or a fully qualified path to the executable.
454454
*/
455-
customBinary(command: string): this;
455+
customBinary(command: Exclude<types.SimpleGitOptions['binary'], undefined>): this;
456456

457457
/**
458458
* Delete one local branch. Supply the branchName as a string to return a

0 commit comments

Comments
 (0)
Please sign in to comment.