Skip to content

Commit

Permalink
Feature/array custom binary (#987)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
steveukx committed Mar 28, 2024
1 parent 2575c48 commit c355317
Show file tree
Hide file tree
Showing 20 changed files with 288 additions and 31 deletions.
5 changes: 5 additions & 0 deletions .changeset/forty-trains-float.md
@@ -0,0 +1,5 @@
---
"simple-git": minor
---

Enable the use of a two part custom binary
72 changes: 72 additions & 0 deletions docs/PLUGIN-CUSTOM-BINARY.md
@@ -0,0 +1,72 @@
## Custom Binary

The `simple-git` library relies on `git` being available on the `$PATH` when spawning the child processes
to handle each `git` command.

```typescript
simpleGit().init();
```

Is equivalent to opening a terminal prompt and typing

```shell
git init
```

### Configuring the binary for a new instance

When `git` isn't available on the `$PATH`, which can often be the case if you're running in a custom
or virtualised container, the `git` binary can be replaced using the configuration object:

```typescript
simpleGit({ binary: 'my-custom-git' });
```

For environments where you need even further customisation of the path (for example flatpak or WSL),
the `binary` configuration property can be supplied as an array of up to two strings which will become
the command and first argument of the spawned child processes:

```typescript
simpleGit({ binary: ['wsl', 'git'] }).init();
```

Is equivalent to:

```shell
wsl git init
```

### Changing the binary on an existing instance

From v3.24.0 and above, the `simpleGit.customBinary` method supports the same parameter type and can be
used to change the `binary` configuration on an existing `simple-git` instance:

```typescript
const git = await simpleGit().init();
git.customBinary('./custom/git').raw('add', '.');
```

Is equivalent to:

```shell
git init
./custom/git add .
```

### Caveats / Security

To prevent accidentally merging arbitrary code into the spawned child processes, the strings supplied
in the `binary` config are limited to alphanumeric, slashes, dot, hyphen and underscore. Colon is also
permitted when part of a valid windows path (ie: after one letter at the start of the string).

This protection can be overridden by passing an additional unsafe configuration setting:

```typescript
// this would normally throw because of the invalid value for `binary`
simpleGit({
unsafe: {
allowUnsafeCustomBinary: true
},
binary: '!'
});
```
5 changes: 4 additions & 1 deletion simple-git/readme.md
Expand Up @@ -90,6 +90,9 @@ await git.pull();
- [AbortController](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-ABORT-CONTROLLER.md)
Terminate pending and future tasks in a `simple-git` instance (requires node >= 16).

- [Custom Binary](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-CUSTOM-BINARY.md)
Customise the `git` binary `simple-git` uses when spawning `git` child processes.

- [Completion Detection](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-COMPLETION-DETECTION.md)
Customise how `simple-git` detects the end of a `git` process.

Expand Down Expand Up @@ -195,7 +198,7 @@ in v2 (deprecation notices were logged to `stdout` as `console.warn` in v2).
| `.clearQueue()` | immediately clears the queue of pending tasks (note: any command currently in progress will still call its completion callback) |
| `.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) |
| `.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 |
| `.customBinary(gitPath)` | sets the command to use to reference git, allows for using a git binary not available on the path environment variable |
| `.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) |
| `.env(name, value)` | Set environment variables to be passed to the spawned child processes, [see usage in detail below](#environment-variables). |
| `.exec(handlerFn)` | calls a simple function in the current step |
| `.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). |
Expand Down
7 changes: 2 additions & 5 deletions simple-git/src/git.js
Expand Up @@ -49,8 +49,8 @@ const { addAnnotatedTagTask, addTagTask, tagListTask } = require('./lib/tasks/ta
const { straightThroughBufferTask, straightThroughStringTask } = require('./lib/tasks/task');

function Git(options, plugins) {
this._plugins = plugins;
this._executor = new GitExecutor(
options.binary,
options.baseDir,
new Scheduler(options.maxConcurrentProcesses),
plugins
Expand All @@ -64,12 +64,9 @@ function Git(options, plugins) {
/**
* Sets the path to a custom git binary, should either be `git` when there is an installation of git available on
* the system path, or a fully qualified path to the executable.
*
* @param {string} command
* @returns {Git}
*/
Git.prototype.customBinary = function (command) {
this._executor.binary = command;
this._plugins.reconfigure('binary', command);
return this;
};

Expand Down
3 changes: 3 additions & 0 deletions simple-git/src/lib/git-factory.ts
Expand Up @@ -6,6 +6,7 @@ import {
blockUnsafeOperationsPlugin,
commandConfigPrefixingPlugin,
completionDetectionPlugin,
customBinaryPlugin,
errorDetectionHandler,
errorDetectionPlugin,
PluginStore,
Expand Down Expand Up @@ -68,5 +69,7 @@ export function gitInstanceFactory(
plugins.add(errorDetectionPlugin(errorDetectionHandler(true)));
config.errors && plugins.add(errorDetectionPlugin(config.errors));

customBinaryPlugin(plugins, config.binary, config.unsafe?.allowUnsafeCustomBinary);

return new Git(config, plugins);
}
56 changes: 56 additions & 0 deletions simple-git/src/lib/plugins/custom-binary.plugin.ts
@@ -0,0 +1,56 @@
import type { SimpleGitOptions } from '../types';

import { GitPluginError } from '../errors/git-plugin-error';
import { asArray } from '../utils';
import { PluginStore } from './plugin-store';

const WRONG_NUMBER_ERR = `Invalid value supplied for custom binary, requires a single string or an array containing either one or two strings`;
const WRONG_CHARS_ERR = `Invalid value supplied for custom binary, restricted characters must be removed or supply the unsafe.allowUnsafeCustomBinary option`;

function isBadArgument(arg: string) {
return !arg || !/^([a-z]:)?([a-z0-9/.\\_-]+)$/i.test(arg);
}

function toBinaryConfig(
input: string[],
allowUnsafe: boolean
): { binary: string; prefix?: string } {
if (input.length < 1 || input.length > 2) {
throw new GitPluginError(undefined, 'binary', WRONG_NUMBER_ERR);
}

const isBad = input.some(isBadArgument);
if (isBad) {
if (allowUnsafe) {
console.warn(WRONG_CHARS_ERR);
} else {
throw new GitPluginError(undefined, 'binary', WRONG_CHARS_ERR);
}
}

const [binary, prefix] = input;
return {
binary,
prefix,
};
}

export function customBinaryPlugin(
plugins: PluginStore,
input: SimpleGitOptions['binary'] = ['git'],
allowUnsafe = false
) {
let config = toBinaryConfig(asArray(input), allowUnsafe);

plugins.on('binary', (input) => {
config = toBinaryConfig(asArray(input), allowUnsafe);
});

plugins.append('spawn.binary', () => {
return config.binary;
});

plugins.append('spawn.args', (data) => {
return config.prefix ? [config.prefix, ...data] : data;
});
}
1 change: 1 addition & 0 deletions simple-git/src/lib/plugins/index.ts
Expand Up @@ -2,6 +2,7 @@ export * from './abort-plugin';
export * from './block-unsafe-operations-plugin';
export * from './command-config-prefixing-plugin';
export * from './completion-detection.plugin';
export * from './custom-binary.plugin';
export * from './error-detection.plugin';
export * from './plugin-store';
export * from './progress-monitor-plugin';
Expand Down
27 changes: 26 additions & 1 deletion simple-git/src/lib/plugins/plugin-store.ts
@@ -1,8 +1,33 @@
import { SimpleGitPlugin, SimpleGitPluginType, SimpleGitPluginTypes } from './simple-git-plugin';
import { EventEmitter } from 'node:events';

import type {
SimpleGitPlugin,
SimpleGitPluginType,
SimpleGitPluginTypes,
} from './simple-git-plugin';
import { append, asArray } from '../utils';
import type { SimpleGitPluginConfig } from '../types';

export class PluginStore {
private plugins: Set<SimpleGitPlugin<SimpleGitPluginType>> = new Set();
private events = new EventEmitter();

on<K extends keyof SimpleGitPluginConfig>(
type: K,
listener: (data: SimpleGitPluginConfig[K]) => void
) {
this.events.on(type, listener);
}

reconfigure<K extends keyof SimpleGitPluginConfig>(type: K, data: SimpleGitPluginConfig[K]) {
this.events.emit(type, data);
}

public append<T extends SimpleGitPluginType>(type: T, action: SimpleGitPlugin<T>['action']) {
const plugin = append(this.plugins, { type, action });

return () => this.plugins.delete(plugin);
}

public add<T extends SimpleGitPluginType>(
plugin: void | SimpleGitPlugin<T> | SimpleGitPlugin<T>[]
Expand Down
4 changes: 4 additions & 0 deletions simple-git/src/lib/plugins/simple-git-plugin.ts
Expand Up @@ -11,6 +11,10 @@ export interface SimpleGitPluginTypes {
data: string[];
context: SimpleGitTaskPluginContext & {};
};
'spawn.binary': {
data: string;
context: SimpleGitTaskPluginContext & {};
};
'spawn.options': {
data: Partial<SpawnOptions>;
context: SimpleGitTaskPluginContext & {};
Expand Down
7 changes: 2 additions & 5 deletions simple-git/src/lib/runners/git-executor-chain.ts
Expand Up @@ -20,10 +20,6 @@ export class GitExecutorChain implements SimpleGitExecutor {
private _queue = new TasksPendingQueue();
private _cwd: string | undefined;

public get binary() {
return this._executor.binary;
}

public get cwd() {
return this._cwd || this._executor.cwd;
}
Expand Down Expand Up @@ -84,6 +80,7 @@ export class GitExecutorChain implements SimpleGitExecutor {
}

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

const raw = await this.gitResponse(
task,
this.binary,
binary,
args,
this.outputHandler,
logger.step('SPAWN')
Expand Down
1 change: 0 additions & 1 deletion simple-git/src/lib/runners/git-executor.ts
Expand Up @@ -11,7 +11,6 @@ export class GitExecutor implements SimpleGitExecutor {
public outputHandler?: outputHandler;

constructor(
public binary: string = 'git',
public cwd: string,
private _scheduler: Scheduler,
private _plugins: PluginStore
Expand Down
20 changes: 15 additions & 5 deletions simple-git/src/lib/types/index.ts
Expand Up @@ -45,7 +45,6 @@ export type GitExecutorEnv = NodeJS.ProcessEnv | undefined;
export interface SimpleGitExecutor {
env: GitExecutorEnv;
outputHandler?: outputHandler;
binary: string;
cwd: string;

chain(): SimpleGitExecutor;
Expand All @@ -66,6 +65,15 @@ export interface GitExecutorResult {
export interface SimpleGitPluginConfig {
abort: AbortSignal;

/**
* Name of the binary the child processes will spawn - defaults to `git`,
* supply as a tuple to enable the use of platforms that require `git` to be
* called through an alternative binary (eg: `wsl git ...`).
* Note: commands supplied in this way support a restricted set of characters
* and should not be used as a way to supply arbitrary config arguments etc.
*/
binary: string | [string] | [string, string];

/**
* Configures the events that should be used to determine when the unederlying child process has
* been terminated.
Expand Down Expand Up @@ -122,6 +130,12 @@ export interface SimpleGitPluginConfig {
spawnOptions: Pick<SpawnOptions, 'uid' | 'gid'>;

unsafe: {
/**
* Allows potentially unsafe values to be supplied in the `binary` configuration option and
* `git.customBinary()` method call.
*/
allowUnsafeCustomBinary?: boolean;

/**
* By default `simple-git` prevents the use of inline configuration
* options to override the protocols available for the `git` child
Expand Down Expand Up @@ -154,10 +168,6 @@ export interface SimpleGitOptions extends Partial<SimpleGitPluginConfig> {
* Base directory for all tasks run through this `simple-git` instance
*/
baseDir: string;
/**
* Name of the binary the child processes will spawn - defaults to `git`
*/
binary: string;
/**
* Limit for the number of child processes that will be spawned concurrently from a `simple-git` instance
*/
Expand Down
Expand Up @@ -5,8 +5,8 @@ import {
createAbortController,
newSimpleGit,
wait,
} from './__fixtures__';
import { GitPluginError } from '../..';
} from '../__fixtures__';
import { GitPluginError } from '../../..';

describe('plugin.abort', function () {
it('aborts an active child process', async () => {
Expand Down

0 comments on commit c355317

Please sign in to comment.