Skip to main content

vpg/
pgpass.rs

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