Skip to main content

yah_qed/provider/
appcast.rs

1//! Shared Sparkle/WinSparkle appcast builder (R509-F3, reused by R509-F4).
2//!
3//! Both the `sparkle` (macOS) and `winsparkle` (Windows) adapters publish a
4//! Sparkle-shaped **appcast** — an RSS 2.0 feed whose `<item>`s carry an
5//! `<enclosure>` with the update URL, length, and an EdDSA signature in the
6//! `sparkle:` XML namespace. The feed schema is identical across the two
7//! platforms (WinSparkle deliberately mirrors Sparkle's appcast), so the entry
8//! model and the XML renderer live here and both adapters call them. The
9//! adapters differ only in *what signs the archive* (Sparkle: an ed25519
10//! `sparkle:edSignature`; WinSparkle: it trusts the Authenticode signature on
11//! the installer, optionally adding its own) — captured by the optional
12//! [`AppcastEntry::ed_signature`].
13//!
14//! The renderer is a small deterministic string builder rather than a generic
15//! XML library: the appcast shape is fixed and narrow, and determinism keeps
16//! the dry-run output and the unit tests stable.
17
18use std::fmt::Write as _;
19
20/// Release notes for an item — either an external link Sparkle fetches, or
21/// inline HTML embedded in a `<description>` CDATA block.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ReleaseNotes {
24    /// `<sparkle:releaseNotesLink>` — Sparkle fetches and renders this URL.
25    Link(String),
26    /// `<description><![CDATA[…]]></description>` — inline HTML notes.
27    Html(String),
28}
29
30/// A delta update from a specific prior version — rendered inside the item's
31/// `<sparkle:deltas>` block alongside the full-update enclosure.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DeltaEnclosure {
34    /// The `sparkle:version` (build) this delta patches *from*.
35    pub delta_from: String,
36    /// Public URL of the `.delta` file.
37    pub url: String,
38    /// Byte length of the `.delta`.
39    pub length: u64,
40    /// EdDSA signature over the `.delta` bytes (base64).
41    pub ed_signature: Option<String>,
42}
43
44/// One appcast `<item>` — a single published update.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AppcastEntry {
47    /// Human title (`Version 1.2.3`).
48    pub title: String,
49    /// `sparkle:shortVersionString` — the marketing version (`1.2.3`).
50    pub short_version: String,
51    /// `sparkle:version` — the monotonic build / `CFBundleVersion`. Sparkle
52    /// compares updates by this, so it must increase across releases.
53    pub build: String,
54    /// Public URL of the full update archive (`.dmg` / `.zip` / `.exe`).
55    pub enclosure_url: String,
56    /// Byte length of the full update archive.
57    pub length: u64,
58    /// MIME type of the enclosure (`application/octet-stream` by default).
59    pub mime_type: String,
60    /// EdDSA signature over the archive bytes (base64). `None` when the
61    /// platform relies on a different trust anchor (WinSparkle + Authenticode).
62    pub ed_signature: Option<String>,
63    /// `sparkle:minimumSystemVersion` (`11.0`), when constrained.
64    pub min_system_version: Option<String>,
65    /// `sparkle:channel` (`stable`, `beta`), when the feed is multi-channel.
66    pub channel: Option<String>,
67    /// Release notes (link or inline HTML).
68    pub release_notes: Option<ReleaseNotes>,
69    /// RFC822 `<pubDate>`, when known. Passed in (the seam has no clock).
70    pub pub_date: Option<String>,
71    /// Delta updates patching from prior builds.
72    pub deltas: Vec<DeltaEnclosure>,
73}
74
75impl AppcastEntry {
76    /// A minimal full-update entry: title/version/build + a signed enclosure.
77    /// Optional fields are filled with the builder setters or left default.
78    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
102/// XML-escape text for an element body or a double-quoted attribute value.
103fn 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("&amp;"),
108            '<' => out.push_str("&lt;"),
109            '>' => out.push_str("&gt;"),
110            '"' => out.push_str("&quot;"),
111            '\'' => out.push_str("&apos;"),
112            _ => out.push(c),
113        }
114    }
115    out
116}
117
118/// The Sparkle XML namespace URI — identical for Sparkle and WinSparkle feeds.
119const SPARKLE_NS: &str = "http://www.andymatuschak.org/xml-namespaces/sparkle";
120
121/// Render a complete appcast document for `channel_title` over `entries`.
122/// Deterministic: identical inputs produce byte-identical output.
123pub 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
140/// Render a single `<item>`.
141fn 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            // CDATA carries HTML verbatim; guard against a literal `]]>`.
174            let safe = html.replace("]]>", "]]&gt;");
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/// Render one `<enclosure>` element. `delta_from` set marks it a delta entry.
196#[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 &amp; B &lt;release&gt;</title>"));
269        assert!(xml.contains("url=\"https://r/x.dmg?a=1&amp;b=2\""));
270        assert!(xml.contains("<title>Title &amp; &lt;co&gt;</title>"));
271        // CDATA passes HTML through but neutralizes a literal ]]> terminator.
272        assert!(xml.contains("<![CDATA[<b>hi</b> & bye ]]&gt; 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}