Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { GitCommandError } from "@t3tools/contracts";
import { ServerConfig } from "../config.ts";
import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";
import { attachGitStderrExcerpt, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";
import * as GitVcsDriver from "./GitVcsDriver.ts";

const encodeGitCommandError = Schema.encodeSync(GitCommandError);

const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-git-vcs-driver-test-",
});
Expand Down Expand Up @@ -209,8 +212,79 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
assert.isAbove(error.stderrLength ?? 0, 0);
assert.notInclude(error.detail, secret);
assert.notInclude(error.message, secret);
assert.notInclude(error.stderrExcerpt ?? "", secret);
assert.notProperty(error, "args");
assert.notProperty(error, "stderr");

const encoded = encodeGitCommandError(error);
assert.notProperty(encoded, "stderrExcerpt");
}),
);

it.effect("carries a bounded stderr excerpt for in-process consumers", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const driver = yield* GitVcsDriver.GitVcsDriver;

const error = yield* driver
.execute({
operation: "GitVcsDriver.test.stderrExcerpt",
cwd,
args: ["log", "-1"],
})
.pipe(Effect.flip);

assert.equal(error._tag, "GitCommandError");
assert.include(error.stderrExcerpt ?? "", "not a git repository");
// Stderr must stay out of the message so client-facing events and
// telemetry keep their existing redaction guarantees.
assert.notInclude(error.message, "not a git repository");
}),
);

it.effect("redacts argument values and caps the stderr excerpt", () =>
Effect.sync(() => {
const longStderr = `fatal: ${"x".repeat(600)}`;
const capped = attachGitStderrExcerpt(
new GitCommandError({
operation: "GitVcsDriver.test.cap",
command: "git",
cwd: "/tmp/repo",
detail: "Git command exited with a non-zero status.",
}),
longStderr,
[],
);
assert.isAtMost(capped.stderrExcerpt?.length ?? 0, 403);
assert.equal(capped.stderrExcerpt?.endsWith("..."), true);

const redacted = attachGitStderrExcerpt(
new GitCommandError({
operation: "GitVcsDriver.test.redact",
command: "git",
cwd: "/tmp/repo",
detail: "Git command exited with a non-zero status.",
}),
"fatal: could not read from 'https://token-value@example.com/repo.git'",
["fetch", "--depth=1", "https://token-value@example.com/repo.git"],
);
assert.notInclude(redacted.stderrExcerpt ?? "", "token-value");
assert.include(redacted.stderrExcerpt ?? "", "could not read from");

// Option values below the whole-argument length floor are still
// redacted: a short secret must not survive the filter.
const shortValue = attachGitStderrExcerpt(
new GitCommandError({
operation: "GitVcsDriver.test.shortValue",
command: "git",
cwd: "/tmp/repo",
detail: "Git command exited with a non-zero status.",
}),
"fatal: authentication failed for abc",
["fetch", "--token=abc"],
);
assert.notInclude(shortValue.stderrExcerpt ?? "", "abc");
assert.include(shortValue.stderrExcerpt ?? "", "authentication failed");
}),
);

Expand Down
83 changes: 69 additions & 14 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,53 @@ function gitCommandContext(
} as const;
}

const STDERR_EXCERPT_MAX_LENGTH = 400;
const STDERR_REDACTION_MIN_LENGTH = 4;

/**
* Attach a bounded stderr excerpt to a command failure for in-process
* consumers. Command arguments (and `--option=value` values) are redacted
* first so tokens embedded in URLs or options keep the same redaction
* guarantees as the structured error fields. The excerpt lives outside the
* error schema, so it never leaves the process.
*/
export function attachGitStderrExcerpt(
error: GitCommandError,
stderr: string,
args: readonly string[],
): GitCommandError {
const redactionCandidates = args
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
.flatMap((arg) => {
// The length floor only applies to whole arguments, where it prevents
// mangling ordinary words like subcommand names. Extracted option
// values are redacted regardless of length so a short secret in
// `--option=value` form cannot slip through.
const candidates = arg.length >= STDERR_REDACTION_MIN_LENGTH ? [arg] : [];
const separatorIndex = arg.indexOf("=");
const value = separatorIndex >= 0 ? arg.slice(separatorIndex + 1) : "";
if (value.length > 0) {
candidates.push(value);
}
return candidates;
})
.sort((left, right) => right.length - left.length);

let excerpt = stderr;
for (const candidate of redactionCandidates) {
excerpt = excerpt.split(candidate).join("<redacted>");
}
excerpt = excerpt.replace(/\s+/g, " ").trim();
if (excerpt.length === 0) {
return error;
}

error.stderrExcerpt =
excerpt.length > STDERR_EXCERPT_MAX_LENGTH
? `${excerpt.slice(0, STDERR_EXCERPT_MAX_LENGTH)}...`
: excerpt;
return error;
}

function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
const trimmed = value.trim();
const prefix = `refs/remotes/${remoteName}/`;
Expand Down Expand Up @@ -730,13 +777,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
yield* trace2Monitor.flush;

if (!input.allowNonZeroExit && exitCode !== 0) {
return yield* new GitCommandError({
...gitCommandContext(commandInput),
detail: "Git command exited with a non-zero status.",
exitCode,
stdoutLength: stdout.text.length,
stderrLength: stderr.text.length,
});
return yield* attachGitStderrExcerpt(
new GitCommandError({
...gitCommandContext(commandInput),
detail: "Git command exited with a non-zero status.",
exitCode,
stdoutLength: stdout.text.length,
stderrLength: stderr.text.length,
}),
stderr.text,
commandInput.args,
);
}

return {
Expand Down Expand Up @@ -811,13 +862,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
return Effect.succeed(result);
}
return Effect.fail(
new GitCommandError({
...gitCommandContext({ operation, cwd, args }),
detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.",
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
stdoutLength: result.stdout.length,
stderrLength: result.stderr.length,
}),
attachGitStderrExcerpt(
new GitCommandError({
...gitCommandContext({ operation, cwd, args }),
detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.",
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
stdoutLength: result.stdout.length,
stderrLength: result.stderr.length,
}),
result.stderr,
args,
),
);
}),
);
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,13 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()(
detail: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {
/**
* Bounded, argument-redacted git stderr excerpt for in-process consumers.
* Deliberately not part of the schema and not part of `message`, so it
* never crosses RPC, client event, or telemetry serialization boundaries.
*/
stderrExcerpt?: string;

override get message(): string {
return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`;
}
Expand Down
Loading