mirror of
https://github.com/EndBug/add-and-commit.git
synced 2026-08-27 06:45:21 -05:00
Short-option clusters such as -Sm were treated as consuming the next argument, which let --pathspec-from-file reach git and leak file contents into workflow logs. Co-authored-by: Cursor <cursoragent@cursor.com>
686 lines
23 KiB
TypeScript
686 lines
23 KiB
TypeScript
import {
|
|
assertNoUnexpectedGitlinks,
|
|
assertValidBranchName,
|
|
assertWorkingDirectory,
|
|
findUnexpectedGitlinks,
|
|
matchGitArgs,
|
|
neutralizeForLog,
|
|
neutralizeLogString,
|
|
parseInputArray,
|
|
pickGitIdentityConfig,
|
|
resolveBaseDir,
|
|
} from '../src/util';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
describe('resolveBaseDir', () => {
|
|
const from = path.join(path.sep, 'home', 'runner', 'work', 'repo', 'repo');
|
|
|
|
it('resolves relative paths against from', () => {
|
|
expect(resolveBaseDir('./my-checkout', from)).toBe(
|
|
path.join(from, 'my-checkout'),
|
|
);
|
|
expect(resolveBaseDir('subdir', from)).toBe(path.join(from, 'subdir'));
|
|
});
|
|
|
|
it('keeps absolute paths', () => {
|
|
const abs = path.join(from, 'my-checkout-path');
|
|
expect(resolveBaseDir(abs, from)).toBe(abs);
|
|
expect(resolveBaseDir(abs, path.join(from, 'other'))).toBe(abs);
|
|
});
|
|
|
|
it('treats empty and "." as from', () => {
|
|
expect(resolveBaseDir('', from)).toBe(from);
|
|
expect(resolveBaseDir('.', from)).toBe(from);
|
|
});
|
|
});
|
|
|
|
describe('assertWorkingDirectory', () => {
|
|
it('accepts an existing directory', () => {
|
|
expect(() =>
|
|
assertWorkingDirectory(os.tmpdir(), os.tmpdir()),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('rejects a missing path', () => {
|
|
const missing = path.join(
|
|
os.tmpdir(),
|
|
`aac-missing-${process.pid}-${Date.now()}`,
|
|
);
|
|
expect(() => assertWorkingDirectory(missing, missing)).toThrow(
|
|
/not an existing directory/,
|
|
);
|
|
});
|
|
|
|
it('rejects a file path', () => {
|
|
const file = path.join(
|
|
os.tmpdir(),
|
|
`aac-file-${process.pid}-${Date.now()}.txt`,
|
|
);
|
|
fs.writeFileSync(file, 'x');
|
|
try {
|
|
expect(() => assertWorkingDirectory(file, file)).toThrow(
|
|
/not an existing directory/,
|
|
);
|
|
} finally {
|
|
fs.unlinkSync(file);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('parseInputArray', () => {
|
|
beforeAll(() => {
|
|
process.env.GITHUB_EVENT_PATH = 'a';
|
|
process.env.GITHUB_EVENT_NAME = 'b';
|
|
process.env.GITHUB_REF = 'c';
|
|
process.env.GITHUB_ACTOR = 'd';
|
|
});
|
|
|
|
afterAll(() => {
|
|
delete process.env.GITHUB_EVENT_PATH;
|
|
delete process.env.GITHUB_EVENT_NAME;
|
|
delete process.env.GITHUB_REF;
|
|
delete process.env.GITHUB_ACTOR;
|
|
});
|
|
|
|
it('parses string arrays', () => {
|
|
expect(parseInputArray('["a", "bcd"]')).toStrictEqual(['a', 'bcd']);
|
|
});
|
|
|
|
it('passes strings through', () => {
|
|
expect(parseInputArray('"hello"')).toStrictEqual(['"hello"']);
|
|
});
|
|
|
|
it('passes non-string elements through', () => {
|
|
expect(parseInputArray('[42]')).toStrictEqual(['[42]']);
|
|
});
|
|
|
|
it('ignores failures', () => {
|
|
expect(parseInputArray('"')).toStrictEqual(['"']);
|
|
});
|
|
});
|
|
|
|
describe('assertValidBranchName', () => {
|
|
beforeAll(() => {
|
|
process.env.GITHUB_EVENT_PATH = 'a';
|
|
process.env.GITHUB_EVENT_NAME = 'b';
|
|
process.env.GITHUB_REF = 'c';
|
|
process.env.GITHUB_ACTOR = 'd';
|
|
});
|
|
|
|
afterAll(() => {
|
|
delete process.env.GITHUB_EVENT_PATH;
|
|
delete process.env.GITHUB_EVENT_NAME;
|
|
delete process.env.GITHUB_REF;
|
|
delete process.env.GITHUB_ACTOR;
|
|
});
|
|
|
|
it('accepts typical branch names', () => {
|
|
expect(() => assertValidBranchName('feature/foo')).not.toThrow();
|
|
expect(() =>
|
|
assertValidBranchName('dependabot/npm-and-yarn/pkg-1.2.3'),
|
|
).not.toThrow();
|
|
expect(() => assertValidBranchName('fix/issue-123')).not.toThrow();
|
|
});
|
|
|
|
it('rejects empty or whitespace-only names', () => {
|
|
expect(() => assertValidBranchName('')).toThrow(/empty/);
|
|
expect(() => assertValidBranchName(' ')).toThrow(/empty/);
|
|
});
|
|
|
|
it('rejects names that start with a hyphen', () => {
|
|
expect(() => assertValidBranchName('--force')).toThrow(/cannot start with/);
|
|
expect(() => assertValidBranchName('-f')).toThrow(/cannot start with/);
|
|
expect(() => assertValidBranchName('--delete')).toThrow(
|
|
/cannot start with/,
|
|
);
|
|
expect(() => assertValidBranchName('-branch')).toThrow(/cannot start with/);
|
|
});
|
|
|
|
it('rejects names with whitespace or control characters', () => {
|
|
expect(() => assertValidBranchName('has space')).toThrow(
|
|
/whitespace or control/,
|
|
);
|
|
expect(() => assertValidBranchName('has\ttab')).toThrow(
|
|
/whitespace or control/,
|
|
);
|
|
expect(() => assertValidBranchName('has\nnewline')).toThrow(
|
|
/whitespace or control/,
|
|
);
|
|
expect(() => assertValidBranchName('has\u00A0nbsp')).toThrow(
|
|
/whitespace or control/,
|
|
);
|
|
expect(() => assertValidBranchName('has\u0085nel')).toThrow(
|
|
/whitespace or control/,
|
|
);
|
|
});
|
|
|
|
it('rejects names that fail git check-ref-format --branch', () => {
|
|
expect(() => assertValidBranchName('feature..name')).toThrow(
|
|
/not a valid git branch name/,
|
|
);
|
|
expect(() => assertValidBranchName('name@{x}')).toThrow(
|
|
/not a valid git branch name/,
|
|
);
|
|
expect(() => assertValidBranchName('name~1')).toThrow(
|
|
/not a valid git branch name/,
|
|
);
|
|
expect(() => assertValidBranchName('name.')).toThrow(
|
|
/not a valid git branch name/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('matchGitArgs', () => {
|
|
beforeAll(() => {
|
|
process.env.GITHUB_EVENT_PATH = 'a';
|
|
process.env.GITHUB_EVENT_NAME = 'b';
|
|
process.env.GITHUB_REF = 'c';
|
|
process.env.GITHUB_ACTOR = 'd';
|
|
});
|
|
|
|
afterAll(() => {
|
|
delete process.env.GITHUB_EVENT_PATH;
|
|
delete process.env.GITHUB_EVENT_NAME;
|
|
delete process.env.GITHUB_REF;
|
|
delete process.env.GITHUB_ACTOR;
|
|
});
|
|
|
|
it('parses safe documented argument strings', () => {
|
|
expect(matchGitArgs('--tags --force')).toStrictEqual(['--tags', '--force']);
|
|
expect(matchGitArgs('--rebase --autostash')).toStrictEqual([
|
|
'--rebase',
|
|
'--autostash',
|
|
]);
|
|
expect(matchGitArgs('origin main --force')).toStrictEqual([
|
|
'origin',
|
|
'main',
|
|
'--force',
|
|
]);
|
|
expect(matchGitArgs('--set-upstream')).toStrictEqual(['--set-upstream']);
|
|
expect(matchGitArgs('v1.0.0 --force')).toStrictEqual(['v1.0.0', '--force']);
|
|
expect(matchGitArgs('-a -m "release"')).toStrictEqual([
|
|
'-a',
|
|
'-m',
|
|
'release',
|
|
]);
|
|
expect(matchGitArgs('v1.0.0 -f')).toStrictEqual(['v1.0.0', '-f']);
|
|
expect(matchGitArgs('v1.0.0 -u ABCDEF')).toStrictEqual([
|
|
'v1.0.0',
|
|
'-u',
|
|
'ABCDEF',
|
|
]);
|
|
});
|
|
|
|
it('returns an empty array for blank input', () => {
|
|
expect(matchGitArgs(' ')).toStrictEqual([]);
|
|
expect(matchGitArgs('')).toStrictEqual([]);
|
|
});
|
|
|
|
it('rejects --upload-pack with equals value (PoC form)', () => {
|
|
expect(() => matchGitArgs('--upload-pack=touch$IFS/tmp/pwned;')).toThrow(
|
|
/not allowed/,
|
|
);
|
|
});
|
|
|
|
it('rejects --upload-pack as a separate flag token', () => {
|
|
expect(() => matchGitArgs('--upload-pack touch /tmp/pwned')).toThrow(
|
|
/not allowed/,
|
|
);
|
|
});
|
|
|
|
it('rejects --receive-pack and --exec overrides', () => {
|
|
expect(() => matchGitArgs('--receive-pack=/bin/sh')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--exec=/bin/sh')).toThrow(/not allowed/);
|
|
});
|
|
|
|
it('rejects abbreviations of blocked remote-helper options', () => {
|
|
expect(() => matchGitArgs('--upl=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--uplo=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--upload-pac=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--upload=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--rece=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--receive-pac=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--receive=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--e=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--exe=evil')).toThrow(/not allowed/);
|
|
});
|
|
|
|
it('rejects remote-helper overrides after -u (fetch/push flag, not a value option)', () => {
|
|
expect(() => matchGitArgs('-u --upl=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('-u --upload-pack=evil')).toThrow(/not allowed/);
|
|
});
|
|
|
|
it('rejects remote-helper overrides after -m / --message', () => {
|
|
expect(() => matchGitArgs('-m --upl=evil')).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('--message --exec=evil')).toThrow(/not allowed/);
|
|
});
|
|
|
|
it('rejects unmatched quotes that would inject flags via string-argv', () => {
|
|
expect(() => matchGitArgs("origin fix'--force --set-upstream")).toThrow(
|
|
/unmatched ' quote/,
|
|
);
|
|
expect(() => matchGitArgs('origin fix"--force --set-upstream')).toThrow(
|
|
/unmatched " quote/,
|
|
);
|
|
});
|
|
|
|
it('rejects a closing quote glued to following text (string-argv would split it)', () => {
|
|
expect(() => matchGitArgs("origin 'main'--force --set-upstream")).toThrow(
|
|
/quoted segment immediately followed by non-whitespace/,
|
|
);
|
|
expect(() => matchGitArgs('origin "main"--force --set-upstream')).toThrow(
|
|
/quoted segment immediately followed by non-whitespace/,
|
|
);
|
|
expect(() => matchGitArgs("origin ''--force")).toThrow(
|
|
/quoted segment immediately followed by non-whitespace/,
|
|
);
|
|
expect(() => matchGitArgs('origin \'foo\'"--force"')).toThrow(
|
|
/quoted segment immediately followed by non-whitespace/,
|
|
);
|
|
expect(() => matchGitArgs("origin a'b'c --set-upstream")).toThrow(
|
|
/quoted segment immediately followed by non-whitespace/,
|
|
);
|
|
});
|
|
|
|
it('parses balanced quotes that wrap a whole argument', () => {
|
|
expect(matchGitArgs("origin 'main' --set-upstream")).toStrictEqual([
|
|
'origin',
|
|
'main',
|
|
'--set-upstream',
|
|
]);
|
|
expect(matchGitArgs("origin 'a b c' --set-upstream")).toStrictEqual([
|
|
'origin',
|
|
'a b c',
|
|
'--set-upstream',
|
|
]);
|
|
expect(matchGitArgs("--longOption 'hello world'")).toStrictEqual([
|
|
'--longOption',
|
|
'hello world',
|
|
]);
|
|
expect(
|
|
matchGitArgs('--longOption \'This uses the "other" quotes\''),
|
|
).toStrictEqual(['--longOption', 'This uses the "other" quotes']);
|
|
expect(matchGitArgs("--message='hello'")).toStrictEqual([
|
|
"--message='hello'",
|
|
]);
|
|
});
|
|
|
|
it('rejects -F / --file message-from-file flags (PoC form)', () => {
|
|
expect(() =>
|
|
matchGitArgs('1.0.0 -F ../runner-secrets/aws-credentials.txt'),
|
|
).toThrow(/not allowed/);
|
|
expect(() => matchGitArgs('-F ../secrets')).toThrow(/message from a file/);
|
|
expect(() => matchGitArgs('--file=../secrets')).toThrow(
|
|
/message from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--file ../secrets')).toThrow(
|
|
/message from a file/,
|
|
);
|
|
});
|
|
|
|
it('rejects --file abbreviations and short-option clusters containing F', () => {
|
|
expect(() => matchGitArgs('--fi=../secrets')).toThrow(
|
|
/message from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--fil=../secrets')).toThrow(
|
|
/message from a file/,
|
|
);
|
|
expect(() => matchGitArgs('-aF ../secrets')).toThrow(/message from a file/);
|
|
expect(() => matchGitArgs('-Fa')).toThrow(/message from a file/);
|
|
expect(() => matchGitArgs('-F../secrets')).toThrow(/message from a file/);
|
|
});
|
|
|
|
it('rejects -F / --file even when they follow -m / --message', () => {
|
|
expect(() => matchGitArgs('-m "-F"')).toThrow(/message from a file/);
|
|
expect(() => matchGitArgs('-m --file=/tmp/value')).toThrow(
|
|
/message from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--message "-F"')).toThrow(/message from a file/);
|
|
expect(() => matchGitArgs('v1.0.0 -a -m "-F"')).toThrow(
|
|
/message from a file/,
|
|
);
|
|
});
|
|
|
|
it('treats -m-F as a glued message value, not a file flag', () => {
|
|
expect(matchGitArgs('-m-F')).toStrictEqual(['-m-F']);
|
|
});
|
|
|
|
it('still rejects a real -F after a message value', () => {
|
|
expect(() => matchGitArgs('-m "ok" -F ../secrets')).toThrow(
|
|
/message from a file/,
|
|
);
|
|
});
|
|
|
|
it('rejects --pathspec-from-file inline and separate-arg forms', () => {
|
|
expect(() => matchGitArgs('--pathspec-from-file=/path')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--pathspec-from-file /path')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
});
|
|
|
|
it('rejects --pathspec-file-nul alone and combined with --pathspec-from-file', () => {
|
|
expect(() => matchGitArgs('--pathspec-file-nul')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
expect(() =>
|
|
matchGitArgs('--pathspec-from-file=/path --pathspec-file-nul'),
|
|
).toThrow(/pathspecs from a file/);
|
|
});
|
|
|
|
it('rejects --pathspec-from-file and --pathspec-file-nul abbreviations', () => {
|
|
expect(() => matchGitArgs('--pathspec-fr=/path')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--pathspec-from=/path')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--pathspec-fi')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--pathspec-file')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
});
|
|
|
|
it('rejects --pathspec-from-file even when it follows -m / --message', () => {
|
|
expect(() => matchGitArgs('-m "--pathspec-from-file=/x"')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
expect(() => matchGitArgs('--message --pathspec-from-file=/x')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
});
|
|
|
|
it('still rejects a real --pathspec-from-file after a message value', () => {
|
|
expect(() => matchGitArgs('-m "ok" --pathspec-from-file=/x')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
});
|
|
|
|
it('rejects --pathspec-from-file after a short-option cluster whose last letter is m', () => {
|
|
expect(() =>
|
|
matchGitArgs('-Sm --pathspec-from-file=/x -Sm --pathspec-file-nul'),
|
|
).toThrow(/pathspecs from a file/);
|
|
expect(() => matchGitArgs('-tm --pathspec-from-file=/x')).toThrow(
|
|
/pathspecs from a file/,
|
|
);
|
|
});
|
|
|
|
it('still rejects --pathspec-from-file when allowUnsafeGitProtocols is true', () => {
|
|
expect(() =>
|
|
matchGitArgs('--pathspec-from-file=/path', {
|
|
allowUnsafeGitProtocols: true,
|
|
}),
|
|
).toThrow(/pathspecs from a file/);
|
|
expect(() =>
|
|
matchGitArgs('--pathspec-file-nul', {allowUnsafeGitProtocols: true}),
|
|
).toThrow(/pathspecs from a file/);
|
|
});
|
|
|
|
it('rejects scheme:: remote-helper URL tokens (PoC form)', () => {
|
|
expect(() => matchGitArgs('ext::sh -c touch\\ /tmp/pwned')).toThrow(
|
|
/remote-helper URLs/,
|
|
);
|
|
expect(() => matchGitArgs('ext::touch /tmp/pwned')).toThrow(
|
|
/allow_unsafe_git_protocols/,
|
|
);
|
|
expect(() => matchGitArgs('evil::anything')).toThrow(/remote-helper URLs/);
|
|
expect(() => matchGitArgs('origin evil::x --force')).toThrow(
|
|
/remote-helper URLs/,
|
|
);
|
|
expect(() => matchGitArgs("'ext::sh -c touch /tmp/pwned'")).toThrow(
|
|
/remote-helper URLs/,
|
|
);
|
|
});
|
|
|
|
it('allows scheme:: tokens when allowUnsafeGitProtocols is true', () => {
|
|
expect(
|
|
matchGitArgs('ext::sh -c true', {allowUnsafeGitProtocols: true}),
|
|
).toStrictEqual(['ext::sh', '-c', 'true']);
|
|
expect(
|
|
matchGitArgs('evil::anything', {allowUnsafeGitProtocols: true}),
|
|
).toStrictEqual(['evil::anything']);
|
|
expect(
|
|
matchGitArgs("'ext::sh -c true'", {allowUnsafeGitProtocols: true}),
|
|
).toStrictEqual(['ext::sh -c true']);
|
|
});
|
|
|
|
it('still rejects --upload-pack when allowUnsafeGitProtocols is true', () => {
|
|
expect(() =>
|
|
matchGitArgs('--upload-pack=/bin/sh', {allowUnsafeGitProtocols: true}),
|
|
).toThrow(/not allowed/);
|
|
});
|
|
|
|
it('rejects scheme:: even when it follows -m / --message', () => {
|
|
expect(() => matchGitArgs('-m "foo::bar"')).toThrow(/remote-helper URLs/);
|
|
expect(() => matchGitArgs('--message foo::bar')).toThrow(
|
|
/remote-helper URLs/,
|
|
);
|
|
});
|
|
|
|
it('rejects scheme:: after a short-option cluster that used to skip the next token', () => {
|
|
expect(() => matchGitArgs('-Sm ext::sh')).toThrow(/remote-helper URLs/);
|
|
expect(() => matchGitArgs('-om ext::sh origin')).toThrow(
|
|
/remote-helper URLs/,
|
|
);
|
|
});
|
|
|
|
it('allows scheme:: after -m when allowUnsafeGitProtocols is true', () => {
|
|
expect(
|
|
matchGitArgs('-m "foo::bar"', {allowUnsafeGitProtocols: true}),
|
|
).toStrictEqual(['-m', 'foo::bar']);
|
|
expect(
|
|
matchGitArgs('--message foo::bar', {allowUnsafeGitProtocols: true}),
|
|
).toStrictEqual(['--message', 'foo::bar']);
|
|
expect(
|
|
matchGitArgs('-Sm ext::sh', {allowUnsafeGitProtocols: true}),
|
|
).toStrictEqual(['-Sm', 'ext::sh']);
|
|
});
|
|
});
|
|
|
|
describe('pickGitIdentityConfig', () => {
|
|
it('keeps only identity keys', () => {
|
|
const picked = pickGitIdentityConfig({
|
|
'user.name': 'Alice',
|
|
'user.email': 'alice@example.com',
|
|
'author.name': 'Alice',
|
|
'author.email': 'alice@example.com',
|
|
'committer.name': 'Bot',
|
|
'committer.email': 'bot@example.com',
|
|
'http.https://github.com/.extraheader':
|
|
'AUTHORIZATION: basic dGVzdDp0b2tlbg==',
|
|
'credential.helper': 'store',
|
|
'remote.origin.url':
|
|
'https://x-access-token:ghp_secret@github.com/o/r.git',
|
|
'core.sshCommand': 'ssh -i /secrets/id_rsa',
|
|
});
|
|
|
|
expect(picked).toStrictEqual({
|
|
'user.name': 'Alice',
|
|
'user.email': 'alice@example.com',
|
|
'author.name': 'Alice',
|
|
'author.email': 'alice@example.com',
|
|
'committer.name': 'Bot',
|
|
'committer.email': 'bot@example.com',
|
|
});
|
|
expect(picked).not.toHaveProperty('http.https://github.com/.extraheader');
|
|
expect(picked).not.toHaveProperty('credential.helper');
|
|
expect(picked).not.toHaveProperty('remote.origin.url');
|
|
expect(picked).not.toHaveProperty('core.sshCommand');
|
|
});
|
|
|
|
it('returns an empty object when no identity keys are present', () => {
|
|
expect(
|
|
pickGitIdentityConfig({
|
|
'http.https://github.com/.extraheader':
|
|
'AUTHORIZATION: basic dGVzdDp0b2tlbg==',
|
|
}),
|
|
).toStrictEqual({});
|
|
});
|
|
});
|
|
|
|
describe('findUnexpectedGitlinks', () => {
|
|
it('returns empty for empty or unrelated output', () => {
|
|
expect(findUnexpectedGitlinks('')).toStrictEqual([]);
|
|
expect(
|
|
findUnexpectedGitlinks(
|
|
':000000 100644 0000000000000000000000000000000000000000 abcdefabcdefabcdefabcdefabcdefabcdefabcd A\tREADME.md',
|
|
),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it('flags a newly added gitlink', () => {
|
|
expect(
|
|
findUnexpectedGitlinks(
|
|
':000000 160000 0000000000000000000000000000000000000000 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 A\tevil_nested_repo',
|
|
),
|
|
).toStrictEqual(['evil_nested_repo']);
|
|
});
|
|
|
|
it('flags a mode change into a gitlink', () => {
|
|
expect(
|
|
findUnexpectedGitlinks(
|
|
':100644 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 T\twas_a_file',
|
|
),
|
|
).toStrictEqual(['was_a_file']);
|
|
});
|
|
|
|
it('allows existing submodule SHA updates (160000→160000)', () => {
|
|
expect(
|
|
findUnexpectedGitlinks(
|
|
':160000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 M\tvendor/lib',
|
|
),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it('flags rename/copy into a gitlink and uses the destination path', () => {
|
|
expect(
|
|
findUnexpectedGitlinks(
|
|
':100644 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 R100\told_name\tnested_renamed',
|
|
),
|
|
).toStrictEqual(['nested_renamed']);
|
|
expect(
|
|
findUnexpectedGitlinks(
|
|
':100644 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 C75\tsrc_file\tnested_copied',
|
|
),
|
|
).toStrictEqual(['nested_copied']);
|
|
});
|
|
|
|
it('allows rename/copy that stays a gitlink (160000→160000)', () => {
|
|
expect(
|
|
findUnexpectedGitlinks(
|
|
':160000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 R100\told_sub\tnew_sub',
|
|
),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it('collects multiple unexpected gitlinks among mixed changes', () => {
|
|
const raw = [
|
|
':000000 100644 0000000000000000000000000000000000000000 abcdefabcdefabcdefabcdefabcdefabcdefabcd A\tok.txt',
|
|
':000000 160000 0000000000000000000000000000000000000000 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 A\tnested_a',
|
|
':160000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd fedcbafedcbafedcbafedcbafedcbafedcbafedc M\tok_submodule',
|
|
':040000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 T\tnested_b',
|
|
].join('\n');
|
|
expect(findUnexpectedGitlinks(raw)).toStrictEqual(['nested_a', 'nested_b']);
|
|
});
|
|
});
|
|
|
|
describe('assertNoUnexpectedGitlinks', () => {
|
|
it('does nothing when there are no paths', () => {
|
|
expect(() => assertNoUnexpectedGitlinks([])).not.toThrow();
|
|
});
|
|
|
|
it('throws with path names and remediation hints', () => {
|
|
expect(() => assertNoUnexpectedGitlinks(['evil_nested_repo'])).toThrow(
|
|
/unexpected gitlink/,
|
|
);
|
|
expect(() => assertNoUnexpectedGitlinks(['evil_nested_repo'])).toThrow(
|
|
/evil_nested_repo/,
|
|
);
|
|
expect(() => assertNoUnexpectedGitlinks(['evil_nested_repo'])).toThrow(
|
|
/git rm --cached -- evil_nested_repo/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('neutralizeLogString', () => {
|
|
it('leaves normal filenames unchanged', () => {
|
|
expect(neutralizeLogString('src/main.ts')).toBe('src/main.ts');
|
|
expect(neutralizeLogString('docs/README.md')).toBe('docs/README.md');
|
|
});
|
|
|
|
it('escapes RLO and related bidi controls', () => {
|
|
const rlo = '\u202E';
|
|
const lro = '\u202D';
|
|
const rli = '\u2067';
|
|
expect(neutralizeLogString(`doc${rlo}txt.exe`)).toBe('doc\\u202etxt.exe');
|
|
expect(neutralizeLogString(`a${lro}b${rli}c`)).toBe('a\\u202db\\u2067c');
|
|
});
|
|
|
|
it('escapes newlines and other C0 controls', () => {
|
|
expect(neutralizeLogString('line1\nline2')).toBe('line1\\u000aline2');
|
|
expect(neutralizeLogString('a\rb')).toBe('a\\u000db');
|
|
});
|
|
|
|
it('escapes line and paragraph separators', () => {
|
|
expect(neutralizeLogString('a\u2028b\u2029c')).toBe('a\\u2028b\\u2029c');
|
|
});
|
|
|
|
it('collapses a workflow-command payload onto one escaped line', () => {
|
|
expect(neutralizeLogString('Normal title\n::stop-commands::7a3f9c1e')).toBe(
|
|
'Normal title\\u000a::stop-commands::7a3f9c1e',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('neutralizeForLog', () => {
|
|
it('recursively sanitizes StatusSummary-shaped objects', () => {
|
|
const rlo = '\u202E';
|
|
const spoofed = `doc${rlo}txt.exe`;
|
|
const input = {
|
|
conflicted: [],
|
|
created: [spoofed],
|
|
modified: ['ok.txt', spoofed],
|
|
files: [{path: spoofed, index: 'A', working_dir: ' '}],
|
|
not_added: 1,
|
|
isClean: () => true,
|
|
};
|
|
const result = neutralizeForLog(input) as typeof input;
|
|
expect(result.created).toStrictEqual(['doc\\u202etxt.exe']);
|
|
expect(result.modified).toStrictEqual(['ok.txt', 'doc\\u202etxt.exe']);
|
|
expect(result.files[0].path).toBe('doc\\u202etxt.exe');
|
|
expect(result.not_added).toBe(1);
|
|
});
|
|
|
|
it('sanitizes Error messages', () => {
|
|
const err = new Error('bad: file\u202Ename');
|
|
const result = neutralizeForLog(err) as Error;
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect(result.message).toBe('bad: file\\u202ename');
|
|
});
|
|
|
|
it('passes through nullish and primitives', () => {
|
|
expect(neutralizeForLog(null)).toBeNull();
|
|
expect(neutralizeForLog(undefined)).toBeUndefined();
|
|
expect(neutralizeForLog(42)).toBe(42);
|
|
expect(neutralizeForLog(true)).toBe(true);
|
|
});
|
|
|
|
it('returns a marker for circular references', () => {
|
|
const obj: Record<string, unknown> = {path: 'ok.txt'};
|
|
obj.self = obj;
|
|
const arr: unknown[] = ['a'];
|
|
arr.push(arr);
|
|
|
|
expect(neutralizeForLog(obj)).toStrictEqual({
|
|
path: 'ok.txt',
|
|
self: '[Circular]',
|
|
});
|
|
expect(neutralizeForLog(arr)).toStrictEqual(['a', '[Circular]']);
|
|
});
|
|
});
|