Files
SteamFramelyHomebrew--framely/src/tests.rs
T
toorux cdbe974e13 Apply unified branding and configurable plugin memory limits
Prepare Framely 0.4.2-preview.6 and Installer 0.4.1-preview.4 with shared SVG artwork, platform icons, release packaging, and bilingual documentation.
2026-10-04 21:07:49 +08:00

1610 lines
64 KiB
Rust

use crate::{model::*, package, service::Service};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde_json::{json, Value};
use std::{
collections::BTreeMap,
fs,
io::{Cursor, Write},
path::Path,
};
use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter};
pub(crate) fn accepted_service(root: &Path, manager: u32) -> anyhow::Result<Service> {
let mut core = Service::load(root, manager)?;
core.handle(
"agreement.accept",
json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true}),
)?;
Ok(core)
}
pub(super) fn fixture(
root: &Path,
id: &str,
version: &str,
variant: u8,
run_as: Option<&str>,
) -> Vec<u8> {
let payload = root.join(format!("payload-{version}-{variant}"));
fs::create_dir_all(&payload).unwrap();
fs::write(payload.join("page.js"), "console.log('test')").unwrap();
fs::write(payload.join("backend"), "#!/bin/sh\nexit 0\n").unwrap();
let m = json!({"schemaVersion":1,"apiVersion":1,"id":id,"name":"Test","author":"Developer","version":version,"backend":run_as.map(|r|json!({"entry":"backend","runAs":r})),"ui":{"quickPage":"page.js"},"files":{}});
let manifest = root.join(format!("manifest-{version}-{variant}.json"));
fs::write(&manifest, serde_json::to_vec(&m).unwrap()).unwrap();
let out = root.join(format!("{version}-{variant}.framely"));
package::pack(&manifest, &payload, &out).unwrap();
fs::read(out).unwrap()
}
fn rewrite(bytes: &[u8], replace: impl Fn(&str, &mut Vec<u8>)) -> Vec<u8> {
use std::io::Read;
let mut input = ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut output = ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..input.len() {
let mut f = input.by_index(i).unwrap();
let name = f.name().to_owned();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
replace(&name, &mut data);
output
.start_file(name, SimpleFileOptions::default())
.unwrap();
output.write_all(&data).unwrap();
}
output.finish().unwrap().into_inner()
}
fn install(core: &mut Service, bytes: &[u8], extra: Value) -> anyhow::Result<Value> {
use base64::{engine::general_purpose::STANDARD, Engine};
let mut p = json!({"package":STANDARD.encode(bytes),"approve":true});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
core.handle("install", p)
}
#[test]
fn plugin_packages_can_exceed_previous_archive_and_expanded_limits() {
for (size, compression) in [
(65 * 1024 * 1024, zip::CompressionMethod::Stored),
(129 * 1024 * 1024, zip::CompressionMethod::Deflated),
] {
let payload = vec![b'x'; size];
let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":"test.large","name":"Large","author":"Test","version":"1.0.0","ui":{"quickPage":"page.js"},"files":{"page.js":package::digest(&payload)}});
let mut archive = ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default().compression_method(compression);
archive.start_file("manifest.json", options).unwrap();
archive
.write_all(&serde_json::to_vec(&manifest).unwrap())
.unwrap();
archive.start_file("page.js", options).unwrap();
archive.write_all(&payload).unwrap();
drop(payload);
let bytes = archive.finish().unwrap().into_inner();
let verified = package::verify(&bytes).unwrap();
assert_eq!(verified.files["page.js"].len(), size);
drop(verified);
if compression == zip::CompressionMethod::Stored {
let request = json!({"package":STANDARD.encode(&bytes)});
assert_eq!(package::from_request(&request).unwrap(), bytes);
drop(request);
let expected = package::digest(&bytes);
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let url = format!("http://{}/large.framely", server.server_addr());
let worker = std::thread::spawn(move || {
server
.recv()
.unwrap()
.respond(tiny_http::Response::from_data(bytes))
.unwrap();
});
let downloaded = package::stage_request_proxy(
&json!({"url":url,"allowHttp":true,"sha256":expected}),
&Default::default(),
|_, _| Ok(()),
)
.unwrap();
assert!(std::fs::metadata(&downloaded.path).unwrap().len() > 64 * 1024 * 1024);
assert_eq!(downloaded.manifest().unwrap().id, "test.large");
let path = downloaded.path.clone();
drop(downloaded);
assert!(!path.exists());
worker.join().unwrap();
}
}
}
#[test]
fn unsigned_package_hashes_reject_unlisted_metadata() {
let dir = tempfile::tempdir().unwrap();
let bytes = fixture(dir.path(), "test.plugin", "1.0.0", 1, None);
let mut zip = ZipArchive::new(Cursor::new(&bytes)).unwrap();
assert!(zip.by_name("manifest.sig").is_err());
assert!(zip.by_name("public-key.hex").is_err());
assert!(package::verify(&bytes).is_ok());
assert!(package::verify(&rewrite(&bytes, |n, b| if n == "page.js" {
b.push(b'!')
}))
.is_err());
// Manifest formatting is no longer bound to a developer signature.
assert!(
package::verify(&rewrite(&bytes, |n, b| if n == "manifest.json" {
b.push(b' ')
}))
.is_ok()
);
assert!(
package::verify(&rewrite(&bytes, |n, b| if n == "manifest.json" {
let mut m: Value = serde_json::from_slice(b).unwrap();
m["files"]["page.js"] = json!("0".repeat(64));
*b = serde_json::to_vec(&m).unwrap();
}))
.is_err()
);
let mut old = ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
old.start_file(f.name(), SimpleFileOptions::default())
.unwrap();
std::io::copy(&mut f, &mut old).unwrap();
}
for name in ["public-key.hex", "manifest.sig"] {
old.start_file(name, SimpleFileOptions::default()).unwrap();
old.write_all(b"legacy metadata ignored").unwrap();
}
assert!(package::verify(&old.finish().unwrap().into_inner()).is_err());
}
#[test]
fn malicious_archive_paths_links_and_duplicates_are_rejected() {
for path in ["../escape", "/absolute", "a/../../b", "a\\b"] {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file(path, SimpleFileOptions::default()).unwrap();
zip.write_all(b"x").unwrap();
assert!(package::verify(&zip.finish().unwrap().into_inner()).is_err());
}
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.add_symlink("link", "/etc/shadow", SimpleFileOptions::default())
.unwrap();
assert!(package::verify(&zip.finish().unwrap().into_inner()).is_err());
}
#[test]
fn backend_memory_limit_defaults_validation_and_round_trip() {
let base = json!({"schemaVersion":1,"apiVersion":1,"id":"test.memory","name":"Memory","author":"Dev","version":"1","backend":{"entry":"backend"},"files":{"backend":package::digest(b"x")}});
let manifest: Manifest = serde_json::from_value(base.clone()).unwrap();
manifest.validate().unwrap();
assert_eq!(manifest.memory_limit_mib(), 512);
// Preserve the old serialized shape when the default is used.
assert!(serde_json::to_value(&manifest).unwrap()["backend"]
.get("memoryLimitMiB")
.is_none());
for limit in [1, 512, 2048, u32::MAX] {
let mut value = base.clone();
value["backend"]["memoryLimitMiB"] = json!(limit);
let manifest: Manifest = serde_json::from_value(value).unwrap();
manifest.validate().unwrap();
let packed = serde_json::to_value(&manifest).unwrap();
let restored: Manifest = serde_json::from_value(packed).unwrap();
assert_eq!(restored.memory_limit_mib(), limit);
}
for limit in [
json!(0),
json!(-1),
json!(1.5),
json!("2048"),
json!(true),
json!(null),
json!(4294967296u64),
] {
let mut value = base.clone();
value["backend"]["memoryLimitMiB"] = limit;
assert!(serde_json::from_value::<Manifest>(value).map_or(true, |m| m.validate().is_err()));
}
let mut ui_only = base;
ui_only.as_object_mut().unwrap().remove("backend");
assert_eq!(
serde_json::from_value::<Manifest>(ui_only)
.unwrap()
.memory_limit_mib(),
512
);
}
#[test]
fn default_identity_and_invalid_identities() {
let m = json!({"schemaVersion":1,"apiVersion":1,"id":"test","name":"Test","author":"Dev","version":"1","backend":{"entry":"backend"},"ui":{},"files":{"backend":package::digest(b"x")}});
let manifest: Manifest = serde_json::from_value(m.clone()).unwrap();
assert_eq!(manifest.run_as(), RunAs::Steamos);
manifest.validate().unwrap();
for user in ["framely", "superuser"] {
let mut bad = m.clone();
bad["backend"]["runAs"] = json!(user);
assert!(serde_json::from_value::<Manifest>(bad).is_err());
}
let mut retired = m;
retired["permissions"] = json!([]);
assert!(serde_json::from_value::<Manifest>(retired).is_err());
}
#[test]
fn selected_versions_and_reinstallation_preserve_favorites() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("state");
let mut core = accepted_service(&state, 1000).unwrap();
let a = fixture(dir.path(), "test.plugin", "1.0.0", 1, None);
install(&mut core, &a, json!({})).unwrap();
core.handle(
"plugin.favorite",
json!({"plugin":"test.plugin","favorite":true}),
)
.unwrap();
let b = fixture(dir.path(), "test.plugin", "2.0.0", 2, None);
assert!(core.db.plugins["test.plugin"].source.is_none());
let plan = crate::planner::prepare(&core.db, b.clone(), None, Default::default()).unwrap();
assert_eq!(plan.plan.items[0].action, "update");
install(&mut core, &b, json!({})).unwrap();
assert_eq!(core.db.plugins.len(), 1);
assert!(core.db.plugins["test.plugin"].favorite);
install(&mut core, &a, json!({})).unwrap();
install(&mut core, &a, json!({})).unwrap();
assert!(core
.handle("plugin.rollback", json!({"plugin":"test.plugin"}))
.is_err());
assert_eq!(core.db.plugins["test.plugin"].manifest.version, "1.0.0");
let altered = rewrite(&a, |name, data| {
if name == "manifest.json" {
let mut m: Value = serde_json::from_slice(data).unwrap();
m["name"] = json!("Changed release");
*data = serde_json::to_vec(&m).unwrap();
}
});
assert!(install(&mut core, &altered, json!({}))
.unwrap_err()
.to_string()
.contains("同一插件版本"));
assert_eq!(core.db.plugins["test.plugin"].manifest.version, "1.0.0");
assert!(!state
.join("plugins/test.plugin/versions/1.0.0/public-key.hex")
.exists());
let loaded = accepted_service(&state, 1000).unwrap();
assert!(loaded.db.plugins["test.plugin"].favorite);
}
#[test]
fn privilege_expansion_requires_consent() {
let dir = tempfile::tempdir().unwrap();
let mut core = accepted_service(&dir.path().join("state"), 1000).unwrap();
let a = fixture(dir.path(), "test.plugin", "1", 1, Some("steamos"));
install(&mut core, &a, json!({})).unwrap();
let same_user = rewrite(&a, |name, bytes| {
if name == "manifest.json" {
let mut manifest: Value = serde_json::from_slice(bytes).unwrap();
manifest["version"] = json!("1.1");
*bytes = serde_json::to_vec(&manifest).unwrap();
}
});
let inspection = core
.handle("inspect", json!({"package":STANDARD.encode(&same_user)}))
.unwrap();
assert_eq!(inspection["runAsChanged"], false);
install(&mut core, &same_user, json!({})).unwrap();
let b = fixture(dir.path(), "test.plugin", "2", 1, Some("root"));
assert!(install(&mut core, &b, json!({})).is_err());
install(&mut core, &b, json!({"approveRunAs":true})).unwrap();
assert_eq!(
core.db.plugins["test.plugin"].manifest.run_as(),
RunAs::Root
);
}
#[test]
fn notification_limits_and_sources() {
let n = Notification {
id: "test".into(),
title: "t".into(),
body: "b".into(),
image: None,
actions: vec![],
duration_ms: 8000,
};
n.validate().unwrap();
let mut invalid = n.clone();
invalid.actions = (0..4)
.map(|i| Action {
id: i.to_string(),
label: "x".into(),
icon: "x".into(),
})
.collect();
assert!(invalid.validate().is_err());
invalid = n.clone();
invalid.image = Some("https://example.org/image.png".into());
invalid.validate().unwrap();
invalid.image = Some("file:///etc/shadow".into());
assert!(invalid.validate().is_err());
let s = Source {
id: "local".into(),
name: "Local".into(),
url: "http://localhost:8080/catalog.json".into(),
enabled: true,
allow_http: false,
};
assert!(s.validate().is_err());
let mut allowed = s;
allowed.allow_http = true;
allowed.validate().unwrap();
}
#[test]
fn unlisted_entries_and_incompatible_manifests() {
let dir = tempfile::tempdir().unwrap();
let bytes = fixture(dir.path(), "test.plugin", "1", 1, None);
let mut zip = ZipArchive::new(Cursor::new(&bytes)).unwrap();
let mut entries = BTreeMap::new();
use std::io::Read;
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
let mut b = Vec::new();
f.read_to_end(&mut b).unwrap();
entries.insert(f.name().to_owned(), b);
}
entries.insert("extra".into(), b"unlisted".to_vec());
let mut out = ZipWriter::new(Cursor::new(Vec::new()));
for (n, b) in entries {
out.start_file(n, SimpleFileOptions::default()).unwrap();
out.write_all(&b).unwrap();
}
assert!(package::verify(&out.finish().unwrap().into_inner()).is_err());
let mut m = package::verify(&bytes).unwrap().manifest;
m.api_version = 999;
assert!(m.validate().is_err());
}
#[test]
fn presentation_rehydrates_after_legacy_state_downgrade() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("state");
let mut core = accepted_service(&state, 1000).unwrap();
let bytes = fixture(dir.path(), "test.meta", "1", 3, None);
install(&mut core, &bytes, json!({})).unwrap();
let path = state.join("plugins/test.meta/versions/1/manifest.json");
let mut m: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
m["details"] = json!("Description restored");
m["category"] = json!("旧分类");
m["authorUrl"] = json!("https://example.org/author");
m["documentationUrl"] = json!("https://example.org/docs#usage");
m["homepage"] = json!("https://example.org/plugin");
fs::write(path, serde_json::to_vec(&m).unwrap()).unwrap();
let loaded = accepted_service(&state, 1000).unwrap();
assert_eq!(
loaded.db.plugins["test.meta"].manifest.details,
"Description restored"
);
assert_eq!(
loaded.db.plugins["test.meta"]
.manifest
.documentation_url
.as_deref(),
Some("https://example.org/docs#usage")
);
assert_eq!(
loaded.db.plugins["test.meta"]
.manifest
.author_url
.as_deref(),
Some("https://example.org/author")
);
assert_eq!(
loaded.db.plugins["test.meta"].manifest.homepage.as_deref(),
Some("https://example.org/plugin")
);
assert!(
serde_json::to_value(&loaded.db.plugins["test.meta"].manifest)
.unwrap()
.get("category")
.is_none()
);
}
#[test]
fn source_is_strict_and_catalog_allows_additional_metadata() {
assert!(serde_json::from_value::<UpdateSource>(
json!({"url":"https://example.org/release.json","publicKey":"old"})
)
.is_err());
assert!(serde_json::from_value::<CatalogEntry>(json!({"id":"test.plugin","name":"Test","version":"1.0.0","description":"Test","author":"Developer","apiVersion":1,"url":"https://example.org/test.framely","sha256":"a".repeat(64),"futureMetadata":{"custom":true}})).is_ok());
}
#[test]
fn publication_metadata_survives_pack_and_rejects_invalid_image_urls() {
let dir = tempfile::tempdir().unwrap();
fixture(dir.path(), "test.plugin", "1.0.0", 1, None);
let path = dir.path().join("manifest-1.0.0-1.json");
let mut manifest: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
manifest["downloadUrl"] =
json!("https://github.com/example/plugin/releases/download/v1.0.0/plugin.framely");
manifest["publish"] = json!({"icon":"https://example.org/icon.png","screenshots":["https://example.org/screen.png"]});
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
let out = dir.path().join("publish.framely");
package::pack(&path, &dir.path().join("payload-1.0.0-1"), &out).unwrap();
let verified = package::verify(&fs::read(&out).unwrap()).unwrap();
assert_eq!(verified.manifest.publish.unwrap().screenshots.len(), 1);
manifest["publish"]["icon"] = json!("http://example.org/icon.png");
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
assert!(package::pack(&path, &dir.path().join("payload-1.0.0-1"), &out).is_err());
}
#[test]
fn lifecycle_transactions_recovery_and_privilege_arguments() {
use crate::process::TEST_TOOLS;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
let temp = tempfile::tempdir().unwrap();
let tools = temp.path().join("tools");
fs::create_dir(&tools).unwrap();
let runner = format!(
r#"#!/usr/bin/python3
import os,sys,json,pathlib
root=pathlib.Path({root:?});args=sys.argv[1:]
(root/'arguments.jsonl').open('a').write(json.dumps(args)+'\n')
unit=next(a.split('=',1)[1] for a in args if a.startswith('--unit='))
for a in args:
if a.startswith('--setenv='):
k,v=a[len('--setenv='):].split('=',1);os.environ[k]=v
os.setsid();(root/(unit+'.pid')).write_text(str(os.getpid()))
command=args[args.index('--')+1:];os.execv(command[0],command)
"#,
root = temp.path().to_str().unwrap()
);
let ctl = format!(
r#"#!/usr/bin/python3
import sys,os,signal,pathlib
root=pathlib.Path({root:?});args=sys.argv[1:]
if args[0]=='show':
p=root/(args[1]+'.result');print(p.read_text() if p.exists() else '',end='')
elif args[0]=='stop':
p=root/(args[1]+'.pid')
if p.exists():
try:os.killpg(int(p.read_text()),signal.SIGTERM)
except ProcessLookupError:pass
p.unlink(missing_ok=True)
elif args[0]=='reset-failed':(root/(args[1]+'.result')).unlink(missing_ok=True)
"#,
root = temp.path().to_str().unwrap()
);
for (name, source) in [
("systemd-run", runner),
("systemctl", ctl),
("chown", "#!/bin/sh\nexit 0\n".into()),
] {
let p = tools.join(name);
fs::write(&p, source).unwrap();
fs::set_permissions(p, fs::Permissions::from_mode(0o755)).unwrap();
}
TEST_TOOLS.with(|p| *p.borrow_mut() = Some(tools));
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
}
let _reset = Reset;
let backend = r#"#!/usr/bin/python3
import os,sys,json,pathlib,time
trace=pathlib.Path(os.environ['FRAMELY_DATA_DIR'])/'trace.jsonl'
def record(ctx):
with trace.open('a') as f:f.write(json.dumps(ctx)+'\n')
if os.environ['FRAMELY_PLUGIN_VERSION']=='badupdate' and ctx['phase']=='onUpdate':raise RuntimeError('migration failed')
if ctx['phase']=='onUninstall':raise RuntimeError('cleanup failed')
if os.environ['FRAMELY_PLUGIN_VERSION']=='slowinstall':time.sleep(3)
phase=os.environ.get('FRAMELY_LIFECYCLE')
if phase:
try:record(json.loads(os.environ['FRAMELY_LIFECYCLE_CONTEXT']))
except Exception as e:print(e,file=sys.stderr);sys.exit(1)
sys.exit(0)
for line in sys.stdin:
request=json.loads(line);method=request['method']
if method=='crash':os._exit(7)
if method=='finish':os._exit(0)
if method=='hang':time.sleep(30)
if method.startswith('framely.lifecycle.'):
if os.environ['FRAMELY_PLUGIN_VERSION']=='slowstart' and method.endswith('start'):time.sleep(30)
record(request['params'])
if os.environ['FRAMELY_PLUGIN_VERSION'] in ('badstart','badrollback') and method.endswith('start'):
print(json.dumps({'id':request['id'],'error':'init failed'}),flush=True);continue
print(json.dumps({'id':request['id'],'result':{'ok':True}}),flush=True)
"#;
let make = |version: &str, restart: &str| {
let payload = temp.path().join(format!("fixture-{version}"));
fs::create_dir_all(&payload).unwrap();
fs::write(payload.join("backend.py"), backend).unwrap();
fs::write(payload.join("page.js"), "test").unwrap();
let hook = json!({"entry":"backend.py"});
let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":"test.lifecycle","name":"Lifecycle","author":"Test","version":version,"backend":{"entry":"backend.py","runAs":"steamos","autostart":version!="badrollback","restart":restart,"restartLimit":3,"memoryLimitMiB":if version=="1" {2048} else {512}},"lifecycle":{"onInstall":hook,"onUpdate":hook,"onUninstall":hook,"onCrashCleanup":hook,"onStart":true,"onStop":true,"timeoutSeconds":1},"ui":{"quickPage":"page.js"},"files":{}});
let path = temp.path().join(format!("manifest-{version}.json"));
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
let out = temp.path().join(format!("{version}.framely"));
package::pack(&path, &payload, &out).unwrap();
fs::read(out).unwrap()
};
let mut core =
accepted_service(&temp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
install(&mut core, &make("1", "on-failure"), json!({})).unwrap();
let trace = core.root.join("data/test.lifecycle/steamos/trace.jsonl");
let phases = || {
fs::read_to_string(&trace)
.unwrap()
.lines()
.map(|l| {
serde_json::from_str::<Value>(l).unwrap()["phase"]
.as_str()
.unwrap()
.to_owned()
})
.collect::<Vec<_>>()
};
assert_eq!(phases(), ["onInstall", "onStart"]);
core.handle("plugin.restart", json!({"plugin":"test.lifecycle"}))
.unwrap();
assert_eq!(&phases()[2..], ["onStop", "onStart"]);
assert!(core
.handle(
"plugin.call",
json!({"plugin":"test.lifecycle","method":"framely.lifecycle.stop"})
)
.is_err());
assert!(install(&mut core, &make("badupdate", "on-failure"), json!({})).is_err());
assert_eq!(core.db.plugins["test.lifecycle"].manifest.version, "1");
assert_eq!(
fs::read_link(core.root.join("plugins/test.lifecycle/current")).unwrap(),
Path::new("versions/1")
);
assert!(core
.handle(
"plugin.call",
json!({"plugin":"test.lifecycle","method":"ping"})
)
.is_ok());
assert!(install(&mut core, &make("badstart", "on-failure"), json!({})).is_err());
assert_eq!(core.db.plugins["test.lifecycle"].manifest.version, "1");
assert!(!core
.root
.join("plugins/test.lifecycle/versions/badstart")
.exists());
let timeout_start = Instant::now();
assert!(install(&mut core, &make("slowstart", "on-failure"), json!({})).is_err());
assert!(timeout_start.elapsed() < Duration::from_secs(4));
assert_eq!(core.db.plugins["test.lifecycle"].manifest.version, "1");
assert!(core
.handle(
"plugin.call",
json!({"plugin":"test.lifecycle","method":"crash"})
)
.is_err());
let state = core.handle("status", json!({})).unwrap();
assert_eq!(state["runtime"]["test.lifecycle"]["phase"], "recovering");
assert!(phases().iter().any(|s| s == "onCrashCleanup"));
let events = core.handle("events", json!({})).unwrap();
assert!(events
.as_array()
.unwrap()
.iter()
.any(|v| v["kind"] == "plugin.lifecycle"
&& v["state"]["phase"] == "crashed"
&& v["state"]["detail"]["exitCode"] == 7));
std::thread::sleep(Duration::from_millis(1100));
core.maintenance();
assert!(core.handle("status", json!({})).unwrap()["running"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "test.lifecycle"));
fs::write(
temp.path().join("framely-backend-test.lifecycle.result"),
"Result=oom-kill\nExecMainCode=2\nExecMainStatus=9\n",
)
.unwrap();
assert!(core
.handle(
"plugin.call",
json!({"plugin":"test.lifecycle","method":"crash"})
)
.is_err());
let events = core.handle("events", json!({})).unwrap();
assert!(events
.as_array()
.unwrap()
.iter()
.any(|v| v["kind"] == "plugin.lifecycle"
&& v["state"]["phase"] == "crashed"
&& v["state"]["detail"]["oom"] == true
&& v["state"]["detail"]["signal"] == 9));
install(&mut core, &make("never", "never"), json!({})).unwrap();
assert!(core
.handle(
"plugin.call",
json!({"plugin":"test.lifecycle","method":"crash"})
)
.is_err());
std::thread::sleep(Duration::from_millis(1100));
core.maintenance();
assert!(core.handle("status", json!({})).unwrap()["running"]
.as_array()
.unwrap()
.is_empty());
install(&mut core, &make("badrollback", "never"), json!({})).unwrap();
install(&mut core, &make("good", "never"), json!({})).unwrap();
assert!(install(&mut core, &make("badrollback", "never"), json!({})).is_err());
assert_eq!(core.db.plugins["test.lifecycle"].manifest.version, "good");
assert!(core
.handle(
"plugin.call",
json!({"plugin":"test.lifecycle","method":"ping"})
)
.is_ok());
core.handle(
"agreement.revoke",
json!({"version":AGREEMENT_VERSION,"approve":true}),
)
.unwrap();
assert!(core.handle("status", json!({})).unwrap()["running"]
.as_array()
.unwrap()
.is_empty());
let last: Value =
serde_json::from_str(fs::read_to_string(&trace).unwrap().lines().last().unwrap()).unwrap();
assert_eq!(last["phase"], "onStop");
assert_eq!(last["reason"], "agreement-revoked");
assert!(!core.db.plugins["test.lifecycle"].enabled);
core.handle(
"agreement.accept",
json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true}),
)
.unwrap();
assert!(core.handle("status", json!({})).unwrap()["running"]
.as_array()
.unwrap()
.is_empty());
core.handle(
"plugin.enable",
json!({"plugin":"test.lifecycle","enabled":true}),
)
.unwrap();
assert!(core
.handle("plugin.uninstall", json!({"plugin":"test.lifecycle"}))
.is_err());
assert!(core.db.plugins.contains_key("test.lifecycle"));
core.handle(
"plugin.uninstall",
json!({"plugin":"test.lifecycle","force":true}),
)
.unwrap();
assert!(core.db.plugins.is_empty());
assert!(trace.exists());
let start = Instant::now();
assert!(install(&mut core, &make("slowinstall", "never"), json!({})).is_err());
assert!(start.elapsed() < Duration::from_secs(4));
assert!(!core.db.plugins.contains_key("test.lifecycle"));
let arguments = fs::read_to_string(temp.path().join("arguments.jsonl")).unwrap();
assert!(arguments.contains(&format!("User={}", unsafe { libc::geteuid() })));
assert!(!arguments.contains("PrivateNetwork=yes"));
assert!(arguments.contains("NoNewPrivileges=yes"));
let launches: Vec<Vec<String>> = arguments
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
for launch in &launches {
let expected = if launch
.iter()
.any(|arg| arg == "--setenv=FRAMELY_PLUGIN_VERSION=1")
{
"--property=MemoryMax=2048M"
} else {
"--property=MemoryMax=512M"
};
assert!(launch.iter().any(|arg| arg == expected));
}
assert!(launches.iter().any(|args| args
.iter()
.any(|arg| arg == "--setenv=FRAMELY_LIFECYCLE=onInstall")
&& args.iter().any(|arg| arg == "--property=MemoryMax=2048M")));
assert!(arguments.contains("RuntimeMaxSec=1"));
core.shutdown();
}
#[test]
fn lifecycle_manifest_entries_users_and_timeouts_are_validated() {
let dir = tempfile::tempdir().unwrap();
let bytes = fixture(dir.path(), "test.lifecycle", "1", 1, Some("steamos"));
let mut manifest = package::verify(&bytes).unwrap().manifest;
manifest.lifecycle =
Some(serde_json::from_value(json!({"onInstall":{"entry":"../escape"}})).unwrap());
assert!(manifest.validate().is_err());
manifest.lifecycle =
Some(serde_json::from_value(json!({"runAs":"root","onStart":true})).unwrap());
assert!(manifest.validate().is_err());
manifest.lifecycle = Some(
serde_json::from_value(json!({"onInstall":{"entry":"backend"},"timeoutSeconds":0}))
.unwrap(),
);
assert!(manifest.validate().is_err());
manifest.lifecycle = Some(
serde_json::from_value(json!({"onInstall":{"entry":"backend"},"timeoutSeconds":1}))
.unwrap(),
);
assert!(manifest.validate().is_ok());
let target = dir.path().join("unpacked");
let mut verified = package::verify(&bytes).unwrap();
verified.manifest.backend = None;
verified.manifest.lifecycle = manifest.lifecycle;
package::unpack(&verified, &target).unwrap();
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(target.join("backend"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o755
);
}
#[test]
fn plugin_window_dimensions_validate_and_allow_custom_sizes() {
let mut m: Manifest = serde_json::from_value(json!({"schemaVersion":1,"apiVersion":1,"id":"test.window","name":"Window","author":"Test","version":"1","ui":{"windows":{"main":{"entry":"page.js","title":"Window","dockIcon":true}}},"files":{"page.js":"0".repeat(64)}})).unwrap();
m.validate().unwrap();
let w = m.ui.windows.get_mut("main").unwrap();
assert_eq!((w.width, w.height), (1600, 900));
assert_eq!(w.width_meters, Some(3.0));
w.width = 1920;
w.height = 1080;
w.width_meters = Some(1.8);
m.validate().unwrap();
let serialized = serde_json::to_value(&m).unwrap();
assert_eq!(serialized["ui"]["windows"]["main"]["width"], 1920);
m.ui.windows.get_mut("main").unwrap().width = 2561;
assert!(m.validate().is_err());
m.ui.windows.get_mut("main").unwrap().width = 1600;
m.ui.windows.get_mut("main").unwrap().width_meters = Some(f32::NAN);
assert!(m.validate().is_err());
}
fn relation_package(dir: &Path, id: &str, version: &str, extra: Value) -> Vec<u8> {
let payload = dir.join(format!("payload-{id}-{version}"));
fs::create_dir_all(&payload).unwrap();
fs::write(payload.join("page.js"), "test").unwrap();
fs::write(payload.join("fail.sh"), "#!/bin/sh\nexit 1\n").unwrap();
let mut m = json!({"schemaVersion":1,"apiVersion":1,"id":id,"name":id,"author":"test","version":version,"ui":{"quickPage":"page.js"},"files":{}});
for (key, value) in extra.as_object().unwrap() {
m[key] = value.clone();
}
let path = dir.join(format!("{id}-{version}.json"));
fs::write(&path, serde_json::to_vec(&m).unwrap()).unwrap();
let out = dir.join(format!("{id}-{version}.framely"));
package::pack(&path, &payload, &out).unwrap();
fs::read(out).unwrap()
}
fn apply_prepared(core: &mut Service, prepared: crate::planner::Prepared) -> anyhow::Result<Value> {
core.handle("install.batch",json!({"approve":true,"approveRunAs":true,"plan":prepared.plan,"packages":prepared.packages.iter().map(|(bytes,source)|{let mut request=bytes.request();request["source"]=json!(source);request}).collect::<Vec<_>>()}))
}
#[test]
fn dependency_install_conflicts_reverse_constraints_and_removal() {
let tmp = tempfile::tempdir().unwrap();
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
let base = relation_package(tmp.path(), "test.base", "1.0.0", json!({}));
install(&mut core, &base, json!({})).unwrap();
let child = relation_package(
tmp.path(),
"test.child",
"1.0.0",
json!({"dependencies":{"test.base":"^1.0.0"}}),
);
install(&mut core, &child, json!({})).unwrap();
let bad = relation_package(tmp.path(), "test.base", "2.0.0", json!({}));
assert!(install(&mut core, &bad, json!({})).is_err());
assert_eq!(core.db.plugins["test.base"].manifest.version, "1.0.0");
let other = relation_package(
tmp.path(),
"test.other",
"1.0.0",
json!({"exclusiveResources":["test.resource"],"conflicts":{"test.base":"*"}}),
);
assert!(install(&mut core, &other, json!({})).is_err());
let prepared = crate::planner::prepare(&core.db, other, None, Default::default()).unwrap();
assert_eq!(prepared.plan.disable, ["test.base", "test.child"]);
apply_prepared(&mut core, prepared).unwrap();
assert!(!core.db.plugins["test.child"].enabled);
let plan = core
.handle("plugin.enable.preview", json!({"plugin":"test.child"}))
.unwrap();
assert_eq!(plan["disable"], json!(["test.other"]));
assert!(core
.handle(
"plugin.enable",
json!({"plugin":"test.child","enabled":true})
)
.is_err());
core.handle("plugin.enable",json!({"plugin":"test.child","enabled":true,"approve":true,"fingerprint":plan["fingerprint"]})).unwrap();
assert!(core
.handle("plugin.uninstall", json!({"plugin":"test.base"}))
.is_err());
let preview = core
.handle("plugin.disable.preview", json!({"plugin":"test.base"}))
.unwrap();
core.handle(
"plugin.uninstall",
json!({"plugin":"test.base","approveDependents":true,"fingerprint":preview["fingerprint"]}),
)
.unwrap();
assert!(!core.db.plugins["test.child"].enabled);
let unrelated = relation_package(tmp.path(), "test.unrelated", "1.0.0", json!({}));
install(&mut core, &unrelated, json!({})).unwrap();
}
#[test]
fn same_source_dependency_downloads_and_stale_plan_are_verified() {
use tiny_http::{Response, Server};
let tmp = tempfile::tempdir().unwrap();
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
let base = relation_package(tmp.path(), "test.base", "1.0.0", json!({}));
let child = relation_package(
tmp.path(),
"test.child",
"1.0.0",
json!({"dependencies":{"test.base":"^1.0.0"}}),
);
let server = Server::http("127.0.0.1:0").unwrap();
let url = format!("http://{}", server.server_addr());
let latest = json!({"id":"test.base","name":"test.base","version":"2.0.0","author":"test","description":"","apiVersion":1,"url":format!("{url}/unused.framely"),"sha256":package::digest(&base)});
let old = json!({"id":"test.base","name":"test.base","version":"1.0.0","author":"test","description":"","apiVersion":1,"url":format!("{url}/base.framely"),"sha256":package::digest(&base)});
let catalog = json!({"schemaVersion":1,"name":"test","plugins":[latest]});
let history = json!({"schemaVersion":1,"id":"test.base","versions":[old]});
let worker = std::thread::spawn(move || {
for _ in 0..3 {
let request = server.recv().unwrap();
let response = if request.url() == "/catalog.json" {
Response::from_data(serde_json::to_vec(&catalog).unwrap())
} else if request.url() == "/plugins/test.base/versions.json" {
Response::from_data(serde_json::to_vec(&history).unwrap())
} else {
Response::from_data(base.clone())
};
request.respond(response).unwrap();
}
});
core.handle("sources.save",json!({"sources":[{"id":"test.source","name":"test","url":format!("{url}/catalog.json"),"allowHttp":true}]})).unwrap();
let prepared = crate::planner::prepare(
&core.db,
child.clone(),
Some("test.source".into()),
Default::default(),
)
.unwrap();
worker.join().unwrap();
assert_eq!(
prepared
.plan
.items
.iter()
.map(|p| p.manifest.id.as_str())
.collect::<Vec<_>>(),
["test.base", "test.child"]
);
apply_prepared(&mut core, prepared).unwrap();
let next = relation_package(
tmp.path(),
"test.child",
"1.1.0",
json!({"dependencies":{"test.base":"^1.0.0"}}),
);
let stale = crate::planner::prepare(
&core.db,
next,
Some("test.source".into()),
Default::default(),
)
.unwrap();
core.handle(
"plugin.favorite",
json!({"plugin":"test.child","favorite":true}),
)
.unwrap();
assert!(apply_prepared(&mut core, stale).is_err());
assert_eq!(core.db.plugins["test.child"].manifest.version, "1.0.0");
}
fn fake_backend_tools(temp: &Path) -> impl Drop {
use std::os::unix::fs::PermissionsExt;
let tools = temp.join("tools");
fs::create_dir_all(&tools).unwrap();
let runner = format!(
r#"#!/usr/bin/python3
import os,sys,pathlib
root=pathlib.Path({root:?});args=sys.argv[1:];unit=next(a.split('=',1)[1] for a in args if a.startswith('--unit='))
for arg in args:
if arg.startswith('--setenv='):
key,value=arg[9:].split('=',1);os.environ[key]=value
os.setsid();(root/(unit+'.pid')).write_text(str(os.getpid()))
command=args[args.index('--')+1:];os.execv(command[0],command)
"#,
root = temp.to_str().unwrap()
);
let ctl = format!(
r#"#!/usr/bin/python3
import os,sys,pathlib,signal
root=pathlib.Path({root:?});args=sys.argv[1:]
if args[0]=='stop':
p=root/(args[1]+'.pid')
if p.exists():
try:os.killpg(int(p.read_text()),signal.SIGTERM)
except ProcessLookupError:pass
p.unlink(missing_ok=True)
"#,
root = temp.to_str().unwrap()
);
for (name, source) in [
("systemd-run", runner),
("systemctl", ctl),
("chown", "#!/bin/sh\nexit 0\n".into()),
] {
let path = tools.join(name);
fs::write(&path, source).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = Some(tools));
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
}
Reset
}
#[test]
fn dependency_crash_suspends_and_recovers_in_start_stop_order() {
let tmp = tempfile::tempdir().unwrap();
let _tools = fake_backend_tools(tmp.path());
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
let trace = tmp.path().join("trace");
let backend = format!(
r#"#!/usr/bin/python3
import os,sys,json,pathlib
for line in sys.stdin:
request=json.loads(line)
if request['method']=='crash':os._exit(7)
if request['method'].startswith('framely.lifecycle.'):
with pathlib.Path({trace:?}).open('a') as log:log.write(os.environ['FRAMELY_PLUGIN_ID']+':'+request['method'].split('.')[-1]+'\n')
print(json.dumps({{'id':request['id'],'result':True}}),flush=True)
"#,
trace = trace.to_str().unwrap()
);
for (id, deps) in [
("test.base", json!({})),
("test.child", json!({"test.base":"^1.0.0"})),
] {
let payload = tmp.path().join(id);
fs::create_dir(&payload).unwrap();
fs::write(payload.join("backend.py"), &backend).unwrap();
let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":id,"name":id,"author":"test","version":"1.0.0","dependencies":deps,"backend":{"entry":"backend.py","runAs":"steamos"},"lifecycle":{"onStart":true,"onStop":true},"files":{}});
let path = tmp.path().join(format!("{id}.json"));
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
let out = tmp.path().join(format!("{id}.framely"));
package::pack(&path, &payload, &out).unwrap();
install(&mut core, &fs::read(out).unwrap(), json!({})).unwrap();
}
core.handle("plugin.open", json!({"plugin":"test.child"}))
.unwrap();
assert_eq!(
fs::read_to_string(&trace).unwrap(),
"test.base:start\ntest.child:start\n"
);
assert!(core
.handle(
"plugin.call",
json!({"plugin":"test.base","method":"crash"})
)
.is_err());
let status = core.handle("status", json!({})).unwrap();
assert_eq!(
status["runtime"]["test.child"]["phase"],
"waiting-dependency"
);
assert!(core.db.plugins["test.child"].enabled);
assert!(core.db.plugins["test.child"].error.is_none());
std::thread::sleep(std::time::Duration::from_millis(1100));
core.maintenance();
assert_eq!(
fs::read_to_string(&trace).unwrap(),
"test.base:start\ntest.child:start\ntest.child:stop\ntest.base:start\ntest.child:start\n"
);
core.shutdown();
}
#[test]
fn batch_hook_failure_and_interrupted_transaction_restore_state() {
let tmp = tempfile::tempdir().unwrap();
let _tools = fake_backend_tools(tmp.path());
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
let original_package = relation_package(tmp.path(), "test.original", "1.0.0", json!({}));
install(&mut core, &original_package, json!({})).unwrap();
let original = core.db.clone();
let first = relation_package(tmp.path(), "test.first", "1.0.0", json!({}));
let bad = relation_package(
tmp.path(),
"test.bad",
"1.0.0",
json!({"lifecycle":{"runAs":"steamos","onInstall":{"entry":"fail.sh"}}}),
);
let first_manifest = package::verify(&first).unwrap().manifest;
let bad_manifest = package::verify(&bad).unwrap().manifest;
let plan = crate::planner::Plan {
fingerprint: crate::relations::fingerprint(&core.db).unwrap(),
root: "test.bad".into(),
items: vec![
crate::planner::Item {
manifest: first_manifest,
source: None,
action: "install".into(),
run_as_changed: false,
},
crate::planner::Item {
manifest: bad_manifest,
source: None,
action: "install".into(),
run_as_changed: false,
},
],
add_sources: vec![],
enable: vec!["test.bad".into()],
disable: vec![],
affected: vec!["test.first".into(), "test.bad".into()],
};
assert!(apply_prepared(
&mut core,
crate::planner::Prepared {
plan,
packages: vec![
(package::Staged::from_bytes(&first).unwrap(), None),
(package::Staged::from_bytes(&bad).unwrap(), None)
]
}
)
.is_err());
assert_eq!(
serde_json::to_value(&core.db).unwrap(),
serde_json::to_value(&original).unwrap()
);
assert!(!core.root.join("plugins/test.first").exists());
assert!(!core.root.join("install-transaction.json").exists());
install(&mut core, &first, json!({})).unwrap();
fs::write(
core.root.join("install-transaction.json"),
serde_json::to_vec(&json!({"database":original,"targets":["test.first"],"running":[]}))
.unwrap(),
)
.unwrap();
let restored = accepted_service(&core.root, core.manager).unwrap();
assert!(!restored.db.plugins.contains_key("test.first"));
assert!(!restored.root.join("plugins/test.first").exists());
}
#[test]
fn cross_source_resolution_requires_choice_and_uses_the_chosen_package() {
use tiny_http::{Response, Server};
let tmp = tempfile::tempdir().unwrap();
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
let base = relation_package(tmp.path(), "test.base", "1.0.0", json!({}));
let child = relation_package(
tmp.path(),
"test.child",
"1.0.0",
json!({"dependencies":{"test.base":"^1.0.0"}}),
);
let server = Server::http("127.0.0.1:0").unwrap();
let url = format!("http://{}", server.server_addr());
let catalog = json!({"schemaVersion":1,"name":"test","plugins":[{"id":"test.base","name":"test.base","version":"1.0.0","author":"test","description":"","apiVersion":1,"url":format!("{url}/base.framely"),"sha256":package::digest(&base)}]});
let worker = std::thread::spawn(move || {
for _ in 0..3 {
let request = server
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
.expect("resolver request missing");
let response = if request.url() == "/catalog.json" {
Response::from_data(serde_json::to_vec(&catalog).unwrap())
} else {
Response::from_data(base.clone())
};
request.respond(response).unwrap();
}
});
let source_url = format!("{url}/catalog.json");
core.handle(
"sources.save",
json!({"sources":[{"id":"other-source","name":"other","url":source_url,"allowHttp":true}]}),
)
.unwrap();
let error = crate::planner::prepare(&core.db, child.clone(), None, Default::default())
.err()
.unwrap();
let choice = error
.downcast_ref::<crate::planner::ChoiceRequired>()
.unwrap();
assert_eq!(choice.dependency, "test.base");
assert_eq!(choice.candidates.len(), 1);
let prepared = crate::planner::prepare(
&core.db,
child,
None,
std::collections::BTreeMap::from([("test.base".into(), source_url)]),
)
.unwrap();
apply_prepared(&mut core, prepared).unwrap();
assert_eq!(
core.db.plugins["test.base"].source.as_deref(),
Some("other-source")
);
worker.join().unwrap();
}
#[test]
fn plugin_web_links_survive_pack_and_catalog_and_reject_unsafe_schemes() {
let dir = tempfile::tempdir().unwrap();
fixture(dir.path(), "test.links", "1", 1, None);
let path = dir.path().join("manifest-1-1.json");
let mut value: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
for key in ["authorUrl", "documentationUrl", "homepage"] {
value[key] = json!("https://example.org/plugin#usage");
}
fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
let out = dir.path().join("links.framely");
package::pack(&path, &dir.path().join("payload-1-1"), &out).unwrap();
let manifest = package::verify(&fs::read(out).unwrap()).unwrap().manifest;
let mut catalog = serde_json::to_value(&manifest).unwrap();
// Catalog and manifest use the same optional field names.
catalog = json!({"id":"test.links","name":"Links","version":"1","description":"Links","author":"Test","apiVersion":1,"url":"https://example.org/plugin.framely","sha256":"a".repeat(64),"authorUrl":catalog["authorUrl"],"documentationUrl":catalog["documentationUrl"],"homepage":catalog["homepage"]});
let entry: CatalogEntry = serde_json::from_value(catalog.clone()).unwrap();
entry.validate(false).unwrap();
assert_eq!(entry.author_url, manifest.author_url);
assert_eq!(entry.documentation_url, manifest.documentation_url);
assert_eq!(entry.homepage, manifest.homepage);
for key in ["authorUrl", "documentationUrl", "homepage"] {
for url in [
"javascript:alert(1)",
"file:///tmp/test",
"https://user:password@example.org",
"https://",
"https://example.org/a b",
] {
value[key] = json!(url);
assert!(serde_json::from_value::<Manifest>(value.clone())
.unwrap()
.validate()
.is_err());
catalog[key] = json!(url);
assert!(serde_json::from_value::<CatalogEntry>(catalog.clone())
.unwrap()
.validate(false)
.is_err());
}
value[key] = json!("https://example.org/plugin#usage");
catalog[key] = value[key].clone();
}
}
#[test]
fn plugin_web_links_launch_browser_with_a_single_argument() {
use crate::process::{open_web_link, TEST_TOOLS};
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let opener = dir.path().join("xdg-open");
fs::write(
&opener,
"#!/bin/sh\n[ \"$#\" = 1 ] && [ \"$1\" = 'https://example.org/docs#usage' ]\n",
)
.unwrap();
fs::set_permissions(&opener, fs::Permissions::from_mode(0o755)).unwrap();
TEST_TOOLS.with(|p| *p.borrow_mut() = Some(dir.path().to_owned()));
assert!(open_web_link("https://example.org/docs#usage").is_ok());
assert!(open_web_link("javascript:alert(1)").is_err());
assert!(open_web_link("https://example.org/other").is_err());
TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
#[test]
fn network_panel_defaults_persist_and_validate() {
let root = tempfile::tempdir().unwrap();
let mut core = accepted_service(root.path(), 1000).unwrap();
assert!(core.db.network_panel.enabled);
assert_eq!(core.db.network_panel.port, 15915);
assert!(!core.db.network_panel.password_enabled);
assert!(core
.handle(
"network.save",
json!({"enabled":true,"port":15915,"passwordEnabled":true})
)
.is_err());
assert!(core
.handle("network.save", json!({"enabled":false,"port":80}))
.is_err());
core.handle("network.save", json!({"enabled":false,"port":16000}))
.unwrap();
let restored = accepted_service(root.path(), 1000).unwrap();
assert!(!restored.db.network_panel.enabled);
assert_eq!(restored.db.network_panel.port, 16000);
}
#[test]
fn proxy_settings_persist_and_reject_invalid_values() {
let root = tempfile::tempdir().unwrap();
let mut core = accepted_service(root.path(), 1000).unwrap();
assert_eq!(core.db.proxy, crate::model::ProxySettings::default());
core.handle("proxy.save",json!({"httpEnabled":true,"githubEnabled":true,"http":" http://127.0.0.1:7890 ","github":"https://gh-proxy.com/"})).unwrap();
let loaded = accepted_service(root.path(), 1000).unwrap();
assert_eq!(loaded.db.proxy.http, "http://127.0.0.1:7890");
assert_eq!(loaded.db.proxy.github, "https://gh-proxy.com");
assert!(loaded.db.proxy.http_enabled && loaded.db.proxy.github_enabled);
assert!(core
.handle(
"proxy.save",
json!({"http":"file:///tmp/proxy","github":""})
)
.is_err());
assert_eq!(core.db.proxy.http, "http://127.0.0.1:7890");
core.handle("proxy.save",json!({"httpEnabled":false,"githubEnabled":false,"http":"http://127.0.0.1:7890","github":"https://gh-proxy.com"})).unwrap();
let disabled = accepted_service(root.path(), 1000).unwrap();
assert!(!disabled.db.proxy.http_enabled && !disabled.db.proxy.github_enabled);
assert_eq!(disabled.db.proxy.github, "https://gh-proxy.com");
assert!(core
.handle(
"proxy.save",
json!({"httpEnabled":true,"http":"","github":""})
)
.is_err());
core.handle("proxy.save", json!({"http":"","github":""}))
.unwrap();
assert_eq!(core.db.proxy, crate::model::ProxySettings::default());
}
#[test]
fn first_launch_agreement_gates_operations_and_persists_consent() {
let root = tempfile::tempdir().unwrap();
let mut core = Service::load(root.path(), 1000).unwrap();
assert_eq!(
core.handle("agreement.status", json!({})).unwrap()["accepted"],
false
);
core.autostart();
core.maintenance();
assert!(core.handle("status", json!({})).unwrap()["running"]
.as_array()
.unwrap()
.is_empty());
for method in [
"install",
"plugin.open",
"sources.save",
"proxy.save",
"system.check.start",
"network.save",
] {
assert!(core
.handle(method, json!({}))
.unwrap_err()
.to_string()
.contains("请先同意"));
}
for params in [
json!({"version":AGREEMENT_VERSION,"userAgreement":true}),
json!({"version":"old","userAgreement":true,"privacyStatement":true}),
json!({"version":AGREEMENT_VERSION,"userAgreement":false,"privacyStatement":true}),
] {
assert!(core.handle("agreement.accept", params).is_err());
}
core.handle(
"agreement.accept",
json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true}),
)
.unwrap();
let time = core.db.agreement_acceptance.as_ref().unwrap().accepted_at;
assert!(time > 0);
core.handle(
"agreement.accept",
json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true}),
)
.unwrap();
assert_eq!(
core.db.agreement_acceptance.as_ref().unwrap().accepted_at,
time
);
let mut restored = Service::load(root.path(), 1000).unwrap();
assert_eq!(
restored.handle("agreement.status", json!({})).unwrap()["accepted"],
true
);
restored
.handle("proxy.save", json!({"http":"","github":""}))
.unwrap();
restored.db.agreement_acceptance.as_mut().unwrap().version = "old".into();
assert_eq!(
restored.handle("status", json!({})).unwrap()["agreement"]["accepted"],
false
);
assert!(restored
.handle("proxy.save", json!({"http":"","github":""}))
.is_err());
}
#[test]
fn agreement_revocation_requires_confirmation_and_disables_all_plugins_persistently() {
let root = tempfile::tempdir().unwrap();
let mut core = accepted_service(root.path(), 1000).unwrap();
for id in ["test.agreement-a", "test.agreement-b"] {
let bytes = fixture(root.path(), id, "1", 1, None);
install(&mut core, &bytes, json!({})).unwrap();
}
assert!(core.db.plugins.values().all(|p| p.enabled));
assert!(core
.handle("agreement.revoke", json!({"version":AGREEMENT_VERSION}))
.is_err());
assert!(core.db.agreement_acceptance.is_some());
assert!(core.db.plugins.values().all(|p| p.enabled));
fs::create_dir(root.path().join("state.json.tmp")).unwrap();
assert!(core
.handle(
"agreement.revoke",
json!({"version":AGREEMENT_VERSION,"approve":true})
)
.is_err());
assert!(core.db.agreement_acceptance.is_some());
assert!(core.db.plugins.values().all(|p| p.enabled));
fs::remove_dir(root.path().join("state.json.tmp")).unwrap();
core.handle(
"agreement.revoke",
json!({"version":AGREEMENT_VERSION,"approve":true}),
)
.unwrap();
assert!(core.db.agreement_acceptance.is_none());
assert!(core.db.plugins.values().all(|p| !p.enabled));
let status = core.handle("status", json!({})).unwrap();
assert_eq!(status["agreement"]["accepted"], false);
assert!(status["running"].as_array().unwrap().is_empty());
assert!(core
.handle(
"plugin.enable",
json!({"plugin":"test.agreement-a","enabled":true})
)
.is_err());
let events = core.handle("events", json!({})).unwrap();
assert!(events
.as_array()
.unwrap()
.iter()
.any(|e| e["kind"] == "plugin.disabled" && e["plugin"] == "test.agreement-a"));
let mut restored = Service::load(root.path(), 1000).unwrap();
assert!(restored.db.agreement_acceptance.is_none());
assert!(restored.db.plugins.values().all(|p| !p.enabled));
restored
.handle(
"agreement.accept",
json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true}),
)
.unwrap();
assert!(restored.db.plugins.values().all(|p| !p.enabled));
}
#[test]
fn localization_defaults_install_fallback_and_persistence() {
use crate::localization::{valid_locale, LanguagePack};
let root = tempfile::tempdir().unwrap();
let mut core = Service::load(root.path(), 1000).unwrap();
assert_eq!(core.db.language, "auto");
assert!(core.handle("language.list", json!({})).is_ok());
core.handle("language.save", json!({"language":"en-US"}))
.unwrap();
assert_eq!(
Service::load(root.path(), 1000).unwrap().db.language,
"en-US"
);
assert!(core.db.agreement_acceptance.is_none());
assert!(core
.handle("language.save", json!({"language":"../en-US"}))
.is_err());
assert!(core.handle("language.install", json!({})).is_err());
core.handle(
"agreement.accept",
json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true}),
)
.unwrap();
let pack = json!({"schemaVersion":1,"locale":"fr-FR","name":"Français","messages":{"语言":"Langue","启用 {0}":"Activer {0}"}});
core.handle("language.install", json!({"pack":pack}))
.unwrap();
let listed = core.handle("language.list", json!({})).unwrap();
assert_eq!(listed["packs"][0]["locale"], "fr-FR");
core.handle("language.save", json!({"language":"fr-FR"}))
.unwrap();
assert_eq!(
Service::load(root.path(), 1000).unwrap().db.language,
"fr-FR"
);
for code in ["../en-US", "en/US", "auto.json", ""] {
assert!(!valid_locale(code));
assert!(core
.handle("language.save", json!({"language":code}))
.is_err());
}
assert!(core
.handle("language.save", json!({"language":"de-DE"}))
.is_err());
let mut bad: LanguagePack = serde_json::from_value(pack.clone()).unwrap();
bad.messages.insert("启用 {0}".into(), "Activer".into());
assert!(bad.validate().is_err());
fs::write(root.path().join("locales/broken.json"), "not json").unwrap();
let listed = core.handle("language.list", json!({})).unwrap();
assert_eq!(listed["invalidFiles"], json!(["broken.json"]));
assert_eq!(listed["packs"].as_array().unwrap().len(), 1);
fs::create_dir(root.path().join("state.json.tmp")).unwrap();
assert!(core
.handle("language.save", json!({"language":"en-US"}))
.is_err());
assert_eq!(core.db.language, "fr-FR");
let old: crate::model::Database =
serde_json::from_value(json!({"plugins":{},"sources":[],"safeMode":false})).unwrap();
assert_eq!(old.language, "auto");
}
#[test]
fn hook_failure_does_not_stop_plugin_with_hook_suffix() {
let tmp = tempfile::tempdir().unwrap();
let _tools = fake_backend_tools(tmp.path());
let runner = tmp.path().join("tools/systemd-run");
let script = fs::read_to_string(&runner).unwrap();
fs::write(
&runner,
script.replace(
"for arg in args:",
"if (root/(unit+'.pid')).exists(): sys.exit(1)\nfor arg in args:",
),
)
.unwrap();
let backend = b"#!/bin/sh\nsleep 60\n".to_vec();
let m:Manifest=serde_json::from_value(json!({"schemaVersion":1,"apiVersion":1,"id":"review.foo-hook","name":"Victim","author":"Test","version":"1.0.0","backend":{"entry":"backend","runAs":"steamos"},"ui":{"quickPage":"page.js"},"files":{"backend":package::digest(&backend),"page.js":package::digest(b"test")}})).unwrap();
let payload = tmp.path().join("victim");
package::unpack(
&package::Verified {
manifest: m.clone(),
files: BTreeMap::from([
("backend".into(), backend),
("page.js".into(), b"test".to_vec()),
]),
},
&payload,
)
.unwrap();
let mut victim = crate::process::Running::start(
&m,
&payload,
unsafe { libc::geteuid() },
Default::default(),
&tmp.path().join("logs"),
)
.unwrap();
let pid = tmp.path().join("framely-backend-review.foo-hook.pid");
for _ in 0..100 {
if pid.exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(pid.exists());
assert!(!victim.exited());
let bytes = relation_package(
tmp.path(),
"review.foo",
"1.0.0",
json!({"lifecycle":{"runAs":"steamos","onInstall":{"entry":"fail.sh"}}}),
);
let verified = package::verify(&bytes).unwrap();
let hook_payload = tmp.path().join("hook");
package::unpack(&verified, &hook_payload).unwrap();
let result = crate::process::hook(
&verified.manifest,
verified
.manifest
.lifecycle
.as_ref()
.unwrap()
.on_install
.as_ref()
.unwrap(),
&hook_payload,
unsafe { libc::geteuid() },
&tmp.path().join("logs"),
json!({"phase":"onInstall"}),
std::time::Duration::from_secs(1),
);
assert!(result.is_err());
for _ in 0..100 {
if !victim.exited() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!victim.exited());
println!("Hook and backend use disjoint service names");
}
#[test]
fn manager_uninstall_disables_all_plugins_and_removes_packages_before_body() {
let root = tempfile::tempdir().unwrap();
let mut core = accepted_service(root.path(), 1000).unwrap();
let a = fixture(root.path(), "test.first", "1.0.0", 70, None);
let b = fixture(root.path(), "test.second", "1.0.0", 71, None);
install(&mut core, &a, json!({})).unwrap();
install(&mut core, &b, json!({})).unwrap();
fs::create_dir_all(root.path().join("data/test.first")).unwrap();
fs::write(root.path().join("data/test.first/saved"), b"retain").unwrap();
fs::write(root.path().join("manager-marker"), b"body").unwrap();
// Uninstall must work even when the user has not accepted current agreements.
core.db.agreement_acceptance = None;
assert!(core.handle("system.uninstall.prepare", json!({})).is_err());
let result = core
.handle("system.uninstall.prepare", json!({"approve":true}))
.unwrap();
assert_eq!(result["uninstalled"].as_array().unwrap().len(), 2);
assert!(core.db.plugins.is_empty());
assert!(core.db.safe_mode);
assert!(!root.path().join("plugins/test.first").exists());
assert!(!root.path().join("plugins/test.second").exists());
assert!(root.path().join("data/test.first/saved").exists());
assert!(root.path().join("manager-marker").exists());
let reloaded = Service::load(root.path(), 1000).unwrap();
assert!(reloaded.db.plugins.is_empty());
core.handle("system.uninstall.prepare", json!({"approve":true}))
.unwrap();
}
#[test]
fn manager_uninstall_failure_keeps_plugin_and_persists_disabling() {
let root = tempfile::tempdir().unwrap();
let mut core = accepted_service(root.path(), 1000).unwrap();
let bytes = fixture(root.path(), "test.retained", "1.0.0", 72, None);
install(&mut core, &bytes, json!({})).unwrap();
let _tools = fake_backend_tools(root.path());
core.db
.plugins
.get_mut("test.retained")
.unwrap()
.manifest
.lifecycle = Some(
serde_json::from_value(
json!({"runAs":"steamos","onUninstall":{"entry":"missing-hook.sh"},"timeoutSeconds":1}),
)
.unwrap(),
);
assert!(core
.handle("system.uninstall.prepare", json!({"approve":true}))
.is_err());
assert!(core.db.plugins.contains_key("test.retained"));
assert!(!core.db.plugins["test.retained"].enabled);
assert!(core.db.safe_mode);
assert!(root.path().join("plugins/test.retained").exists());
let reloaded = Service::load(root.path(), 1000).unwrap();
assert!(!reloaded.db.plugins["test.retained"].enabled);
}
#[test]
fn disk_packages_stream_validation_and_review_hash() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let bytes = fixture(root.path(), "test.disk", "1.0.0", 51, None);
let staged = package::Staged::from_bytes(&bytes).unwrap();
let path = staged.path.clone();
assert!(path.starts_with("/tmp"));
assert_eq!(staged.manifest().unwrap().id, "test.disk");
// A different non-root session UID must still be rejected.
let owner = unsafe { libc::geteuid() };
if owner != 0 {
assert!(package::open_staged(&staged.request(), owner + 1).is_err());
}
assert_eq!(
package::verify_manifest(
package::open_staged(&staged.request(), unsafe { libc::getuid() }).unwrap()
)
.unwrap()
.id,
"test.disk"
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
std::fs::write(&path, b"replacement").unwrap();
assert!(package::open_staged(&staged.request(), unsafe { libc::getuid() }).is_err());
drop(staged);
assert!(!path.exists());
}