Skip to main content

tempo_ext/installer/
manifest.rs

1//! Release manifest fetching and validation.
2
3use crate::installer::{error::InstallerError, file_url_to_path, http_client};
4
5use serde::Deserialize;
6use std::{collections::HashMap, fs};
7
8/// Deserialized release manifest describing available binaries for an extension.
9#[derive(Debug, Clone, Deserialize)]
10pub(super) struct ReleaseManifest {
11    /// Semver version string (e.g. `"1.0.0"` or `"v1.0.0"`).
12    pub(super) version: String,
13    /// Optional short description of the extension.
14    pub(super) description: Option<String>,
15    /// Per-platform binary entries, keyed by platform name (e.g. `"tempo-wallet-darwin-arm64"`).
16    pub(super) binaries: HashMap<String, ReleaseBinary>,
17    /// Optional URL for the extension's agent skill file.
18    pub(super) skill: Option<String>,
19    /// Expected SHA-256 hex digest of the skill file.
20    pub(super) skill_sha256: Option<String>,
21    /// Base64-encoded minisign signature of the skill file.
22    pub(super) skill_signature: Option<String>,
23}
24
25/// A single platform binary entry within a release manifest.
26#[derive(Debug, Clone, Deserialize)]
27pub(super) struct ReleaseBinary {
28    /// Download URL (`https://` or `file://`).
29    pub(super) url: String,
30    /// Expected SHA-256 hex digest of the binary.
31    pub(super) sha256: String,
32    /// Base64-encoded minisign signature of the binary.
33    pub(super) signature: Option<String>,
34}
35
36/// Fetches and deserializes a release manifest from a URL or local path.
37pub(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
55/// Returns `true` if `location` is an HTTPS URL, a `file://` URL, or a local
56/// filesystem path (i.e. not a URL with some other scheme).
57pub(crate) fn is_allowed_manifest_url(location: &str) -> bool {
58    match url::Url::parse(location) {
59        Ok(url) => matches!(url.scheme(), "https" | "file"),
60        // Not a URL at all (e.g. `./manifest.json`) — treat as local path.
61        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}