tempo_ext/installer/
manifest.rs1use crate::installer::{error::InstallerError, file_url_to_path, http_client};
4
5use serde::Deserialize;
6use std::{collections::HashMap, fs};
7
8#[derive(Debug, Clone, Deserialize)]
10pub(super) struct ReleaseManifest {
11 pub(super) version: String,
13 pub(super) description: Option<String>,
15 pub(super) binaries: HashMap<String, ReleaseBinary>,
17 pub(super) skill: Option<String>,
19 pub(super) skill_sha256: Option<String>,
21 pub(super) skill_signature: Option<String>,
23}
24
25#[derive(Debug, Clone, Deserialize)]
27pub(super) struct ReleaseBinary {
28 pub(super) url: String,
30 pub(super) sha256: String,
32 pub(super) signature: Option<String>,
34}
35
36pub(super) fn load_manifest(location: &str) -> Result<ReleaseManifest, InstallerError> {
38 let body = if location.starts_with("https://") {
39 http_client()?
40 .get(location)
41 .send()?
42 .error_for_status()?
43 .text()?
44 } else if let Some(path) = file_url_to_path(location) {
45 fs::read_to_string(path)
46 .map_err(|_| InstallerError::ReleaseManifestNotFound(location.to_string()))?
47 } else {
48 fs::read_to_string(location)
49 .map_err(|_| InstallerError::ReleaseManifestNotFound(location.to_string()))?
50 };
51
52 Ok(serde_json::from_str(&body)?)
53}
54
55pub(crate) fn is_allowed_manifest_url(location: &str) -> bool {
58 match url::Url::parse(location) {
59 Ok(url) => matches!(url.scheme(), "https" | "file"),
60 Err(url::ParseError::RelativeUrlWithoutBase) => true,
62 Err(_) => false,
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use std::io::Write;
70
71 #[test]
72 fn deserialize_minimal_manifest() {
73 let json = r#"{
74 "version": "1.0.0",
75 "binaries": {
76 "wallet": {
77 "url": "https://example.com/wallet",
78 "sha256": "abc123"
79 }
80 }
81 }"#;
82 let manifest: ReleaseManifest = serde_json::from_str(json).unwrap();
83 assert_eq!(manifest.version, "1.0.0");
84 assert_eq!(
85 manifest.binaries["wallet"].url,
86 "https://example.com/wallet"
87 );
88 assert!(manifest.binaries["wallet"].signature.is_none());
89 assert!(manifest.description.is_none());
90 assert!(manifest.skill.is_none());
91 assert!(manifest.skill_sha256.is_none());
92 assert!(manifest.skill_signature.is_none());
93 }
94
95 #[test]
96 fn deserialize_full_manifest() {
97 let json = r#"{
98 "version": "2.0.0",
99 "description": "Tempo wallet extension",
100 "binaries": {
101 "wallet": {
102 "url": "https://example.com/wallet",
103 "sha256": "abc123",
104 "signature": "sig456"
105 }
106 },
107 "skill": "https://example.com/skill.wasm",
108 "skill_sha256": "skillhash",
109 "skill_signature": "skillsig"
110 }"#;
111 let manifest: ReleaseManifest = serde_json::from_str(json).unwrap();
112 assert_eq!(manifest.version, "2.0.0");
113 assert_eq!(
114 manifest.description.as_deref(),
115 Some("Tempo wallet extension")
116 );
117 assert_eq!(manifest.binaries["wallet"].sha256, "abc123");
118 assert_eq!(
119 manifest.binaries["wallet"].signature.as_deref(),
120 Some("sig456")
121 );
122 assert_eq!(
123 manifest.skill.as_deref(),
124 Some("https://example.com/skill.wasm")
125 );
126 assert_eq!(manifest.skill_sha256.as_deref(), Some("skillhash"));
127 assert_eq!(manifest.skill_signature.as_deref(), Some("skillsig"));
128 }
129
130 #[test]
131 fn deserialize_missing_version_fails() {
132 let json = r#"{
133 "binaries": {
134 "wallet": {
135 "url": "https://example.com/wallet",
136 "sha256": "abc123"
137 }
138 }
139 }"#;
140 assert!(serde_json::from_str::<ReleaseManifest>(json).is_err());
141 }
142
143 #[test]
144 fn deserialize_missing_binary_sha256_fails() {
145 let json = r#"{
146 "version": "1.0.0",
147 "binaries": {
148 "wallet": {
149 "url": "https://example.com/wallet"
150 }
151 }
152 }"#;
153 assert!(serde_json::from_str::<ReleaseManifest>(json).is_err());
154 }
155
156 #[test]
157 fn load_manifest_from_file() {
158 let json = r#"{
159 "version": "3.0.0",
160 "binaries": {
161 "wallet": {
162 "url": "https://example.com/wallet",
163 "sha256": "abc123"
164 }
165 }
166 }"#;
167 let mut tmp = tempfile::NamedTempFile::new().unwrap();
168 tmp.write_all(json.as_bytes()).unwrap();
169 tmp.flush().unwrap();
170
171 let path = tmp.path().to_str().unwrap();
172 let manifest = load_manifest(path).unwrap();
173 assert_eq!(manifest.version, "3.0.0");
174 }
175
176 #[test]
177 fn load_manifest_missing_file() {
178 let result = load_manifest("./nonexistent-test-manifest-12345.json");
179 assert!(result.is_err());
180 let err = result.unwrap_err();
181 assert!(
182 matches!(err, InstallerError::ReleaseManifestNotFound(_)),
183 "expected ReleaseManifestNotFound, got: {err:?}"
184 );
185 }
186
187 #[test]
188 fn load_manifest_from_file_url() {
189 let json = r#"{
190 "version": "4.0.0",
191 "binaries": {}
192 }"#;
193 let mut tmp = tempfile::NamedTempFile::new().unwrap();
194 tmp.write_all(json.as_bytes()).unwrap();
195 tmp.flush().unwrap();
196
197 let url = format!("file://{}", tmp.path().display());
198 let manifest = load_manifest(&url).unwrap();
199 assert_eq!(manifest.version, "4.0.0");
200 }
201
202 #[test]
203 fn load_manifest_invalid_json() {
204 let mut tmp = tempfile::NamedTempFile::new().unwrap();
205 tmp.write_all(b"not json").unwrap();
206 tmp.flush().unwrap();
207
208 let result = load_manifest(tmp.path().to_str().unwrap());
209 assert!(matches!(result, Err(InstallerError::Json(_))));
210 }
211
212 #[test]
213 fn is_allowed_rejects_http() {
214 assert!(!is_allowed_manifest_url(
215 "http://insecure.example.com/manifest.json"
216 ));
217 }
218
219 #[test]
220 fn is_allowed_rejects_ftp() {
221 assert!(!is_allowed_manifest_url("ftp://example.com/manifest.json"));
222 }
223
224 #[test]
225 fn is_allowed_rejects_data_url() {
226 assert!(!is_allowed_manifest_url("data:text/plain,hello"));
227 }
228
229 #[test]
230 fn is_allowed_rejects_javascript_url() {
231 assert!(!is_allowed_manifest_url("javascript:alert(1)"));
232 }
233
234 #[test]
235 fn is_allowed_accepts_absolute_path() {
236 assert!(is_allowed_manifest_url("/tmp/manifest.json"));
237 }
238
239 #[test]
240 fn is_allowed_accepts_relative_path() {
241 assert!(is_allowed_manifest_url("./manifest.json"));
242 assert!(is_allowed_manifest_url("../manifest.json"));
243 }
244}