use crate::error::{Error, Result};
pub fn validate_group(group: &str) -> Result<&str> {
if group.is_empty() {
return Err(Error::new("group must not be empty"));
}
if !group
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
return Err(Error::new("group contains invalid characters"));
}
if group == "." || group == ".." {
return Err(Error::new("group is not a valid identifier"));
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
{
let Some(rest) = app_group_suffix(group) else {
return Err(Error::new(if cfg!(target_os = "macos") {
"group must be an App Group id: 'group.<id>' or '<TEAMID>.<id>'"
} else {
"group must be an App Group id starting with 'group.'"
}));
};
if rest.is_empty() || rest == "." || rest == ".." {
return Err(Error::new("group App Group suffix is invalid"));
}
}
Ok(group)
}
pub fn app_group_suffix(group: &str) -> Option<&str> {
if let Some(rest) = group.strip_prefix("group.") {
return Some(rest);
}
let (team, rest) = group.split_once('.')?;
let team_id = team.len() == 10
&& team
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
(cfg!(target_os = "macos") && team_id).then_some(rest)
}
pub fn validate_widget_id(widget_id: &str) -> Result<&str> {
if widget_id.is_empty()
|| !widget_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
return Err(Error::new(format!(
"widgetId \"{widget_id}\" must be non-empty and use only letters, digits, '.', '_' or '-'"
)));
}
Ok(widget_id)
}
pub fn validate_instance_id(instance: Option<&str>) -> Result<Option<&str>> {
match instance.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(i)
if i.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) =>
{
Ok(Some(i))
}
Some(i) => Err(Error::new(format!(
"instance \"{i}\" must use only letters, digits, '.', '_' or '-'"
))),
}
}
static DEFAULT_GROUP: std::sync::OnceLock<String> = std::sync::OnceLock::new();
pub(crate) fn set_default_group(group: &str) {
let _ = DEFAULT_GROUP.set(group.to_string());
}
pub fn default_group() -> Option<&'static str> {
DEFAULT_GROUP.get().map(String::as_str)
}
pub fn resolve_group(requested: Option<&str>) -> Result<String> {
resolve_with(
requested.map(str::trim).filter(|s| !s.is_empty()),
default_group(),
)
}
fn resolve_with(requested: Option<&str>, default: Option<&str>) -> Result<String> {
let Some(group) = requested.or(default) else {
return Err(Error::new(
"no widget group: pass `group` or set plugins.widgets.appGroup in tauri.conf.json",
));
};
validate_group(group)?;
#[cfg(any(target_os = "macos", target_os = "ios"))]
if let Some(default) = default {
if group != default {
return Err(Error::new(format!(
"group \"{group}\" does not match plugins.widgets.appGroup \"{default}\"; \
an Apple app has one App Group, omit `group` or pass \"{default}\""
)));
}
}
Ok(group.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const APPLE: bool = cfg!(any(target_os = "macos", target_os = "ios"));
fn safe_charset(s: &str) -> bool {
s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
fn group_ok(g: &str) -> bool {
let base = !g.is_empty() && safe_charset(g) && g != "." && g != "..";
if !APPLE {
return base;
}
let rest = g.strip_prefix("group.").or_else(|| {
g.strip_prefix("ABCDE12345.")
.filter(|_| cfg!(target_os = "macos"))
});
base && rest.is_some_and(|rest| !rest.is_empty() && rest != "." && rest != "..")
}
fn ident() -> impl Strategy<Value = String> {
(
prop::option::of(prop_oneof![
Just("group."),
Just("ABCDE12345."),
Just("abcde12345.")
]),
"[a-z0-9._/\\\\# -]{0,6}",
prop::option::of(Just(" ")),
)
.prop_map(|(p, body, pad)| format!("{}{}{}", p.unwrap_or(""), body, pad.unwrap_or("")))
}
proptest! {
#![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
#[test]
fn validators_match_reference_models(s in ident()) {
prop_assert_eq!(validate_group(&s).is_ok(), group_ok(&s), "group {:?}", s);
prop_assert_eq!(validate_widget_id(&s).is_ok(), !s.is_empty() && safe_charset(&s), "widgetId {:?}", s);
let trimmed = s.trim();
let want = if trimmed.is_empty() { Some(None) } else if safe_charset(trimmed) { Some(Some(trimmed)) } else { None };
prop_assert_eq!(validate_instance_id(Some(&s)).ok(), want, "instance {:?}", s);
}
}
#[test]
fn rejections_name_the_bad_value() {
let e = validate_widget_id("a/b").unwrap_err().to_string();
assert!(e.contains("widgetId \"a/b\""), "{e}");
let e = validate_instance_id(Some("a#b")).unwrap_err().to_string();
assert!(e.contains("instance \"a#b\""), "{e}");
for g in [".", ".."] {
let e = validate_group(g).unwrap_err().to_string();
assert!(e.contains("not a valid identifier"), "{g}: {e}");
}
}
#[test]
fn group_resolution_without_a_default() {
assert!(resolve_with(None, None).is_err());
assert_eq!(
resolve_with(None, Some("group.com.a")).unwrap(),
"group.com.a"
);
let other = resolve_with(Some("group.com.b"), Some("group.com.a"));
assert_eq!(other.is_ok(), !APPLE);
assert!(resolve_with(Some("group.a/b"), None).is_err());
set_default_group("group.unit.test");
let g = default_group().expect("default set");
assert!(g.starts_with("group.") && g.len() > "group.".len(), "{g}");
assert_eq!(resolve_group(None).unwrap(), g);
}
}