1use 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#[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 pub emit_slot: Option<PathBuf>,
65 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
106pub 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
133fn 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
172pub 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
212pub 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 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
277pub 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 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 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 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 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 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(); 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 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 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 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 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 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}