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
2 changes: 2 additions & 0 deletions apps/cli/src/cli/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { logoutCommand } from "../commands/logout/logout.command.ts";
import { migrationCommand } from "../commands/migration/migration.command.ts";
import { networkBansCommand } from "../commands/network-bans/network-bans.command.ts";
import { networkRestrictionsCommand } from "../commands/network-restrictions/network-restrictions.command.ts";
import { notebooksCommand } from "../commands/notebooks/notebooks.command.ts";
import { orgsCommand } from "../commands/orgs/orgs.command.ts";
import { postgresConfigCommand } from "../commands/postgres-config/postgres-config.command.ts";
import { projectsCommand } from "../commands/projects/projects.command.ts";
Expand Down Expand Up @@ -118,6 +119,7 @@ export const rootCommandForFeatures = (
migrationCommand,
networkBansCommand,
networkRestrictionsCommand,
notebooksCommand,
orgsCommand,
postgresConfigCommand,
projectsCommand,
Expand Down
100 changes: 3 additions & 97 deletions apps/cli/src/command-internal/config-validate.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { lstatSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { statSync } from "node:fs";
import { isAbsolute, join } from "node:path";

import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityFingerprintId,
ErrorActionabilityId,
} from "../shared/telemetry/error-actionability.ts";
import { canonicalPathForContainment, isPathContainedInRoot } from "./path-containment.ts";
import { BRANCH_PROJECT_REF_PATTERN } from "./ref-patterns.ts";
import { goUrlParse } from "./storage-url.ts";

Expand Down Expand Up @@ -596,101 +597,6 @@ export function signingKeysDecodeErrorMessage(cause: unknown): string {
// D only asserts Array.isArray(JSON.parse(text)); L further decodes into Jwk[] to sign
// with the first key — that JWK-specific decode/signing logic stays in L.

/**
* Whether `candidatePath` resolves inside (or exactly to) `root`. Both
* arguments must already be canonicalized (see `canonicalPathForContainment`).
* Only rejects a genuine `..` traversal — a same-level sibling whose name
* happens to start with two dots (e.g. `..templates`) is a distinct,
* in-root path and must not be rejected.
*/
function isPathContainedInRoot(root: string, candidatePath: string): boolean {
const rel = relative(root, candidatePath);
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
}

// `readlinkSync` bypasses the OS's own `ELOOP` symlink-cycle detection when
// manually following a dangling/unsearchable/looping symlink one hop at a
// time (see `canonicalizeExistingPath` below), so that manual follow needs
// its own explicit bound.
const MAX_SYMLINK_FOLLOW_DEPTH = 40;

/**
* Canonicalizes `path` when it exists (per `lstatSync`), or returns `undefined` so
* {@link canonicalPathForContainment} keeps walking up to an existing ancestor.
*
* `realpathSync` can throw for a path that exists (dangling symlink, `EACCES` target, `ELOOP`),
* so such a symlink is followed one hop by hand, bounded by {@link MAX_SYMLINK_FOLLOW_DEPTH},
* and its target canonicalized in turn; a chain still unresolved at the bound returns the lexical
* path so a loop is rejected rather than accepted. An `lstatSync` failure unrelated to the path
* itself (unreadable ancestor, over-long name) counts as "doesn't exist yet", so an honest in-root
* path behind a restricted ancestor isn't falsely rejected.
*/
function canonicalizeExistingPath(path: string, depth: number): string | undefined {
try {
return realpathSync(path);
} catch {
// Wraps only the `lstatSync` call, not the recursive canonicalization below it: including
// that would let a deep throw there return the outer symlink's own lexically-in-root path,
// turning a rejection into an accept.
let entry: Stats | undefined;
try {
entry = lstatSync(path, { throwIfNoEntry: false });
} catch {
return undefined;
}
if (entry === undefined) return undefined;
if (entry.isSymbolicLink()) {
if (depth < MAX_SYMLINK_FOLLOW_DEPTH) {
const target = readlinkSync(path);
return canonicalPathForContainment(
isAbsolute(target) ? target : join(dirname(path), target),
depth + 1,
);
}
// Must not be treated as "doesn't exist": returning `undefined` here would let the
// ancestor walk-up canonicalize past the whole unresolvable loop and silently accept it
// instead of failing closed.
return path;
}
// A non-symlink entry `lstat` can see but `realpath` can't resolve — e.g. a chmod-000
// directory on Darwin, whose realpath(3) needs search permission on itself, not just its
// parent. Deferred to the same "doesn't exist yet" ancestor walk-up as a genuinely missing
// path, since a plain entry can't recurse into a loop.
return undefined;
}
}

/**
* Canonicalizes `path` for the containment check, tolerating a path (or an ancestor of it)
* that genuinely doesn't exist yet — the normal case for a missing template file, which
* should surface as a missing-file error, not a containment error. Walks up to the deepest
* existing ancestor, resolves it with `realpathSync` (dereferencing any symlinks, including a
* symlinked project root itself), then re-appends the missing tail lexically. The walk-up is
* iterative, not recursive, so it stays correct against a pathologically long chain of missing
* ancestors; each ancestor still goes through {@link canonicalizeExistingPath}, so an
* intermediate dangling/unsearchable/looping symlink is followed rather than lexically
* skipped.
*/
function canonicalPathForContainment(path: string, depth = 0): string {
const canonical = canonicalizeExistingPath(path, depth);
if (canonical !== undefined) return canonical;

const tail: string[] = [basename(path)];
let current = dirname(path);
for (;;) {
const ancestorCanonical = canonicalizeExistingPath(current, depth);
if (ancestorCanonical !== undefined) {
return tail.reduceRight((acc, name) => join(acc, name), ancestorCanonical);
}
const parent = dirname(current);
if (parent === current) {
return resolve(tail.reduceRight((acc, name) => join(acc, name), current));
}
tail.push(basename(current));
current = parent;
}
}

/**
* Pure exclusivity decision plus the path to read for one template/notification entry. Throws
* {@link ConfigValidateError} when `contentPath === ""` and `contentPresent` (`content_path`
Expand Down
103 changes: 103 additions & 0 deletions apps/cli/src/command-internal/path-containment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Canonical-path containment shared by every consumer that resolves a user- or
* checkout-supplied path and must keep it inside the project root: email template
* `content_path` values and the `supabase/notebooks/` directory.
*/

import { lstatSync, readlinkSync, realpathSync, type Stats } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";

// `readlinkSync` bypasses the OS's own `ELOOP` symlink-cycle detection when
// manually following a dangling/unsearchable/looping symlink one hop at a
// time (see `canonicalizeExistingPath` below), so that manual follow needs
// its own explicit bound.
const MAX_SYMLINK_FOLLOW_DEPTH = 40;

/**
* Whether `candidatePath` resolves inside (or exactly to) `root`. Both
* arguments must already be canonicalized (see {@link canonicalPathForContainment}).
* Only rejects a genuine `..` traversal — a same-level sibling whose name
* happens to start with two dots (e.g. `..templates`) is a distinct,
* in-root path and must not be rejected.
*/
export function isPathContainedInRoot(root: string, candidatePath: string): boolean {
const rel = relative(root, candidatePath);
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
}

/**
* Canonicalizes `path` when it exists (per `lstatSync`), or returns `undefined` so
* {@link canonicalPathForContainment} keeps walking up to an existing ancestor.
*
* `realpathSync` can throw for a path that exists (dangling symlink, `EACCES` target, `ELOOP`),
* so such a symlink is followed one hop by hand, bounded by {@link MAX_SYMLINK_FOLLOW_DEPTH},
* and its target canonicalized in turn; a chain still unresolved at the bound returns the lexical
* path so a loop is rejected rather than accepted. An `lstatSync` failure unrelated to the path
* itself (unreadable ancestor, over-long name) counts as "doesn't exist yet", so an honest in-root
* path behind a restricted ancestor isn't falsely rejected.
*/
function canonicalizeExistingPath(path: string, depth: number): string | undefined {
try {
return realpathSync(path);
} catch {
// Wraps only the `lstatSync` call, not the recursive canonicalization below it: including
// that would let a deep throw there return the outer symlink's own lexically-in-root path,
// turning a rejection into an accept.
let entry: Stats | undefined;
try {
entry = lstatSync(path, { throwIfNoEntry: false });
} catch {
return undefined;
}
if (entry === undefined) return undefined;
if (entry.isSymbolicLink()) {
if (depth < MAX_SYMLINK_FOLLOW_DEPTH) {
const target = readlinkSync(path);
return canonicalPathForContainment(
isAbsolute(target) ? target : join(dirname(path), target),
depth + 1,
);
}
// Must not be treated as "doesn't exist": returning `undefined` here would let the
// ancestor walk-up canonicalize past the whole unresolvable loop and silently accept it
// instead of failing closed.
return path;
Comment thread
avallete marked this conversation as resolved.
}
// A non-symlink entry `lstat` can see but `realpath` can't resolve — e.g. a chmod-000
// directory on Darwin, whose realpath(3) needs search permission on itself, not just its
// parent. Deferred to the same "doesn't exist yet" ancestor walk-up as a genuinely missing
// path, since a plain entry can't recurse into a loop.
return undefined;
}
}

/**
* Canonicalizes `path` for the containment check, tolerating a path (or an ancestor of it)
* that genuinely doesn't exist yet — the normal case for a missing template file or a
* notebooks directory a pull is about to create, which should surface as a missing-file error,
* not a containment error. Walks up to the deepest existing ancestor, resolves it with
* `realpathSync` (dereferencing any symlinks, including a symlinked project root itself), then
* re-appends the missing tail lexically. The walk-up is iterative, not recursive, so it stays
* correct against a pathologically long chain of missing ancestors; each ancestor still goes
* through {@link canonicalizeExistingPath}, so an intermediate dangling/unsearchable/looping
* symlink is followed rather than lexically skipped.
*/
export function canonicalPathForContainment(path: string, depth = 0): string {
const canonical = canonicalizeExistingPath(path, depth);
if (canonical !== undefined) return canonical;

const tail: string[] = [basename(path)];
let current = dirname(path);
for (;;) {
const ancestorCanonical = canonicalizeExistingPath(current, depth);
if (ancestorCanonical !== undefined) {
return tail.reduceRight((acc, name) => join(acc, name), ancestorCanonical);
}
const parent = dirname(current);
if (parent === current) {
return resolve(tail.reduceRight((acc, name) => join(acc, name), current));
}
tail.push(basename(current));
current = parent;
}
}
10 changes: 10 additions & 0 deletions apps/cli/src/commands/notebooks/notebooks.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Command } from "effect/unstable/cli";
import { notebooksPullCommand } from "./pull/pull.command.ts";

export const notebooksCommand = Command.make("notebooks").pipe(
Command.withDescription(
"Manage Supabase notebooks: SQL and markdown cells stored with your project, kept in supabase/notebooks/<name>.json.",
),
Command.withShortDescription("Manage Supabase notebooks"),
Command.withSubcommands([notebooksPullCommand]),
);
89 changes: 89 additions & 0 deletions apps/cli/src/commands/notebooks/notebooks.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Data } from "effect";
import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityId,
statusCodeActionability,
} from "../../shared/telemetry/error-actionability.ts";

/**
* One network / status pair covers every notebook route rather than one pair
* per call: the notebook commands all walk the same routes, and the failing one
* is already named by the message the caller templates in ("failed to list
* notebooks", "failed to update notebook <name>", …).
*/
export class NotebooksNetworkError extends Data.TaggedError("NotebooksNetworkError")<{
readonly message: string;
readonly decode?: boolean;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return this.decode === true
? { ...actionability.apiStatus, fingerprint_suffix: "api_response" }
: actionability.externalNetwork;
}
}

export class NotebooksUnexpectedStatusError extends Data.TaggedError(
"NotebooksUnexpectedStatusError",
)<{
readonly status: number;
readonly body: string;
readonly message: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return statusCodeActionability(this.status);
}
}

/** A file under `supabase/notebooks/` is not readable, not JSON, or not a notebook. */
export class NotebookFileError extends Data.TaggedError("NotebookFileError")<{
readonly detail: string;
readonly suggestion: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.invalidConfig;
}
}

/** The single-notebook pull argument is not a Management API notebook UUID. */
export class NotebookIdError extends Data.TaggedError("NotebookIdError")<{
readonly detail: string;
readonly suggestion: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.invalidInput;
}
}

/**
* Two project notebooks share one name. The API does not require notebook names
* to be unique, but a directory of files does — so there is no way to say which
* of them a local file corresponds to, and guessing would write one user's
* notebook over another's.
*/
export class NotebookNameConflictError extends Data.TaggedError("NotebookNameConflictError")<{
readonly detail: string;
readonly suggestion: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.invalidConfig;
}
}

export class NotebooksEnvNotSupportedError extends Data.TaggedError(
"NotebooksEnvNotSupportedError",
)<{
readonly message: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.invalidInput;
}
}

export class NotebooksPaginationError extends Data.TaggedError("NotebooksPaginationError")<{
readonly message: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return { ...actionability.apiStatus, fingerprint_suffix: "api_response" };
}
}
Loading
Loading