1use core::fmt::Display;
2use std::fs::File;
3use std::fs::OpenOptions;
4use std::io::Write;
5use std::os::unix::fs::PermissionsExt;
6use std::path::Path;
7use std::path::PathBuf;
8
9use color_eyre::eyre::WrapErr;
10use color_eyre::eyre::bail;
11
12use crate::vault::VaultCreds;
13
14#[derive(Debug)]
19pub struct PgpassFile<'a> {
20 pub idx_lines: Vec<(usize, &'a str)>,
22 pub entries: Vec<PgpassEntry>,
24}
25
26impl<'a> PgpassFile<'a> {
27 pub fn parse(pgpass_content: &'a str) -> color_eyre::eyre::Result<Self> {
36 let mut idx_lines = vec![];
37 let mut entries = vec![];
38
39 let mut file_lines = pgpass_content.lines().enumerate();
40 while let Some(idx_line @ (_, line)) = file_lines.next() {
41 idx_lines.push(idx_line);
42
43 if line.is_empty() {
44 continue;
45 }
46
47 if let Some((alias, vault_path)) = line.strip_prefix('#').and_then(|s| s.split_once(' ')) {
48 let metadata = Metadata {
49 alias: alias.to_string(),
50 vault_path: vault_path.to_string(),
51 };
52
53 if let Some(idx_line) = file_lines.next() {
54 idx_lines.push(idx_line);
55
56 let conn = ConnectionParams::try_from(idx_line)?;
57 entries.push(PgpassEntry {
58 metadata,
59 connection_params: conn,
60 });
61
62 continue;
63 }
64 bail!("missing pgpass connection line after metadata | metadata={metadata:#?} idx_line={idx_line:#?}")
65 }
66 }
67
68 Ok(Self { idx_lines, entries })
69 }
70}
71
72#[derive(Clone, Debug)]
74pub struct PgpassEntry {
75 pub connection_params: ConnectionParams,
77 pub metadata: Metadata,
79}
80
81impl Display for PgpassEntry {
82 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83 write!(f, "{}", self.metadata.alias)
84 }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct Metadata {
90 pub alias: String,
92 pub vault_path: String,
94}
95
96impl Display for Metadata {
97 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
98 write!(f, "{}", self.alias)
99 }
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct ConnectionParams {
105 db: String,
107 host: String,
109 idx: usize,
111 port: u16,
113 pwd: String,
115 user: String,
117}
118
119impl ConnectionParams {
120 pub fn db_url(&self) -> String {
122 format!("postgres://{}@{}:{}/{}", self.user, self.host, self.port, self.db)
123 }
124
125 pub fn update(&mut self, creds: &VaultCreds) {
127 self.user.clone_from(&creds.username);
128 self.pwd.clone_from(&creds.password);
129 }
130}
131
132impl TryFrom<(usize, &str)> for ConnectionParams {
133 type Error = color_eyre::eyre::Error;
134
135 fn try_from(idx_line @ (idx, line): (usize, &str)) -> Result<Self, Self::Error> {
136 if let [host, port, db, user, pwd] = line.split(':').collect::<Vec<_>>().as_slice() {
137 let port = port.parse().context(format!("unexpected port | port={port}"))?;
138 return Ok(Self {
139 idx,
140 host: (*host).to_string(),
141 port,
142 db: (*db).to_string(),
143 user: (*user).to_string(),
144 pwd: (*pwd).to_string(),
145 });
146 }
147 bail!("malformed pgpass connection line | idx_line={idx_line:#?}")
148 }
149}
150
151impl Display for ConnectionParams {
152 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
153 write!(f, "{}:{}:{}:{}:{}", self.host, self.port, self.db, self.user, self.pwd)
154 }
155}
156
157pub fn save_new_pgpass_file(
162 pgpass_idx_lines: Vec<(usize, &str)>,
163 updated_conn_params: &ConnectionParams,
164 pgpass_path: &Path,
165) -> color_eyre::Result<()> {
166 let mut tmp_path = PathBuf::from(pgpass_path);
167 tmp_path.set_file_name(".pgpass.tmp");
168 let mut tmp_file = File::create(&tmp_path)?;
169
170 for (idx, pgpass_line) in pgpass_idx_lines {
171 let file_line = if idx == updated_conn_params.idx {
172 updated_conn_params.to_string()
173 } else {
174 pgpass_line.to_string()
175 };
176 writeln!(tmp_file, "{file_line}")?;
177 }
178
179 std::fs::rename(&tmp_path, pgpass_path)?;
180
181 let file = OpenOptions::new().read(true).open(pgpass_path)?;
182 let mut permissions = file.metadata()?.permissions();
183 permissions.set_mode(0o600);
184 file.set_permissions(permissions)?;
185
186 Ok(())
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn creds_try_from_returns_the_expected_creds() {
195 assert2::let_assert!(Ok(actual) = ConnectionParams::try_from((42, "host:5432:db:user:pwd")));
196 assert_eq!(
197 actual,
198 ConnectionParams {
199 idx: 42,
200 host: "host".into(),
201 port: 5432,
202 db: "db".into(),
203 user: "user".into(),
204 pwd: "pwd".into(),
205 }
206 );
207 }
208
209 #[test]
210 fn creds_try_from_returns_an_error_if_port_is_not_a_number() {
211 assert2::let_assert!(Err(err) = ConnectionParams::try_from((42, "host:foo:db:user:pwd")));
212 assert_eq!(format!("{err}"), "unexpected port | port=foo");
213 }
214
215 #[test]
216 fn creds_try_from_returns_an_error_if_str_is_malformed() {
217 assert2::let_assert!(Err(err) = ConnectionParams::try_from((42, "host:5432:db:user")));
218 assert_eq!(
219 format!("{err}"),
220 "malformed pgpass connection line | idx_line=(\n 42,\n \"host:5432:db:user\",\n)",
221 "unexpected {err}"
222 );
223 }
224
225 #[test]
226 fn creds_db_url_returns_the_expected_output() {
227 assert_eq!(
228 ConnectionParams {
229 idx: 42,
230 host: "host".into(),
231 port: 5432,
232 db: "db".into(),
233 user: "user".into(),
234 pwd: "whatever".into()
235 }
236 .db_url(),
237 "postgres://user@host:5432/db".to_string()
238 );
239 }
240}