use std::collections::BTreeMap;
use std::io::{Cursor, Read as _};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use apple_bom::ParsedBom;
use apple_xar::reader::XarReader;
use embala_pkg::{DirSpec, Error, FileSpec, PkgSpec, build};
const HELLO: &[u8] = b"#!/bin/sh\necho hello\n";
const README: &[u8] = b"read me\n";
fn write_src(dir: &Path, name: &str, bytes: &[u8], mode: u32) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
path
}
fn spec(src_dir: &Path) -> PkgSpec {
PkgSpec {
name: "hello".to_string(),
display_name: "Embala Hello".to_string(),
identifier: "ca.hrndz.embala.hello".to_string(),
version: "0.1.0".to_string(),
install_location: "/usr/local".to_string(),
enable_user_home: true,
files: vec![
FileSpec {
src: write_src(src_dir, "hello.sh", HELLO, 0o755),
dest: "bin/embala-hello".to_string(),
mode: None,
},
FileSpec {
src: write_src(src_dir, "README", README, 0o644),
dest: "share/doc/hello/README".to_string(),
mode: None,
},
],
directories: vec![],
scripts: None,
}
}
fn build_archive(dir: &Path, name: &str) -> Vec<u8> {
let out = dir.join(name);
build(&spec(dir), &out).unwrap();
std::fs::read(out).unwrap()
}
fn element_attrs(xml: &[u8], tag: &str) -> Vec<BTreeMap<String, String>> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(std::str::from_utf8(xml).unwrap());
let mut found = Vec::new();
loop {
match reader.read_event().unwrap() {
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == tag.as_bytes() => {
found.push(
e.attributes()
.map(|a| {
let a = a.unwrap();
(
String::from_utf8(a.key.as_ref().to_vec()).unwrap(),
String::from_utf8(a.value.to_vec()).unwrap(),
)
})
.collect(),
);
}
Event::Eof => break,
_ => {}
}
}
found
}
struct CpioEntry {
name: String,
mode: u64,
nlink: u64,
inode: u64,
data: Vec<u8>,
}
fn parse_odc(archive: &[u8]) -> Vec<CpioEntry> {
parse_odc_owned(archive, true)
}
fn parse_odc_owned(archive: &[u8], root_owned: bool) -> Vec<CpioEntry> {
let mut entries = Vec::new();
let mut rest = archive;
loop {
let header = &rest[..76];
assert_eq!(&header[..6], b"070707", "odc magic");
let field = |lo: usize, hi: usize| {
u64::from_str_radix(std::str::from_utf8(&header[lo..hi]).unwrap(), 8).unwrap()
};
if root_owned {
assert_eq!(field(6, 12), 0, "dev");
assert_eq!(field(24, 30), 0, "uid");
assert_eq!(field(30, 36), 0, "gid");
assert_eq!(field(42, 48), 0, "rdev");
}
let (inode, mode, nlink) = (field(12, 18), field(18, 24), field(36, 42));
let (name_size, file_size) = (field(59, 65) as usize, field(65, 76) as usize);
let name = std::str::from_utf8(&rest[76..76 + name_size - 1])
.unwrap()
.to_string();
let data = rest[76 + name_size..76 + name_size + file_size].to_vec();
rest = &rest[76 + name_size + file_size..];
let trailer = name == "TRAILER!!!";
entries.push(CpioEntry {
name,
mode,
nlink,
inode,
data,
});
if trailer {
assert!(rest.iter().all(|b| *b == 0), "trailer padding is zeros");
break;
}
}
entries
}
#[test]
fn product_archive_has_productbuild_layout() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
assert!(reader.verify_table_of_contents_checksum().unwrap());
let files = reader.files().unwrap();
let names: Vec<(String, String)> = files
.iter()
.map(|(name, file)| (name.clone(), file.file_type.to_string()))
.collect();
assert_eq!(
names,
vec![
("hello.pkg".to_string(), "directory".to_string()),
("hello.pkg/Bom".to_string(), "file".to_string()),
("hello.pkg/Payload".to_string(), "file".to_string()),
("hello.pkg/PackageInfo".to_string(), "file".to_string()),
("Distribution".to_string(), "file".to_string()),
]
);
}
#[test]
fn package_info_matches_pkgbuild_shape() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let xml = reader
.get_file_data_from_path("hello.pkg/PackageInfo")
.unwrap()
.unwrap();
let pkg_info = &element_attrs(&xml, "pkg-info")[0];
for (key, value) in [
("overwrite-permissions", "true"),
("relocatable", "false"),
("identifier", "ca.hrndz.embala.hello"),
("postinstall-action", "none"),
("version", "0.1.0"),
("format-version", "2"),
("install-location", "/usr/local"),
("auth", "root"),
] {
assert_eq!(pkg_info.get(key).map(String::as_str), Some(value), "{key}");
}
let payload = &element_attrs(&xml, "payload")[0];
assert_eq!(payload.get("numberOfFiles").unwrap(), "7");
assert_eq!(payload.get("installKBytes").unwrap(), "3");
for tag in [
"bundle-version",
"upgrade-bundle",
"update-bundle",
"atomic-update-bundle",
"strict-identifier",
"relocate",
] {
assert_eq!(element_attrs(&xml, tag).len(), 1, "{tag}");
}
}
#[test]
fn distribution_enables_user_home_and_references_component() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let xml = reader
.get_file_data_from_path("Distribution")
.unwrap()
.unwrap();
let script = &element_attrs(&xml, "installer-gui-script")[0];
assert_eq!(script.get("minSpecVersion").unwrap(), "2");
let domains = &element_attrs(&xml, "domains")[0];
assert_eq!(domains.get("enable_localSystem").unwrap(), "true");
assert_eq!(domains.get("enable_currentUserHome").unwrap(), "true");
let options = &element_attrs(&xml, "options")[0];
assert_eq!(options.get("hostArchitectures").unwrap(), "x86_64,arm64");
let pkg_refs = element_attrs(&xml, "pkg-ref");
assert_eq!(pkg_refs.len(), 2);
let full = &pkg_refs[1];
assert_eq!(full.get("id").unwrap(), "ca.hrndz.embala.hello");
assert_eq!(full.get("version").unwrap(), "0.1.0");
assert_eq!(full.get("onConclusion").unwrap(), "none");
assert_eq!(full.get("installKBytes").unwrap(), "3");
let text = std::str::from_utf8(&xml).unwrap();
assert!(text.contains("#hello.pkg</pkg-ref>"), "component reference");
let mut system_only = spec(dir.path());
system_only.enable_user_home = false;
let out = dir.path().join("system.pkg");
build(&system_only, &out).unwrap();
let mut reader = XarReader::new(Cursor::new(std::fs::read(out).unwrap())).unwrap();
let xml = reader
.get_file_data_from_path("Distribution")
.unwrap()
.unwrap();
let domains = &element_attrs(&xml, "domains")[0];
assert_eq!(domains.get("enable_localSystem").unwrap(), "true");
assert!(!domains.contains_key("enable_currentUserHome"));
}
#[test]
fn payload_is_pkgbuild_shaped_cpio() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let gz = reader
.get_file_data_from_path("hello.pkg/Payload")
.unwrap()
.unwrap();
let mut cpio = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut cpio)
.unwrap();
assert_eq!(cpio.len() % 512, 0, "payload padded to 512-byte blocks");
let entries = parse_odc(&cpio);
let listed: Vec<(&str, u64, u64)> = entries
.iter()
.map(|e| (e.name.as_str(), e.mode, e.nlink))
.collect();
assert_eq!(
listed,
vec![
(".", 0o40755, 4),
("./bin", 0o40755, 3),
("./bin/embala-hello", 0o100755, 1),
("./share", 0o40755, 3),
("./share/doc", 0o40755, 3),
("./share/doc/hello", 0o40755, 3),
("./share/doc/hello/README", 0o100644, 1),
("TRAILER!!!", 0, 1),
]
);
let inodes: Vec<u64> = entries.iter().map(|e| e.inode).collect();
assert_eq!(inodes, (0..8).collect::<Vec<u64>>());
assert_eq!(entries[2].data, HELLO);
assert_eq!(entries[6].data, README);
}
#[test]
fn bom_payload_and_pkginfo_agree() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let bom = reader
.get_file_data_from_path("hello.pkg/Bom")
.unwrap()
.unwrap();
let bom_paths: Vec<String> = ParsedBom::parse(&bom)
.unwrap()
.paths()
.unwrap()
.into_iter()
.map(|p| p.path().to_string())
.collect();
let gz = reader
.get_file_data_from_path("hello.pkg/Payload")
.unwrap()
.unwrap();
let mut cpio = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut cpio)
.unwrap();
let cpio_paths: Vec<String> = parse_odc(&cpio)
.iter()
.map(|e| e.name.clone())
.filter(|n| n != "TRAILER!!!")
.collect();
let mut bom_paths = bom_paths;
let mut cpio_paths = cpio_paths;
bom_paths.sort();
cpio_paths.sort();
assert_eq!(bom_paths, cpio_paths);
let xml = reader
.get_file_data_from_path("hello.pkg/PackageInfo")
.unwrap()
.unwrap();
let payload = &element_attrs(&xml, "payload")[0];
assert_eq!(
payload.get("numberOfFiles").unwrap(),
&bom_paths.len().to_string()
);
}
#[test]
fn builds_are_byte_stable() {
let dir = tempfile::tempdir().unwrap();
let first = build_archive(dir.path(), "first.pkg");
let second = build_archive(dir.path(), "second.pkg");
assert!(!first.is_empty());
assert_eq!(first, second);
}
#[test]
fn rejects_bad_specs() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out.pkg");
let mut empty = spec(dir.path());
empty.files.clear();
assert!(matches!(build(&empty, &out), Err(Error::NoFiles)));
let mut relative = spec(dir.path());
relative.install_location = "usr/local".to_string();
assert!(matches!(
build(&relative, &out),
Err(Error::InvalidInstallLocation(_))
));
for bad in ["../evil", "bin//x", "/abs", "bin/.."] {
let mut traversal = spec(dir.path());
traversal.files[0].dest = bad.to_string();
assert!(
matches!(build(&traversal, &out), Err(Error::InvalidDest(_))),
"dest {bad:?}"
);
}
let mut duplicate = spec(dir.path());
duplicate.files[1].dest = duplicate.files[0].dest.clone();
assert!(matches!(
build(&duplicate, &out),
Err(Error::DuplicateDest(_))
));
let mut conflict = spec(dir.path());
conflict.files[1].dest = "bin".to_string();
assert!(matches!(
build(&conflict, &out),
Err(Error::DestConflict(_))
));
assert!(!out.exists());
}
const CREDENTIALS: &[u8] = b"secret\n";
fn spec_with_modes(src_dir: &Path) -> PkgSpec {
let mut spec = spec(src_dir);
spec.files.push(FileSpec {
src: write_src(src_dir, "credentials", CREDENTIALS, 0o755),
dest: "etc/credentials".to_string(),
mode: Some(0o600),
});
spec.directories = vec![
DirSpec {
dest: "var/keys".to_string(),
mode: Some(0o700),
},
DirSpec {
dest: "share/doc".to_string(),
mode: Some(0o750),
},
];
spec
}
const MODED_TREE: &[(&str, u64)] = &[
(".", 0o40755),
("./bin", 0o40755),
("./bin/embala-hello", 0o100755),
("./etc", 0o40755),
("./etc/credentials", 0o100600),
("./share", 0o40755),
("./share/doc", 0o40750),
("./share/doc/hello", 0o40755),
("./share/doc/hello/README", 0o100644),
("./var", 0o40755),
("./var/keys", 0o40700),
];
fn bom_modes(bom: &[u8]) -> Vec<(String, u64)> {
let mut listed: Vec<(String, u64)> = ParsedBom::parse(bom)
.unwrap()
.paths()
.unwrap()
.into_iter()
.map(|p| (p.path().to_string(), u64::from(p.file_mode())))
.collect();
listed.sort();
listed
}
#[test]
fn declared_modes_reach_cpio_bom_and_pkginfo() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("modes.pkg");
build(&spec_with_modes(dir.path()), &out).unwrap();
let archive = std::fs::read(&out).unwrap();
let expected: Vec<(String, u64)> = MODED_TREE
.iter()
.map(|(n, m)| (n.to_string(), *m))
.collect();
let cpio = gunzip(&component_file(&archive, "hello.pkg/Payload").unwrap());
let entries = parse_odc(&cpio);
let cpio_modes: Vec<(String, u64)> = entries
.iter()
.filter(|e| e.name != "TRAILER!!!")
.map(|e| (e.name.clone(), e.mode))
.collect();
assert_eq!(cpio_modes, expected);
let keys = entries.iter().find(|e| e.name == "./var/keys").unwrap();
assert_eq!(keys.nlink, 2, "empty directory: no children");
let creds = entries
.iter()
.find(|e| e.name == "./etc/credentials")
.unwrap();
assert_eq!(creds.data, CREDENTIALS);
let bom = component_file(&archive, "hello.pkg/Bom").unwrap();
assert_eq!(bom_modes(&bom), expected);
let xml = component_file(&archive, "hello.pkg/PackageInfo").unwrap();
let payload = &element_attrs(&xml, "payload")[0];
assert_eq!(payload["numberOfFiles"], "11");
assert_eq!(payload["installKBytes"], "5");
let again = dir.path().join("again.pkg");
build(&spec_with_modes(dir.path()), &again).unwrap();
assert_eq!(archive, std::fs::read(again).unwrap(), "byte-stable");
}
#[test]
fn rejects_bad_modes_and_directory_layouts() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out.pkg");
for mode in [0o4755, 0o2755, 0o1777, 0o10000] {
let mut file = spec_with_modes(dir.path());
file.files[2].mode = Some(mode);
assert!(
matches!(build(&file, &out), Err(Error::InvalidMode { .. })),
"file mode {mode:#o}"
);
let mut directory = spec_with_modes(dir.path());
directory.directories[0].mode = Some(mode);
assert!(
matches!(build(&directory, &out), Err(Error::InvalidMode { .. })),
"directory mode {mode:#o}"
);
}
let mut traversal = spec_with_modes(dir.path());
traversal.directories[0].dest = "../keys".to_string();
assert!(matches!(
build(&traversal, &out),
Err(Error::InvalidDest(_))
));
let mut duplicate = spec_with_modes(dir.path());
duplicate.directories[1].dest = "var/keys".to_string();
assert!(matches!(
build(&duplicate, &out),
Err(Error::DuplicateDest(_))
));
let mut same_as_file = spec_with_modes(dir.path());
same_as_file.directories[0].dest = "etc/credentials".to_string();
assert!(build(&same_as_file, &out).is_err());
let mut under_file = spec_with_modes(dir.path());
under_file.directories[0].dest = "etc/credentials/keys".to_string();
assert!(matches!(
build(&under_file, &out),
Err(Error::DestConflict(_))
));
assert!(!out.exists());
}
fn scripts_dir(dir: &Path) -> PathBuf {
let scripts = dir.join("scripts");
std::fs::create_dir_all(scripts.join("lib")).unwrap();
write_src(&scripts, "preinstall", b"#!/bin/sh\necho pre\n", 0o755);
write_src(&scripts, "postinstall", b"#!/bin/sh\necho post\n", 0o755);
write_src(&scripts, "helper.sh", b"helper\n", 0o644);
write_src(&scripts, "lib/inner.txt", b"x\n", 0o644);
scripts
}
fn component_file(archive: &[u8], path: &str) -> Option<Vec<u8>> {
let mut reader = XarReader::new(Cursor::new(archive.to_vec())).unwrap();
reader.get_file_data_from_path(path).unwrap()
}
fn gunzip(gz: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
flate2::read::GzDecoder::new(gz)
.read_to_end(&mut out)
.unwrap();
out
}
fn cpio_listing(cpio: &[u8], root_owned: bool) -> Vec<(String, u64, Vec<u8>)> {
let mut listed: Vec<_> = parse_odc_owned(cpio, root_owned)
.into_iter()
.filter(|e| e.name != "TRAILER!!!")
.map(|e| (e.name, e.mode, e.data))
.collect();
listed.sort();
listed
}
#[test]
fn scripts_ship_as_pkgbuild_scripts_archive() {
let dir = tempfile::tempdir().unwrap();
let mut with_scripts = spec(dir.path());
with_scripts.scripts = Some(scripts_dir(dir.path()));
let out = dir.path().join("scripts.pkg");
build(&with_scripts, &out).unwrap();
let archive = std::fs::read(&out).unwrap();
let reader = XarReader::new(Cursor::new(archive.clone())).unwrap();
let names: Vec<String> = reader
.files()
.unwrap()
.into_iter()
.map(|(n, _)| n)
.collect();
assert_eq!(
names,
[
"hello.pkg",
"hello.pkg/Bom",
"hello.pkg/Payload",
"hello.pkg/Scripts",
"hello.pkg/PackageInfo",
"Distribution"
]
);
let cpio = gunzip(&component_file(&archive, "hello.pkg/Scripts").unwrap());
assert_eq!(cpio.len() % 512, 0);
let listed: Vec<(String, u64)> = cpio_listing(&cpio, true)
.into_iter()
.map(|(n, m, _)| (n, m))
.collect();
let expected = [
(".", 0o40755),
("./helper.sh", 0o100644),
("./lib", 0o40755),
("./lib/inner.txt", 0o100644),
("./postinstall", 0o100755),
("./preinstall", 0o100755),
];
assert_eq!(listed, expected.map(|(n, m)| (n.to_string(), m)).to_vec());
let xml = component_file(&archive, "hello.pkg/PackageInfo").unwrap();
let hooks: Vec<_> = ["preinstall", "postinstall"]
.iter()
.flat_map(|tag| element_attrs(&xml, tag))
.collect();
assert_eq!(hooks.len(), 2);
assert_eq!(hooks[0]["file"], "./preinstall");
assert_eq!(hooks[1]["file"], "./postinstall");
assert!(hooks.iter().all(|h| h["timeout"] == "600"));
let plain = build_archive(dir.path(), "plain.pkg");
assert!(component_file(&plain, "hello.pkg/Scripts").is_none());
let xml = component_file(&plain, "hello.pkg/PackageInfo").unwrap();
assert!(element_attrs(&xml, "scripts").is_empty());
}
#[test]
fn bad_scripts_dirs_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out.pkg");
let mut missing = spec(dir.path());
missing.scripts = Some(dir.path().join("nope"));
assert!(matches!(
build(&missing, &out),
Err(Error::ScriptsNotDir(_))
));
let mut not_exec = spec(dir.path());
let scripts = scripts_dir(dir.path());
std::fs::set_permissions(
scripts.join("postinstall"),
std::fs::Permissions::from_mode(0o644),
)
.unwrap();
not_exec.scripts = Some(scripts);
assert!(matches!(
build(¬_exec, &out),
Err(Error::ScriptNotExecutable(_))
));
assert!(!out.exists());
}
#[cfg(target_os = "macos")]
#[test]
fn scripts_match_pkgbuild() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let scripts = scripts_dir(dir.path());
let root = dir.path().join("root/bin");
std::fs::create_dir_all(&root).unwrap();
write_src(&root, "embala-hello", HELLO, 0o755);
let reference = dir.path().join("ref.pkg");
let status = Command::new("pkgbuild")
.args([
"--identifier",
"ca.hrndz.embala.hello",
"--version",
"0.1.0",
])
.args(["--install-location", "/usr/local", "--quiet", "--root"])
.arg(dir.path().join("root"))
.arg("--scripts")
.arg(&scripts)
.arg(&reference)
.status()
.expect("pkgbuild on PATH");
assert!(status.success());
let reference = std::fs::read(reference).unwrap();
let mut ours = spec(dir.path());
ours.scripts = Some(scripts);
let out = dir.path().join("ours.pkg");
build(&ours, &out).unwrap();
let ours = std::fs::read(out).unwrap();
let theirs_cpio = gunzip(&component_file(&reference, "Scripts").unwrap());
let ours_cpio = gunzip(&component_file(&ours, "hello.pkg/Scripts").unwrap());
assert_eq!(
cpio_listing(&ours_cpio, true),
cpio_listing(&theirs_cpio, false)
);
let theirs_xml = component_file(&reference, "PackageInfo").unwrap();
let ours_xml = component_file(&ours, "hello.pkg/PackageInfo").unwrap();
for tag in ["scripts", "preinstall", "postinstall"] {
assert_eq!(
element_attrs(&ours_xml, tag),
element_attrs(&theirs_xml, tag),
"{tag}"
);
}
}
#[cfg(target_os = "macos")]
#[test]
fn modes_match_pkgbuild() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let spec = spec_with_modes(dir.path());
let root = dir.path().join("root");
std::fs::create_dir(&root).unwrap();
for (path, mode) in &MODED_TREE[1..] {
let staged = root.join(path);
if mode & 0o40000 != 0 {
std::fs::create_dir_all(&staged).unwrap();
} else {
let src = spec.files.iter().find(|f| format!("./{}", f.dest) == *path);
std::fs::copy(&src.unwrap().src, &staged).unwrap();
}
}
for (path, mode) in MODED_TREE.iter().rev() {
let perms = std::fs::Permissions::from_mode(*mode as u32 & 0o7777);
std::fs::set_permissions(root.join(path), perms).unwrap();
}
let reference = dir.path().join("ref.pkg");
let status = Command::new("pkgbuild")
.args([
"--identifier",
"ca.hrndz.embala.hello",
"--version",
"0.1.0",
])
.args(["--install-location", "/usr/local", "--quiet", "--root"])
.arg(&root)
.arg(&reference)
.status()
.expect("pkgbuild on PATH");
assert!(status.success());
let reference = std::fs::read(reference).unwrap();
let out = dir.path().join("ours.pkg");
build(&spec, &out).unwrap();
let ours = std::fs::read(out).unwrap();
let lsbom = |bom: Vec<u8>, name: &str| {
let path = dir.path().join(name);
std::fs::write(&path, bom).unwrap();
let output = Command::new("lsbom")
.args(["-p", "fmsc"])
.arg(&path)
.output()
.unwrap();
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap()
};
assert_eq!(
lsbom(component_file(&ours, "hello.pkg/Bom").unwrap(), "ours.bom"),
lsbom(component_file(&reference, "Bom").unwrap(), "ref.bom")
);
let ours_cpio = gunzip(&component_file(&ours, "hello.pkg/Payload").unwrap());
let theirs_cpio = gunzip(&component_file(&reference, "Payload").unwrap());
assert_eq!(
cpio_listing(&ours_cpio, true),
cpio_listing(&theirs_cpio, false)
);
let ours_xml = component_file(&ours, "hello.pkg/PackageInfo").unwrap();
let theirs_xml = component_file(&reference, "PackageInfo").unwrap();
assert_eq!(
element_attrs(&ours_xml, "payload"),
element_attrs(&theirs_xml, "payload")
);
}