Skip to main content

andysu_cli/
pack.rs

1//! `andysu pack`: turn a payload directory into a release (content-addressed blobs, deltas
2//! against earlier releases, and a release JSON) inside a site directory.
3//!
4//! Payload directory layout:
5//!
6//! ```text
7//! andysu-payload.json        descriptor (spec/schema/payload.schema.json)
8//! dex/*.dex                  JVM code (any *.dex is kind=dex)
9//! lib/<abi>/*.so             native libraries (renamed lib<name>.<hash8>.so here)
10//! anything else              content
11//! ```
12
13use std::collections::{BTreeMap, HashMap, HashSet};
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use andysu_core::hash::sha256_hex;
18use andysu_core::manifest::{
19    Apply, Archive, Component, Delta, Kind, Manifest, PatchFormat, Release, Requires, DESCRIPTOR_PATH,
20};
21use andysu_core::payload::{Descriptor, NativeLib};
22use serde::{Deserialize, Serialize};
23
24use crate::{elf, read, write, R};
25
26/// What the frozen host declares (written when the host APK is built; `andysu-host.json`).
27#[derive(Serialize, Deserialize, Clone, Debug, Default)]
28pub struct HostSpec {
29    pub app: String,
30    pub host_api: u32,
31    #[serde(default)]
32    pub shell_version: u64,
33    #[serde(default)]
34    pub min_sdk: u64,
35    #[serde(default)]
36    pub permissions: Vec<String>,
37    #[serde(default)]
38    pub activities: u32,
39    #[serde(default)]
40    pub services: u32,
41    #[serde(default)]
42    pub receivers: u32,
43    #[serde(default)]
44    pub providers: u32,
45    #[serde(default)]
46    pub features: Vec<String>,
47}
48
49#[derive(Clone, Debug)]
50pub struct Options {
51    pub payload: PathBuf,
52    pub site: PathBuf,
53    pub channel: String,
54    pub id: Option<String>,
55    pub host: Option<HostSpec>,
56    pub hot: Vec<String>,
57    pub archive: Vec<String>,
58    pub deltas: usize,
59    pub delta_format: PatchFormat,
60    pub base_url: Option<String>,
61    pub require_16k: bool,
62    pub allow_shell: bool,
63    /// Also write the release as a ready slot directory (for the payload built into a host).
64    pub emit_slot: Option<PathBuf>,
65    /// Keep library file names and SONAMEs (transparent mode: one generation per process, and
66    /// engines that dlopen("libmain.so") by name keep working).
67    pub no_rename: bool,
68}
69
70#[derive(Serialize, Debug, Default)]
71pub struct Report {
72    pub release: String,
73    pub sequence: u64,
74    pub components: usize,
75    pub new_blobs: Vec<String>,
76    pub deltas: Vec<String>,
77    pub renamed: BTreeMap<String, String>,
78    pub warnings: Vec<String>,
79    pub release_json: String,
80}
81
82pub fn walk(root: &Path) -> R<Vec<String>> {
83    fn go(root: &Path, dir: &Path, out: &mut Vec<String>) -> R<()> {
84        let mut ents: Vec<_> = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten().collect();
85        ents.sort_by_key(|e| e.file_name());
86        for e in ents {
87            let p = e.path();
88            let ft = e.file_type().map_err(|e| e.to_string())?;
89            if ft.is_symlink() {
90                return Err(format!("{}: symlinks are not allowed in a payload", p.display()));
91            }
92            if ft.is_dir() {
93                go(root, &p, out)?;
94            } else {
95                let rel = p.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/");
96                out.push(rel);
97            }
98        }
99        Ok(())
100    }
101    let mut v = vec![];
102    go(root, root, &mut v)?;
103    Ok(v)
104}
105
106/// Stable component name for a logical path (no hashes), used to match deltas across releases.
107pub fn component_name(logical: &str) -> String {
108    let mut n: String =
109        logical.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '.' }).collect();
110    while n.starts_with(['.', '_', '-']) {
111        n.remove(0);
112    }
113    if n.len() > 128 {
114        let h = &sha256_hex(logical.as_bytes())[..8];
115        n = format!("{}.{h}", &n[..119]);
116    }
117    if n.is_empty() {
118        n = format!("c{}", &sha256_hex(logical.as_bytes())[..8]);
119    }
120    n
121}
122
123fn hash8_name(file: &str, data: &[u8]) -> String {
124    let stem = file.strip_suffix(".so").unwrap_or(file);
125    format!("{stem}.{}.so", &sha256_hex(data)[..8])
126}
127
128fn already_hashed(file: &str) -> bool {
129    let Some(stem) = file.strip_suffix(".so") else { return false };
130    stem.rsplit_once('.').is_some_and(|(_, h)| h.len() == 8 && h.bytes().all(|c| c.is_ascii_hexdigit()))
131}
132
133/// Dependency-first order of libraries (by file name) within one ABI.
134fn load_order(libs: &BTreeMap<String, Vec<String>>) -> R<Vec<String>> {
135    let mut out = vec![];
136    let mut state: HashMap<&str, u8> = HashMap::new();
137    fn visit<'a>(
138        n: &'a str,
139        libs: &'a BTreeMap<String, Vec<String>>,
140        st: &mut HashMap<&'a str, u8>,
141        out: &mut Vec<String>,
142    ) -> R<()> {
143        match st.get(n) {
144            Some(2) => return Ok(()),
145            Some(1) => return Err(format!("dependency cycle through {n}")),
146            _ => {}
147        }
148        st.insert(n, 1);
149        for d in &libs[n] {
150            if libs.contains_key(d) {
151                visit(d, libs, st, out)?;
152            }
153        }
154        st.insert(n, 2);
155        out.push(n.to_string());
156        Ok(())
157    }
158    for n in libs.keys() {
159        visit(n, libs, &mut state, &mut out)?;
160    }
161    Ok(out)
162}
163
164pub fn load_manifest(site: &Path, channel: &str) -> R<Option<Manifest>> {
165    let p = site.join(channel).join("update.json");
166    if !p.exists() {
167        return Ok(None);
168    }
169    Manifest::parse(&read(&p)?).map(Some).map_err(|e| format!("{}: {e}", p.display()))
170}
171
172/// All releases known to the site (every channel manifest plus releases/*.json).
173pub fn known_releases(site: &Path) -> R<Vec<Release>> {
174    let mut v = vec![];
175    if let Ok(rd) = fs::read_dir(site) {
176        for e in rd.flatten() {
177            if e.path().join("update.json").is_file() {
178                if let Some(m) = load_manifest(site, &e.file_name().to_string_lossy())? {
179                    v.extend(m.releases);
180                }
181            }
182        }
183    }
184    if let Ok(rd) = fs::read_dir(site.join("releases")) {
185        for e in rd.flatten() {
186            let p = e.path();
187            if p.extension().is_some_and(|x| x == "json") {
188                if let Ok(r) = serde_json::from_slice::<Release>(&read(&p)?) {
189                    v.push(r);
190                }
191            }
192        }
193    }
194    Ok(v)
195}
196
197fn blob_bytes(site: &Path, c_sha: &str, size: u64, urls: &[String]) -> Option<Vec<u8>> {
198    let p = site.join("blobs/sha256").join(c_sha);
199    if let Ok(b) = fs::read(&p) {
200        return Some(b);
201    }
202    for u in urls.iter().filter(|u| u.starts_with("http")) {
203        if let Ok(andysu_core::transport::Got::Body { bytes, .. }) = andysu_core::transport::get_max(u, None, size) {
204            if sha256_hex(&bytes) == c_sha {
205                return Some(bytes);
206            }
207        }
208    }
209    None
210}
211
212/// Check what the payload needs against what the frozen host declares.
213pub fn check_host(d: &Descriptor, h: &HostSpec, allow_shell: bool) -> R<Vec<String>> {
214    let mut errs = vec![];
215    if d.app != h.app {
216        errs.push(format!("payload app {} != host app {}", d.app, h.app));
217    }
218    if d.host_api_min > h.host_api && !allow_shell {
219        errs.push(format!(
220            "payload needs host API {} but the frozen host has {}: this would need an APK update",
221            d.host_api_min, h.host_api
222        ));
223    }
224    let short = |p: &str| p.rsplit('.').next().unwrap_or(p).to_string();
225    let declared: HashSet<String> = h.permissions.iter().map(|p| short(p)).collect();
226    for p in &d.needs.permissions {
227        if !declared.contains(&short(p)) {
228            errs.push(format!(
229                "payload needs permission {p}, which the frozen host does not declare: this would need an APK update"
230            ));
231        }
232    }
233    for (what, need, have) in [
234        ("activities", d.needs.activities, h.activities),
235        ("services", d.needs.services, h.services),
236        ("receivers", d.needs.receivers, h.receivers),
237        ("providers", d.needs.providers, h.providers),
238    ] {
239        if need > have {
240            errs.push(format!(
241                "payload needs {need} stub {what}, the frozen host declares {have}: this would need an APK update"
242            ));
243        }
244    }
245    for f in &d.needs.features {
246        if !h.features.contains(f) {
247            errs.push(format!(
248                "payload needs feature {f}, which the frozen host does not declare: this would need an APK update"
249            ));
250        }
251    }
252    if errs.is_empty() {
253        Ok(vec![])
254    } else {
255        Err(errs.join("\n"))
256    }
257}
258
259fn zip_dir(dir: &Path) -> R<Vec<u8>> {
260    use std::io::Write;
261    let mut buf = std::io::Cursor::new(Vec::new());
262    {
263        let mut w = zip::ZipWriter::new(&mut buf);
264        // Fixed timestamps so identical content gives identical blobs.
265        let opts = zip::write::SimpleFileOptions::default()
266            .compression_method(zip::CompressionMethod::Deflated)
267            .last_modified_time(zip::DateTime::default());
268        for rel in walk(dir)? {
269            w.start_file(rel.as_str(), opts).map_err(|e| e.to_string())?;
270            w.write_all(&read(&dir.join(&rel))?).map_err(|e| e.to_string())?;
271        }
272        w.finish().map_err(|e| e.to_string())?;
273    }
274    Ok(buf.into_inner())
275}
276
277/// Above this many loose files, `pack` bundles content directories into zip components.
278pub const AUTO_ARCHIVE_LIMIT: usize = 400;
279
280pub fn pack(o: &Options) -> R<Report> {
281    let mut rep = Report::default();
282    let dpath = o.payload.join(DESCRIPTOR_PATH);
283    let mut desc = Descriptor::parse(&read(&dpath)?).map_err(|e| format!("{}: {e}", dpath.display()))?;
284    if let Some(h) = &o.host {
285        check_host(&desc, h, o.allow_shell)?;
286    }
287    let id = o.id.clone().unwrap_or_else(|| desc.version.clone());
288    if !andysu_core::manifest::is_ident(&id, b"._+-", 64) {
289        return Err(format!("bad release id {id:?}"));
290    }
291
292    // logical path -> (bytes, kind, abi)
293    let mut files: BTreeMap<String, (Vec<u8>, Kind, Option<String>)> = BTreeMap::new();
294    let mut archives: Vec<String> = o.archive.iter().map(|a| a.trim_matches('/').to_string()).collect();
295    // Keep releases small in components (the spec allows 4096; a GitHub release holds 1000 assets):
296    // bundle the largest content directories
297    // (by file count) into zip components until at most AUTO_ARCHIVE_LIMIT files remain loose.
298    let all = walk(&o.payload)?;
299    let loose = |archives: &Vec<String>| {
300        all.iter().filter(|r| !archives.iter().any(|a| r.starts_with(&format!("{a}/")))).count()
301    };
302    if loose(&archives) > AUTO_ARCHIVE_LIMIT {
303        let mut dirs: BTreeMap<String, usize> = BTreeMap::new();
304        for r in &all {
305            let parts: Vec<&str> = r.split('/').collect();
306            // Candidate directories: anything below the top level except code (dex/, lib/).
307            for depth in 1..parts.len() {
308                let d = parts[..depth].join("/");
309                if d == "dex"
310                    || d == "lib"
311                    || d.starts_with("lib/")
312                    || depth == 1 && parts[0] != "assets" && parts[0] != "res"
313                {
314                    continue;
315                }
316                *dirs.entry(d).or_default() += 1;
317            }
318        }
319        // Prefer deeper directories (smaller archives update better), largest first.
320        let mut cands: Vec<(String, usize)> = dirs.into_iter().filter(|(d, n)| *n >= 16 && d.contains('/')).collect();
321        cands.sort_by_key(|(d, n)| (std::cmp::Reverse(d.matches('/').count().min(2)), std::cmp::Reverse(*n)));
322        for (d, _) in cands {
323            if loose(&archives) <= AUTO_ARCHIVE_LIMIT {
324                break;
325            }
326            if archives.iter().any(|a| d.starts_with(&format!("{a}/")) || a.starts_with(&format!("{d}/")) || *a == d) {
327                continue;
328            }
329            archives.push(d);
330        }
331        if loose(&archives) > AUTO_ARCHIVE_LIMIT {
332            let top: Vec<String> = all
333                .iter()
334                .filter_map(|r| r.split_once('/').map(|x| x.0.to_string()))
335                .filter(|t| t == "assets")
336                .collect();
337            if !top.is_empty() && !archives.contains(&"assets".to_string()) {
338                archives.retain(|a| !a.starts_with("assets/"));
339                archives.push("assets".into());
340            }
341        }
342        rep.warnings.push(format!("{} files: bundled {} directories into zip components", all.len(), archives.len()));
343    }
344    for rel in walk(&o.payload)? {
345        if rel == DESCRIPTOR_PATH || archives.iter().any(|a| rel.starts_with(&format!("{a}/"))) {
346            continue;
347        }
348        andysu_core::manifest::check_rel_path(&rel).map_err(|e| e.to_string())?;
349        let data = read(&o.payload.join(&rel))?;
350        let parts: Vec<&str> = rel.split('/').collect();
351        let (kind, abi) = if parts.len() == 3 && parts[0] == "lib" && rel.ends_with(".so") {
352            (Kind::Native, Some(parts[1].to_string()))
353        } else if rel.ends_with(".dex") {
354            (Kind::Dex, None)
355        } else {
356            (Kind::Content, None)
357        };
358        files.insert(rel, (data, kind, abi));
359    }
360
361    // Native libraries: unique names, DT_NEEDED rewrite, alignment check, load order.
362    let mut by_abi: BTreeMap<String, Vec<String>> = BTreeMap::new();
363    for (rel, (_, k, abi)) in &files {
364        if *k == Kind::Native {
365            by_abi.entry(abi.clone().unwrap()).or_default().push(rel.clone());
366        }
367    }
368    let mut renamed_files: BTreeMap<String, (Vec<u8>, Kind, Option<String>, String)> = BTreeMap::new(); // new path -> (bytes, kind, abi, logical)
369    for (abi, rels) in &by_abi {
370        let mut rename: HashMap<String, String> = HashMap::new();
371        for rel in rels {
372            let file = rel.rsplit('/').next().unwrap();
373            let new =
374                if o.no_rename || already_hashed(file) { file.to_string() } else { hash8_name(file, &files[rel].0) };
375            rename.insert(file.to_string(), new);
376        }
377        let mut deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
378        for rel in rels {
379            let file = rel.rsplit('/').next().unwrap().to_string();
380            let data = &files[rel].0;
381            let info = elf::info(data).map_err(|e| format!("{rel}: {e}"))?;
382            if info.abi() != abi {
383                return Err(format!("{rel}: ELF machine is {} but it sits in lib/{abi}/", info.abi()));
384            }
385            let al = info.min_load_align();
386            if al < 16384 {
387                let msg = format!("{rel}: LOAD segments aligned to {al} bytes, not 16 KB; Android 15+ devices with 16 KB pages cannot load it (link with -Wl,-z,max-page-size=16384)");
388                if o.require_16k {
389                    return Err(msg);
390                }
391                rep.warnings.push(msg);
392            }
393            let new_name = &rename[&file];
394            let soname = info.soname.as_ref().map(|_| new_name.as_str());
395            let out = if o.no_rename {
396                data.clone()
397            } else {
398                elf::rewrite(data, soname, &rename).map_err(|e| format!("{rel}: {e}"))?
399            };
400            deps.insert(file.clone(), info.needed.clone());
401            let new_rel = format!("lib/{abi}/{new_name}");
402            if new_rel != *rel {
403                rep.renamed.insert(rel.clone(), new_rel.clone());
404            }
405            renamed_files.insert(new_rel, (out, Kind::Native, Some(abi.clone()), rel.clone()));
406        }
407        let order = load_order(&deps)?;
408        desc.native.insert(
409            abi.clone(),
410            order
411                .iter()
412                .map(|f| NativeLib {
413                    name: f.strip_suffix(".so").unwrap_or(f).to_string(),
414                    path: format!("lib/{abi}/{}", rename[f]),
415                })
416                .collect(),
417        );
418    }
419    if let Some(entry) = &desc.entry.native_lib {
420        for abi in by_abi.keys() {
421            if desc.native_path(abi, entry).is_none() {
422                return Err(format!("entry.native_lib {entry} is missing for {abi}"));
423            }
424        }
425    }
426    if desc.dex.is_empty() {
427        let mut dex: Vec<String> = files.iter().filter(|(_, v)| v.1 == Kind::Dex).map(|(k, _)| k.clone()).collect();
428        // classes.dex, classes2.dex, classes10.dex in numeric order
429        dex.sort_by_key(|p| {
430            let f = p.rsplit('/').next().unwrap();
431            let n: u32 = f.trim_start_matches("classes").trim_end_matches(".dex").parse().unwrap_or(1);
432            (p.rsplit_once('/').map(|x| x.0.to_string()).unwrap_or_default(), n, p.clone())
433        });
434        desc.dex = dex;
435    }
436    desc.validate().map_err(|e| e.to_string())?;
437
438    // Assemble the component list: (path, logical, bytes, kind, abi, archive)
439    // (slot path, logical path, bytes, kind, abi, archive)
440    type Entry = (String, String, Vec<u8>, Kind, Option<String>, Option<Archive>);
441    let mut entries: Vec<Entry> = vec![];
442    let mut dj = serde_json::to_vec_pretty(&desc).unwrap();
443    dj.push(b'\n');
444    entries.push((DESCRIPTOR_PATH.into(), DESCRIPTOR_PATH.into(), dj, Kind::Content, None, None));
445    for (rel, (data, kind, abi)) in files {
446        if kind != Kind::Native {
447            entries.push((rel.clone(), rel, data, kind, abi, None));
448        }
449    }
450    for (rel, (data, kind, abi, logical)) in renamed_files {
451        entries.push((rel, logical, data, kind, abi, None));
452    }
453    for a in &archives {
454        let dir = o.payload.join(a);
455        if !dir.is_dir() {
456            return Err(format!("--archive {a}: not a directory in the payload"));
457        }
458        entries.push((a.clone(), format!("{a}/"), zip_dir(&dir)?, Kind::Content, None, Some(Archive::Zip)));
459    }
460
461    // Previous releases for deltas and URL reuse.
462    let known = known_releases(&o.site)?;
463    if known.iter().any(|r| r.id == id) {
464        return Err(format!("release {id} already exists in {}", o.site.display()));
465    }
466    let sequence = known.iter().map(|r| r.sequence).max().unwrap_or(0) + 1;
467    let chan = load_manifest(&o.site, &o.channel)?;
468    let mut prev: Vec<&Release> = chan.as_ref().map(|m| m.releases.iter().collect()).unwrap_or_default();
469    prev.sort_by_key(|r| std::cmp::Reverse(r.sequence));
470    prev.truncate(o.deltas);
471    let known_urls: HashMap<String, Vec<String>> = known
472        .iter()
473        .flat_map(|r| r.components.iter())
474        .filter(|c| !c.urls.is_empty())
475        .map(|c| (c.sha256.clone(), c.urls.clone()))
476        .collect();
477
478    let blobs = o.site.join("blobs/sha256");
479    let mut comps = vec![];
480    for (path, logical, data, kind, abi, archive) in entries {
481        let sha = sha256_hex(&data);
482        let bp = blobs.join(&sha);
483        if !bp.exists() && !known_urls.contains_key(&sha) {
484            write(&bp, &data)?;
485            rep.new_blobs.push(sha.clone());
486        }
487        let name = component_name(&match &abi {
488            Some(a) if !logical.starts_with("lib/") => format!("{a}/{logical}"),
489            _ => logical.clone(),
490        });
491        let apply = if kind == Kind::Content
492            && path != DESCRIPTOR_PATH
493            && o.hot.iter().any(|h| logical.starts_with(h.as_str()))
494        {
495            Apply::Hot
496        } else {
497            Apply::Restart
498        };
499        let urls = match (&o.base_url, known_urls.get(&sha)) {
500            (_, Some(u)) => u.clone(),
501            (Some(b), None) => vec![format!("{}/{sha}", b.trim_end_matches('/'))],
502            (None, None) => vec![],
503        };
504        let unpacked_size = if archive.is_some() {
505            Some(
506                walk(&o.payload.join(&path))?
507                    .iter()
508                    .map(|r| fs::metadata(o.payload.join(&path).join(r)).map(|m| m.len()).unwrap_or(0))
509                    .sum(),
510            )
511        } else {
512            None
513        };
514        // Deltas from the same logical component in recent releases.
515        let mut deltas: Vec<Delta> = vec![];
516        if path != DESCRIPTOR_PATH && data.len() > 4096 {
517            let mut seen = HashSet::new();
518            for r in &prev {
519                for oc in r
520                    .components
521                    .iter()
522                    .filter(|c| c.name == name && c.abi == abi && c.sha256 != sha && c.archive == archive)
523                {
524                    if !seen.insert(oc.sha256.clone()) {
525                        continue;
526                    }
527                    let Some(old) = blob_bytes(&o.site, &oc.sha256, oc.size, &oc.urls) else {
528                        rep.warnings.push(format!("no local copy of {} ({}); skipping delta", oc.sha256, name));
529                        continue;
530                    };
531                    let p = andysu_core::patch::create(o.delta_format, &old, &data).map_err(|e| e.to_string())?;
532                    if (p.len() as f64) > 0.8 * data.len() as f64 {
533                        continue;
534                    }
535                    let ph = sha256_hex(&p);
536                    let dp = o.site.join(format!("deltas/{}-{sha}", oc.sha256)).join(&ph);
537                    if !dp.exists() {
538                        write(&dp, &p)?;
539                        rep.new_blobs.push(format!("deltas/{}-{sha}/{ph}", oc.sha256));
540                    }
541                    let urls = o
542                        .base_url
543                        .as_ref()
544                        .map(|b| vec![format!("{}/{ph}", b.trim_end_matches('/'))])
545                        .unwrap_or_default();
546                    rep.deltas.push(format!("{name}: {} -> {} bytes (from {})", data.len(), p.len(), r.id));
547                    deltas.push(Delta {
548                        from_sha256: oc.sha256.clone(),
549                        format: o.delta_format,
550                        sha256: ph,
551                        size: p.len() as u64,
552                        urls,
553                    });
554                }
555            }
556        }
557        comps.push(Component {
558            name,
559            kind,
560            apply,
561            abi,
562            path,
563            archive,
564            unpacked_size,
565            sha256: sha,
566            size: data.len() as u64,
567            urls,
568            deltas,
569        });
570    }
571
572    let abis: Vec<String> = by_abi.keys().cloned().collect();
573    let release = Release {
574        id: id.clone(),
575        sequence,
576        requires: Requires {
577            host_api_min: Some(desc.host_api_min as u64),
578            shell_min: None,
579            api_min: o.host.as_ref().map(|h| h.min_sdk).filter(|&v| v > 0),
580            abis,
581        },
582        rollout: None,
583        mandatory: false,
584        notes_url: None,
585        components: comps,
586        shell: None,
587    };
588    release.validate().map_err(|e| e.to_string())?;
589    if let Some(dir) = &o.emit_slot {
590        if dir.exists() {
591            andysu_core::fsx::remove_tree(dir).map_err(|e| e.to_string())?;
592        }
593        for c in &release.components {
594            let b =
595                blob_bytes(&o.site, &c.sha256, c.size, &c.urls).ok_or_else(|| format!("blob {} missing", c.sha256))?;
596            if c.archive.is_some() {
597                let tmp = dir.join(format!(".{}.zip", c.sha256));
598                write(&tmp, &b)?;
599                fs::create_dir_all(dir.join(&c.path)).map_err(|e| e.to_string())?;
600                andysu_core::extract::extract_zip(&tmp, &dir.join(&c.path), c.unpacked_size.unwrap_or(4 << 30))
601                    .map_err(|e| e.to_string())?;
602                fs::remove_file(&tmp).map_err(|e| e.to_string())?;
603            } else {
604                write(&dir.join(&c.path), &b)?;
605            }
606        }
607        // A slot index, so clients can use this built-in payload as a delta base.
608        let ix = andysu_core::store::SlotIndex {
609            id: release.id.clone(),
610            sequence: release.sequence,
611            abi: None,
612            components: release
613                .components
614                .iter()
615                .map(|c| andysu_core::store::IndexEntry {
616                    name: c.name.clone(),
617                    path: c.path.clone(),
618                    sha256: c.sha256.clone(),
619                    kind: c.kind,
620                    apply: c.apply,
621                    archive: c.archive,
622                })
623                .collect(),
624        };
625        write(&dir.join(andysu_core::store::BUILTIN_INDEX), &serde_json::to_vec_pretty(&ix).unwrap())?;
626    }
627    let rp = o.site.join("releases").join(format!("{id}.json"));
628    write(&rp, &serde_json::to_vec_pretty(&release).unwrap())?;
629    rep.release = id;
630    rep.sequence = sequence;
631    rep.components = release.components.len();
632    rep.release_json = rp.display().to_string();
633    Ok(rep)
634}