summaryrefslogtreecommitdiffstats
path: root/src/launcher/assets.rs
blob: dacd01d3c0cb936a0c597c50d8bf5768bb052dc2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
use std::error::Error;
use std::ffi::OsStr;
use std::fmt::{Display, Formatter};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::path::Component::Normal;
use futures::{stream, TryStreamExt};
use log::{debug, info, warn};
use reqwest::Client;
use sha1_smol::Sha1;
use tokio::{fs, io};
use tokio::fs::File;
use crate::assets::{Asset, AssetIndex};
use crate::launcher::download::{MultiDownloader, VerifiedDownload};
use crate::util;
use crate::util::{FileVerifyError, IntegrityError};
use crate::version::DownloadInfo;

const INDEX_PATH: &'static str = "indexes";
const OBJECT_PATH: &'static str = "objects";

pub struct AssetRepository {
    online: bool,
    home: PathBuf
}

#[derive(Debug)]
pub enum AssetError {
    InvalidId(Option<String>),
    IO { what: &'static str, error: io::Error },
    IndexParse(serde_json::Error),
    Offline,
    MissingURL,
    DownloadIndex(reqwest::Error),
    Integrity(IntegrityError),
    AssetObjectDownload,
    AssetVerifyError(FileVerifyError),
    AssetNameError(&'static str)
}

impl Display for AssetError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            AssetError::InvalidId(None) => f.write_str("missing asset index id"),
            AssetError::InvalidId(Some(id)) => write!(f, "invalid asset index id: {}", id),
            AssetError::IO { what, error } => write!(f, "i/o error ({}): {}", what, error),
            AssetError::IndexParse(error) => write!(f, "error parsing asset index: {}", error),
            AssetError::Offline => f.write_str("cannot download asset index while offline"),
            AssetError::MissingURL => f.write_str("missing asset index URL"),
            AssetError::DownloadIndex(e) => write!(f, "error downloading asset index: {}", e),
            AssetError::Integrity(e) => write!(f, "asset index integrity error: {}", e),
            AssetError::AssetObjectDownload => f.write_str("asset object download failed"),
            AssetError::AssetVerifyError(e) => write!(f, "error verifying asset object: {e}"),
            AssetError::AssetNameError(e) => write!(f, "invalid asset name: {e}")
        }
    }
}

impl Error for AssetError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            AssetError::IO { error, .. } => Some(error),
            AssetError::IndexParse(error) => Some(error),
            AssetError::DownloadIndex(error) => Some(error),
            AssetError::Integrity(error) => Some(error),
            AssetError::AssetVerifyError(error) => Some(error),
            _ => None
        }
    }
}

impl From<(&'static str, io::Error)> for AssetError {
    fn from((what, error): (&'static str, io::Error)) -> Self {
        AssetError::IO { what, error }
    }
}

impl AssetRepository {
    pub async fn new(online: bool, home: impl AsRef<Path>) -> Result<AssetRepository, io::Error> {
        let home = home.as_ref().to_owned();

        match fs::create_dir_all(&home).await {
            Ok(_) => (),
            Err(e) => match e.kind() {
                ErrorKind::AlreadyExists => (),
                _ => return Err(e)
            }
        };

        Ok(AssetRepository {
            online,
            home
        })
    }
    
    pub fn get_home(&self) -> &Path {
        self.home.as_path()
    }

    fn get_index_path(&self, id: &str) -> Result<PathBuf, AssetError> {
        let mut indexes_path: PathBuf = [self.home.as_ref(), OsStr::new(INDEX_PATH)].iter().collect();
        let Some(Normal(path)) = Path::new(id).components().last() else {
            return Err(AssetError::InvalidId(Some(id.into())));
        };

        let path = path.to_str().ok_or(AssetError::InvalidId(Some(path.to_string_lossy().into())))?;

        // FIXME: change this once "add_extension" is stabilized
        indexes_path.push(format!("{}.json", path));

        Ok(indexes_path)
    }

    pub async fn load_index(&self, index: &DownloadInfo, id: Option<&str>) -> Result<AssetIndex, AssetError> {
        let Some(id) = id else {
            return Err(AssetError::InvalidId(None));
        };

        info!("Loading asset index {}", id);

        let path = self.get_index_path(id)?;
        debug!("Asset index {} is located at {}", id, path.display());

        match util::verify_file(&path, index.size, index.sha1).await {
            Ok(_) => {
                debug!("Asset index {} verified on disk. Loading it.", id);
                let idx_data = fs::read_to_string(&path).await.map_err(|e| AssetError::IO {
                    what: "reading asset index",
                    error: e
                })?;

                return Ok(serde_json::from_str(&idx_data).map_err(|e| AssetError::IndexParse(e))?);
            },
            Err(FileVerifyError::Open(_, e)) => match e.kind() {
                ErrorKind::NotFound => {
                    debug!("Asset index {} not found on disk. Must download it.", id);
                },
                _ => return Err(("opening asset index", e).into())
            },
            Err(FileVerifyError::Integrity(_, e)) => {
                info!("Asset index {} has mismatched integrity: {}, must download it.", id, e);
                let _ = fs::remove_file(&path).await.map_err(|e| warn!("Error deleting modified index {}: {} (ignored)", id, e));
            },
            Err(FileVerifyError::Read(_, e)) => return Err(("reading asset index", e).into())
        }

        if !self.online {
            warn!("Must download asset index {}, but the launcher is in offline mode. Please try again in online mode.", id);
            return Err(AssetError::Offline);
        }

        let Some(url) = index.url.as_ref() else {
            return Err(AssetError::MissingURL);
        };

        debug!("Downloading asset index {} from {}", id, url);

        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).await.map_err(|e| AssetError::IO {
                what: "creating asset index folder",
                error: e
            })?;
        }

        let idx_text = reqwest::get(url).await
            .map_err(|e| AssetError::DownloadIndex(e))?
            .text().await
            .map_err(|e| AssetError::DownloadIndex(e))?;

        if index.size.is_some_and(|s| s != idx_text.len()) {
            return Err(AssetError::Integrity(IntegrityError::SizeMismatch {
                expect: index.size.unwrap(),
                actual: idx_text.len()
            }));
        }

        if let Some(expect) = index.sha1 {
            let actual = Sha1::from(&idx_text).digest();

            if actual != expect {
                return Err(AssetError::Integrity(IntegrityError::Sha1Mismatch { expect, actual }));
            }
        }

        debug!("Saving downloaded asset index to {}", path.display());
        fs::write(&path, &idx_text).await.map_err(|e| AssetError::IO {
            what: "writing asset index",
            error: e
        })?;

        Ok(serde_json::from_str(&idx_text).map_err(|e| AssetError::IndexParse(e))?)
    }

    fn get_object_url(obj: &Asset) -> String {
        format!("{}{:02x}/{}", super::constants::URL_RESOURCE_BASE, obj.hash.bytes()[0], obj.hash)
    }

    pub fn get_object_path(&self, obj: &Asset) -> PathBuf {
        let hex_digest = obj.hash.to_string();
        [self.home.as_ref(), OsStr::new(OBJECT_PATH), OsStr::new(&hex_digest[..2]), OsStr::new(&hex_digest)].iter().collect()
    }

    async fn ensure_dir(path: impl AsRef<Path>) -> Result<(), io::Error> {
        match fs::create_dir(path).await {
            Ok(_) => Ok(()),
            Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()),
            Err(e) => Err(e)
        }
    }

    pub async fn ensure_assets(&self, index: &AssetIndex) -> Result<(), AssetError> {
        let mut downloads = Vec::new();
        let objects_path = [self.home.as_ref(), OsStr::new(OBJECT_PATH)].iter().collect::<PathBuf>();

        Self::ensure_dir(&objects_path).await.map_err(|e| AssetError::IO {
            what: "creating objects directory",
            error: e
        })?;

        for object in index.objects.values() {
            let path = self.get_object_path(object);

            Self::ensure_dir(path.parent().unwrap()).await.map_err(|error| AssetError::IO { error, what: "creating directory for object" })?;

            downloads.push(VerifiedDownload::new(&Self::get_object_url(object), &path, Some(object.size), Some(object.hash)));
        }

        if self.online {
            info!("Downloading {} asset objects...", downloads.len());
            let client = Client::new();
            MultiDownloader::with_concurrent(downloads.iter_mut(), 32).perform(&client).await
                .inspect_err(|e| warn!("asset download failed: {e}"))
                .try_fold((), |_, _| async {Ok(())})
                .await
                .map_err(|_| AssetError::AssetObjectDownload)?;
        } else {
            info!("Verifying {} asset objects...", downloads.len());
            super::download::verify_files(downloads.iter_mut()).await.map_err(|e| AssetError::AssetVerifyError(e))?;
        }

        Ok(())
    }

    pub async fn reconstruct_assets(&self, index: &AssetIndex, instance_path: &Path, index_id: Option<&str>) -> Result<Option<PathBuf>, AssetError> {
        let target_path: PathBuf;
        let Some(index_id) = index_id else {
            return Err(AssetError::InvalidId(None));
        };

        if index.virtual_assets {
            target_path = [self.home.as_ref(), OsStr::new("virtual"), OsStr::new(index_id)].iter().collect();
        } else if index.map_to_resources {
            target_path = [instance_path, Path::new("resources")].iter().collect();
        } else {
            info!("This asset index does not request a virtual assets folder. Nothing to be done.");
            return Ok(None);
        }

        info!("Reconstructing virtual assets for {}", index_id);

        fs::create_dir_all(&target_path).await.map_err(|e| AssetError::from(("creating virtual assets directory", e)))?;

        stream::iter(index.objects.values()
            .map(|object| {
                let obj_path = util::check_path(object.name.as_str()).map_err(AssetError::AssetNameError)?;
                let obj_path = target_path.join(obj_path);

                Ok((object, obj_path))
            }))
            .try_filter_map(|(object, obj_path)| async move {
                match util::verify_file(&obj_path, Some(object.size), Some(object.hash)).await {
                    Ok(_) => {
                        debug!("Not copying asset {}, integrity matches.", object.name);
                        Ok(None)
                    }
                    Err(FileVerifyError::Open(_, e)) if e.kind() == ErrorKind::NotFound => {
                        debug!("Copying asset {}, file does not exist.", object.name);
                        Ok(Some((object, obj_path)))
                    },
                    Err(FileVerifyError::Integrity(_, e)) => {
                        debug!("Copying asset {}: {}", object.name, e);
                        Ok(Some((object, obj_path)))
                    },
                    Err(e) => {
                        debug!("Error while reconstructing assets: {e}");
                        Err(AssetError::AssetVerifyError(e))
                    }
                }
            })
            .try_for_each_concurrent(32, |(object, obj_path)| async move {
                if let Some(parent) = obj_path.parent() {
                    fs::create_dir_all(parent).await
                        .inspect_err(|e| debug!("Error creating directory for asset object {}: {e}", object.name))
                        .map_err(|e| AssetError::from(("creating asset object directory", e)))?;
                }

                let mut fromfile = File::open(self.get_object_path(object)).await
                    .map_err(|e| AssetError::from(("opening source object", e)))?;
                let mut tofile = File::create(&obj_path).await
                    .map_err(|e| AssetError::from(("creating target object", e)))?;

                io::copy(&mut fromfile, &mut tofile).await.map_err(|e| AssetError::from(("copying asset object", e)))?;
                debug!("Copied object {} to {}.", object.name, obj_path.display());
                Ok(())
            }).await.map(|_| Some(target_path))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_it() {
        let digest_str = "ad1115931887a73cd596300f2c93f84adf39521d";
        assert_eq!(AssetRepository::get_object_url(&Asset {
            name: String::from("test"),
            hash: digest_str.parse().unwrap(),
            size: 0usize
        }), "https://resources.download.minecraft.net/ad/ad1115931887a73cd596300f2c93f84adf39521d");
    }
}