1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::resolve::resolve_env_vars;
7use crate::tuning::{TuningConfig, TuningProfile};
8
9#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
10#[serde(deny_unknown_fields)]
11pub struct SourceConfig {
12 #[serde(rename = "type")]
13 pub source_type: SourceType,
14
15 pub url: Option<String>,
16 pub url_env: Option<String>,
17 pub url_file: Option<String>,
18
19 pub host: Option<String>,
20 pub port: Option<u16>,
21 pub user: Option<String>,
22 pub password: Option<String>,
23 pub password_env: Option<String>,
24 pub database: Option<String>,
25
26 #[serde(default)]
39 pub environment: Option<SourceEnvironment>,
40
41 #[serde(default)]
42 pub tuning: Option<TuningConfig>,
43
44 #[serde(default)]
47 pub tls: Option<TlsConfig>,
48
49 #[serde(default)]
52 pub mongo: Option<MongoConfig>,
53}
54
55#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
58#[serde(deny_unknown_fields)]
59pub struct MongoConfig {
60 #[serde(default)]
65 pub json: MongoJsonMode,
66
67 #[serde(default)]
72 pub read_concern: MongoReadConcern,
73
74 #[serde(default = "default_true")]
78 pub no_cursor_timeout: bool,
79
80 #[serde(default)]
89 pub page_size: Option<usize>,
90
91 #[serde(default)]
97 pub resume: bool,
98}
99
100impl Default for MongoConfig {
101 fn default() -> Self {
102 Self {
103 json: MongoJsonMode::default(),
104 read_concern: MongoReadConcern::default(),
105 no_cursor_timeout: true,
106 page_size: None,
107 resume: false,
108 }
109 }
110}
111
112fn default_true() -> bool {
113 true
114}
115
116#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
118#[serde(rename_all = "lowercase")]
119pub enum MongoJsonMode {
120 #[default]
123 Relaxed,
124 Canonical,
127}
128
129#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
131#[serde(rename_all = "lowercase")]
132pub enum MongoReadConcern {
133 #[default]
136 Server,
137 Snapshot,
140}
141
142#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
146#[serde(rename_all = "lowercase")]
147pub enum SourceEnvironment {
148 Local,
151 Replica,
154 Production,
156}
157
158impl SourceEnvironment {
159 pub fn default_profile(self) -> TuningProfile {
162 match self {
163 SourceEnvironment::Local => TuningProfile::Fast,
164 SourceEnvironment::Replica | SourceEnvironment::Production => TuningProfile::Balanced,
165 }
166 }
167}
168
169#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)]
186#[serde(deny_unknown_fields)]
187pub struct TlsConfig {
188 #[serde(default)]
190 pub mode: TlsMode,
191 pub ca_file: Option<String>,
194 #[serde(default)]
197 pub accept_invalid_certs: bool,
198 #[serde(default)]
201 pub accept_invalid_hostnames: bool,
202}
203
204#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
206#[serde(rename_all = "kebab-case")]
207pub enum TlsMode {
208 Disable,
210 Require,
213 VerifyCa,
216 #[default]
219 VerifyFull,
220}
221
222impl TlsMode {
223 pub fn is_enforced(self) -> bool {
224 !matches!(self, TlsMode::Disable)
225 }
226}
227
228impl SourceConfig {
229 pub fn redact_for_artifact(&self) -> (Self, bool) {
242 let mut out = self.clone();
243 let mut redacted = false;
244
245 if out.password.is_some() {
246 out.password = None;
247 redacted = true;
248 }
249
250 if let Some(ref raw) = out.url
251 && let Some((userinfo_end, scheme_end)) = find_userinfo(raw)
252 {
253 let mut s = String::with_capacity(raw.len());
254 s.push_str(&raw[..scheme_end]); s.push_str("REDACTED");
256 s.push_str(&raw[userinfo_end..]); out.url = Some(s);
258 redacted = true;
259 }
260
261 (out, redacted)
262 }
263
264 pub(crate) fn has_structured_fields(&self) -> bool {
265 self.host.is_some()
266 || self.user.is_some()
267 || self.database.is_some()
268 || self.password.is_some()
269 || self.password_env.is_some()
270 }
271
272 pub(crate) fn has_url_fields(&self) -> bool {
273 self.url.is_some() || self.url_env.is_some() || self.url_file.is_some()
274 }
275
276 fn build_url_from_fields(&self) -> crate::error::Result<String> {
277 let host = self.host.as_deref().ok_or_else(|| {
282 anyhow::anyhow!(
283 "source: structured config is missing 'host'.\n Hint: add `host: localhost` (or your DB host) under `source:` in rivet.yaml.\n Or switch to URL-based config: `url_env: DATABASE_URL`."
284 )
285 })?;
286 let user = self.user.as_deref().ok_or_else(|| {
287 anyhow::anyhow!(
288 "source: structured config is missing 'user'.\n Hint: add `user: <username>` under `source:` in rivet.yaml."
289 )
290 })?;
291 let database = self.database.as_deref().ok_or_else(|| {
292 anyhow::anyhow!(
293 "source: structured config is missing 'database'.\n Hint: add `database: <dbname>` under `source:` in rivet.yaml."
294 )
295 })?;
296
297 let password: zeroize::Zeroizing<String> =
302 zeroize::Zeroizing::new(match (&self.password, &self.password_env) {
303 (Some(_), Some(_)) => {
304 anyhow::bail!("source: specify 'password' or 'password_env', not both");
305 }
306 (Some(p), None) => {
307 static WARNED: std::sync::Once = std::sync::Once::new();
308 WARNED.call_once(|| {
309 log::warn!(
310 "source config contains plaintext password -- consider using password_env"
311 );
312 });
313 resolve_env_vars(p)?
314 }
315 (None, Some(env)) => std::env::var(env).map_err(|_| {
316 anyhow::anyhow!(
317 "source: env var '{0}' is not set (referenced by password_env).\n Hint: export the value before running, e.g.\n export {0}='your-database-password'",
318 env
319 )
320 })?,
321 (None, None) => String::new(),
322 });
323
324 let default_port = match self.source_type {
325 SourceType::Postgres => 5432,
326 SourceType::Mysql => 3306,
327 SourceType::Mssql => 1433,
328 SourceType::Mongo => 27017,
329 };
330 let port = self.port.unwrap_or(default_port);
331
332 let scheme = match self.source_type {
333 SourceType::Postgres => "postgresql",
334 SourceType::Mysql => "mysql",
335 SourceType::Mssql => "sqlserver",
336 SourceType::Mongo => "mongodb",
337 };
338
339 if password.is_empty() {
340 Ok(format!(
341 "{}://{}@{}:{}/{}",
342 scheme, user, host, port, database
343 ))
344 } else {
345 Ok(format!(
346 "{}://{}:{}@{}:{}/{}",
347 scheme,
348 user,
349 password.as_str(),
350 host,
351 port,
352 database
353 ))
354 }
355 }
356
357 pub fn resolve_url(&self) -> crate::error::Result<String> {
358 if self.has_url_fields() && self.has_structured_fields() {
359 anyhow::bail!(
360 "source: pick either URL-based config (url/url_env/url_file) OR structured fields (host/user/database/port/password_env), not both.\n Hint: remove whichever block you don't want; mixing the two is ambiguous."
361 );
362 }
363
364 if self.has_structured_fields() {
365 return self.build_url_from_fields();
366 }
367
368 #[allow(dead_code)]
379 enum UrlSource<'a> {
380 InlineYaml,
381 EnvVar(&'a str),
382 File(&'a str),
383 }
384 let (raw, source) = match (&self.url, &self.url_env, &self.url_file) {
385 (Some(u), None, None) => (u.clone(), UrlSource::InlineYaml),
386 (None, Some(env), None) => (
387 std::env::var(env).map_err(|_| {
388 anyhow::anyhow!(
389 "source: env var '{0}' is not set (referenced by url_env).\n Hint: export the value before running, e.g.\n export {0}='postgresql://user:pass@host:5432/dbname'\n Or change `url_env: {0}` in your config to a different env var name.",
390 env
391 )
392 })?,
393 UrlSource::EnvVar(env),
394 ),
395 (None, None, Some(file)) => (
396 std::fs::read_to_string(file)
397 .map_err(|e| {
398 anyhow::anyhow!(
399 "source: cannot read url_file '{}': {}.\n Hint: ensure the file exists and is readable; the file should contain only the URL on a single line.",
400 file,
401 e
402 )
403 })?
404 .trim()
405 .to_string(),
406 UrlSource::File(file),
407 ),
408 _ => anyhow::bail!(
409 "source: configure exactly one connection method:\n url_env: DATABASE_URL (URL from env var — recommended)\n url: 'postgresql://user:pass@host:5432/db' (inline — not recommended for committed configs)\n url_file: /etc/rivet/source.url (URL from file — rotation-friendly)\n host/user/database/... (structured fields under `source:`)"
410 ),
411 };
412
413 let resolved = resolve_env_vars(&raw)?;
414
415 if resolved.contains('@')
416 && resolved.contains(':')
417 && let Some(userinfo) = resolved.split('@').next()
418 && userinfo.contains(':')
419 && !userinfo.ends_with(':')
420 {
421 match source {
432 UrlSource::InlineYaml => {
433 static WARNED: std::sync::Once = std::sync::Once::new();
434 WARNED.call_once(|| {
435 log::warn!(
436 "source: inline `url:` in YAML contains a plaintext password — \
437 move it to `url_env: DATABASE_URL` (or `url_file:`) to keep \
438 credentials out of committed configs"
439 );
440 });
441 }
442 UrlSource::EnvVar(_) | UrlSource::File(_) => {
443 }
446 }
447 }
448
449 Ok(resolved)
450 }
451}
452
453#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
454#[serde(rename_all = "lowercase")]
455pub enum SourceType {
456 Postgres,
457 Mysql,
458 Mssql,
459 Mongo,
467}
468
469impl SourceType {
470 pub fn is_sql(self) -> bool {
476 !matches!(self, SourceType::Mongo)
477 }
478}
479
480fn find_userinfo(raw: &str) -> Option<(usize, usize)> {
488 let scheme = raw.find("://")? + 3;
489 let rest = &raw[scheme..];
490 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
493 let at = rest[..authority_end].rfind('@')?;
499 Some((scheme + at, scheme))
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
509 fn tls_mode_disable_not_enforced() {
510 assert!(!TlsMode::Disable.is_enforced());
511 }
512
513 #[test]
514 fn tls_mode_require_is_enforced() {
515 assert!(TlsMode::Require.is_enforced());
516 assert!(TlsMode::VerifyCa.is_enforced());
517 assert!(TlsMode::VerifyFull.is_enforced());
518 }
519
520 fn make_source(source_type: SourceType) -> SourceConfig {
523 SourceConfig {
524 source_type,
525 url: None,
526 url_env: None,
527 url_file: None,
528 host: None,
529 port: None,
530 user: None,
531 password: None,
532 password_env: None,
533 database: None,
534 environment: None,
535 tuning: None,
536 tls: None,
537 mongo: None,
538 }
539 }
540
541 #[test]
542 fn redact_plaintext_password() {
543 let mut src = make_source(SourceType::Postgres);
544 src.password = Some("s3cr3t".into());
545 let (redacted, flag) = src.redact_for_artifact();
546 assert!(flag, "redaction should be flagged");
547 assert!(
548 redacted.password.is_none(),
549 "plaintext password must be stripped"
550 );
551 }
552
553 #[test]
554 fn redact_url_with_password() {
555 let mut src = make_source(SourceType::Postgres);
556 src.url = Some("postgresql://user:hunter2@db.example.com:5432/app".into());
557 let (redacted, flag) = src.redact_for_artifact();
558 assert!(flag, "URL redaction flagged");
559 let url = redacted.url.unwrap();
560 assert!(!url.contains("hunter2"), "password must not appear: {url}");
561 assert!(url.contains("REDACTED"), "placeholder must appear: {url}");
562 assert!(url.contains("@db.example.com"), "host retained: {url}");
563 }
564
565 #[test]
566 fn redact_url_without_at_sign_not_flagged() {
567 let mut src = make_source(SourceType::Postgres);
568 src.url = Some("postgresql://db.example.com:5432/app".into());
569 let (_, flag) = src.redact_for_artifact();
570 assert!(!flag, "URL with no userinfo must not be flagged");
571 }
572
573 #[test]
574 fn redact_url_with_user_but_no_password_is_flagged() {
575 let mut src = make_source(SourceType::Postgres);
576 src.url = Some("postgresql://user@db.example.com:5432/app".into());
577 let (redacted, flag) = src.redact_for_artifact();
578 assert!(flag, "bare user@ is still userinfo and gets redacted");
579 let url = redacted.url.unwrap();
580 assert!(url.contains("REDACTED"), "userinfo replaced: {url}");
581 assert!(!url.contains("user@"), "bare username removed: {url}");
582 }
583
584 #[test]
585 fn redact_env_var_reference_kept_intact() {
586 let mut src = make_source(SourceType::Mysql);
587 src.url_env = Some("DB_URL".into());
588 src.password_env = Some("DB_PASS".into());
589 let (redacted, flag) = src.redact_for_artifact();
590 assert!(!flag, "env var references are not secrets");
591 assert_eq!(redacted.url_env.as_deref(), Some("DB_URL"));
592 assert_eq!(redacted.password_env.as_deref(), Some("DB_PASS"));
593 }
594
595 #[test]
596 fn redact_mysql_url_with_password() {
597 let mut src = make_source(SourceType::Mysql);
598 src.url = Some("mysql://root:pass@127.0.0.1:3306/mydb".into());
599 let (redacted, flag) = src.redact_for_artifact();
600 assert!(flag);
601 let url = redacted.url.unwrap();
602 assert!(url.contains("REDACTED"), "{url}");
603 assert!(!url.contains("pass"), "{url}");
604 }
605
606 #[test]
609 fn resolve_url_from_structured_fields_postgres() {
610 let mut src = make_source(SourceType::Postgres);
611 src.host = Some("pg.internal".into());
612 src.user = Some("alice".into());
613 src.database = Some("warehouse".into());
614 src.port = Some(5433);
615 let url = src.resolve_url().unwrap();
616 assert_eq!(url, "postgresql://alice@pg.internal:5433/warehouse");
617 }
618
619 #[test]
620 fn resolve_url_from_structured_fields_defaults_port() {
621 let mut src = make_source(SourceType::Mysql);
622 src.host = Some("my.internal".into());
623 src.user = Some("bob".into());
624 src.database = Some("orders".into());
625 let url = src.resolve_url().unwrap();
626 assert_eq!(url, "mysql://bob@my.internal:3306/orders");
627 }
628
629 #[test]
630 fn resolve_url_direct_url_passthrough() {
631 let mut src = make_source(SourceType::Postgres);
632 src.url = Some("postgresql://carol@pg.example.com:5432/db".into());
633 let url = src.resolve_url().unwrap();
634 assert_eq!(url, "postgresql://carol@pg.example.com:5432/db");
635 }
636
637 #[test]
638 fn resolve_url_rejects_mixed_url_and_structured() {
639 let mut src = make_source(SourceType::Postgres);
640 src.url = Some("postgresql://carol@pg.example.com/db".into());
641 src.host = Some("other".into());
642 let err = src.resolve_url().unwrap_err();
643 let msg = format!("{err:#}");
644 assert!(
645 msg.contains("URL-based") || msg.contains("structured"),
646 "{msg}"
647 );
648 }
649
650 #[test]
651 fn resolve_url_rejects_missing_host() {
652 let mut src = make_source(SourceType::Postgres);
653 src.user = Some("alice".into());
654 src.database = Some("warehouse".into());
655 let err = src.resolve_url().unwrap_err();
656 let msg = format!("{err:#}");
657 assert!(msg.contains("host"), "{msg}");
658 }
659
660 #[test]
663 fn find_userinfo_detects_password_in_url() {
664 let url = "postgresql://user:pass@host/db";
665 let result = find_userinfo(url);
666 assert!(result.is_some(), "should detect user:pass@");
667 }
668
669 #[test]
670 fn find_userinfo_no_password_no_at_returns_none() {
671 assert!(find_userinfo("postgresql://host/db").is_none());
672 }
673
674 #[test]
675 fn find_userinfo_user_only_at_sign_matches() {
676 let url = "postgresql://user@host/db";
677 assert!(find_userinfo(url).is_some(), "bare user@ should match");
678 }
679
680 #[test]
681 fn find_userinfo_no_at_sign_returns_none() {
682 assert!(find_userinfo("postgresql://db.example.com:5432/app").is_none());
683 }
684
685 #[test]
688 fn sec_artifact_redaction_password_with_at() {
689 let mut src = make_source(SourceType::Postgres);
699 src.url = Some("postgresql://rivet:p@ssw0rd@db.example.com:5432/orders".into());
700 let (redacted, flag) = src.redact_for_artifact();
701 assert!(flag, "URL with userinfo must be flagged as redacted");
702 let url = redacted.url.expect("url retained after redaction");
703 assert!(
704 !url.contains("ssw0rd"),
705 "password tail after embedded @ must not leak into artifact: {url}"
706 );
707 assert!(
708 !url.contains("p@ssw0rd"),
709 "full password must not leak into artifact: {url}"
710 );
711 assert!(url.contains("REDACTED"), "placeholder must appear: {url}");
712 assert!(
713 url.contains("@db.example.com:5432/orders"),
714 "host and path must be retained: {url}"
715 );
716 }
717}