rahti-native 0.0.2

Run a Rahti application inside a native package: packaged paths, a loopback-only embedded server, and a per-installation session key.
Documentation
//! Packaged paths: where an installed application writes, and where it does
//! not.

use super::TempDir;
use crate::paths::*;

fn packaged(root: &std::path::Path) -> AppPaths {
    AppPaths::from_host(
        "com.example.myapp",
        root.join("data"),
        root.join("cache"),
        root.join("resources"),
    )
    .expect("packaged paths")
}

#[test]
fn nothing_writable_is_inside_the_installation_directory() {
    // The rule the whole module exists for. A Windows installation under
    // `Program Files` is not writable by the user running it, and an Android
    // package's resources are entries in a zip.
    let temp = TempDir::new("paths");
    let paths = packaged(temp.path());

    for writable in [
        paths.database(),
        paths.uploads(),
        paths.spill(),
        paths.logs(),
        paths.temp(),
        paths.exports(),
        paths.public(),
        paths.secret_file(),
    ] {
        assert!(
            !writable.starts_with(paths.resources()),
            "{} is inside the installation directory",
            writable.display()
        );
    }
}

#[test]
fn a_spilled_upload_goes_to_cache_and_the_database_does_not() {
    // Android deletes a cache directory when the device is short of space. A
    // spilled upload part can survive that; a database cannot.
    let temp = TempDir::new("paths");
    let paths = packaged(temp.path());

    assert!(paths.spill().starts_with(paths.cache()));
    assert!(paths.temp().starts_with(paths.cache()));
    assert!(paths.database().starts_with(paths.data()));
    assert!(paths.uploads().starts_with(paths.data()));
    assert!(paths.secret_file().starts_with(paths.data()));
}

#[test]
fn prepare_creates_every_directory_and_can_be_run_twice() {
    let temp = TempDir::new("paths");
    let paths = packaged(temp.path());

    paths.prepare().expect("a first launch");
    paths.prepare().expect("a second launch");

    for dir in [
        paths.data().to_path_buf(),
        paths.cache().to_path_buf(),
        paths.config().to_path_buf(),
        paths.public(),
        paths.uploads(),
        paths.spill(),
        paths.logs(),
        paths.exports(),
    ] {
        assert!(dir.is_dir(), "{} was not created", dir.display());
    }
}

#[test]
fn the_sqlite_url_is_absolute_and_uses_forward_slashes() {
    let temp = TempDir::new("paths");
    let paths = packaged(temp.path());
    let url = paths.sqlite_url();

    assert!(url.starts_with("sqlite://"), "{url}");
    assert!(url.ends_with("app.db?mode=rwc"), "{url}");
    // A backslash in a URL is not a path separator: `C:\Users\…` arrives at
    // SQLite as one filename with no directories in it.
    assert!(!url.contains('\\'), "{url}");
    // Absolute, because a packaged application does not control its working
    // directory.
    assert!(
        url.trim_start_matches("sqlite://").len() > "app.db?mode=rwc".len() + 3,
        "{url}"
    );
}

#[test]
fn an_identifier_that_is_not_one_is_refused_before_a_directory_is_named() {
    let temp = TempDir::new("paths");
    assert!(
        AppPaths::from_host("not-an-identifier", temp.path(), temp.path(), temp.path()).is_err()
    );
}

// -------------------------------------------------------------- staging

fn write(path: &std::path::Path, contents: &str) {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).expect("a directory");
    }
    std::fs::write(path, contents).expect("a file");
}

#[test]
fn assets_are_staged_once_per_version() {
    let temp = TempDir::new("assets");
    let source = temp.path().join("bundled");
    let destination = temp.path().join("staged");

    write(&source.join("js/main.js"), "one");

    assert!(stage_public_assets(&source, &destination, "1.0.0").expect("a first launch"));
    assert_eq!(
        std::fs::read_to_string(destination.join("js/main.js")).unwrap(),
        "one"
    );

    // Same version: nothing copied, so an application that opens twice a day
    // is not rewriting its asset tree twice a day.
    write(&source.join("js/main.js"), "two");
    assert!(!stage_public_assets(&source, &destination, "1.0.0").expect("a second launch"));
    assert_eq!(
        std::fs::read_to_string(destination.join("js/main.js")).unwrap(),
        "one"
    );
}

#[test]
fn an_upgrade_removes_an_asset_the_new_version_does_not_have() {
    // A stale framework-owned file is the failure this prevents: a
    // PulsePoint bundle from the previous version sitting beside the current
    // `main.js` breaks in ways nothing explains.
    let temp = TempDir::new("assets");
    let source = temp.path().join("bundled");
    let destination = temp.path().join("staged");

    write(&source.join("js/old.js"), "gone next version");
    stage_public_assets(&source, &destination, "1.0.0").expect("a first install");
    assert!(destination.join("js/old.js").exists());

    std::fs::remove_file(source.join("js/old.js")).unwrap();
    write(&source.join("js/new.js"), "current");

    assert!(stage_public_assets(&source, &destination, "1.1.0").expect("an upgrade"));
    assert!(!destination.join("js/old.js").exists());
    assert!(destination.join("js/new.js").exists());
}

#[test]
fn an_upgrade_does_not_touch_application_data() {
    // Staging replaces the asset tree wholesale, so the test that matters is
    // that the asset tree is not the data directory.
    let temp = TempDir::new("assets");
    let paths = packaged(temp.path());
    paths.prepare().expect("a first launch");

    let source = temp.path().join("bundled");
    write(&source.join("js/main.js"), "one");
    stage_public_assets(&source, &paths.public(), "1.0.0").expect("a first install");

    let database = paths.database();
    std::fs::write(&database, b"user data").expect("a database");
    let upload = paths.uploads().join("photo.png");
    std::fs::write(&upload, b"a photo").expect("an upload");

    write(&source.join("js/main.js"), "two");
    stage_public_assets(&source, &paths.public(), "2.0.0").expect("an upgrade");

    assert_eq!(std::fs::read(&database).unwrap(), b"user data");
    assert_eq!(std::fs::read(&upload).unwrap(), b"a photo");
    assert_eq!(
        std::fs::read_to_string(paths.public().join("js/main.js")).unwrap(),
        "two"
    );
}

#[test]
fn staging_from_a_package_with_no_assets_says_so() {
    let temp = TempDir::new("assets");
    let error = stage_public_assets(
        &temp.path().join("missing"),
        &temp.path().join("staged"),
        "1.0.0",
    )
    .expect_err("nothing to stage");
    assert_eq!(error.step, "assets");
}

#[test]
fn a_nested_asset_tree_is_copied_whole() {
    let temp = TempDir::new("assets");
    let source = temp.path().join("bundled");
    let destination = temp.path().join("staged");

    write(&source.join("css/styles.css"), "body{}");
    write(&source.join("js/pp-reactive-v2.min.js"), "runtime");
    write(&source.join("images/deep/nested/icon.svg"), "<svg/>");

    stage_public_assets(&source, &destination, "1.0.0").expect("a first install");

    assert!(destination.join("css/styles.css").exists());
    assert!(destination.join("js/pp-reactive-v2.min.js").exists());
    assert!(destination.join("images/deep/nested/icon.svg").exists());
}

// ----------------------------------------------------- embedded assets

use crate::paths::EmbeddedAsset;

fn embedded() -> Vec<EmbeddedAsset<'static>> {
    vec![
        EmbeddedAsset {
            path: "js/main.js",
            bytes: b"import runtime",
        },
        EmbeddedAsset {
            path: "js/pp-reactive-v2.min.js",
            bytes: b"the runtime",
        },
        EmbeddedAsset {
            path: "css/styles.css",
            bytes: b"body{}",
        },
        EmbeddedAsset {
            path: "favicon.ico",
            bytes: b"",
        },
    ]
}

#[test]
fn embedded_assets_are_written_out_with_their_directories() {
    // The Android case, and the reason the bytes are in the binary at all:
    // package resources there are zip entries, and Tauri reports their
    // location as the URI `asset://localhost/` — not a path a file server can
    // open.
    let temp = TempDir::new("embedded");
    let destination = temp.path().join("assets");

    assert!(stage_embedded_assets(&embedded(), &destination, "1.0.0").expect("a first launch"));

    assert_eq!(
        std::fs::read_to_string(destination.join("js/main.js")).unwrap(),
        "import runtime"
    );
    assert_eq!(
        std::fs::read_to_string(destination.join("css/styles.css")).unwrap(),
        "body{}"
    );
    assert!(destination.join("js/pp-reactive-v2.min.js").exists());
    assert!(destination.join("favicon.ico").exists());
}

#[test]
fn embedded_assets_are_written_once_per_version() {
    let temp = TempDir::new("embedded");
    let destination = temp.path().join("assets");

    assert!(stage_embedded_assets(&embedded(), &destination, "1.0.0").expect("a first launch"));
    assert!(!stage_embedded_assets(&embedded(), &destination, "1.0.0").expect("a second launch"));
    assert!(stage_embedded_assets(&embedded(), &destination, "1.1.0").expect("an upgrade"));
}

#[test]
fn an_upgrade_removes_an_embedded_asset_the_new_version_dropped() {
    let temp = TempDir::new("embedded");
    let destination = temp.path().join("assets");
    stage_embedded_assets(&embedded(), &destination, "1.0.0").expect("a first launch");
    assert!(destination.join("favicon.ico").exists());

    let fewer = vec![EmbeddedAsset {
        path: "js/main.js",
        bytes: b"import runtime",
    }];
    stage_embedded_assets(&fewer, &destination, "2.0.0").expect("an upgrade");

    assert!(!destination.join("favicon.ico").exists());
    assert!(destination.join("js/main.js").exists());
}

#[test]
fn a_package_that_embedded_nothing_says_so_rather_than_404ing_everything() {
    // The failure mode this replaces: an application that starts, binds, opens
    // a window, and serves no stylesheet and no browser runtime.
    let temp = TempDir::new("embedded");
    let error = stage_embedded_assets(&[], &temp.path().join("assets"), "1.0.0")
        .expect_err("nothing to write");
    assert_eq!(error.step, "assets");
    assert!(error.to_string().contains("404"), "{error}");
}

#[test]
fn an_embedded_path_cannot_climb_out_of_the_asset_directory() {
    let temp = TempDir::new("embedded");
    let destination = temp.path().join("assets");

    for bad in ["../escaped.txt", "js/../../escaped.txt", "/etc/passwd"] {
        let assets = vec![EmbeddedAsset {
            path: bad,
            bytes: b"no",
        }];
        let error = stage_embedded_assets(&assets, &destination, "1.0.0")
            .expect_err(&format!("`{bad}` was accepted"));
        assert_eq!(error.step, "assets");
    }

    // And nothing landed outside.
    assert!(!temp.path().join("escaped.txt").exists());
}