Skip to main content

pg_embed/
pg_fetch.rs

1//! Download PostgreSQL binaries from Maven Central.
2//!
3//! The [`PgFetchSettings`] struct describes *which* binary to fetch (OS,
4//! architecture, version) and exposes [`PgFetchSettings::fetch_postgres`] to
5//! perform the actual HTTP download.  The downloaded bytes are a JAR file
6//! (ZIP) that is later unpacked by [`crate::pg_unpack`].
7
8use std::path::Path;
9
10use tokio::io::AsyncWriteExt;
11
12use crate::pg_enums::{Architecture, OperationSystem};
13use crate::pg_errors::Error;
14use crate::pg_errors::Result;
15
16/// A PostgreSQL version string in `MAJOR.MINOR.PATCH` form.
17///
18/// Use one of the provided constants ([`PG_V17`], [`PG_V16`], …) rather than
19/// constructing this type directly.
20#[derive(Debug, Copy, Clone)]
21pub struct PostgresVersion(pub &'static str);
22
23
24/// PostgreSQL 18.2.0 binaries.
25pub const PG_V18: PostgresVersion = PostgresVersion("18.2.0");
26/// PostgreSQL 17.8.0 binaries.
27pub const PG_V17: PostgresVersion = PostgresVersion("17.8.0");
28/// PostgreSQL 16.12.0 binaries.
29pub const PG_V16: PostgresVersion = PostgresVersion("16.12.0");
30/// PostgreSQL 15.16.0 binaries.
31pub const PG_V15: PostgresVersion = PostgresVersion("15.16.0");
32/// PostgreSQL 14.21.0 binaries.
33pub const PG_V14: PostgresVersion = PostgresVersion("14.21.0");
34/// PostgreSQL 13.23.0 binaries.
35pub const PG_V13: PostgresVersion = PostgresVersion("13.23.0");
36/// PostgreSQL 12.22.0 binaries.
37pub const PG_V12: PostgresVersion = PostgresVersion("12.22.0");
38/// PostgreSQL 11.22.1 binaries.
39pub const PG_V11: PostgresVersion = PostgresVersion("11.22.1");
40/// PostgreSQL 10.23.0 binaries.
41pub const PG_V10: PostgresVersion = PostgresVersion("10.23.0");
42
43/// Settings that determine which PostgreSQL binary package to download.
44///
45/// Construct with [`Default::default`] and override individual fields as
46/// needed:
47///
48/// ```rust
49/// use pg_embed::pg_fetch::{PgFetchSettings, PG_V17};
50///
51/// let settings = PgFetchSettings {
52///     version: PG_V17,
53///     ..Default::default()
54/// };
55/// ```
56///
57/// The default target OS and architecture are detected at compile time via
58/// `#[cfg(target_os)]` / `#[cfg(target_arch)]`.
59#[derive(Debug, Clone)]
60pub struct PgFetchSettings {
61    /// Base URL of the Maven repository hosting the binaries.
62    ///
63    /// Defaults to `https://repo1.maven.org`.  Override to point at a local
64    /// mirror or artifact proxy.
65    pub host: String,
66    /// Target operating system.  Determines the package classifier used in the
67    /// Maven artifact name.
68    pub operating_system: OperationSystem,
69    /// Target CPU architecture.  Combined with [`Self::operating_system`] to
70    /// form the Maven classifier.
71    pub architecture: Architecture,
72    /// PostgreSQL version to download.  Use one of the `PG_Vxx` constants.
73    pub version: PostgresVersion,
74}
75
76impl Default for PgFetchSettings {
77    fn default() -> Self {
78        PgFetchSettings {
79            host: "https://repo1.maven.org".to_string(),
80            operating_system: OperationSystem::default(),
81            architecture: Architecture::default(),
82            version: PG_V18,
83        }
84    }
85}
86
87impl PgFetchSettings {
88    /// Returns the Maven classifier string for this OS/architecture combination.
89    ///
90    /// The classifier is the middle segment of the artifact name, e.g.
91    /// `linux-amd64` or `darwin-amd64`.  For Alpine Linux the architecture
92    /// gets an `-alpine` suffix instead of a separate OS segment.
93    ///
94    /// # Returns
95    ///
96    /// A `String` of the form `{os}-{arch}` (or `{os}-{arch}-alpine` for
97    /// [`OperationSystem::AlpineLinux`]).
98    pub fn platform(&self) -> String {
99        let os = self.operating_system.to_string();
100        let arch = if self.operating_system == OperationSystem::AlpineLinux {
101            format!("{}-alpine", self.architecture)
102        } else {
103            self.architecture.to_string()
104        };
105        format!("{}-{}", os, arch)
106    }
107
108    /// Initiates an HTTP GET for the Maven artifact and checks the response status.
109    ///
110    /// Constructs the full artifact URL from [`Self::host`], [`Self::platform`],
111    /// and [`Self::version`] and issues the request.  The caller streams the
112    /// response body.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`Error::DownloadFailure`] if the request fails or the server
117    /// returns a non-2xx status.
118    async fn start_download(&self) -> Result<reqwest::Response> {
119        let platform = self.platform();
120        let version = self.version.0;
121        let download_url = format!(
122            "{}/maven2/io/zonky/test/postgres/embedded-postgres-binaries-{}/{}/embedded-postgres-binaries-{}-{}.jar",
123            &self.host,
124            &platform,
125            version,
126            &platform,
127            version
128        );
129
130        let response = reqwest::get(download_url)
131            .await
132            .map_err(|e| Error::DownloadFailure(e.to_string()))?;
133
134        let status = response.status();
135        if !status.is_success() {
136            return Err(Error::DownloadFailure(format!(
137                "HTTP {status} fetching PostgreSQL {version} for platform '{platform}'. \
138                 This version may not be available for the current OS/architecture. \
139                 Note: darwin-arm64v8 (Apple Silicon) only has binaries for PG 14 and newer.",
140            )));
141        }
142
143        Ok(response)
144    }
145
146    /// Downloads the PostgreSQL binaries JAR from Maven Central.
147    ///
148    /// Constructs the full artifact URL from [`Self::host`], [`Self::platform`],
149    /// and [`Self::version`], performs an HTTP GET, and returns the raw bytes of
150    /// the JAR file.  The caller is responsible for persisting and unpacking the
151    /// data (see [`crate::pg_unpack::unpack_postgres`]).
152    ///
153    /// Prefer [`Self::fetch_postgres_to_file`] when the bytes will be written
154    /// to disk — it streams directly without buffering the entire archive in
155    /// memory.
156    ///
157    /// # Returns
158    ///
159    /// The raw bytes of the downloaded JAR on success.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`Error::DownloadFailure`] if the HTTP request fails or the
164    /// server returns a non-2xx status (e.g. 404 when the requested
165    /// PostgreSQL version is not available for the current platform).
166    /// Returns [`Error::ConversionFailure`] if reading the response body fails.
167    pub async fn fetch_postgres(&self) -> Result<Vec<u8>> {
168        let response = self.start_download().await?;
169        let content = response
170            .bytes()
171            .await
172            .map_err(|e| Error::ConversionFailure(e.to_string()))?;
173
174        log::debug!("Downloaded {} bytes", content.len());
175        log::trace!(
176            "First 1024 bytes: {:?}",
177            &String::from_utf8_lossy(&content[..content.len().min(1024)])
178        );
179
180        Ok(content.to_vec())
181    }
182
183    /// Downloads the PostgreSQL binaries JAR and streams it directly to `zip_path`.
184    ///
185    /// Unlike [`Self::fetch_postgres`], this method never loads the full archive
186    /// into memory — each HTTP chunk is written to the file as it arrives.
187    /// Use this method when you intend to write the JAR to disk (as
188    /// [`crate::pg_access::PgAccess`] does), since it avoids a 100–200 MB
189    /// in-memory buffer.
190    ///
191    /// # Arguments
192    ///
193    /// * `zip_path` — Destination file path for the downloaded JAR.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`Error::DownloadFailure`] if the HTTP request fails or the
198    /// server returns a non-2xx status.
199    /// Returns [`Error::WriteFileError`] if the file cannot be created or a
200    /// chunk cannot be written.
201    /// Returns [`Error::ConversionFailure`] if reading a response chunk fails.
202    pub(crate) async fn fetch_postgres_to_file(&self, zip_path: &Path) -> Result<()> {
203        let mut response = self.start_download().await?;
204        let mut file = tokio::fs::File::create(zip_path)
205            .await
206            .map_err(|e| Error::WriteFileError(e.to_string()))?;
207        let mut total = 0u64;
208        while let Some(chunk) = response
209            .chunk()
210            .await
211            .map_err(|e| Error::ConversionFailure(e.to_string()))?
212        {
213            file.write_all(&chunk)
214                .await
215                .map_err(|e| Error::WriteFileError(e.to_string()))?;
216            total += chunk.len() as u64;
217        }
218        file.sync_data()
219            .await
220            .map_err(|e| Error::WriteFileError(e.to_string()))?;
221        log::debug!("Downloaded and wrote {} bytes to disk", total);
222        Ok(())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230#[tokio::test]
231    async fn fetch_postgres() -> Result<()> {
232        let pg_settings = PgFetchSettings::default();
233        let content = pg_settings.fetch_postgres().await?;
234        assert!(!content.is_empty(), "downloaded content should not be empty");
235        Ok(())
236    }
237
238    /// Verify that every bundled `PG_Vxx` constant can actually be downloaded
239    /// for the compile-time platform.
240    ///
241    /// Each version is fetched in full and the byte count is printed.  This
242    /// test is marked `#[ignore]` because it downloads several hundred MB and
243    /// should only be run explicitly:
244    ///
245    /// ```text
246    /// cargo test --features rt_tokio -- --ignored all_versions_downloadable --nocapture
247    /// ```
248    ///
249    /// Maven Central returns a tiny HTML error page with HTTP 200 for missing
250    /// artifacts, so a 1 MB minimum is enforced to detect that case.
251    ///
252    /// **Platform notes:**
253    /// - `darwin-arm64v8` (Apple Silicon): binaries exist from PG 14 onward.
254    ///   PG 10–13 are excluded on that target via `#[cfg]`.
255    /// - All other platforms: all constants are tested.
256    #[tokio::test]
257    #[ignore]
258    async fn all_versions_downloadable() -> Result<()> {
259        // PG 10–13 were released before zonky added darwin-arm64v8 support.
260        #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
261        let versions: &[(&str, PostgresVersion)] = &[
262            ("PG_V10", PG_V10),
263            ("PG_V11", PG_V11),
264            ("PG_V12", PG_V12),
265            ("PG_V13", PG_V13),
266            ("PG_V14", PG_V14),
267            ("PG_V15", PG_V15),
268            ("PG_V16", PG_V16),
269            ("PG_V17", PG_V17),
270            ("PG_V18", PG_V18),
271        ];
272        #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
273        let versions: &[(&str, PostgresVersion)] = &[
274            ("PG_V14", PG_V14),
275            ("PG_V15", PG_V15),
276            ("PG_V16", PG_V16),
277            ("PG_V17", PG_V17),
278            ("PG_V18", PG_V18),
279        ];
280
281        for (name, version) in versions {
282            let settings = PgFetchSettings {
283                version: *version,
284                ..Default::default()
285            };
286            let bytes = settings.fetch_postgres().await?;
287            println!("{name} ({}): {} bytes", version.0, bytes.len());
288            assert!(
289                bytes.len() > 1_000_000,
290                "{name} ({}) returned only {} bytes — likely missing for platform '{}'",
291                version.0,
292                bytes.len(),
293                settings.platform(),
294            );
295        }
296        Ok(())
297    }
298}