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 rootcause::prelude::ResultExt;
10use rootcause::report;
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) -> rootcause::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 Err(report!("missing pgpass connection line after metadata"))
65 .attach_with(|| format!("metadata={metadata:#?} idx_line={idx_line:#?}"))?;
66 }
67 }
68
69 Ok(Self { idx_lines, entries })
70 }
71}
72
73#[derive(Clone, Debug)]
75pub struct PgpassEntry {
76 pub connection_params: ConnectionParams,
78 pub metadata: Metadata,
80}
81
82impl Display for PgpassEntry {
83 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
84 write!(f, "{}", self.metadata.alias)
85 }
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct Metadata {
91 pub alias: String,
93 pub vault_path: String,
95}
96
97impl Display for Metadata {
98 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99 write!(f, "{}", self.alias)
100 }
101}
102
103#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct ConnectionParams {
106 db: String,
108 host: String,
110 idx: usize,
112 port: u16,
114 pwd: String,
116 user: String,
118}
119
120impl ConnectionParams {
121 pub fn db_url(&self) -> String {
123 format!("postgres://{}@{}:{}/{}", self.user, self.host, self.port, self.db)
124 }
125
126 pub fn update(&mut self, creds: &VaultCreds) {
128 self.user.clone_from(&creds.username);
129 self.pwd.clone_from(&creds.password);
130 }
131}
132
133impl TryFrom<(usize, &str)> for ConnectionParams {
134 type Error = rootcause::Report;
135
136 fn try_from(idx_line @ (idx, line): (usize, &str)) -> Result<Self, Self::Error> {
137 let mut parts = line.splitn(5, ':');
139 let (Some(host), Some(port_str), Some(db), Some(user), Some(pwd)) =
140 (parts.next(), parts.next(), parts.next(), parts.next(), parts.next())
141 else {
142 return Err(report!("malformed pgpass connection line")).attach_with(|| format!("idx_line={idx_line:#?}"));
143 };
144 let port = port_str
145 .parse()
146 .context("unexpected port")
147 .attach_with(|| format!("port={port_str}"))?;
148 Ok(Self {
149 idx,
150 host: host.to_string(),
151 port,
152 db: db.to_string(),
153 user: user.to_string(),
154 pwd: pwd.to_string(),
155 })
156 }
157}
158
159impl Display for ConnectionParams {
160 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
161 write!(f, "{}:{}:{}:{}:{}", self.host, self.port, self.db, self.user, self.pwd)
162 }
163}
164
165pub fn save_new_pgpass_file(
170 pgpass_idx_lines: Vec<(usize, &str)>,
171 updated_conn_params: &ConnectionParams,
172 pgpass_path: &Path,
173) -> rootcause::Result<()> {
174 let mut tmp_path = PathBuf::from(pgpass_path);
175 tmp_path.set_file_name(".pgpass.tmp");
176 let mut tmp_file = File::create(&tmp_path)?;
177
178 for (idx, pgpass_line) in pgpass_idx_lines {
179 let file_line = if idx == updated_conn_params.idx {
180 updated_conn_params.to_string()
181 } else {
182 pgpass_line.to_string()
183 };
184 writeln!(tmp_file, "{file_line}")?;
185 }
186
187 std::fs::rename(&tmp_path, pgpass_path)?;
188
189 let file = OpenOptions::new().read(true).open(pgpass_path)?;
190 let mut permissions = file.metadata()?.permissions();
191 permissions.set_mode(0o600);
192 file.set_permissions(permissions)?;
193
194 Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn creds_try_from_returns_the_expected_creds() {
203 assert2::assert!(let Ok(actual) = ConnectionParams::try_from((42, "host:5432:db:user:pwd")));
204 assert_eq!(
205 actual,
206 ConnectionParams {
207 idx: 42,
208 host: "host".into(),
209 port: 5432,
210 db: "db".into(),
211 user: "user".into(),
212 pwd: "pwd".into(),
213 }
214 );
215 }
216
217 #[test]
218 fn creds_try_from_returns_an_error_if_port_is_not_a_number() {
219 assert2::assert!(let Err(err) = ConnectionParams::try_from((42, "host:foo:db:user:pwd")));
220 assert_eq!(err.format_current_context().to_string(), "unexpected port");
221 }
222
223 #[test]
224 fn creds_try_from_returns_an_error_if_str_is_malformed() {
225 assert2::assert!(let Err(err) = ConnectionParams::try_from((42, "host:5432:db:user")));
226 assert_eq!(
227 err.format_current_context().to_string(),
228 "malformed pgpass connection line"
229 );
230 }
231
232 #[test]
233 fn creds_db_url_returns_the_expected_output() {
234 assert_eq!(
235 ConnectionParams {
236 idx: 42,
237 host: "host".into(),
238 port: 5432,
239 db: "db".into(),
240 user: "user".into(),
241 pwd: "whatever".into()
242 }
243 .db_url(),
244 "postgres://user@host:5432/db".to_string()
245 );
246 }
247}