Skip to main content

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 rootcause::prelude::ResultExt;
10use rootcause::report;
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) -> 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/// A validated `.pgpass` entry with associated metadata and connection parameters.
74#[derive(Clone, Debug)]
75pub struct PgpassEntry {
76    /// Parsed connection parameters from a valid `.pgpass` line.
77    pub connection_params: ConnectionParams,
78    /// Metadata from preceding comment lines (alias/vault references).
79    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/// Metadata extracted from comment lines preceding a `.pgpass` entry.
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct Metadata {
91    /// Human-readable identifier for the connection (from comments).
92    pub alias: String,
93    /// Vault path reference for secure password management (from comments).
94    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/// Connection parameters parsed from a `.pgpass` line.
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct ConnectionParams {
106    /// Database name.
107    db: String,
108    /// Hostname.
109    host: String,
110    /// 0-based index referencing the original line in `PgpassFile.idx_lines`.
111    idx: usize,
112    /// TCP port number.
113    port: u16,
114    /// Password.
115    pwd: String,
116    /// Username.
117    user: String,
118}
119
120impl ConnectionParams {
121    /// Generates a `PostgreSQL` connection [`String`] URL from the connection parameters.
122    pub fn db_url(&self) -> String {
123        format!("postgres://{}@{}:{}/{}", self.user, self.host, self.port, self.db)
124    }
125
126    /// Updates the user and password fields with the provided [`VaultCreds`].
127    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        // Use splitn to avoid Vec allocation; pgpass format is exactly 5 colon-separated fields
138        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
165/// Saves updated `PostgreSQL` `.pgpass` to a temporary file, replaces the original, and sets permissions.
166///
167/// # Errors
168/// - A filesystem operation (open/read/write/remove) fails.
169pub 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}