vpg/
pgpass.rs

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/// A parsed `.pgpass` file with line references and connection entries.
15///
16/// Stores both raw lines (preserving comments and formatting) and validated connection entries.
17/// Follows `PostgreSQL`'s password file format: `host:port:db:user:pwd` with colon-separated fields.
18#[derive(Debug)]
19pub struct PgpassFile<'a> {
20    /// Original file lines with their 0-based indices, preserving comments and metadata.
21    pub idx_lines: Vec<(usize, &'a str)>,
22    /// Validated connection entries parsed from non-comment lines.
23    pub entries: Vec<PgpassEntry>,
24}
25
26impl<'a> PgpassFile<'a> {
27    /// Parses the raw `.pgpass` content into a [`PgpassFile`].
28    ///
29    /// Expects alternating metadata comment lines (prefixed with `#`) and connection lines.
30    /// Non‑comment / non‑metadata lines are ignored except when part of metadata + connection pair.
31    ///
32    /// # Errors
33    /// - A metadata line is not followed by a valid connection line.
34    /// - A connection line cannot be parsed into [`ConnectionParams`].
35    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/// A validated `.pgpass` entry with associated metadata and connection parameters.
73#[derive(Clone, Debug)]
74pub struct PgpassEntry {
75    /// Parsed connection parameters from a valid `.pgpass` line.
76    pub connection_params: ConnectionParams,
77    /// Metadata from preceding comment lines (alias/vault references).
78    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/// Metadata extracted from comment lines preceding a `.pgpass` entry.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct Metadata {
90    /// Human-readable identifier for the connection (from comments).
91    pub alias: String,
92    /// Vault path reference for secure password management (from comments).
93    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/// Connection parameters parsed from a `.pgpass` line.
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct ConnectionParams {
105    /// Database name.
106    db: String,
107    /// Hostname.
108    host: String,
109    /// 0-based index referencing the original line in `PgpassFile.idx_lines`.
110    idx: usize,
111    /// TCP port number.
112    port: u16,
113    /// Password.
114    pwd: String,
115    /// Username.
116    user: String,
117}
118
119impl ConnectionParams {
120    /// Generates a `PostgreSQL` connection [`String`] URL from the connection parameters.
121    pub fn db_url(&self) -> String {
122        format!("postgres://{}@{}:{}/{}", self.user, self.host, self.port, self.db)
123    }
124
125    /// Updates the user and password fields with the provided [`VaultCreds`].
126    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
157/// Saves updated `PostgreSQL` `.pgpass` to a temporary file, replaces the original, and sets permissions.
158///
159/// # Errors
160/// - A filesystem operation (open/read/write/remove) fails.
161pub 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}