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}