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
3 changes: 3 additions & 0 deletions crates/pgls_configuration/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub struct DatabaseConfiguration {
#[partial(bpaf(long("database")))]
pub database: String,

/// Glob patterns of the databases that code actions may execute statements against, matched
/// against `host:port/database`. A pattern without a port, e.g. `localhost/*`, is matched
/// against `host/database` and allows every port.
#[partial(bpaf(long("allow_statement_executions_against")))]
pub allow_statement_executions_against: StringSet,

Expand Down
122 changes: 117 additions & 5 deletions crates/pgls_workspace/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,9 +785,19 @@ impl DatabaseSettings {
}

let host = &self.host;
let port = self.port;
let database = &self.database;
// A pattern can't name the port of an IPv6 address, see `pattern_names_port`.
let (target, pattern) = if host.contains(':') {
(format!("[{host}]:{port}/{database}"), format!("{host}/*"))
} else {
(
format!("{host}:{port}/{database}"),
format!("{host}:{port}/*"),
)
};
let mut reason = format!(
"Statement execution is disabled for `{host}/{database}`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. [\"{host}/*\"]."
"Statement execution is disabled for `{target}`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. [\"{pattern}\"]."
);
if !self.invalid_statement_execution_patterns.is_empty() {
let patterns = self
Expand Down Expand Up @@ -840,7 +850,7 @@ impl Default for DatabaseSettings {
password: None,
database: "postgres".to_string(),
conn_timeout_secs: Duration::from_secs(10),
allow_statement_executions: true,
allow_statement_executions: false,
invalid_statement_execution_patterns: Vec::new(),
connection_string_error: None,
}
Expand Down Expand Up @@ -903,8 +913,10 @@ impl From<PartialDatabaseConfiguration> for DatabaseSettings {
&& connection_string_error.is_none();

// An invalid pattern is ignored rather than failing the whole configuration; the reason a
// statement cannot be executed names it.
// statement cannot be executed names it. A pattern that names a port only matches that
// port, any other pattern matches every port.
let target = format!("{host}/{database}");
let target_with_port = format!("{host}:{port}/{database}");
let mut allow_statement_executions = false;
let mut invalid_statement_execution_patterns = Vec::new();
for pattern in value
Expand All @@ -913,7 +925,14 @@ impl From<PartialDatabaseConfiguration> for DatabaseSettings {
.flat_map(|set| set.iter())
{
match Glob::new(pattern) {
Ok(glob) => allow_statement_executions |= glob.compile_matcher().is_match(&target),
Ok(glob) => {
let target = if pattern_names_port(pattern) {
&target_with_port
} else {
&target
};
allow_statement_executions |= glob.compile_matcher().is_match(target);
}
Err(err) => {
warn!(
"Ignoring invalid pattern `{pattern}` in `db.allowStatementExecutionsAgainst`: {err}"
Expand Down Expand Up @@ -946,6 +965,14 @@ impl From<PartialDatabaseConfiguration> for DatabaseSettings {
}
}

/// Whether a pattern of `db.allowStatementExecutionsAgainst` names a port, as `localhost:54322/*`
/// does. That is the case if its host, the part before the first `/`, has exactly one `:`, so a
/// pattern for an IPv6 address like `::1/*` names no port.
fn pattern_names_port(pattern: &str) -> bool {
let host = pattern.split('/').next().unwrap_or(pattern);
host.matches(':').count() == 1
}

/// Parses `db.connectionString`. The error says why it is invalid without repeating it, as it may
/// contain a password.
#[cfg(feature = "db")]
Expand Down Expand Up @@ -1220,7 +1247,7 @@ mod tests {
assert_eq!(
config.statement_execution_disabled_reason().as_deref(),
Some(
"Statement execution is disabled for `production/test-db`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. [\"production/*\"]."
"Statement execution is disabled for `production:5432/test-db`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. [\"production:5432/*\"]."
)
);
}
Expand Down Expand Up @@ -1345,4 +1372,89 @@ mod tests {
assert!(!config.enable_connection);
assert_eq!(config.connection_string_error, None);
}

fn allows(patterns: &[&str], host: &str, port: u16, database: &str) -> bool {
DatabaseSettings::from(PartialDatabaseConfiguration {
allow_statement_executions_against: Some(StringSet::from_iter(
patterns.iter().map(|pattern| pattern.to_string()),
)),
host: Some(host.into()),
port: Some(port),
database: Some(database.into()),
..Default::default()
})
.allow_statement_executions
}

#[test]
fn patterns_with_a_port_only_match_that_port() {
assert!(allows(
&["127.0.0.1:54322/*"],
"127.0.0.1",
54322,
"postgres"
));
assert!(!allows(
&["127.0.0.1:54322/*"],
"127.0.0.1",
5432,
"postgres"
));
assert!(allows(&["localhost:*/dev"], "localhost", 6543, "dev"));
assert!(!allows(&["localhost:*/dev"], "localhost", 6543, "prod"));
assert!(allows(&["*:5432/*"], "db.internal", 5432, "app"));
}

#[test]
fn patterns_without_a_port_match_every_port() {
assert!(allows(&["localhost/*"], "localhost", 5432, "postgres"));
assert!(allows(&["localhost/*"], "localhost", 15432, "postgres"));
assert!(allows(&["*"], "production", 5432, "postgres"));
assert!(allows(&["::1/*"], "::1", 5432, "postgres"));
}

#[test]
fn disabled_reason_suggests_a_pattern_with_the_port() {
let config = DatabaseSettings::from(PartialDatabaseConfiguration {
host: Some("::1".into()),
port: Some(5433),
database: Some("postgres".into()),
..Default::default()
});

assert_eq!(
config.statement_execution_disabled_reason().as_deref(),
Some(
"Statement execution is disabled for `[::1]:5433/postgres`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. [\"::1/*\"]."
)
);
}

#[cfg(feature = "db")]
#[test]
fn connection_string_port_is_matched() {
let allows = |connection_string: &str| {
DatabaseSettings::from(PartialDatabaseConfiguration {
connection_string: Some(connection_string.to_string()),
allow_statement_executions_against: Some(StringSet::from_iter([String::from(
"localhost:54322/*",
)])),
..Default::default()
})
.allow_statement_executions
};

assert!(allows(
"postgres://postgres:postgres@localhost:54322/postgres"
));
// A production database reached through a tunnel on another local port.
assert!(!allows(
"postgres://postgres:postgres@localhost:15432/postgres"
));
}

#[test]
fn statement_executions_are_disallowed_by_default() {
assert!(!DatabaseSettings::default().allow_statement_executions);
}
}
2 changes: 1 addition & 1 deletion crates/pgls_workspace/src/workspace/server.tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2084,7 +2084,7 @@ async fn execute_statement_is_disabled_without_matching_pattern(test_db: PgPool)
.unwrap()
.to_string();
let reason = format!(
"Statement execution is disabled for `{host}/{database}`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. [\"{host}/*\"]."
"Statement execution is disabled for `{host}:5432/{database}`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. [\"{host}:5432/*\"]."
);
assert_eq!(disabled_reason.as_deref(), Some(reason.as_str()));
assert_eq!(
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Some tools that the Postgres Language Server provides are implemented as mere in
"password": "postgres",
"database": "postgres",
"connTimeoutSecs": 10,
"allowStatementExecutionsAgainst": ["127.0.0.1/*", "localhost/*"]
"allowStatementExecutionsAgainst": ["127.0.0.1:5432/*", "localhost:5432/*"]
}
}
```
Expand All @@ -191,7 +191,7 @@ individual fields. The URI takes precedence over any other connection fields.
{
"db": {
"connectionString": "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable",
"allowStatementExecutionsAgainst": ["localhost/*"]
"allowStatementExecutionsAgainst": ["localhost:5432/*"]
}
}
```
Expand Down
14 changes: 8 additions & 6 deletions docs/guides/configure_database.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ Configure database connection details in your `postgres-language-server.jsonc` f
"database": "your_database_name",
// Connection timeout in seconds (default: 10)
"connTimeoutSecs": 10,
// Glob patterns matched against `host/database`; code actions may execute
// Glob patterns matched against `host:port/database`; code actions may execute
// statements only when one of them matches (default: [], never)
"allowStatementExecutionsAgainst": ["localhost/*", "127.0.0.1/*"],
"allowStatementExecutionsAgainst": ["localhost:5432/*", "127.0.0.1:5432/*"],
// Completely disable database features (default: false)
"disableConnection": false
}
Expand Down Expand Up @@ -106,18 +106,20 @@ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO postgres_language_server;
```

### Statement Execution Control
Code actions that execute the statement under the cursor are disabled by default. To enable them, list glob patterns in `allowStatementExecutionsAgainst`. Each pattern is matched against `host/database` of the connection (taken from the connection string when one is set), and statements can only be executed when one of them matches:
Code actions that execute the statement under the cursor are disabled by default. To enable them, list glob patterns in `allowStatementExecutionsAgainst`. Each pattern is matched against `host:port/database` of the connection (taken from the connection string when one is set), and statements can only be executed when one of them matches:

```json
{
"db": {
// any database on the local machine
"allowStatementExecutionsAgainst": ["localhost/*", "127.0.0.1/*"]
// any database of the local Postgres on port 5432, and of a local Supabase project
"allowStatementExecutionsAgainst": ["127.0.0.1:5432/*", "127.0.0.1:54322/*"]
}
}
```

The patterns do not refer to schemas. To allow a single database only, name it, e.g. `"localhost/my_dev_db"`.
Name the port. A port forward, an SSH tunnel, `cloud-sql-proxy` or `fly proxy` makes a remote database reachable on `localhost` too, on another port. A pattern without a port, such as `localhost/*`, is matched against `host/database` and allows every port, including those.

The patterns do not refer to schemas. To allow a single database only, name it, e.g. `"localhost:5432/my_dev_db"`. A pattern can't name the port of an IPv6 address: `::1/*` matches every port of `::1`.

When no pattern matches, the code action is disabled and its reason names the setting. An invalid pattern is ignored with a warning in the log, and the reason lists it.

Expand Down
9 changes: 6 additions & 3 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ Lints your database schema.
The password to connect to the database. Can also be set via the `PGPASSWORD` environment variable.
- **` --database`**=_`ARG`_ &mdash;
The name of the database. Can also be set via the `PGDATABASE` environment variable.
- **` --allow_statement_executions_against`**=_`ARG`_
- **` --allow_statement_executions_against`**=_`ARG`_ &mdash;
Glob patterns of the databases that code actions may execute statements against, matched against `host:port/database`. A pattern without a port, e.g. `localhost/*`, is matched against `host/database` and allows every port.
- **` --conn_timeout_secs`**=_`ARG`_ &mdash;
The connection timeout in seconds.

Expand Down Expand Up @@ -305,7 +306,8 @@ Runs everything to the requested files.
The password to connect to the database. Can also be set via the `PGPASSWORD` environment variable.
- **` --database`**=_`ARG`_ &mdash;
The name of the database. Can also be set via the `PGDATABASE` environment variable.
- **` --allow_statement_executions_against`**=_`ARG`_
- **` --allow_statement_executions_against`**=_`ARG`_ &mdash;
Glob patterns of the databases that code actions may execute statements against, matched against `host:port/database`. A pattern without a port, e.g. `localhost/*`, is matched against `host/database` and allows every port.
- **` --conn_timeout_secs`**=_`ARG`_ &mdash;
The connection timeout in seconds.

Expand Down Expand Up @@ -453,7 +455,8 @@ Formats the requested files.
The password to connect to the database. Can also be set via the `PGPASSWORD` environment variable.
- **` --database`**=_`ARG`_ &mdash;
The name of the database. Can also be set via the `PGDATABASE` environment variable.
- **` --allow_statement_executions_against`**=_`ARG`_
- **` --allow_statement_executions_against`**=_`ARG`_ &mdash;
Glob patterns of the databases that code actions may execute statements against, matched against `host:port/database`. A pattern without a port, e.g. `localhost/*`, is matched against `host/database` and allows every port.
- **` --conn_timeout_secs`**=_`ARG`_ &mdash;
The connection timeout in seconds.

Expand Down
1 change: 1 addition & 0 deletions docs/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@
"type": "object",
"properties": {
"allowStatementExecutionsAgainst": {
"description": "Glob patterns of the databases that code actions may execute statements against, matched against `host:port/database`. A pattern without a port, e.g. `localhost/*`, is matched against `host/database` and allows every port.",
"anyOf": [
{
"$ref": "#/definitions/StringSet"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,9 @@ export interface PartialConfiguration {
* The configuration of the database connection.
*/
export interface PartialDatabaseConfiguration {
/**
* Glob patterns of the databases that code actions may execute statements against, matched against `host:port/database`. A pattern without a port, e.g. `localhost/*`, is matched against `host/database` and allows every port.
*/
allowStatementExecutionsAgainst?: StringSet;
/**
* The connection timeout in seconds.
Expand Down
3 changes: 3 additions & 0 deletions packages/@postgrestools/backend-jsonrpc/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,9 @@ export interface PartialConfiguration {
* The configuration of the database connection.
*/
export interface PartialDatabaseConfiguration {
/**
* Glob patterns of the databases that code actions may execute statements against, matched against `host:port/database`. A pattern without a port, e.g. `localhost/*`, is matched against `host/database` and allows every port.
*/
allowStatementExecutionsAgainst?: StringSet;
/**
* The connection timeout in seconds.
Expand Down
Loading