1use std::fmt::Write as _;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ReleaseNotes {
24 Link(String),
26 Html(String),
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DeltaEnclosure {
34 pub delta_from: String,
36 pub url: String,
38 pub length: u64,
40 pub ed_signature: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AppcastEntry {
47 pub title: String,
49 pub short_version: String,
51 pub build: String,
54 pub enclosure_url: String,
56 pub length: u64,
58 pub mime_type: String,
60 pub ed_signature: Option<String>,
63 pub min_system_version: Option<String>,
65 pub channel: Option<String>,
67 pub release_notes: Option<ReleaseNotes>,
69 pub pub_date: Option<String>,
71 pub deltas: Vec<DeltaEnclosure>,
73}
74
75impl AppcastEntry {
76 pub fn new(
79 title: impl Into<String>,
80 short_version: impl Into<String>,
81 build: impl Into<String>,
82 enclosure_url: impl Into<String>,
83 length: u64,
84 ) -> Self {
85 Self {
86 title: title.into(),
87 short_version: short_version.into(),
88 build: build.into(),
89 enclosure_url: enclosure_url.into(),
90 length,
91 mime_type: "application/octet-stream".to_string(),
92 ed_signature: None,
93 min_system_version: None,
94 channel: None,
95 release_notes: None,
96 pub_date: None,
97 deltas: Vec::new(),
98 }
99 }
100}
101
102fn xml_escape(s: &str) -> String {
104 let mut out = String::with_capacity(s.len());
105 for c in s.chars() {
106 match c {
107 '&' => out.push_str("&"),
108 '<' => out.push_str("<"),
109 '>' => out.push_str(">"),
110 '"' => out.push_str("""),
111 '\'' => out.push_str("'"),
112 _ => out.push(c),
113 }
114 }
115 out
116}
117
118const SPARKLE_NS: &str = "http://www.andymatuschak.org/xml-namespaces/sparkle";
120
121pub fn render_appcast(channel_title: &str, entries: &[AppcastEntry]) -> String {
124 let mut x = String::new();
125 let _ = writeln!(x, r#"<?xml version="1.0" encoding="utf-8"?>"#);
126 let _ = writeln!(
127 x,
128 r#"<rss version="2.0" xmlns:sparkle="{SPARKLE_NS}" xmlns:dc="http://purl.org/dc/elements/1.1/">"#
129 );
130 let _ = writeln!(x, " <channel>");
131 let _ = writeln!(x, " <title>{}</title>", xml_escape(channel_title));
132 for e in entries {
133 render_item(&mut x, e);
134 }
135 let _ = writeln!(x, " </channel>");
136 let _ = writeln!(x, "</rss>");
137 x
138}
139
140fn render_item(x: &mut String, e: &AppcastEntry) {
142 let _ = writeln!(x, " <item>");
143 let _ = writeln!(x, " <title>{}</title>", xml_escape(&e.title));
144 let _ = writeln!(
145 x,
146 " <sparkle:version>{}</sparkle:version>",
147 xml_escape(&e.build)
148 );
149 let _ = writeln!(
150 x,
151 " <sparkle:shortVersionString>{}</sparkle:shortVersionString>",
152 xml_escape(&e.short_version)
153 );
154 if let Some(min) = &e.min_system_version {
155 let _ = writeln!(
156 x,
157 " <sparkle:minimumSystemVersion>{}</sparkle:minimumSystemVersion>",
158 xml_escape(min)
159 );
160 }
161 if let Some(ch) = &e.channel {
162 let _ = writeln!(x, " <sparkle:channel>{}</sparkle:channel>", xml_escape(ch));
163 }
164 match &e.release_notes {
165 Some(ReleaseNotes::Link(url)) => {
166 let _ = writeln!(
167 x,
168 " <sparkle:releaseNotesLink>{}</sparkle:releaseNotesLink>",
169 xml_escape(url)
170 );
171 }
172 Some(ReleaseNotes::Html(html)) => {
173 let safe = html.replace("]]>", "]]>");
175 let _ = writeln!(x, " <description><![CDATA[{safe}]]></description>");
176 }
177 None => {}
178 }
179 if let Some(date) = &e.pub_date {
180 let _ = writeln!(x, " <pubDate>{}</pubDate>", xml_escape(date));
181 }
182 if !e.deltas.is_empty() {
183 let _ = writeln!(x, " <sparkle:deltas>");
184 for d in &e.deltas {
185 render_enclosure(x, " ", &d.url, &e.short_version, &d.delta_from, d.length,
186 &e.mime_type, d.ed_signature.as_deref(), Some(&d.delta_from));
187 }
188 let _ = writeln!(x, " </sparkle:deltas>");
189 }
190 render_enclosure(x, " ", &e.enclosure_url, &e.short_version, &e.build, e.length,
191 &e.mime_type, e.ed_signature.as_deref(), None);
192 let _ = writeln!(x, " </item>");
193}
194
195#[allow(clippy::too_many_arguments)]
197fn render_enclosure(
198 x: &mut String,
199 indent: &str,
200 url: &str,
201 short_version: &str,
202 build: &str,
203 length: u64,
204 mime_type: &str,
205 ed_signature: Option<&str>,
206 delta_from: Option<&str>,
207) {
208 let _ = write!(x, "{indent}<enclosure url=\"{}\"", xml_escape(url));
209 let _ = write!(x, " sparkle:version=\"{}\"", xml_escape(build));
210 let _ = write!(
211 x,
212 " sparkle:shortVersionString=\"{}\"",
213 xml_escape(short_version)
214 );
215 if let Some(from) = delta_from {
216 let _ = write!(x, " sparkle:deltaFrom=\"{}\"", xml_escape(from));
217 }
218 let _ = write!(x, " length=\"{length}\"");
219 let _ = write!(x, " type=\"{}\"", xml_escape(mime_type));
220 if let Some(sig) = ed_signature {
221 let _ = write!(x, " sparkle:edSignature=\"{}\"", xml_escape(sig));
222 }
223 let _ = writeln!(x, " />");
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn sample() -> AppcastEntry {
231 let mut e = AppcastEntry::new("Version 1.2.3", "1.2.3", "1203", "https://r/App-1.2.3.dmg", 4096);
232 e.ed_signature = Some("c2lnbmF0dXJl".into());
233 e.min_system_version = Some("11.0".into());
234 e.channel = Some("stable".into());
235 e.release_notes = Some(ReleaseNotes::Link("https://r/notes/1.2.3.html".into()));
236 e
237 }
238
239 #[test]
240 fn renders_well_formed_item() {
241 let xml = render_appcast("Yah Desktop", &[sample()]);
242 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>"));
243 assert!(xml.contains(&format!("xmlns:sparkle=\"{SPARKLE_NS}\"")));
244 assert!(xml.contains("<sparkle:shortVersionString>1.2.3</sparkle:shortVersionString>"));
245 assert!(xml.contains("<sparkle:version>1203</sparkle:version>"));
246 assert!(xml.contains("<sparkle:minimumSystemVersion>11.0</sparkle:minimumSystemVersion>"));
247 assert!(xml.contains("<sparkle:channel>stable</sparkle:channel>"));
248 assert!(xml.contains("<sparkle:releaseNotesLink>https://r/notes/1.2.3.html</sparkle:releaseNotesLink>"));
249 assert!(xml.contains("sparkle:edSignature=\"c2lnbmF0dXJl\""));
250 assert!(xml.contains("url=\"https://r/App-1.2.3.dmg\""));
251 assert!(xml.contains("length=\"4096\""));
252 assert!(xml.trim_end().ends_with("</rss>"));
253 }
254
255 #[test]
256 fn render_is_deterministic() {
257 assert_eq!(
258 render_appcast("Yah Desktop", &[sample()]),
259 render_appcast("Yah Desktop", &[sample()])
260 );
261 }
262
263 #[test]
264 fn escapes_xml_metacharacters() {
265 let mut e = AppcastEntry::new("A & B <release>", "1.0", "100", "https://r/x.dmg?a=1&b=2", 1);
266 e.release_notes = Some(ReleaseNotes::Html("<b>hi</b> & bye ]]> done".into()));
267 let xml = render_appcast("Title & <co>", &[e]);
268 assert!(xml.contains("<title>A & B <release></title>"));
269 assert!(xml.contains("url=\"https://r/x.dmg?a=1&b=2\""));
270 assert!(xml.contains("<title>Title & <co></title>"));
271 assert!(xml.contains("<![CDATA[<b>hi</b> & bye ]]> done]]>"));
273 }
274
275 #[test]
276 fn html_notes_use_cdata_description() {
277 let mut e = AppcastEntry::new("v1", "1.0", "100", "https://r/x.dmg", 1);
278 e.release_notes = Some(ReleaseNotes::Html("<h1>Notes</h1>".into()));
279 let xml = render_appcast("T", &[e]);
280 assert!(xml.contains("<description><![CDATA[<h1>Notes</h1>]]></description>"));
281 assert!(!xml.contains("releaseNotesLink"));
282 }
283
284 #[test]
285 fn delta_renders_in_deltas_block() {
286 let mut e = AppcastEntry::new("v2", "2.0", "200", "https://r/App-2.0.dmg", 8192);
287 e.deltas.push(DeltaEnclosure {
288 delta_from: "100".into(),
289 url: "https://r/App-2.0-from-100.delta".into(),
290 length: 512,
291 ed_signature: Some("ZGVsdGFzaWc=".into()),
292 });
293 let xml = render_appcast("T", &[e]);
294 assert!(xml.contains("<sparkle:deltas>"));
295 assert!(xml.contains("sparkle:deltaFrom=\"100\""));
296 assert!(xml.contains("App-2.0-from-100.delta"));
297 assert!(xml.contains("sparkle:edSignature=\"ZGVsdGFzaWc=\""));
298 assert!(xml.contains("</sparkle:deltas>"));
299 }
300
301 #[test]
302 fn omits_optional_fields_when_absent() {
303 let e = AppcastEntry::new("v1", "1.0", "100", "https://r/x.dmg", 1);
304 let xml = render_appcast("T", &[e]);
305 assert!(!xml.contains("minimumSystemVersion"));
306 assert!(!xml.contains("sparkle:channel"));
307 assert!(!xml.contains("releaseNotesLink"));
308 assert!(!xml.contains("description"));
309 assert!(!xml.contains("edSignature"));
310 assert!(!xml.contains("sparkle:deltas"));
311 }
312}