winbrew_app\operations\update/
patch.rs

1use anyhow::{Context, Result, bail};
2use rusqlite::Connection;
3use std::fs;
4use std::io::{Cursor, Read};
5use std::path::Path;
6use zstd::stream::read::Decoder;
7
8use crate::core::network::Client;
9
10use super::metadata::{build_catalog_metadata_from_connection, verify_catalog_hash};
11
12/// Applies one or more SQL patch files to an existing catalog database.
13///
14/// This is the incremental refresh path used when the update API returns a
15/// patch plan. The function works on a temporary copy of the current catalog,
16/// applies each patch URL in order, verifies the database integrity, rebuilds
17/// catalog metadata, and validates the final catalog hash before writing the
18/// updated metadata JSON.
19///
20/// # Workflow
21/// 1. Confirm that the source catalog already exists.
22/// 2. Copy the source catalog to the temporary patch working copy.
23/// 3. Open the working copy with foreign keys enabled and `DELETE` journaling.
24/// 4. Download and decompress each patch URL as zstd-compressed SQL.
25/// 5. Execute each patch sequentially against the working copy.
26/// 6. Run `PRAGMA integrity_check` to verify the patched database.
27/// 7. Rebuild metadata from the patched database state.
28/// 8. Verify the patched database hash matches the rebuilt metadata hash.
29/// 9. Write the refreshed metadata JSON to `metadata_temp_path`.
30///
31/// # Errors
32/// Returns an error when the source catalog is missing, when the working copy
33/// cannot be created or opened, when any patch download or SQL execution
34/// fails, when the integrity check fails, when hash verification fails, or
35/// when metadata serialization or writing fails.
36///
37/// # Safety
38/// The source `catalog_path` is never modified directly. All patching happens
39/// on the temporary working copy, which the caller finalizes separately.
40pub(super) fn apply_catalog_patch_release(
41    client: &Client,
42    catalog_path: &Path,
43    catalog_temp_path: &Path,
44    metadata_temp_path: &Path,
45    patch_urls: &[String],
46    expected_hash: &str,
47    previous_hash: &str,
48) -> Result<()> {
49    if !catalog_path.exists() {
50        bail!("cannot apply catalog patch without an existing catalog database");
51    }
52
53    fs::copy(catalog_path, catalog_temp_path)
54        .context("failed to back up local catalog database for patch update")?;
55
56    let connection =
57        Connection::open(catalog_temp_path).context("failed to open catalog patch working copy")?;
58    connection
59        .pragma_update(None, "journal_mode", "DELETE")
60        .context("failed to set catalog patch journal mode")?;
61    connection
62        .execute_batch("PRAGMA foreign_keys = ON;")
63        .context("failed to enable foreign keys for catalog patch update")?;
64
65    for patch_url in patch_urls {
66        let patch_sql = download_catalog_patch_sql(client, patch_url)?;
67        connection
68            .execute_batch(&patch_sql)
69            .with_context(|| format!("failed to apply catalog patch from {patch_url}"))?;
70    }
71
72    let integrity_check: String = connection
73        .query_row("PRAGMA integrity_check", [], |row| row.get(0))
74        .context("failed to run catalog integrity check after patch application")?;
75
76    if integrity_check.trim() != "ok" {
77        bail!("catalog integrity check failed after patch application: {integrity_check}");
78    }
79
80    let metadata =
81        build_catalog_metadata_from_connection(&connection, expected_hash, previous_hash)?;
82
83    drop(connection);
84
85    verify_catalog_hash(catalog_temp_path, &metadata.current_hash)?;
86
87    fs::write(
88        metadata_temp_path,
89        serde_json::to_vec_pretty(&metadata)
90            .context("failed to serialize patched catalog metadata")?,
91    )
92    .context("failed to write patched catalog metadata")?;
93
94    Ok(())
95}
96
97/// Downloads and decompresses a single zstd-compressed catalog patch SQL file.
98///
99/// The patch payload is read fully into memory, decompressed, and returned as
100/// UTF-8 SQL text ready for execution against the working copy database.
101///
102/// # Errors
103/// Returns an error when the HTTP request fails, when the server returns a
104/// non-success status, when the response body cannot be read, when the payload
105/// cannot be decompressed, or when the SQL text cannot be decoded.
106fn download_catalog_patch_sql(client: &Client, patch_url: &str) -> Result<String> {
107    let response = client
108        .get(patch_url.to_string())
109        .send()
110        .with_context(|| format!("failed to send catalog patch request to {patch_url}"))?;
111    let response = response
112        .error_for_status()
113        .with_context(|| format!("catalog patch request failed for {patch_url}"))?;
114
115    let patch_bytes = response
116        .bytes()
117        .with_context(|| format!("failed to read catalog patch response from {patch_url}"))?;
118
119    let mut decoder = Decoder::new(Cursor::new(patch_bytes))
120        .context("failed to decompress catalog patch payload")?;
121    let mut patch_sql = String::new();
122    decoder
123        .read_to_string(&mut patch_sql)
124        .context("failed to decode catalog patch SQL")?;
125
126    Ok(patch_sql)
127}