Skip to main content

andysu_cli/
publish.rs

1//! `andysu publish`, `sign`, `verify`, `rollback`: channel manifests in a site directory.
2
3use std::path::Path;
4
5use andysu_core::keys::{PublicKey, SecretKey};
6use andysu_core::manifest::{Manifest, Release, Rollout};
7use andysu_core::sig::{self, Cert};
8use andysu_core::time;
9
10use crate::{read, write, R};
11
12pub struct Signer {
13    pub key: SecretKey,
14    pub cert: Option<Cert>,
15}
16
17pub struct PublishOptions {
18    pub channel: String,
19    pub release: String,
20    pub rollout: Option<u8>,
21    pub mandatory: bool,
22    pub notes_url: Option<String>,
23    pub keep: usize,
24    pub expires_days: i64,
25    pub now: i64,
26}
27
28pub fn load_release(site: &Path, id: &str) -> R<Release> {
29    let p = site.join("releases").join(format!("{id}.json"));
30    serde_json::from_slice(&read(&p)?).map_err(|e| format!("{}: {e}", p.display()))
31}
32
33fn app_of(site: &Path, r: &Release) -> R<String> {
34    let d = r
35        .components
36        .iter()
37        .find(|c| c.path == andysu_core::manifest::DESCRIPTOR_PATH)
38        .ok_or("release has no descriptor")?;
39    let b = read(&site.join("blobs/sha256").join(&d.sha256)).or_else(|_| {
40        d.urls
41            .iter()
42            .find_map(|u| match andysu_core::transport::get_max(u, None, d.size) {
43                Ok(andysu_core::transport::Got::Body { bytes, .. }) => Some(bytes),
44                _ => None,
45            })
46            .ok_or_else(|| "descriptor blob not available".to_string())
47    })?;
48    Ok(andysu_core::payload::Descriptor::parse(&b).map_err(|e| e.to_string())?.app)
49}
50
51/// Write manifest + signature for a channel, bumping `sequence`, `issued` and `expires`.
52pub fn write_signed(site: &Path, mut m: Manifest, signer: &Signer, now: i64, expires_days: i64) -> R<Manifest> {
53    m.sequence += 1;
54    m.issued = time::format(now);
55    m.expires = time::format(now + expires_days * 86400);
56    m.releases.sort_by_key(|r| r.sequence);
57    m.validate().map_err(|e| e.to_string())?;
58    let mut b = serde_json::to_vec_pretty(&m).unwrap();
59    b.push(b'\n');
60    let s = sig::sign(&b, &signer.key, signer.cert.clone()).map_err(|e| e.to_string())?;
61    let dir = site.join(&m.channel);
62    write(&dir.join("update.json"), &b)?;
63    write(&dir.join("update.json.sig"), &s)?;
64    Ok(m)
65}
66
67pub fn publish(site: &Path, o: &PublishOptions, signer: &Signer) -> R<Manifest> {
68    let mut r = load_release(site, &o.release)?;
69    if let Some(p) = o.rollout {
70        r.rollout = Some(Rollout { percent: p.min(100), salt: r.id.clone() });
71    }
72    r.mandatory = o.mandatory;
73    if o.notes_url.is_some() {
74        r.notes_url = o.notes_url.clone();
75    }
76    let m = match crate::pack::load_manifest(site, &o.channel)? {
77        Some(m) => m,
78        None => Manifest {
79            schema: 1,
80            app: app_of(site, &r)?,
81            channel: o.channel.clone(),
82            issued: time::format(o.now),
83            expires: time::format(o.now),
84            sequence: 0,
85            releases: vec![],
86        },
87    };
88    let mut m = m;
89    m.releases.retain(|x| x.id != r.id);
90    m.releases.push(r);
91    m.releases.sort_by_key(|x| x.sequence);
92    while m.releases.len() > o.keep.max(1) {
93        m.releases.remove(0);
94    }
95    write_signed(site, m, signer, o.now, o.expires_days)
96}
97
98/// Re-sign a channel (refreshing `expires`; run on a schedule so clients never see an expired
99/// manifest), optionally changing a release's rollout.
100pub fn refresh(
101    site: &Path,
102    channel: &str,
103    signer: &Signer,
104    now: i64,
105    expires_days: i64,
106    rollout: Option<(&str, u8)>,
107) -> R<Manifest> {
108    let mut m =
109        crate::pack::load_manifest(site, channel)?.ok_or_else(|| format!("no manifest for channel {channel}"))?;
110    if let Some((id, p)) = rollout {
111        let r = m.releases.iter_mut().find(|r| r.id == id).ok_or_else(|| format!("no release {id}"))?;
112        r.rollout = Some(Rollout {
113            percent: p.min(100),
114            salt: r.rollout.as_ref().map(|x| x.salt.clone()).unwrap_or_else(|| r.id.clone()),
115        });
116    }
117    write_signed(site, m, signer, now, expires_days)
118}
119
120/// Remote-side rollback: pull the newest release(s) after `to` and re-publish `to`'s content as a
121/// new release with a higher sequence, so every client moves forward onto the known-good content.
122pub fn rollback(
123    site: &Path,
124    channel: &str,
125    to: &str,
126    signer: &Signer,
127    now: i64,
128    expires_days: i64,
129) -> R<(Manifest, String)> {
130    let mut m =
131        crate::pack::load_manifest(site, channel)?.ok_or_else(|| format!("no manifest for channel {channel}"))?;
132    let good = m
133        .releases
134        .iter()
135        .find(|r| r.id == to)
136        .cloned()
137        .or_else(|| load_release(site, to).ok())
138        .ok_or_else(|| format!("no release {to}"))?;
139    let top = crate::pack::known_releases(site)?
140        .iter()
141        .map(|r| r.sequence)
142        .max()
143        .unwrap_or(0)
144        .max(m.releases.iter().map(|r| r.sequence).max().unwrap_or(0));
145    let mut again = good.clone();
146    again.sequence = top + 1;
147    again.id = format!("{}-r{}", good.id, again.sequence);
148    again.rollout = None;
149    m.releases.retain(|r| r.sequence <= good.sequence && r.id != good.id);
150    m.releases.push(again.clone());
151    write(&site.join("releases").join(format!("{}.json", again.id)), &serde_json::to_vec_pretty(&again).unwrap())?;
152    Ok((write_signed(site, m, signer, now, expires_days)?, again.id))
153}
154
155/// Verify a manifest (path or URL) exactly as a client would, optionally fetching every blob.
156pub fn verify(
157    src: &str,
158    roots: &[PublicKey],
159    app: Option<&str>,
160    channel: Option<&str>,
161    blobs: bool,
162    now: i64,
163) -> R<String> {
164    let (mb, sb, base) = if src.contains("://") {
165        let get = |u: &str| match andysu_core::transport::get(u, None) {
166            Ok(andysu_core::transport::Got::Body { bytes, .. }) => Ok(bytes),
167            Ok(_) => Err(format!("{u}: not modified?")),
168            Err(e) => Err(e.to_string()),
169        };
170        (get(src)?, get(&format!("{src}.sig"))?, src.to_string())
171    } else {
172        let p = std::fs::canonicalize(src).map_err(|e| format!("{src}: {e}"))?;
173        (read(&p)?, read(Path::new(&format!("{}.sig", p.display())))?, format!("file://{}", p.display()))
174    };
175    let v = sig::verify(&mb, &sb, roots, now).map_err(|e| e.to_string())?;
176    let m = Manifest::parse(&mb).map_err(|e| e.to_string())?;
177    if let Some(a) = app {
178        if m.app != a {
179            return Err(format!("manifest app {} != {a}", m.app));
180        }
181    }
182    if let Some(c) = channel {
183        if m.channel != c {
184            return Err(format!("manifest channel {} != {c}", m.channel));
185        }
186    }
187    if m.expires_ts() <= now {
188        return Err(format!("manifest expired at {}", m.expires));
189    }
190    let mut out = format!(
191        "OK {} channel {} sequence {} expires {} (root {}{})\n",
192        m.app,
193        m.channel,
194        m.sequence,
195        m.expires,
196        v.root_id,
197        v.release_key_id.map(|k| format!(", release key {k}")).unwrap_or_default()
198    );
199    let baseu = url::Url::parse(&base).map_err(|e| e.to_string())?;
200    for r in &m.releases {
201        out.push_str(&format!("  release {} seq {} ({} components)\n", r.id, r.sequence, r.components.len()));
202        if !blobs {
203            continue;
204        }
205        let check = |sha: &str, urls: &[String], default: String, size: u64| -> R<()> {
206            let list = if urls.is_empty() { vec![default] } else { urls.to_vec() };
207            let u = baseu.join(&list[0]).map_err(|e| e.to_string())?;
208            let b = match andysu_core::transport::get_max(u.as_str(), None, size).map_err(|e| e.to_string())? {
209                andysu_core::transport::Got::Body { bytes, .. } => bytes,
210                _ => return Err("not modified".into()),
211            };
212            if b.len() as u64 != size || andysu_core::hash::sha256_hex(&b) != sha {
213                return Err(format!("blob {u} does not match sha256 {sha}"));
214            }
215            Ok(())
216        };
217        for c in &r.components {
218            check(&c.sha256, &c.urls, format!("../blobs/sha256/{}", c.sha256), c.size)?;
219            for d in &c.deltas {
220                check(&d.sha256, &d.urls, format!("../deltas/{}-{}/{}", d.from_sha256, c.sha256, d.sha256), d.size)?;
221            }
222        }
223        out.push_str("    all blobs verified\n");
224    }
225    Ok(out)
226}