-
Notifications
You must be signed in to change notification settings - Fork 273
feat(config): expand environment variables in pgdog.toml and users.toml #1493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ChrisRx
wants to merge
2
commits into
pgdogdev:main
Choose a base branch
from
censys-oss:env-toml
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| //! Environment variable expansion in configuration files. | ||
|
|
||
| use std::borrow::Cow; | ||
| use std::env::var; | ||
|
|
||
| use serde::de::DeserializeOwned; | ||
|
|
||
| use crate::Error; | ||
|
|
||
| /// Start of a variable reference. | ||
| const OPEN: &str = "${"; | ||
|
|
||
| /// Expand `${VAR}` references in a configuration file against the process | ||
| /// environment. | ||
| /// | ||
| /// Only the braced form is a reference: a bare `$VAR`, a `${` that's malformed | ||
| /// or unterminated, and a reference to a variable that isn't set are all literal | ||
| /// text, so values that merely contain a `$` (passwords, most commonly) survive | ||
| /// untouched. Write `$${VAR}` for a literal `${VAR}`, and `${VAR:-value}` to | ||
| /// supply a fallback. | ||
| /// | ||
| /// **Note:** expansion happens on the document source, before it's parsed, so a | ||
| /// variable is interpolated as TOML rather than as a string. `${PASSWORD}` in | ||
| /// value position needs surrounding quotes, and a value containing `"` or a | ||
| /// newline changes how the rest of the document parses. | ||
| pub fn expand(source: &str) -> Cow<'_, str> { | ||
| if !source.contains(OPEN) { | ||
| return Cow::Borrowed(source); | ||
| } | ||
|
|
||
| let mut expanded = String::with_capacity(source.len()); | ||
| let mut rest = source; | ||
|
|
||
| while let Some(start) = rest.find(OPEN) { | ||
| let body = &rest[start + OPEN.len()..]; | ||
|
|
||
| // A reference is `${`, a valid name, an optional `:-fallback`, and `}`. | ||
| // Anything else is literal text: emit through the `${` and rescan right | ||
| // after it, so a stray `${` in one value can't swallow a real reference | ||
| // later in the document. | ||
| let reference = body.find('}').and_then(|end| { | ||
| let (name, fallback) = match body[..end].split_once(":-") { | ||
| Some((name, fallback)) => (name, Some(fallback)), | ||
| None => (&body[..end], None), | ||
| }; | ||
| is_name(name).then_some((name, fallback, end)) | ||
| }); | ||
| let Some((name, fallback, end)) = reference else { | ||
| expanded.push_str(&rest[..start + OPEN.len()]); | ||
| rest = body; | ||
| continue; | ||
| }; | ||
|
|
||
| let stop = start + OPEN.len() + end + 1; | ||
| if rest[..start].ends_with('$') { | ||
| // `$${VAR}` escapes the reference: drop the `$` and keep the | ||
| // reference as written, whether or not the variable is set. | ||
| expanded.push_str(&rest[..start - 1]); | ||
| expanded.push_str(&rest[start..stop]); | ||
| } else { | ||
| expanded.push_str(&rest[..start]); | ||
| match var(name).ok().as_deref().or(fallback) { | ||
| Some(value) => expanded.push_str(value), | ||
| // Unset with no fallback: the reference stays as written. | ||
| None => expanded.push_str(&rest[start..stop]), | ||
| } | ||
| } | ||
| rest = &rest[stop..]; | ||
| } | ||
|
|
||
| expanded.push_str(rest); | ||
| Cow::Owned(expanded) | ||
| } | ||
|
|
||
| /// Is this a shell variable name, i.e. letters, digits and underscores, not | ||
| /// starting with a digit? | ||
| fn is_name(name: &str) -> bool { | ||
| let mut chars = name.chars(); | ||
| chars | ||
| .next() | ||
| .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') | ||
| && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') | ||
| } | ||
|
|
||
| /// Parse a TOML configuration document, expanding environment variables first. | ||
| pub trait FromToml: DeserializeOwned { | ||
| /// Parse `source` as TOML, [`expand`]ing environment variables first. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`Error::MissingField`] if the expanded document isn't valid TOML | ||
| /// or doesn't match the shape of `Self`. | ||
| fn from_toml(source: &str) -> Result<Self, Error> { | ||
| let expanded = expand(source); | ||
| toml::from_str(&expanded).map_err(|err| Error::config(&expanded, err)) | ||
| } | ||
| } | ||
|
|
||
| impl<T: DeserializeOwned> FromToml for T {} | ||
|
|
||
| #[cfg(test)] | ||
| mod test { | ||
| use super::*; | ||
| use crate::test_utils::{remove_env_var, set_env_var}; | ||
| use crate::{Config, Users}; | ||
|
|
||
| #[test] | ||
| fn test_expand() { | ||
| let _set = set_env_var("PGDOG_TEST_VAR", "expanded"); | ||
| let _unset = remove_env_var("PGDOG_TEST_MISSING"); | ||
|
|
||
| assert_eq!(expand("${PGDOG_TEST_VAR}"), "expanded"); | ||
| assert_eq!(expand("${PGDOG_TEST_VAR}/db"), "expanded/db"); | ||
| assert_eq!( | ||
| expand("a${PGDOG_TEST_VAR}b${PGDOG_TEST_VAR}"), | ||
| "aexpandedbexpanded" | ||
| ); | ||
| assert_eq!(expand("${PGDOG_TEST_MISSING}"), "${PGDOG_TEST_MISSING}"); | ||
| assert_eq!(expand("${PGDOG_TEST_MISSING:-fallback}"), "fallback"); | ||
| assert_eq!(expand("${PGDOG_TEST_VAR:-fallback}"), "expanded"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_expand_leaves_unbraced_alone() { | ||
| let _set = set_env_var("PGDOG_TEST_VAR", "expanded"); | ||
|
|
||
| assert_eq!(expand("$PGDOG_TEST_VAR/db"), "$PGDOG_TEST_VAR/db"); | ||
| assert_eq!(expand("sup$rsecret"), "sup$rsecret"); | ||
| assert_eq!(expand("p$$w0rd"), "p$$w0rd"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_expand_leaves_malformed_alone() { | ||
| let _set = set_env_var("PGDOG_TEST_VAR", "expanded"); | ||
|
|
||
| assert_eq!(expand("${PGDOG_TEST_VAR"), "${PGDOG_TEST_VAR"); | ||
| assert_eq!(expand("${PGDOG TEST VAR}"), "${PGDOG TEST VAR}"); | ||
| assert_eq!(expand("${}"), "${}"); | ||
| assert_eq!(expand("${1VAR}"), "${1VAR}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_expand_escape() { | ||
| let _set = set_env_var("PGDOG_TEST_VAR", "expanded"); | ||
| let _unset = remove_env_var("PGDOG_TEST_MISSING"); | ||
|
|
||
| assert_eq!(expand("$${PGDOG_TEST_VAR}"), "${PGDOG_TEST_VAR}"); | ||
| // The escape doesn't depend on the variable being set. | ||
| assert_eq!(expand("$${PGDOG_TEST_MISSING}"), "${PGDOG_TEST_MISSING}"); | ||
| // Only a well-formed reference needs escaping; a `$` before anything | ||
| // else is literal. | ||
| assert_eq!(expand("a$${b"), "a$${b"); | ||
| assert_eq!(expand("p$${a b}q"), "p$${a b}q"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_expand_scans_past_stray_reference() { | ||
| let _set = set_env_var("PGDOG_TEST_VAR", "expanded"); | ||
|
|
||
| // A stray `${` in one value must not swallow a real reference later | ||
| // in the document. | ||
| assert_eq!( | ||
| expand("password = \"ab${cd\"\nhost = \"${PGDOG_TEST_VAR}\""), | ||
| "password = \"ab${cd\"\nhost = \"expanded\"" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_from_toml_expands() { | ||
| let _password = set_env_var("PGDOG_TEST_PASSWORD", "not a real secret"); | ||
| let _timeout = set_env_var("PGDOG_TEST_SHUTDOWN_TIMEOUT", "1_000"); | ||
|
|
||
| let source = r#" | ||
| [admin] | ||
| password = "${PGDOG_TEST_PASSWORD}" | ||
|
|
||
| [general] | ||
| shutdown_timeout = ${PGDOG_TEST_SHUTDOWN_TIMEOUT} | ||
| "#; | ||
|
|
||
| let config = Config::from_toml(source).unwrap(); | ||
| assert_eq!(config.admin.password, "not a real secret"); | ||
| assert_eq!(config.general.shutdown_timeout, 1_000); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_from_toml_leaves_unset_alone() { | ||
| let _unset = remove_env_var("PGDOG_TEST_MISSING"); | ||
|
|
||
| let source = r#" | ||
| [[users]] | ||
| name = "pgdog" | ||
| database = "pgdog" | ||
| password = "${PGDOG_TEST_MISSING}" | ||
| "#; | ||
|
|
||
| let users = Users::from_toml(source).unwrap(); | ||
| assert_eq!( | ||
| users.users[0].password.as_deref(), | ||
| Some("${PGDOG_TEST_MISSING}") | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_from_toml_reports_errors() { | ||
| let err = Config::from_toml("[general]\nnot_a_field = 1\n").unwrap_err(); | ||
| assert!(matches!(err, Error::MissingField(..)), "{err:?}"); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think all characters are allowed in a Postgres password, e.g., $, { and }, so this is a valid password which will be expanded to an empty string:
Curious if you have any thoughts. Maybe we should only expand settings that are entirely covered by an env var, e.g.:
That would require us to perform shellexpand on each value after deserialization (or write a custom serializer).
Just thinking out loud, let me know what you think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the case with
${hello}it would need to be set as the password value and also be set in the environment, so unless it has an environment variable forhello=it will keep it as the original string, ultimately leaving as${hello}.I was definitely concerned with using
shellexpand, I could imagine a situation where generated passwords or especially some longer tokens could easily contain something where it would match shorter commonly set environment variables, likeCC, where a randomly generated value like....$CC.....would get expanded to the value ofCCwith something likegcc. But I feel a lot better with the new non-shellexpand approach being that it requires:${followed by a}[a-zA-Z0-9_]and cannot start with_or a digit (I based it off of POSIX standard, but including lowercase letters)I don't know how to go about calculating a probability on it myself, but it seems like it would be practically impossible given the confluence of things that would need to happen coupled with password generators usually don't create passwords that include
{or}and tokens like JWTs are usually base64 encoded which would exclude that as well.I really appreciate the discussion with this btw, I think this kind of thing IME is not something you can think too much about for sure!
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had another thought that might be a better developer experience and would be even more impossible to match on sequences unintentionally. I have used this other project previously that had a similar capability, but it has a much more specific opening sequence since it can handle both environment variables and reading from files: https://www.apollographql.com/docs/graphos/routing/configuration/yaml#variable-expansion. It uses
${}but needs eitherenv.orfile.to specify the environment variable name or file name, respectively.With an opener of
${env.it means that only sequences matching roughly${env.([a-zA-Z0-9_])+}would even perform an environment variable lookup. I'm reasonably confident the previous approach would be practically impossible, but this would be without any doubt impossible to do unintentionally. And IMO is a better developer/user experience since it is very obvious reading the configuration file what is happening since it says "env" with each variable name.Thoughts on this approach? It is trivial to make this change since it is just adjusting the opener.