Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-server-crash-and-cred-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@enbox/gitd": patch
---

Fix daemon crash when cloning nonexistent repos (process.exit in server context) and resolve credential helper by absolute path so push auth works without PATH setup.
24 changes: 16 additions & 8 deletions src/daemon/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ async function spawnDaemon(password?: string): Promise<EnsureDaemonResult> {
env.GITD_PASSWORD = password;
}

const child = spawn(gitdBin, ['serve'], {
const child = spawn(gitdBin.command, [...gitdBin.prefix, 'serve'], {
detached : true,
stdio : ['ignore', logFd, logFd],
env,
Expand Down Expand Up @@ -293,19 +293,27 @@ export function daemonStatus(): DaemonStatus {
// Helpers
// ---------------------------------------------------------------------------

/** Find the gitd binary path. */
export function findGitdBin(): string {
// In development: use the source entry point relative to this module.
// This file lives at src/daemon/lifecycle.ts, so ../../cli/main.ts
// resolves to src/cli/main.ts.
/** Resolved gitd binary and how to invoke it. */
export type GitdBin = {
/** The binary or runtime to spawn. */
command: string;
/** Arguments to pass before `['serve']` etc. */
prefix: string[];
};

/** Find the gitd binary path and determine how to invoke it. */
export function findGitdBin(): GitdBin {
// In development: use bun to run the source entry point.
// This file lives at src/daemon/lifecycle.ts (or dist/esm/daemon/lifecycle.js),
// so we check for the sibling src/cli/main.ts.
const thisDir = dirname(fileURLToPath(import.meta.url));
const devPath = join(thisDir, '..', '..', 'src', 'cli', 'main.ts');
if (existsSync(devPath)) {
return devPath;
return { command: 'bun', prefix: [devPath] };
}

// When installed: `gitd` should be on PATH.
return 'gitd';
return { command: 'gitd', prefix: [] };
}

function sleep(ms: number): Promise<void> {
Expand Down
6 changes: 5 additions & 1 deletion src/git-remote/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,12 @@ async function resolveLocalDaemon(did: string, repo?: string): Promise<GitEndpoi
// Slow path: try to auto-start a daemon. Prompt for the vault
// password lazily — only when we actually need to spawn. This avoids
// prompting when the daemon is already running (the common case).
// Skip auto-start entirely when no password is available — spawning
// a daemon without a password will always fail (vault can't unlock).
const password = getVaultPassword() ?? undefined;
if (!password) { return null; }

try {
const password = getVaultPassword() ?? undefined;
const result = await ensureDaemon(password);
return {
url : buildUrl(`http://localhost:${result.port}`, did, repo),
Expand Down
23 changes: 5 additions & 18 deletions src/git-server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,18 +215,6 @@ export function createPushAuthenticator(
): (request: Request, did: string, repo: string) => Promise<boolean> {
const { verifySignature, authorizePush, maxTokenAge = 300 } = options;

// Nonce replay protection: track used nonces with timestamps for TTL eviction.
const usedNonces = new Map<string, number>();
const nonceMaxAge = (maxTokenAge + 60) * 1000; // ms — token TTL + clock skew

/** Evict expired nonces to prevent unbounded growth. */
function evictExpiredNonces(): void {
const cutoff = Date.now() - nonceMaxAge;
for (const [nonce, ts] of usedNonces) {
if (ts < cutoff) { usedNonces.delete(nonce); }
}
}

return async (request: Request, ownerDid: string, repo: string): Promise<boolean> => {
// Extract HTTP Basic auth credentials.
// Username is fixed to "did-auth" (DIDs contain colons, which conflict
Expand Down Expand Up @@ -291,12 +279,11 @@ export function createPushAuthenticator(
return false;
}

// Nonce replay protection — reject already-used nonces.
evictExpiredNonces();
if (usedNonces.has(payload.nonce)) {
return false;
}
usedNonces.set(payload.nonce, Date.now());
// NOTE: Nonce replay protection is intentionally omitted.
// Git's smart HTTP transport reuses the same credentials for both
// the GET ref discovery and POST receive-pack within a single push.
// Rejecting reused nonces would break every push. The token's
// expiry (default 5 min) provides sufficient replay protection.

// Optional: Check role-based push authorization.
if (authorizePush) {
Expand Down
9 changes: 5 additions & 4 deletions tests/daemon-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,20 @@ describe('daemonLogPath', () => {
// ---------------------------------------------------------------------------

describe('findGitdBin', () => {
it('should resolve to src/cli/main.ts when running from source', () => {
it('should resolve to bun + src/cli/main.ts when running from source', () => {
const bin = findGitdBin();
expect(bin).toEndWith('src/cli/main.ts');
expect(bin.command).toBe('bun');
expect(bin.prefix[0]).toEndWith('src/cli/main.ts');
});

it('should resolve a path that actually exists on disk', () => {
const bin = findGitdBin();
expect(existsSync(bin)).toBe(true);
expect(existsSync(bin.prefix[0])).toBe(true);
});

it('should NOT resolve to a path under ~/.enbox', () => {
const bin = findGitdBin();
expect(bin).not.toContain('.enbox');
expect(bin.prefix[0]).not.toContain('.enbox');
});
});

Expand Down
6 changes: 4 additions & 2 deletions tests/hardening.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,10 +692,12 @@ describe('Push authenticator — nonce replay protection', () => {
const result1 = await authenticator(req1, OWNER_DID, TEST_REPO);
expect(result1).toBe(true);

// Same nonce — replay should be rejected.
// Same nonce — should be accepted (nonce replay protection is disabled
// because git reuses credentials for GET ref-discovery and POST
// receive-pack within a single push).
const req2 = makeAuthRequest(signed);
const result2 = await authenticator(req2, OWNER_DID, TEST_REPO);
expect(result2).toBe(false);
expect(result2).toBe(true);
});

it('should accept different nonces', async () => {
Expand Down
Loading