chug_cli/
bottles.rs

1use std::{collections::BTreeMap, fs, os::unix, path::PathBuf};
2
3use anyhow::Context;
4use data_encoding::HEXLOWER;
5use flate2::read::GzDecoder;
6use reqwest::blocking::Response;
7use serde::Deserialize;
8
9use crate::{
10    cache::http_client,
11    db::models::{DownloadedBottle, LinkedFile},
12    dirs,
13    extract::{extract, validate::Validate},
14    formulae::Formula,
15};
16
17#[derive(Debug, Deserialize)]
18pub struct Bottles {
19    pub stable: Bottle,
20}
21
22#[derive(Debug, Deserialize)]
23pub struct Bottle {
24    pub files: BTreeMap<String, FileMetadata>,
25}
26
27#[derive(Debug, Deserialize)]
28pub struct FileMetadata {
29    pub url: String,
30    pub sha256: String,
31}
32
33impl Formula {
34    pub fn download_bottle(&self) -> anyhow::Result<DownloadedBottle> {
35        if let Some(bottle) = DownloadedBottle::get(&self.name, &self.versions.stable)? {
36            return Ok(bottle);
37        }
38
39        println!("Dowloading {} {}...", self.name, self.versions.stable);
40        let result = self.download_bottle_inner();
41
42        if result.is_err() {
43            if let Ok(Some(path)) = self.bottle_path() {
44                let _ = fs::remove_dir_all(&path);
45                if let Some(parent) = path.parent() {
46                    let _ = fs::remove_dir(parent);
47                }
48            }
49        }
50
51        result.with_context(|| format!("Downloading {} {}", self.name, self.versions.stable))
52    }
53
54    /// Expects the bottle to not already be downloaded and will not clean up if
55    /// the download fails.
56    fn download_bottle_inner(&self) -> anyhow::Result<DownloadedBottle> {
57        let file_metadata = self.bottle.stable.current_target()?;
58
59        let mut raw_data = file_metadata
60            .fetch()
61            .context("Failed to fetch bottle archive")?;
62        let unzip = GzDecoder::new(&mut raw_data);
63        let path = extract(unzip, self)?;
64
65        raw_data
66            .validate()
67            .context("Failed to validate bottle download")?;
68
69        let bottle = DownloadedBottle::create(&self.name, &self.versions.stable, &path)?;
70
71        Ok(bottle)
72    }
73
74    fn bottle_path(&self) -> anyhow::Result<Option<PathBuf>> {
75        let name = self.name.as_str();
76        let version = self.versions.stable.as_str();
77
78        let bottles_path = dirs::bottles_dir()?;
79        let parent_path = bottles_path.join(name);
80        if !parent_path.exists() {
81            return Ok(None);
82        }
83
84        let path = parent_path.join(version);
85        if path.exists() {
86            return Ok(Some(path));
87        }
88
89        // Sometimes the bottle directory has "_1" appended to the version
90        for child in fs::read_dir(parent_path)? {
91            let child = child?;
92            let file_name = child.file_name();
93            let file_name = file_name.to_str().context("Invalid file name")?;
94            if file_name.starts_with(version) && file_name[version.len()..].starts_with('_') {
95                return Ok(Some(child.path()));
96            }
97        }
98
99        Ok(None)
100    }
101}
102
103impl Bottle {
104    pub fn current_target(&self) -> anyhow::Result<&FileMetadata> {
105        let target = crate::target::Target::current_str()?;
106        if let Some(file) = self.files.get(target) {
107            Ok(file)
108        } else if let Some(file) = self.files.get("all") {
109            Ok(file)
110        } else {
111            anyhow::bail!("No bottle for target: {target}");
112        }
113    }
114}
115
116impl FileMetadata {
117    pub fn fetch(&self) -> anyhow::Result<Validate<Response>> {
118        let response = http_client()
119            .get(&self.url)
120            // https://github.com/orgs/community/discussions/35172#discussioncomment-8738476
121            .bearer_auth("QQ==")
122            .send()?;
123        anyhow::ensure!(
124            response.status().is_success(),
125            "Failed to fetch bottle. Response code was: {}",
126            response.status(),
127        );
128
129        let sha256 = HEXLOWER.decode(self.sha256.as_bytes())?;
130        let reader = Validate::new(response, sha256);
131
132        Ok(reader)
133    }
134}
135
136impl DownloadedBottle {
137    pub fn link(&self) -> anyhow::Result<()> {
138        println!("Linking {} {}...", self.name(), self.version());
139
140        let opt_dir = dirs::opt_dir()?.join(self.name());
141        if opt_dir.exists() {
142            fs::remove_file(&opt_dir)?;
143        }
144        unix::fs::symlink(self.path(), &opt_dir)?;
145
146        let bin_dir = dirs::bin_dir()?;
147        let bottle_bin_dir = PathBuf::from(self.path()).join("bin");
148
149        if bottle_bin_dir.exists() {
150            for entry in fs::read_dir(bottle_bin_dir)? {
151                let entry = entry?;
152                let entry_path = entry.path();
153                let entry_name = entry.file_name();
154                let dest = bin_dir.join(entry_name);
155
156                if dest.exists() {
157                    let Ok(existing_path) = fs::read_link(&dest) else {
158                        continue;
159                    };
160                    if !existing_path.starts_with(dirs::bottles_dir()?) {
161                        continue;
162                    }
163                    fs::remove_file(&dest)?;
164                }
165
166                LinkedFile::create(&dest, self)?;
167
168                unix::fs::symlink(&entry_path, &dest)?;
169            }
170        }
171
172        // Replicate some important post-install scripts
173        if self.name() == "ca-certificates" {
174            // See also: https://github.com/Homebrew/homebrew-core/blob/39c62e85e1aafaaf438741b7d0d37b35bdbb9b32/Formula/c/ca-certificates.rb#L123
175            let source = self.path().join("share/ca-certificates/cacert.pem");
176            let destination_dir = dirs::etc_dir()?.join("ca-certificates");
177            let destination = destination_dir.join("cert.pem");
178            fs::create_dir_all(&destination_dir)?;
179            fs::copy(&source, &destination)?;
180        }
181
182        Ok(())
183    }
184
185    pub fn unlink(&self) -> anyhow::Result<()> {
186        println!("Unlinking {} {}...", self.name(), self.version());
187
188        let opt_dir = dirs::opt_dir()?.join(self.name());
189        if fs::read_link(&opt_dir)? == self.path() {
190            fs::remove_file(&opt_dir)?;
191        }
192
193        let bottle_dir = self.path();
194        for linked_file in self.linked_files()? {
195            if let Ok(linked_path) = fs::read_link(linked_file.path()) {
196                if linked_path.starts_with(bottle_dir) {
197                    fs::remove_file(linked_file.path())?;
198                }
199            }
200
201            linked_file.delete()?;
202        }
203
204        Ok(())
205    }
206
207    pub fn remove(&self) -> anyhow::Result<()> {
208        println!("Deleting {} {}...", self.name(), self.version());
209
210        let _ = fs::remove_dir_all(self.path());
211        if let Some(parent) = self.path().parent() {
212            let _ = fs::remove_dir(parent);
213        }
214
215        self.delete()?;
216
217        Ok(())
218    }
219}