Skip to main content

qualia_client_core/
qapp_install.rs

1//! Atomic qApp package installation into `{storage}/Qapps/`.
2//!
3//! Validates manifest, optional content manifest + signature, copies atomically,
4//! maintains a durable registry, and supports rollback / revocation.
5
6use 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    /// Bundled/dev packages may omit signatures.
61    Development,
62    /// Production installs require a valid signed content manifest.
63    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
412/// Validate a package directory before install.
413pub 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
452/// Install or upgrade a package atomically into `{storage}/Qapps/{package_id}/`.
453pub 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, &registry)?;
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, &registry)
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, &registry)?;
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}