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
56 changes: 13 additions & 43 deletions src/cli/commands/datastore_lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ import {
renderDatastoreLockRelease,
renderDatastoreLockStatus,
} from "../../presentation/output/datastore_output.ts";
import { S3Client } from "../../infrastructure/persistence/s3_client.ts";
import { join } from "@std/path";

// deno-lint-ignore no-explicit-any
type AnyOptions = any;
Expand Down Expand Up @@ -99,47 +97,19 @@ const datastoreLockReleaseCommand = new Command()
}

// Re-verify the lock holder hasn't changed between inspect and delete.
// This guards against deleting a legitimately acquired lock.
if (config.type === "s3") {
const s3 = new S3Client({
bucket: config.bucket,
prefix: config.prefix,
region: config.region,
});
const recheck = await lock.inspect();
if (recheck?.nonce !== info.nonce) {
renderDatastoreLockRelease(
{
released: false,
reason:
"lock holder changed — aborting to avoid breaking an active lock",
},
ctx.outputMode,
);
return;
}
await s3.deleteObject(".datastore.lock");
} else {
const lockPath = join(config.path, ".datastore.lock");
const recheck = await lock.inspect();
if (recheck?.nonce !== info.nonce) {
renderDatastoreLockRelease(
{
released: false,
reason:
"lock holder changed — aborting to avoid breaking an active lock",
},
ctx.outputMode,
);
return;
}
try {
await Deno.remove(lockPath);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
}
// forceRelease() re-reads the nonce immediately before deleting to
// minimise the TOCTOU window.
const released = await lock.forceRelease(info.nonce!);
if (!released) {
renderDatastoreLockRelease(
{
released: false,
reason:
"lock holder changed — aborting to avoid breaking an active lock",
},
ctx.outputMode,
);
return;
}

renderDatastoreLockRelease(
Expand Down
9 changes: 7 additions & 2 deletions src/cli/commands/extension_search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { Command } from "@cliffy/command";
import { createContext, type GlobalOptions } from "../context.ts";
import {
createContext,
type GlobalOptions,
interactiveOutputMode,
} from "../context.ts";
import { requireInitializedRepo } from "../repo_context.ts";
import { resolveModelsDir } from "../resolve_models_dir.ts";
import { resolveVaultsDir } from "../resolve_vaults_dir.ts";
Expand Down Expand Up @@ -119,6 +123,7 @@ export const extensionSearchCommand = new Command()

const response = await client.searchExtensions(params);

const effectiveMode = interactiveOutputMode(ctx);
const result = await renderExtensionSearch(
{
extensions: response.extensions.map((ext) => ({
Expand All @@ -132,7 +137,7 @@ export const extensionSearchCommand = new Command()
})),
meta: response.meta,
},
ctx.outputMode,
effectiveMode,
);

if (result?.action === "install") {
Expand Down
12 changes: 7 additions & 5 deletions src/cli/repo_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import {
} from "../infrastructure/persistence/repository_factory.ts";
import { RepoPath } from "../domain/repo/repo_path.ts";
import { RepoService } from "../domain/repo/repo_service.ts";
import { RepoMarkerRepository } from "../infrastructure/persistence/repo_marker_repository.ts";
import {
type RepoMarkerData,
RepoMarkerRepository,
} from "../infrastructure/persistence/repo_marker_repository.ts";
import { UserError } from "../domain/errors.ts";
import { VERSION } from "./commands/version.ts";
import { resolveWorkflowsDir } from "./resolve_workflows_dir.ts";
Expand Down Expand Up @@ -77,6 +80,7 @@ export interface RepoValidationContext {
export interface DatastoreResolutionResult {
repoDir: string;
datastoreConfig: DatastoreConfig;
marker: RepoMarkerData | null;
}

export async function resolveDatastoreForRepo(
Expand All @@ -101,7 +105,7 @@ export async function resolveDatastoreForRepo(
repoPath.value,
);

return { repoDir: repoPath.value, datastoreConfig };
return { repoDir: repoPath.value, datastoreConfig, marker };
}

/**
Expand All @@ -119,13 +123,11 @@ export async function requireInitializedRepo(
options: RequireRepoOptions,
factoryConfig?: Partial<Omit<RepositoryFactoryConfig, "repoDir">>,
): Promise<RepoValidationContext> {
const { repoDir, datastoreConfig } = await resolveDatastoreForRepo(
const { repoDir, datastoreConfig, marker } = await resolveDatastoreForRepo(
options.repoDir,
);

const repoPath = RepoPath.create(repoDir);
const markerRepo = new RepoMarkerRepository();
const marker = await markerRepo.read(repoPath);

const workflowsDirRel = resolveWorkflowsDir(marker);
const workflowsDir = isAbsolute(workflowsDirRel)
Expand Down
1 change: 1 addition & 0 deletions src/cli/repo_context_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ Deno.test("resolveDatastoreForRepo - returns config for initialized repo without

assertEquals(result.repoDir, dir);
assertEquals(result.datastoreConfig.type, "filesystem");
assertEquals(result.marker !== null, true);

// flushDatastoreSync should be a no-op (no lock was acquired)
await flushDatastoreSync();
Expand Down
13 changes: 13 additions & 0 deletions src/domain/datastore/distributed_lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ export interface DistributedLock {

/** Read the current lock info without acquiring. */
inspect(): Promise<LockInfo | null>;

/**
* Force-release a lock only if its nonce matches the expected value.
*
* This is a breakglass operation for releasing stuck locks. The nonce check
* reduces the TOCTOU window but cannot fully eliminate it — between the
* final nonce verification and the actual delete, another process could
* theoretically acquire a new lock. Each backend minimises this window
* as much as the underlying storage allows.
*
* @returns true if the lock was deleted, false if the nonce didn't match.
*/
forceRelease(expectedNonce: string): Promise<boolean>;
}

/** Thrown when a lock cannot be acquired within the configured timeout. */
Expand Down
15 changes: 15 additions & 0 deletions src/infrastructure/persistence/file_lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,21 @@ export class FileLock implements DistributedLock {
return await this.readLockFile();
}

async forceRelease(expectedNonce: string): Promise<boolean> {
const current = await this.readLockFile();
if (!current || current.nonce !== expectedNonce) {
return false;
}
try {
await Deno.remove(this.lockPath);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
}
return true;
}

private async extend(): Promise<void> {
if (!this.held || !this.nonce) return;

Expand Down
47 changes: 47 additions & 0 deletions src/infrastructure/persistence/file_lock_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,53 @@ Deno.test("FileLock - inspect returns null when no lock exists", async () => {
});
});

Deno.test("FileLock - forceRelease deletes lock when nonce matches", async () => {
await withTempDir(async (dir) => {
const lock = new FileLock(dir, { ttlMs: 5000 });
await lock.acquire();

const info = await lock.inspect();
assertEquals(info !== null, true);

// forceRelease with matching nonce should succeed
const released = await lock.forceRelease(info!.nonce!);
assertEquals(released, true);

// Lock should be gone
const afterRelease = await lock.inspect();
assertEquals(afterRelease, null);

// Clean up the lock's internal state (heartbeat)
await lock.release();
});
});

Deno.test("FileLock - forceRelease returns false when nonce does not match", async () => {
await withTempDir(async (dir) => {
const lock = new FileLock(dir, { ttlMs: 5000 });
await lock.acquire();

// forceRelease with wrong nonce should fail
const released = await lock.forceRelease("wrong-nonce");
assertEquals(released, false);

// Lock should still be held
const info = await lock.inspect();
assertEquals(info !== null, true);

await lock.release();
});
});

Deno.test("FileLock - forceRelease returns false when no lock exists", async () => {
await withTempDir(async (dir) => {
const lock = new FileLock(dir, { ttlMs: 5000 });

const released = await lock.forceRelease("some-nonce");
assertEquals(released, false);
});
});

Deno.test("FileLock - custom lock key", async () => {
await withTempDir(async (dir) => {
const lock = new FileLock(dir, {
Expand Down
9 changes: 9 additions & 0 deletions src/infrastructure/persistence/s3_lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ export class S3Lock implements DistributedLock {
return await this.readLock();
}

async forceRelease(expectedNonce: string): Promise<boolean> {
const current = await this.readLock();
if (!current || current.nonce !== expectedNonce) {
return false;
}
await this.s3.deleteObject(this.lockKey);
return true;
}

private async extend(): Promise<void> {
if (!this.held || !this.nonce) return;

Expand Down
40 changes: 40 additions & 0 deletions src/infrastructure/persistence/s3_lock_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,46 @@ Deno.test("S3Lock - inspect returns null when no lock exists", async () => {
assertEquals(info, null);
});

Deno.test("S3Lock - forceRelease deletes lock when nonce matches", async () => {
const mock = createMockS3Client();
const lock = new S3Lock(mock, { ttlMs: 5000 });
await lock.acquire();

const info = await lock.inspect();
assertEquals(info !== null, true);

const released = await lock.forceRelease(info!.nonce!);
assertEquals(released, true);

// Lock should be gone
assertEquals(mock.storage.has(".datastore.lock"), false);

// Clean up internal state (heartbeat)
await lock.release();
});

Deno.test("S3Lock - forceRelease returns false when nonce does not match", async () => {
const mock = createMockS3Client();
const lock = new S3Lock(mock, { ttlMs: 5000 });
await lock.acquire();

const released = await lock.forceRelease("wrong-nonce");
assertEquals(released, false);

// Lock should still exist
assertEquals(mock.storage.has(".datastore.lock"), true);

await lock.release();
});

Deno.test("S3Lock - forceRelease returns false when no lock exists", async () => {
const mock = createMockS3Client();
const lock = new S3Lock(mock, { ttlMs: 5000 });

const released = await lock.forceRelease("some-nonce");
assertEquals(released, false);
});

Deno.test("S3Lock - custom lock key", async () => {
const mock = createMockS3Client();
const lock = new S3Lock(mock, {
Expand Down
Loading