1use crate::qapp_paths::{ensure_qapps_dir, qapps_dir};
7use crate::qapp_registry::{QappPackageManifest, QAPP_PACKAGE_MANIFEST};
8use crate::qapp_version::{is_version_newer, normalize_version_label};
9use ed25519_dalek::{Signature, Verifier, VerifyingKey};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::HashMap;
13use std::fs;
14use std::io;
15use std::path::{Component, Path, PathBuf};
16
17pub const QAPP_REGISTRY_FILE: &str = "registry.json";
18pub const PACKAGE_MANIFEST_SIDECAR: &str = "package-manifest.json";
19pub const SUPPORTED_QAPP_ABI_VERSION: &str = "1.0";
20pub const SUPPORTED_HOST_API_VERSION: &str = "1";
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct QappFileHash {
24 pub path: String,
25 pub sha256: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct QappContentManifest {
30 pub schema_version: u32,
31 pub package_id: String,
32 pub version: String,
33 #[serde(default, skip_serializing_if = "String::is_empty")]
34 pub abi_version: String,
35 #[serde(default, skip_serializing_if = "String::is_empty")]
36 pub host_api_version: String,
37 pub files: Vec<QappFileHash>,
38 #[serde(default, skip_serializing_if = "String::is_empty")]
39 pub signature_hex: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct QappRegistryEntry {
44 pub package_id: String,
45 pub active_version: String,
46 pub content_hash: String,
47 pub installed_at_unix: u64,
48 pub revoked: bool,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub archived_versions: Vec<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
54pub struct QappInstallRegistry {
55 pub packages: HashMap<String, QappRegistryEntry>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum InstallPolicy {
60 Development,
62 Production,
64}
65
66#[derive(Debug, PartialEq, Eq)]
67pub enum QappInstallError {
68 InvalidPackageId(String),
69 PathTraversal(String),
70 ManifestMissing,
71 ManifestInvalid(String),
72 ContentManifestInvalid(String),
73 HashMismatch {
74 path: String,
75 expected: String,
76 actual: String,
77 },
78 SignatureInvalid(String),
79 AbiMismatch {
80 found: String,
81 supported: String,
82 },
83 PackageRevoked(String),
84 StagingFailed(String),
85 RegistryCorrupt(String),
86 Io(String),
87}
88
89impl std::fmt::Display for QappInstallError {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match self {
92 Self::InvalidPackageId(id) => write!(f, "invalid package id: {id}"),
93 Self::PathTraversal(p) => write!(f, "path traversal rejected: {p}"),
94 Self::ManifestMissing => write!(f, "qapp.json not found"),
95 Self::ManifestInvalid(e) => write!(f, "invalid qapp.json: {e}"),
96 Self::ContentManifestInvalid(e) => write!(f, "invalid package-manifest.json: {e}"),
97 Self::HashMismatch {
98 path,
99 expected,
100 actual,
101 } => {
102 write!(
103 f,
104 "hash mismatch for {path}: expected {expected}, got {actual}"
105 )
106 }
107 Self::SignatureInvalid(e) => write!(f, "signature invalid: {e}"),
108 Self::AbiMismatch { found, supported } => {
109 write!(f, "ABI version {found} not supported (need {supported})")
110 }
111 Self::PackageRevoked(id) => write!(f, "package revoked: {id}"),
112 Self::StagingFailed(e) => write!(f, "staging failed: {e}"),
113 Self::RegistryCorrupt(e) => write!(f, "registry corrupt: {e}"),
114 Self::Io(e) => write!(f, "io error: {e}"),
115 }
116 }
117}
118
119impl From<io::Error> for QappInstallError {
120 fn from(value: io::Error) -> Self {
121 Self::Io(value.to_string())
122 }
123}
124
125pub fn package_id_from_manifest(manifest: &QappPackageManifest) -> String {
126 manifest.name.trim().to_string()
127}
128
129pub fn validate_package_id(package_id: &str) -> Result<(), QappInstallError> {
130 if package_id.is_empty() {
131 return Err(QappInstallError::InvalidPackageId("empty".into()));
132 }
133 if package_id.contains("..") || package_id.contains('/') || package_id.contains('\\') {
134 return Err(QappInstallError::InvalidPackageId(package_id.into()));
135 }
136 for ch in package_id.chars() {
137 let ok = ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' ';
138 if !ok {
139 return Err(QappInstallError::InvalidPackageId(package_id.into()));
140 }
141 }
142 Ok(())
143}
144
145fn sha256_hex(bytes: &[u8]) -> String {
146 let digest = Sha256::digest(bytes);
147 hex::encode(digest.as_slice())
148}
149
150fn sha256_file(path: &Path) -> Result<String, QappInstallError> {
151 let bytes = fs::read(path)?;
152 Ok(sha256_hex(&bytes))
153}
154
155fn registry_path(storage: &Path) -> PathBuf {
156 qapps_dir(storage).join(QAPP_REGISTRY_FILE)
157}
158
159fn versions_dir(storage: &Path, package_id: &str) -> PathBuf {
160 qapps_dir(storage).join(package_id).join("versions")
161}
162
163fn active_package_dir(storage: &Path, package_id: &str) -> PathBuf {
164 qapps_dir(storage).join(package_id)
165}
166
167fn staging_root(storage: &Path) -> PathBuf {
168 qapps_dir(storage).join(".staging")
169}
170
171pub fn load_install_registry(storage: &Path) -> Result<QappInstallRegistry, QappInstallError> {
172 let path = registry_path(storage);
173 if !path.is_file() {
174 return Ok(QappInstallRegistry::default());
175 }
176 let content =
177 fs::read_to_string(&path).map_err(|e| QappInstallError::RegistryCorrupt(e.to_string()))?;
178 serde_json::from_str(&content).map_err(|e| QappInstallError::RegistryCorrupt(e.to_string()))
179}
180
181pub fn save_install_registry(
182 storage: &Path,
183 registry: &QappInstallRegistry,
184) -> Result<(), QappInstallError> {
185 ensure_qapps_dir(storage)?;
186 let path = registry_path(storage);
187 let staging = path.with_extension("json.staging");
188 let json = serde_json::to_string_pretty(registry)
189 .map_err(|e| QappInstallError::RegistryCorrupt(e.to_string()))?;
190 fs::write(&staging, json)?;
191 fs::rename(&staging, &path)?;
192 Ok(())
193}
194
195pub fn resolve_active_package_dir(storage: &Path, package_id: &str) -> PathBuf {
196 let active = active_package_dir(storage, package_id);
197 if active.join(QAPP_PACKAGE_MANIFEST).is_file() {
198 return active;
199 }
200 active
201}
202
203pub fn is_package_revoked(storage: &Path, package_id: &str) -> bool {
204 load_install_registry(storage)
205 .ok()
206 .and_then(|r| r.packages.get(package_id).map(|e| e.revoked))
207 .unwrap_or(false)
208}
209
210fn relative_path_ok(rel: &str) -> bool {
211 let path = Path::new(rel);
212 for component in path.components() {
213 match component {
214 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
215 _ => {}
216 }
217 }
218 true
219}
220
221fn collect_package_files(dir: &Path) -> Result<Vec<(String, String)>, QappInstallError> {
222 let mut out = Vec::new();
223 collect_package_files_inner(dir, dir, &mut out)?;
224 out.sort_by(|a, b| a.0.cmp(&b.0));
225 Ok(out)
226}
227
228fn collect_package_files_inner(
229 root: &Path,
230 current: &Path,
231 out: &mut Vec<(String, String)>,
232) -> Result<(), QappInstallError> {
233 for entry in fs::read_dir(current)? {
234 let entry = entry?;
235 let path = entry.path();
236 let file_name = entry.file_name().to_string_lossy().to_string();
237 if file_name == ".staging" || file_name == QAPP_REGISTRY_FILE {
238 continue;
239 }
240 if path.is_dir() {
241 if file_name == "versions" {
242 continue;
243 }
244 collect_package_files_inner(root, &path, out)?;
245 } else {
246 let rel = path
247 .strip_prefix(root)
248 .map_err(|_| QappInstallError::PathTraversal(path.display().to_string()))?
249 .to_string_lossy()
250 .replace('\\', "/");
251 if !relative_path_ok(&rel) {
252 return Err(QappInstallError::PathTraversal(rel));
253 }
254 let hash = sha256_file(&path)?;
255 out.push((rel, hash));
256 }
257 }
258 Ok(())
259}
260
261fn verify_content_manifest(
262 source_dir: &Path,
263 manifest: &QappContentManifest,
264 policy: InstallPolicy,
265 trust_pubkey: Option<&[u8; 32]>,
266) -> Result<(), QappInstallError> {
267 validate_package_id(&manifest.package_id)?;
268 if !manifest.abi_version.is_empty() && manifest.abi_version != SUPPORTED_QAPP_ABI_VERSION {
269 return Err(QappInstallError::AbiMismatch {
270 found: manifest.abi_version.clone(),
271 supported: SUPPORTED_QAPP_ABI_VERSION.into(),
272 });
273 }
274 if !manifest.host_api_version.is_empty()
275 && manifest.host_api_version != SUPPORTED_HOST_API_VERSION
276 {
277 return Err(QappInstallError::AbiMismatch {
278 found: manifest.host_api_version.clone(),
279 supported: SUPPORTED_HOST_API_VERSION.into(),
280 });
281 }
282
283 for file in &manifest.files {
284 if !relative_path_ok(&file.path) {
285 return Err(QappInstallError::PathTraversal(file.path.clone()));
286 }
287 let disk_path = source_dir.join(&file.path);
288 if !disk_path.is_file() {
289 return Err(QappInstallError::HashMismatch {
290 path: file.path.clone(),
291 expected: file.sha256.clone(),
292 actual: "missing".into(),
293 });
294 }
295 let actual = sha256_file(&disk_path)?;
296 if actual != file.sha256 {
297 return Err(QappInstallError::HashMismatch {
298 path: file.path.clone(),
299 expected: file.sha256.clone(),
300 actual,
301 });
302 }
303 }
304
305 if policy == InstallPolicy::Production {
306 let sig_hex = manifest.signature_hex.trim();
307 if sig_hex.is_empty() {
308 return Err(QappInstallError::SignatureInvalid(
309 "production install requires signature_hex".into(),
310 ));
311 }
312 let pk = trust_pubkey.ok_or_else(|| {
313 QappInstallError::SignatureInvalid("no trust pubkey configured".into())
314 })?;
315 let sig_bytes =
316 hex::decode(sig_hex).map_err(|e| QappInstallError::SignatureInvalid(e.to_string()))?;
317 if sig_bytes.len() != 64 {
318 return Err(QappInstallError::SignatureInvalid(
319 "expected 64-byte ed25519 signature".into(),
320 ));
321 }
322 let mut sig_arr = [0u8; 64];
323 sig_arr.copy_from_slice(&sig_bytes);
324 let signature = Signature::from_bytes(&sig_arr);
325 let verifying_key = VerifyingKey::from_bytes(pk)
326 .map_err(|e| QappInstallError::SignatureInvalid(e.to_string()))?;
327 let mut sign_payload = serde_json::to_vec(manifest)
328 .map_err(|e| QappInstallError::SignatureInvalid(e.to_string()))?;
329 if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&sign_payload) {
330 if let Some(obj) = value.as_object() {
331 let mut unsigned = obj.clone();
332 unsigned.remove("signature_hex");
333 sign_payload = serde_json::to_vec(&unsigned)
334 .map_err(|e| QappInstallError::SignatureInvalid(e.to_string()))?;
335 }
336 }
337 verifying_key
338 .verify(&sign_payload, &signature)
339 .map_err(|e| QappInstallError::SignatureInvalid(e.to_string()))?;
340 }
341
342 Ok(())
343}
344
345fn load_sidecar_manifest(source_dir: &Path) -> Option<QappContentManifest> {
346 let path = source_dir.join(PACKAGE_MANIFEST_SIDECAR);
347 if !path.is_file() {
348 return None;
349 }
350 let content = fs::read_to_string(path).ok()?;
351 serde_json::from_str(&content).ok()
352}
353
354fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), QappInstallError> {
355 fs::create_dir_all(dst)?;
356 for entry in fs::read_dir(src)? {
357 let entry = entry?;
358 let file_type = entry.file_type()?;
359 let name = entry.file_name();
360 let name_str = name.to_string_lossy();
361 if name_str == "versions" {
362 continue;
363 }
364 let target = dst.join(&name);
365 if file_type.is_dir() {
366 copy_dir_all(&entry.path(), &target)?;
367 } else {
368 fs::copy(entry.path(), target)?;
369 }
370 }
371 Ok(())
372}
373
374fn remove_dir_contents(dir: &Path) -> Result<(), QappInstallError> {
375 if !dir.exists() {
376 return Ok(());
377 }
378 for entry in fs::read_dir(dir)? {
379 let entry = entry?;
380 let path = entry.path();
381 let name = entry.file_name().to_string_lossy().to_string();
382 if name == "versions" {
383 continue;
384 }
385 if path.is_dir() {
386 fs::remove_dir_all(&path)?;
387 } else {
388 fs::remove_file(&path)?;
389 }
390 }
391 Ok(())
392}
393
394fn archive_active_version(
395 storage: &Path,
396 package_id: &str,
397 version: &str,
398) -> Result<(), QappInstallError> {
399 let active = active_package_dir(storage, package_id);
400 if !active.join(QAPP_PACKAGE_MANIFEST).is_file() {
401 return Ok(());
402 }
403 let archive_root = versions_dir(storage, package_id).join(version);
404 if archive_root.exists() {
405 return Ok(());
406 }
407 fs::create_dir_all(archive_root.parent().unwrap())?;
408 copy_dir_all(&active, &archive_root)?;
409 Ok(())
410}
411
412pub fn validate_package_source(
414 source_dir: &Path,
415 policy: InstallPolicy,
416 trust_pubkey: Option<&[u8; 32]>,
417) -> Result<(QappPackageManifest, String), QappInstallError> {
418 let manifest_path = source_dir.join(QAPP_PACKAGE_MANIFEST);
419 if !manifest_path.is_file() {
420 return Err(QappInstallError::ManifestMissing);
421 }
422 let content = fs::read_to_string(&manifest_path)?;
423 let manifest: QappPackageManifest = serde_json::from_str(&content)
424 .map_err(|e| QappInstallError::ManifestInvalid(e.to_string()))?;
425 let package_id = package_id_from_manifest(&manifest);
426 validate_package_id(&package_id)?;
427
428 if let Some(sidecar) = load_sidecar_manifest(source_dir) {
429 if sidecar.package_id != package_id {
430 return Err(QappInstallError::ContentManifestInvalid(format!(
431 "package_id mismatch: manifest {} vs sidecar {}",
432 package_id, sidecar.package_id
433 )));
434 }
435 verify_content_manifest(source_dir, &sidecar, policy, trust_pubkey)?;
436 } else if policy == InstallPolicy::Production {
437 return Err(QappInstallError::ContentManifestInvalid(
438 "production install requires package-manifest.json".into(),
439 ));
440 }
441
442 let files = collect_package_files(source_dir)?;
443 let aggregate = files
444 .iter()
445 .map(|(p, h)| format!("{p}:{h}"))
446 .collect::<Vec<_>>()
447 .join("\n");
448 let content_hash = sha256_hex(aggregate.as_bytes());
449 Ok((manifest, content_hash))
450}
451
452pub fn install_package_atomic(
454 storage: &Path,
455 source_dir: &Path,
456 policy: InstallPolicy,
457 trust_pubkey: Option<&[u8; 32]>,
458) -> Result<QappRegistryEntry, QappInstallError> {
459 let (manifest, content_hash) = validate_package_source(source_dir, policy, trust_pubkey)?;
460 let package_id = package_id_from_manifest(&manifest);
461 let version = normalize_version_label(&manifest.version);
462
463 if is_package_revoked(storage, &package_id) {
464 return Err(QappInstallError::PackageRevoked(package_id));
465 }
466
467 ensure_qapps_dir(storage)?;
468 let staging_parent = staging_root(storage);
469 fs::create_dir_all(&staging_parent)?;
470 let staging_dir =
471 staging_parent.join(format!("{package_id}-{version}-{}", uuid::Uuid::new_v4()));
472 copy_dir_all(source_dir, &staging_dir)?;
473
474 let dest = active_package_dir(storage, &package_id);
475 let mut registry = load_install_registry(storage)?;
476
477 let install_result = (|| -> Result<QappRegistryEntry, QappInstallError> {
478 if let Some(existing) = registry.packages.get(&package_id) {
479 if !is_version_newer(&version, &existing.active_version)
480 && version != existing.active_version
481 {
482 return Err(QappInstallError::StagingFailed(format!(
483 "refusing downgrade from {} to {}",
484 existing.active_version, version
485 )));
486 }
487 if version != existing.active_version {
488 archive_active_version(storage, &package_id, &existing.active_version)?;
489 }
490 }
491
492 fs::create_dir_all(dest.parent().unwrap())?;
493 if dest.exists() {
494 remove_dir_contents(&dest)?;
495 } else {
496 fs::create_dir_all(&dest)?;
497 }
498 copy_dir_all(&staging_dir, &dest)?;
499
500 let installed_at_unix = std::time::SystemTime::now()
501 .duration_since(std::time::UNIX_EPOCH)
502 .map(|d| d.as_secs())
503 .unwrap_or(0);
504
505 let mut archived = registry
506 .packages
507 .get(&package_id)
508 .map(|e| e.archived_versions.clone())
509 .unwrap_or_default();
510 if let Some(prev) = registry.packages.get(&package_id) {
511 if prev.active_version != version && !archived.contains(&prev.active_version) {
512 archived.push(prev.active_version.clone());
513 }
514 }
515
516 let entry = QappRegistryEntry {
517 package_id: package_id.clone(),
518 active_version: version,
519 content_hash,
520 installed_at_unix,
521 revoked: false,
522 archived_versions: archived,
523 };
524 registry.packages.insert(package_id, entry.clone());
525 save_install_registry(storage, ®istry)?;
526 Ok(entry)
527 })();
528
529 let _ = fs::remove_dir_all(&staging_dir);
530 install_result
531}
532
533pub fn revoke_package(storage: &Path, package_id: &str) -> Result<(), QappInstallError> {
534 validate_package_id(package_id)?;
535 let mut registry = load_install_registry(storage)?;
536 let entry = registry
537 .packages
538 .get_mut(package_id)
539 .ok_or_else(|| QappInstallError::StagingFailed(format!("unknown package {package_id}")))?;
540 entry.revoked = true;
541 save_install_registry(storage, ®istry)
542}
543
544pub fn list_registry_entries(storage: &Path) -> Result<Vec<QappRegistryEntry>, QappInstallError> {
545 Ok(load_install_registry(storage)?
546 .packages
547 .into_values()
548 .collect())
549}
550
551pub fn reconcile_registry_with_disk(
552 storage: &Path,
553) -> Result<QappInstallRegistry, QappInstallError> {
554 ensure_qapps_dir(storage)?;
555 let mut registry = load_install_registry(storage)?;
556 let root = qapps_dir(storage);
557
558 if let Ok(entries) = fs::read_dir(&root) {
559 for entry in entries.filter_map(Result::ok) {
560 let path = entry.path();
561 if !path.is_dir() {
562 continue;
563 }
564 let name = entry.file_name().to_string_lossy().to_string();
565 if name.starts_with('.') || name == QAPP_REGISTRY_FILE {
566 continue;
567 }
568 if !path.join(QAPP_PACKAGE_MANIFEST).is_file() {
569 continue;
570 }
571 if registry.packages.contains_key(&name) {
572 continue;
573 }
574 let content = fs::read_to_string(path.join(QAPP_PACKAGE_MANIFEST))?;
575 let manifest: QappPackageManifest = serde_json::from_str(&content)
576 .map_err(|e| QappInstallError::ManifestInvalid(e.to_string()))?;
577 let files = collect_package_files(&path)?;
578 let aggregate = files
579 .iter()
580 .map(|(p, h)| format!("{p}:{h}"))
581 .collect::<Vec<_>>()
582 .join("\n");
583 let content_hash = sha256_hex(aggregate.as_bytes());
584 registry.packages.insert(
585 name.clone(),
586 QappRegistryEntry {
587 package_id: name,
588 active_version: normalize_version_label(&manifest.version),
589 content_hash,
590 installed_at_unix: 0,
591 revoked: false,
592 archived_versions: Vec::new(),
593 },
594 );
595 }
596 }
597
598 save_install_registry(storage, ®istry)?;
599 Ok(registry)
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use std::time::{SystemTime, UNIX_EPOCH};
606
607 fn temp_storage() -> PathBuf {
608 let nanos = SystemTime::now()
609 .duration_since(UNIX_EPOCH)
610 .unwrap()
611 .as_nanos();
612 std::env::temp_dir().join(format!("qualia-qapp-install-test-{nanos}"))
613 }
614
615 fn write_minimal_package(dir: &Path, name: &str, version: &str) {
616 fs::create_dir_all(dir).unwrap();
617 let manifest = format!(
618 r#"{{
619 "name": "{name}",
620 "version": "{version}",
621 "required_shapes": ["schema:Test"]
622}}"#
623 );
624 fs::write(dir.join(QAPP_PACKAGE_MANIFEST), manifest).unwrap();
625 fs::write(dir.join("index.html"), "<html></html>").unwrap();
626 }
627
628 #[test]
629 fn rejects_path_traversal_package_id() {
630 assert!(validate_package_id("..").is_err());
631 assert!(validate_package_id("foo/bar").is_err());
632 assert!(validate_package_id("Anatomy").is_ok());
633 }
634
635 #[test]
636 fn atomic_install_and_registry_round_trip() {
637 let storage = temp_storage();
638 let source = storage.join("source");
639 write_minimal_package(&source, "TestApp", "0.0.1");
640
641 let entry =
642 install_package_atomic(&storage, &source, InstallPolicy::Development, None).unwrap();
643 assert_eq!(entry.package_id, "TestApp");
644 assert_eq!(entry.active_version, "0.0.1");
645 assert!(active_package_dir(&storage, "TestApp")
646 .join("index.html")
647 .is_file());
648
649 let registry = load_install_registry(&storage).unwrap();
650 assert!(registry.packages.contains_key("TestApp"));
651 let _ = fs::remove_dir_all(&storage);
652 }
653
654 #[test]
655 fn interrupted_staging_keeps_prior_version() {
656 let storage = temp_storage();
657 let v1 = storage.join("v1");
658 let v2 = storage.join("v2");
659 write_minimal_package(&v1, "TestApp", "0.0.1");
660 write_minimal_package(&v2, "TestApp", "0.0.2");
661 fs::write(v2.join("index.html"), "<html>v2</html>").unwrap();
662
663 install_package_atomic(&storage, &v1, InstallPolicy::Development, None).unwrap();
664 install_package_atomic(&storage, &v2, InstallPolicy::Development, None).unwrap();
665
666 let html =
667 fs::read_to_string(active_package_dir(&storage, "TestApp").join("index.html")).unwrap();
668 assert!(html.contains("v2"));
669 let registry = load_install_registry(&storage).unwrap();
670 assert_eq!(registry.packages["TestApp"].active_version, "0.0.2");
671 assert!(versions_dir(&storage, "TestApp").join("0.0.1").is_dir());
672 let _ = fs::remove_dir_all(&storage);
673 }
674
675 #[test]
676 fn revoked_package_cannot_reinstall() {
677 let storage = temp_storage();
678 let source = storage.join("source");
679 write_minimal_package(&source, "TestApp", "0.0.1");
680 install_package_atomic(&storage, &source, InstallPolicy::Development, None).unwrap();
681 revoke_package(&storage, "TestApp").unwrap();
682 let err = install_package_atomic(&storage, &source, InstallPolicy::Development, None)
683 .unwrap_err();
684 assert!(matches!(err, QappInstallError::PackageRevoked(_)));
685 let _ = fs::remove_dir_all(&storage);
686 }
687
688 #[test]
689 fn reconcile_discovers_flat_install() {
690 let storage = temp_storage();
691 write_minimal_package(
692 &active_package_dir(&storage, "LegacyApp"),
693 "LegacyApp",
694 "1.0.0",
695 );
696 let registry = reconcile_registry_with_disk(&storage).unwrap();
697 assert!(registry.packages.contains_key("LegacyApp"));
698 let _ = fs::remove_dir_all(&storage);
699 }
700}