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