use std::collections::{BTreeMap, BTreeSet};
use day_build::permissions::{OhosScene, PermissionSpec};
use crate::meta::Manifest;
#[derive(Debug)]
pub struct Resolved {
pub spec: &'static PermissionSpec,
pub reason: Option<String>,
pub sources: Vec<String>,
}
#[derive(Debug, Default)]
pub struct Plan {
pub resolved: Vec<Resolved>,
pub raw_android: Vec<AndroidRaw>,
pub raw_apple: BTreeMap<String, String>,
pub raw_ohos: Vec<OhosEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AndroidRaw {
pub name: String,
pub max_sdk: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OhosEntry {
pub name: String,
pub reason_key: Option<String>,
pub when: &'static str,
}
pub fn resolve(
manifest: &Manifest,
platform: &str,
contributed: &[(String, String)],
) -> Result<Plan, String> {
let mut by_name: BTreeMap<&'static str, Resolved> = BTreeMap::new();
for (name, decl) in &manifest.permissions.declared {
let Some(spec) = day_build::permissions::find(name) else {
continue; };
if !decl.enabled() || !decl.covers(platform) {
continue;
}
by_name.insert(
spec.name,
Resolved {
spec,
reason: decl.reason_for(platform).map(str::to_string),
sources: vec!["Day.toml".to_string()],
},
);
}
for (crate_name, perm) in contributed {
let Some(spec) = day_build::permissions::find(perm) else {
return Err(format!(
"{crate_name} declares [package.metadata.day.permissions] uses = [{perm:?}], which \
is not a known permission (valid: {})",
day_build::permissions::names().join(", ")
));
};
match by_name.get_mut(spec.name) {
Some(existing) => existing.sources.push(crate_name.clone()),
None => {
by_name.insert(
spec.name,
Resolved {
spec,
reason: None,
sources: vec![crate_name.clone()],
},
);
}
}
}
let needs_reason_here = matches!(platform, "ios" | "macos" | "ohos");
for r in by_name.values() {
let consumes = match platform {
"ios" => !r.spec.ios.is_empty(),
"macos" => !r.spec.macos.is_empty(),
"ohos" => !r.spec.ohos.is_empty(),
_ => false,
};
if needs_reason_here && consumes && r.spec.needs_reason && r.reason.is_none() {
let who = r
.sources
.iter()
.filter(|s| *s != "Day.toml")
.cloned()
.collect::<Vec<_>>();
let blame = if who.is_empty() {
format!("[permissions] {} needs a reason", r.spec.name)
} else {
format!(
"{} declares [package.metadata.day.permissions] uses = [{:?}], but Day.toml \
gives no reason for it",
who.join(", "),
r.spec.name
)
};
return Err(format!(
"{blame}.\n {platform} shows this text to the user when it prompts, and an app \
that touches the API without it is terminated by the OS.\n Add to Day.toml:\n\
\n [permissions]\n {} = \"…why this app needs it…\"\n",
r.spec.name
));
}
}
let raw = &manifest.permissions.raw;
Ok(Plan {
resolved: by_name.into_values().collect(),
raw_android: raw
.android
.iter()
.map(|name| AndroidRaw {
name: name.clone(),
max_sdk: None,
})
.collect(),
raw_apple: match platform {
"macos" => raw.macos.clone(),
_ => raw.ios.clone(),
},
raw_ohos: raw
.ohos
.iter()
.map(|p| OhosEntry {
name: p.name.clone(),
reason_key: p.reason.as_ref().map(|_| reason_key(&p.name)),
when: match p.when.as_deref() {
Some("always") => "always",
_ => "inuse",
},
})
.collect(),
})
}
pub fn reason_key(permission: &str) -> String {
let slug: String = permission
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
format!("day_perm_reason_{}", slug.to_ascii_lowercase())
}
pub fn android_entries(plan: &Plan) -> Vec<AndroidRaw> {
let mut out: BTreeMap<String, Option<u32>> = BTreeMap::new();
for r in &plan.resolved {
for p in r.spec.android {
let slot = out.entry(p.name.to_string()).or_insert(p.max_sdk);
*slot = match (*slot, p.max_sdk) {
(Some(a), Some(b)) => Some(a.min(b)),
_ => None,
};
}
}
for p in &plan.raw_android {
out.entry(p.name.clone()).or_insert(p.max_sdk);
}
out.into_iter()
.map(|(name, max_sdk)| AndroidRaw { name, max_sdk })
.collect()
}
pub fn apple_keys(plan: &Plan, macos: bool) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for r in &plan.resolved {
let keys = if macos { r.spec.macos } else { r.spec.ios };
let Some(reason) = r.reason.as_ref() else {
continue; };
for key in keys {
out.insert((*key).to_string(), reason.clone());
}
}
for (k, v) in &plan.raw_apple {
out.insert(k.clone(), v.clone());
}
out
}
pub fn apple_managed_keys(macos: bool) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for spec in day_build::permissions::ALL {
for key in if macos { spec.macos } else { spec.ios } {
out.insert((*key).to_string());
}
}
out
}
pub fn ohos_entries(plan: &Plan) -> Vec<OhosEntry> {
let mut out: Vec<OhosEntry> = Vec::new();
for r in &plan.resolved {
for p in r.spec.ohos {
out.push(OhosEntry {
name: p.name.to_string(),
reason_key: r.reason.as_ref().map(|_| reason_key(r.spec.name)),
when: match p.when {
OhosScene::Always => "always",
OhosScene::InUse => "inuse",
},
});
}
}
out.extend(plan.raw_ohos.iter().cloned());
out.sort_by(|a, b| a.name.cmp(&b.name));
out.dedup_by(|a, b| a.name == b.name);
out
}
pub fn ohos_reason_strings(plan: &Plan, manifest: &Manifest) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for r in &plan.resolved {
if r.spec.ohos.is_empty() {
continue;
}
if let Some(reason) = &r.reason {
out.insert(reason_key(r.spec.name), reason.clone());
}
}
for p in &manifest.permissions.raw.ohos {
if let Some(reason) = &p.reason {
out.insert(reason_key(&p.name), reason.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::parse_manifest;
const CARGO: &str = "[package]\nname = \"demo-app\"\nversion = \"1.0.0\"\n";
fn manifest(perms: &str) -> Manifest {
parse_manifest(
&format!("schema = 1\n[app]\nid = \"dev.x.demo\"\n{perms}"),
CARGO,
)
.expect("parse")
}
#[test]
fn day_toml_only() {
let m = manifest("[permissions]\ncamera = \"Scan a document.\"\n");
let plan = resolve(&m, "ios", &[]).expect("resolve");
assert_eq!(plan.resolved.len(), 1);
assert_eq!(plan.resolved[0].reason.as_deref(), Some("Scan a document."));
assert_eq!(plan.resolved[0].sources, ["Day.toml"]);
assert_eq!(
apple_keys(&plan, false)
.get("NSCameraUsageDescription")
.map(String::as_str),
Some("Scan a document.")
);
}
#[test]
fn library_contribution_without_a_reason() {
let m = manifest("");
let contributed = [("day-piece-media".to_string(), "camera".to_string())];
let android = resolve(&m, "android", &contributed).expect("android is permissive");
assert_eq!(android.resolved.len(), 1);
let err = resolve(&m, "ios", &contributed).expect_err("ios must refuse");
assert!(err.contains("day-piece-media"), "{err}");
assert!(
err.contains("camera = "),
"must show the lines to paste: {err}"
);
}
#[test]
fn day_toml_reason_satisfies_a_contribution() {
let m = manifest("[permissions]\ncamera = \"Attach a photo.\"\n");
let plan = resolve(
&m,
"ios",
&[("day-piece-media".to_string(), "camera".to_string())],
)
.expect("resolve");
assert_eq!(plan.resolved.len(), 1);
assert_eq!(plan.resolved[0].sources, ["Day.toml", "day-piece-media"]);
}
#[test]
fn notifications_needs_no_reason() {
let m = manifest("[permissions]\nnotifications = true\n");
let plan = resolve(&m, "ios", &[]).expect("resolve");
assert!(apple_keys(&plan, false).is_empty());
assert_eq!(
android_entries(&resolve(&m, "android", &[]).unwrap())
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>(),
["android.permission.POST_NOTIFICATIONS"]
);
}
#[test]
fn a_platform_without_the_key_needs_no_reason() {
let m = manifest("[permissions.motion]\nios-reason = \"Count your steps.\"\n");
resolve(&m, "macos", &[]).expect("macos has no motion key, so no reason is needed");
let ios = resolve(&m, "ios", &[]).expect("ios reason supplied");
assert_eq!(
apple_keys(&ios, false)
.get("NSMotionUsageDescription")
.map(String::as_str),
Some("Count your steps.")
);
}
#[test]
fn photos_caps_legacy_storage_and_keeps_the_cap_on_merge() {
let m = manifest("[permissions]\nphotos = \"Attach a picture.\"\n");
let plan = resolve(&m, "android", &[]).expect("resolve");
let entries = android_entries(&plan);
let legacy = entries
.iter()
.find(|p| p.name.ends_with("READ_EXTERNAL_STORAGE"))
.expect("legacy storage");
assert_eq!(legacy.max_sdk, Some(32));
let names: Vec<_> = entries.iter().map(|p| p.name.as_str()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted);
}
#[test]
fn platform_subset_is_honored() {
let m = manifest("[permissions.camera]\nreason = \"Scan.\"\nplatforms = [\"ios\"]\n");
assert_eq!(resolve(&m, "ios", &[]).unwrap().resolved.len(), 1);
assert_eq!(resolve(&m, "android", &[]).unwrap().resolved.len(), 0);
}
#[test]
fn ohos_entries_reference_reason_resources() {
let m = manifest("[permissions]\ncamera = \"Scan a document.\"\n");
let plan = resolve(&m, "ohos", &[]).expect("resolve");
let entries = ohos_entries(&plan);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "ohos.permission.CAMERA");
assert_eq!(
entries[0].reason_key.as_deref(),
Some("day_perm_reason_camera")
);
assert_eq!(entries[0].when, "inuse");
let strings = ohos_reason_strings(&plan, &m);
assert_eq!(
strings.get("day_perm_reason_camera").map(String::as_str),
Some("Scan a document.")
);
}
#[test]
fn ohos_background_location_uses_the_always_scene() {
let m = manifest("[permissions]\nlocation-always = \"Track your route.\"\n");
let plan = resolve(&m, "ohos", &[]).expect("resolve");
let entries = ohos_entries(&plan);
let bg = entries
.iter()
.find(|e| e.name.ends_with("LOCATION_IN_BACKGROUND"))
.expect("background entry");
assert_eq!(bg.when, "always");
}
#[test]
fn location_always_writes_both_apple_keys() {
let m = manifest("[permissions]\nlocation-always = \"Track your route.\"\n");
let keys = apple_keys(&resolve(&m, "ios", &[]).unwrap(), false);
assert!(keys.contains_key("NSLocationAlwaysAndWhenInUseUsageDescription"));
assert!(keys.contains_key("NSLocationWhenInUseUsageDescription"));
}
#[test]
fn raw_escape_hatches_pass_through() {
let m = manifest(
"[permissions.raw]\nandroid = [\"android.permission.READ_CONTACTS\"]\n\
ios = { NSContactsUsageDescription = \"Find friends.\" }\n",
);
let android = android_entries(&resolve(&m, "android", &[]).unwrap());
assert!(android.iter().any(|p| p.name.ends_with("READ_CONTACTS")));
let ios = apple_keys(&resolve(&m, "ios", &[]).unwrap(), false);
assert_eq!(
ios.get("NSContactsUsageDescription").map(String::as_str),
Some("Find friends.")
);
}
#[test]
fn managed_keys_cover_the_table() {
let ios = apple_managed_keys(false);
assert!(ios.contains("NSCameraUsageDescription"));
assert!(ios.contains("NSMotionUsageDescription"));
assert!(!ios.contains("NSContactsUsageDescription"));
assert!(!apple_managed_keys(true).contains("NSMotionUsageDescription"));
}
}