chug_cli/
bottles.rs

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