summaryrefslogtreecommitdiffstats
path: root/src/launcher/assets.rs
blob: 7ad368e824f0f72e163d07ca7f47a4e24dd5606c (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
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::TryStreamExt;
use log::{debug, info, warn};
use sha1_smol::Sha1;
use tokio::{fs, io};
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)
}

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}")
        }
    }
}

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
        })
    }

    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) = index.id.as_ref().map(|s| s.as_str()).or(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();
            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)
    }

    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.iter() {
            let hex_digest = object.hash.to_string();
            let path = [objects_path.as_ref(), OsStr::new(&hex_digest[..2]), OsStr::new(&hex_digest)].iter().collect::<PathBuf>();

            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 mut multi = MultiDownloader::new(downloads);
            multi.perform().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).await.map_err(|e| AssetError::AssetVerifyError(e))?;
        }

        Ok(())
    }
}

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");
    }
}