Skip to content

Commit 528827a

Browse files
committed
[gobby-cli-#177] fix: reject postgres bootstrap refs
1 parent 3db8b3f commit 528827a

5 files changed

Lines changed: 58 additions & 223 deletions

File tree

README.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,9 @@ setups can use `GOBBY_FALKORDB_HOST`, `GOBBY_FALKORDB_PORT`, and
9292

9393
`gcode` 0.8.0+ uses the migrated Gobby PostgreSQL hub. It asks the local daemon
9494
broker for the hub DSN first. If the daemon is unavailable, it falls back to
95-
explicit non-keychain sources: `GCODE_DATABASE_URL`, `GOBBY_POSTGRES_DSN`,
96-
`~/.gobby/gcode.yaml` `database_url`, inline bootstrap `database_url`, then
97-
supported `database_url_ref` values. Bootstrap fallback requires
98-
`hub_backend: postgres`. It never reads the native OS keyring directly. For
99-
explicit daemonless setups, use inline `database_url`.
100-
If macOS keeps asking for Keychain authorization, check `which -a gcode`; stale
101-
binaries from before `0.8.4` can still read Keychain directly.
95+
explicit fallback sources: `GCODE_DATABASE_URL`, `GOBBY_POSTGRES_DSN`,
96+
`~/.gobby/gcode.yaml` `database_url`, then bootstrap `database_url`.
97+
Bootstrap fallback requires `hub_backend: postgres`.
10298
Installing from source or crates.io requires Rust 1.88+.
10399

104100
### From source

crates/gcode/README.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,10 @@ setups can use `GOBBY_FALKORDB_HOST`, `GOBBY_FALKORDB_PORT`, and
9797

9898
Runtime indexing/search requires a migrated Gobby PostgreSQL hub. gcode
9999
asks the local daemon broker for the hub DSN first. If the daemon is
100-
unavailable, it falls back to explicit non-keychain sources:
100+
unavailable, it falls back to explicit fallback sources:
101101
`GCODE_DATABASE_URL`, `GOBBY_POSTGRES_DSN`, `~/.gobby/gcode.yaml`
102-
`database_url`, inline bootstrap `database_url`, then supported
103-
`database_url_ref` values. Bootstrap fallback requires `hub_backend: postgres`.
104-
gcode never reads the native OS keyring directly. For explicit daemonless
105-
setups, use inline `database_url`.
106-
If macOS keeps asking for Keychain authorization, check `which -a gcode`; stale
107-
binaries from before `0.8.4` can still read Keychain directly.
102+
`database_url`, then bootstrap `database_url`. Bootstrap fallback requires
103+
`hub_backend: postgres`.
108104

109105
### With Gobby
110106

crates/gcode/src/db.rs

Lines changed: 44 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ use serde::Deserialize;
77

88
use crate::schema;
99

10-
const POSTGRES_DATABASE_URL_REF: &str = "keyring:gobby:postgres_database_url";
11-
const DAEMON_POSTGRES_DATABASE_URL_REF: &str = "daemon:gobby:postgres_database_url";
1210
const GCODE_DATABASE_URL_ENV: &str = "GCODE_DATABASE_URL";
1311
const GOBBY_POSTGRES_DSN_ENV: &str = "GOBBY_POSTGRES_DSN";
1412
const GCODE_CONFIG_FILENAME: &str = "gcode.yaml";
@@ -24,7 +22,6 @@ struct BrokerDatabaseUrlResponse {
2422
struct BootstrapDatabase {
2523
hub_backend: String,
2624
database_url: Option<String>,
27-
database_url_ref: Option<String>,
2825
}
2926

3027
/// Return Gobby home, respecting `GOBBY_HOME` when the daemon was configured with it.
@@ -43,9 +40,8 @@ pub fn bootstrap_path() -> anyhow::Result<PathBuf> {
4340

4441
/// Resolve the PostgreSQL hub DSN from explicit overrides or Gobby bootstrap config.
4542
///
46-
/// gcode intentionally has no local database fallback. Keyring-backed bootstraps resolve
47-
/// through the long-lived daemon broker so short-lived gcode processes never touch the OS
48-
/// keyring directly.
43+
/// gcode intentionally has no local database fallback. It asks the long-lived daemon
44+
/// broker first, then falls back to explicit DSN sources for daemonless operation.
4945
pub fn resolve_database_url() -> anyhow::Result<String> {
5046
let home = gobby_home()?;
5147
resolve_database_url_from_sources(
@@ -83,7 +79,7 @@ fn resolve_database_url_from_sources(
8379
)
8480
})?;
8581
let bootstrap = parse_bootstrap_database(&contents)?;
86-
resolve_database_url_from_bootstrap(&bootstrap, || broker_resolver(&path))
82+
resolve_database_url_from_bootstrap(&bootstrap)
8783
}
8884

8985
fn resolve_database_url_from_env(
@@ -144,18 +140,21 @@ fn parse_bootstrap_database(contents: &str) -> anyhow::Result<BootstrapDatabase>
144140
}
145141
};
146142

143+
let database_url_ref = get_string("database_url_ref")?;
144+
if database_url_ref.is_some() {
145+
bail!(
146+
"database_url_ref is no longer supported in bootstrap.yaml; store the local PostgreSQL DSN in database_url"
147+
);
148+
}
149+
147150
Ok(BootstrapDatabase {
148151
hub_backend: get_string("hub_backend")?
149152
.context("bootstrap.yaml must include `hub_backend: postgres`")?,
150153
database_url: get_string("database_url")?,
151-
database_url_ref: get_string("database_url_ref")?,
152154
})
153155
}
154156

155-
fn resolve_database_url_from_bootstrap(
156-
bootstrap: &BootstrapDatabase,
157-
broker_resolver: impl Fn() -> anyhow::Result<String>,
158-
) -> anyhow::Result<String> {
157+
fn resolve_database_url_from_bootstrap(bootstrap: &BootstrapDatabase) -> anyhow::Result<String> {
159158
if bootstrap.hub_backend != "postgres" {
160159
bail!(
161160
"gcode requires `hub_backend: postgres` in bootstrap.yaml. Current hub_backend is `{}`. Configure the Gobby PostgreSQL hub before running gcode.",
@@ -167,23 +166,7 @@ fn resolve_database_url_from_bootstrap(
167166
return Ok(database_url.to_string());
168167
}
169168

170-
if let Some(database_url_ref) = bootstrap.database_url_ref.as_deref() {
171-
parse_database_url_ref(database_url_ref)?;
172-
let database_url = broker_resolver().with_context(|| {
173-
format!(
174-
"failed to resolve database_url_ref {database_url_ref} through the local Gobby daemon broker. gcode does not read the OS keyring directly; start the Gobby daemon or use inline database_url in bootstrap.yaml for daemonless/manual setups"
175-
)
176-
})?;
177-
let database_url = database_url.trim().to_string();
178-
if database_url.is_empty() {
179-
bail!("database_url broker response for {database_url_ref} was empty");
180-
}
181-
return Ok(database_url);
182-
}
183-
184-
bail!(
185-
"hub_backend=postgres requires `database_url_ref: {POSTGRES_DATABASE_URL_REF}`, `database_url_ref: {DAEMON_POSTGRES_DATABASE_URL_REF}`, or an inline `database_url`"
186-
)
169+
bail!("hub_backend=postgres requires `database_url` in bootstrap.yaml")
187170
}
188171

189172
fn non_empty_trimmed(value: Option<String>) -> Option<String> {
@@ -232,16 +215,6 @@ fn request_broker_database_url(daemon_url: &str, token: &str) -> anyhow::Result<
232215
Ok(database_url)
233216
}
234217

235-
fn parse_database_url_ref(database_url_ref: &str) -> anyhow::Result<()> {
236-
let parts: Vec<&str> = database_url_ref.splitn(3, ':').collect();
237-
match parts.as_slice() {
238-
["keyring" | "daemon", "gobby", "postgres_database_url"] => Ok(()),
239-
_ => bail!(
240-
"database_url_ref must be {POSTGRES_DATABASE_URL_REF} or {DAEMON_POSTGRES_DATABASE_URL_REF}"
241-
),
242-
}
243-
}
244-
245218
pub fn connect_readwrite(database_url: &str) -> anyhow::Result<Client> {
246219
connect(database_url)
247220
}
@@ -281,15 +254,10 @@ mod tests {
281254
use std::net::TcpListener;
282255
use std::thread;
283256

284-
fn bootstrap(
285-
hub_backend: &str,
286-
database_url: Option<&str>,
287-
database_url_ref: Option<&str>,
288-
) -> BootstrapDatabase {
257+
fn bootstrap(hub_backend: &str, database_url: Option<&str>) -> BootstrapDatabase {
289258
BootstrapDatabase {
290259
hub_backend: hub_backend.to_string(),
291260
database_url: database_url.map(str::to_string),
292-
database_url_ref: database_url_ref.map(str::to_string),
293261
}
294262
}
295263

@@ -413,126 +381,47 @@ mod tests {
413381
}
414382

415383
#[test]
416-
fn postgres_bootstrap_resolves_keyring_ref_through_broker() {
417-
let resolved = resolve_database_url_from_bootstrap(
418-
&bootstrap("postgres", None, Some(POSTGRES_DATABASE_URL_REF)),
419-
|| Ok("postgresql://broker/db".to_string()),
420-
)
421-
.expect("resolve ref");
422-
423-
assert_eq!(resolved, "postgresql://broker/db");
424-
}
425-
426-
#[test]
427-
fn postgres_bootstrap_resolves_daemon_ref_through_broker() {
428-
let resolved = resolve_database_url_from_bootstrap(
429-
&bootstrap("postgres", None, Some(DAEMON_POSTGRES_DATABASE_URL_REF)),
430-
|| Ok("postgresql://broker/db".to_string()),
431-
)
432-
.expect("resolve ref");
384+
fn postgres_bootstrap_accepts_inline_url() {
385+
let resolved = resolve_database_url_from_bootstrap(&bootstrap(
386+
"postgres",
387+
Some("postgresql://inline/db"),
388+
))
389+
.expect("resolve inline url");
433390

434-
assert_eq!(resolved, "postgresql://broker/db");
391+
assert_eq!(resolved, "postgresql://inline/db");
435392
}
436393

437394
#[test]
438-
fn postgres_bootstrap_ref_fails_clearly_when_broker_fails() {
439-
let err = resolve_database_url_from_bootstrap(
440-
&bootstrap("postgres", None, Some(POSTGRES_DATABASE_URL_REF)),
441-
|| bail!("broker unavailable"),
395+
fn postgres_bootstrap_rejects_database_url_ref() {
396+
let err = parse_bootstrap_database(
397+
"hub_backend: postgres\n\
398+
database_url_ref: deprecated\n",
442399
)
443-
.expect_err("broker failure must fail");
400+
.expect_err("database_url_ref must fail");
444401

445402
let message = err.to_string();
446-
assert!(message.contains("local Gobby daemon broker"));
447-
assert!(message.contains("does not read the OS keyring directly"));
448-
}
449-
450-
#[test]
451-
fn postgres_bootstrap_fails_when_broker_token_is_missing() {
452-
let home = tempfile::tempdir().expect("temp home");
453-
let bootstrap_path = write_bootstrap(home.path(), 60887);
454-
455-
let err = resolve_database_url_from_bootstrap(
456-
&bootstrap("postgres", None, Some(POSTGRES_DATABASE_URL_REF)),
457-
|| resolve_brokered_database_url_at(home.path(), &bootstrap_path),
458-
)
459-
.expect_err("missing broker token must fail");
460-
461-
assert!(err.to_string().contains("local Gobby daemon broker"));
462-
}
463-
464-
#[test]
465-
fn postgres_bootstrap_fails_when_broker_is_unreachable() {
466-
let home = tempfile::tempdir().expect("temp home");
467-
std::fs::write(home.path().join(LOCAL_CLI_TOKEN_FILENAME), "token\n").expect("write token");
468-
let bootstrap_path = write_bootstrap(home.path(), unused_local_port());
469-
470-
let err = resolve_database_url_from_bootstrap(
471-
&bootstrap("postgres", None, Some(POSTGRES_DATABASE_URL_REF)),
472-
|| resolve_brokered_database_url_at(home.path(), &bootstrap_path),
473-
)
474-
.expect_err("unreachable broker must fail");
475-
476-
assert!(err.to_string().contains("local Gobby daemon broker"));
477-
}
478-
479-
#[test]
480-
fn postgres_bootstrap_fails_after_broker_401() {
481-
assert_broker_http_failure_fails("401 Unauthorized", r#"{"error":"bad token"}"#);
482-
}
483-
484-
#[test]
485-
fn postgres_bootstrap_fails_after_broker_403() {
486-
assert_broker_http_failure_fails("403 Forbidden", r#"{"error":"forbidden"}"#);
487-
}
488-
489-
#[test]
490-
fn postgres_bootstrap_fails_after_broker_non_2xx() {
491-
assert_broker_http_failure_fails("503 Service Unavailable", r#"{"error":"unavailable"}"#);
492-
}
493-
494-
#[test]
495-
fn postgres_bootstrap_fails_after_broker_invalid_json() {
496-
assert_broker_http_failure_fails("200 OK", "not json");
497-
}
498-
499-
#[test]
500-
fn postgres_bootstrap_fails_after_broker_empty_database_url() {
501-
assert_broker_http_failure_fails("200 OK", r#"{"database_url":" "}"#);
502-
}
503-
504-
#[test]
505-
fn postgres_bootstrap_prefers_inline_url_over_ref() {
506-
let resolved = resolve_database_url_from_bootstrap(
507-
&bootstrap(
508-
"postgres",
509-
Some("postgresql://inline/db"),
510-
Some(POSTGRES_DATABASE_URL_REF),
511-
),
512-
|| unreachable!("broker should not be used when inline url is present"),
513-
)
514-
.expect("resolve inline url");
515-
516-
assert_eq!(resolved, "postgresql://inline/db");
403+
assert!(message.contains("database_url_ref is no longer supported"));
404+
assert!(message.contains("database_url"));
517405
}
518406

519407
#[test]
520-
fn postgres_bootstrap_accepts_inline_url() {
521-
let resolved = resolve_database_url_from_bootstrap(
522-
&bootstrap("postgres", Some("postgresql://inline/db"), None),
523-
|| unreachable!("broker should not be used"),
408+
fn postgres_bootstrap_rejects_database_url_ref_even_with_inline_url() {
409+
let err = parse_bootstrap_database(
410+
"hub_backend: postgres\n\
411+
database_url: postgresql://inline/db\n\
412+
database_url_ref: deprecated\n",
524413
)
525-
.expect("resolve inline url");
414+
.expect_err("database_url_ref must fail");
526415

527-
assert_eq!(resolved, "postgresql://inline/db");
416+
assert!(
417+
err.to_string()
418+
.contains("database_url_ref is no longer supported")
419+
);
528420
}
529421

530422
#[test]
531423
fn non_postgres_bootstrap_fails_clearly() {
532-
let err =
533-
resolve_database_url_from_bootstrap(&bootstrap("legacy-local", None, None), || {
534-
unreachable!("broker should not be used")
535-
})
424+
let err = resolve_database_url_from_bootstrap(&bootstrap("legacy-local", None))
536425
.expect_err("non-postgres backend must fail");
537426

538427
let message = err.to_string();
@@ -550,41 +439,24 @@ mod tests {
550439

551440
#[test]
552441
fn missing_postgres_dsn_fails_clearly() {
553-
let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None, None), || {
554-
unreachable!("broker should not be used")
555-
})
556-
.expect_err("missing dsn must fail");
442+
let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None))
443+
.expect_err("missing dsn must fail");
557444

558-
assert!(err.to_string().contains("database_url_ref"));
559-
}
560-
561-
#[test]
562-
fn unsupported_database_url_ref_fails_clearly() {
563-
let err = resolve_database_url_from_bootstrap(
564-
&bootstrap(
565-
"postgres",
566-
None,
567-
Some("keyring:other:postgres_database_url"),
568-
),
569-
|| unreachable!("broker should not be used for unsupported refs"),
570-
)
571-
.expect_err("unsupported ref must fail");
572-
573-
assert!(err.to_string().contains(POSTGRES_DATABASE_URL_REF));
445+
assert!(err.to_string().contains("database_url"));
574446
}
575447

576448
#[test]
577449
fn parse_bootstrap_database_reads_postgres_fields() {
578450
let parsed = parse_bootstrap_database(
579451
"hub_backend: postgres\n\
580-
database_url_ref: daemon:gobby:postgres_database_url\n",
452+
database_url: postgresql://inline/db\n",
581453
)
582454
.expect("parse bootstrap");
583455

584456
assert_eq!(parsed.hub_backend, "postgres");
585457
assert_eq!(
586-
parsed.database_url_ref.as_deref(),
587-
Some(DAEMON_POSTGRES_DATABASE_URL_REF)
458+
parsed.database_url.as_deref(),
459+
Some("postgresql://inline/db")
588460
);
589461
}
590462

@@ -760,22 +632,4 @@ mod tests {
760632
});
761633
(format!("http://{addr}"), handle)
762634
}
763-
764-
fn assert_broker_http_failure_fails(status: &str, body: &str) {
765-
let (daemon_url, request) = spawn_http_response(http_response(status, body));
766-
767-
let err = resolve_database_url_from_bootstrap(
768-
&bootstrap("postgres", None, Some(POSTGRES_DATABASE_URL_REF)),
769-
|| request_broker_database_url(&daemon_url, "token"),
770-
)
771-
.expect_err("broker failure must fail");
772-
let _ = request.join().expect("read request");
773-
774-
assert!(err.to_string().contains("local Gobby daemon broker"));
775-
}
776-
777-
fn unused_local_port() -> u16 {
778-
let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused local port");
779-
listener.local_addr().expect("local addr").port()
780-
}
781635
}

docs/guides/gcode-development-guide.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,10 @@ A new dedicated module that owns all `git` shell-out logic for project-root dete
7777
`src/db.rs` asks the local daemon broker for PostgreSQL DSNs first using
7878
`POST /api/local/runtime/database-url` with `X-Gobby-Local-Token` from
7979
`local_cli_token` and a 3s timeout. If the broker is unavailable, gcode falls
80-
back to explicit non-keychain sources: `GCODE_DATABASE_URL`,
81-
`GOBBY_POSTGRES_DSN`, `$GOBBY_HOME/gcode.yaml` `database_url`, then
80+
back to explicit fallback sources: `GCODE_DATABASE_URL`, `GOBBY_POSTGRES_DSN`,
81+
`$GOBBY_HOME/gcode.yaml` `database_url`, then
8282
`$GOBBY_HOME/bootstrap.yaml` inline `database_url`. Bootstrap still requires
83-
`hub_backend: postgres` when used. gcode never reads the native OS keyring
84-
directly.
83+
`hub_backend: postgres` when used. Bootstrap `database_url_ref` is rejected.
8584
`connect_readwrite()` and `connect_readonly()` both return a synchronous
8685
`postgres::Client`; PostgreSQL permissions decide actual access.
8786

0 commit comments

Comments
 (0)