tar-install 0.2.0

Library core for safely installing Linux app tarballs into desktop-friendly locations.
Documentation
use serde::Deserialize;
use serde_json::json;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::path::Path;

use crate::paths::InstallScope;
use crate::recipe::{load_recipe, InstallInput};

type ApiResult = Result<serde_json::Value, String>;

#[derive(Debug, Deserialize)]
struct InstallArgs {
    archive: String,
    scope: Option<String>,
    app_id: Option<String>,
    name: Option<String>,
    version: Option<String>,
    exec_path: Option<String>,
    command: Option<String>,
    icon: Option<String>,
    recipe: Option<String>,
    force: Option<bool>,
    probe_version: Option<bool>,
}

#[no_mangle]
pub extern "C" fn tar_install_inspect(archive: *const c_char) -> *mut c_char {
    respond(|| {
        let archive = read_string(archive)?;
        let inspection = crate::archive::inspect_archive(Path::new(&archive))
            .map_err(|err| format!("{err:#}"))?;
        serde_json::to_value(inspection).map_err(|err| err.to_string())
    })
}

#[no_mangle]
pub extern "C" fn tar_install_install(config_json: *const c_char) -> *mut c_char {
    respond(|| {
        let config_json = read_string(config_json)?;
        let args: InstallArgs = serde_json::from_str(&config_json).map_err(|err| err.to_string())?;
        let scope = parse_scope(args.scope.as_deref())?;
        let recipe = match args.recipe {
            Some(path) => Some(load_recipe(Path::new(&path)).map_err(|err| format!("{err:#}"))?),
            None => None,
        };
        let input = InstallInput {
            id: args.app_id,
            name: args.name,
            version: args.version,
            probe_version: args.probe_version,
            exec: args.exec_path,
            command: args.command,
            icon: args.icon,
            recipe,
            force: args.force.unwrap_or(false),
            interactive_config: false,
        };
        let report = crate::install_archive(Path::new(&args.archive), scope, input)
            .map_err(|err| format!("{err:#}"))?;
        Ok(json!({
            "installed": report.installed,
            "plan": {
                "archive": report.plan.archive,
                "scope": report.plan.scope,
                "app_id": report.plan.app_id,
                "app_name": report.plan.app_name,
                "version": report.plan.version,
                "probe_version": report.plan.probe_version,
                "exec_path_inside_app": report.plan.exec_path_inside_app,
                "command_name": report.plan.command_name,
                "icon_path_inside_app": report.plan.icon_path_inside_app,
                "targets": report.plan.targets,
                "categories": report.plan.categories,
                "terminal": report.plan.terminal,
                "notes": report.plan.notes,
            }
        }))
    })
}

#[no_mangle]
pub extern "C" fn tar_install_list(scope: *const c_char) -> *mut c_char {
    respond(|| {
        let scope = read_optional_string(scope)?;
        let apps = crate::install::list_apps(parse_scope(scope.as_deref())?)
            .map_err(|err| format!("{err:#}"))?;
        serde_json::to_value(apps).map_err(|err| err.to_string())
    })
}

#[no_mangle]
pub extern "C" fn tar_install_remove(app_id: *const c_char, scope: *const c_char) -> *mut c_char {
    respond(|| {
        let app_id = read_string(app_id)?;
        let scope = read_optional_string(scope)?;
        let report = crate::remove_app(parse_scope(scope.as_deref())?, &app_id)
            .map_err(|err| format!("{err:#}"))?;
        Ok(json!({
            "id": report.id,
            "removed_paths": report.removed_paths,
        }))
    })
}

#[no_mangle]
pub extern "C" fn tar_install_doctor(app_id: *const c_char, scope: *const c_char) -> *mut c_char {
    respond(|| {
        let app_id = read_string(app_id)?;
        let scope = read_optional_string(scope)?;
        let lines = crate::install::doctor_app(parse_scope(scope.as_deref())?, &app_id)
            .map_err(|err| format!("{err:#}"))?;
        serde_json::to_value(lines).map_err(|err| err.to_string())
    })
}

#[no_mangle]
pub extern "C" fn tar_install_free_string(value: *mut c_char) {
    if !value.is_null() {
        unsafe {
            let _ = CString::from_raw(value);
        }
    }
}

fn respond(action: impl FnOnce() -> ApiResult) -> *mut c_char {
    let payload = match action() {
        Ok(value) => json!({ "ok": true, "value": value }),
        Err(error) => json!({ "ok": false, "error": error }),
    };
    into_c_string(payload.to_string())
}

fn read_string(value: *const c_char) -> Result<String, String> {
    if value.is_null() {
        return Err("received null string pointer".to_string());
    }
    unsafe { CStr::from_ptr(value) }
        .to_str()
        .map(|value| value.to_string())
        .map_err(|err| err.to_string())
}

fn read_optional_string(value: *const c_char) -> Result<Option<String>, String> {
    if value.is_null() {
        Ok(None)
    } else {
        read_string(value).map(Some)
    }
}

fn parse_scope(value: Option<&str>) -> Result<InstallScope, String> {
    match value.unwrap_or("user") {
        "user" => Ok(InstallScope::User),
        "system" => Ok(InstallScope::System),
        other => Err(format!("scope must be 'user' or 'system', got {other:?}")),
    }
}

fn into_c_string(value: String) -> *mut c_char {
    let sanitized = value.replace('\0', "\\u0000");
    CString::new(sanitized)
        .expect("sanitized JSON must not contain nul bytes")
        .into_raw()
}