shellcanvas-adapter-sdk 0.1.0

Versioned process protocol and concurrent server for ShellCanvas connection adapters
Documentation
// SPDX-License-Identifier: MPL-2.0
//! Standalone project generation and package tooling. Never installs or connects.
use crate::package::{relative, Manifest, PackageFile};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
    fs::{self, File, OpenOptions},
    io::{Read, Write},
    path::{Path, PathBuf},
    process::Command,
};

pub const SOURCE_SCHEMA: &str = include_str!("../schemas/adapter-source.schema.json");
pub const PACKAGE_SCHEMA: &str = include_str!("../schemas/adapter-package.schema.json");

fn read_json(path: &Path) -> Result<Value> {
    let mut bytes = vec![];
    File::open(path)?
        .take(1024 * 1024 + 1)
        .read_to_end(&mut bytes)?;
    if bytes.len() > 1024 * 1024 {
        bail!("Adapter manifest exceeds 1 MiB");
    }
    serde_json::from_slice(&bytes).context("Invalid adapter manifest JSON")
}
pub fn platform() -> String {
    format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SourceFile {
    path: String,
    #[serde(default)]
    executable: bool,
}
/// Expand source placeholders and validate through the same model as the host.
pub fn source_manifest(path: &Path, version: Option<&str>) -> Result<Manifest> {
    let mut value = read_json(path)?;
    let object = value
        .as_object_mut()
        .context("Source manifest must be an object")?;
    let expand = |name: &str| name.replace("{exe}", std::env::consts::EXE_SUFFIX);
    let files: Vec<SourceFile> =
        serde_json::from_value(object.get("files").context("Missing files")?.clone())?;
    object.insert(
        "files".into(),
        serde_json::to_value(
            files
                .into_iter()
                .map(|file| PackageFile {
                    path: expand(&file.path),
                    size: 0,
                    sha256: "0".repeat(64),
                    executable: file.executable,
                })
                .collect::<Vec<_>>(),
        )?,
    );
    let entrypoint = expand(
        object
            .get("entrypoint")
            .and_then(Value::as_str)
            .context("Missing entrypoint")?,
    );
    object.insert("entrypoint".into(), json!(entrypoint));
    if object.get("platform").and_then(Value::as_str) == Some("current") {
        object.insert("platform".into(), json!(platform()));
    }
    if let Some(version) = version {
        object.insert("version".into(), json!(version));
    }
    let manifest: Manifest = serde_json::from_value(value)?;
    manifest.validate()?;
    tool_paths(&manifest)?;
    Ok(manifest)
}
fn tool_paths(manifest: &Manifest) -> Result<()> {
    let paths: Vec<_> = manifest
        .files
        .iter()
        .map(|file| file.path.to_lowercase())
        .collect();
    for path in &paths {
        if path == "adapter.json"
            || path.starts_with("adapter.json/")
            || paths
                .iter()
                .any(|other| other != path && other.starts_with(&format!("{path}/")))
        {
            bail!("Package assets conflict with a directory or reserved manifest path");
        }
    }
    Ok(())
}
fn asset(root: &Path, name: &str) -> Result<PathBuf> {
    relative(name)?;
    let mut path = root.to_path_buf();
    for part in name.split('/') {
        path.push(part);
        if fs::symlink_metadata(&path)?.file_type().is_symlink() {
            bail!("Package assets cannot be symbolic links");
        }
    }
    let canonical = path.canonicalize()?;
    if !canonical.starts_with(root.canonicalize()?) || !fs::metadata(&canonical)?.is_file() {
        bail!("Package asset escapes its directory");
    }
    Ok(canonical)
}
fn copy_hash(input: &Path, mut output: impl Write) -> Result<(u64, String)> {
    if !fs::symlink_metadata(input)?.is_file() {
        bail!("Package assets must be regular files");
    }
    let mut file = File::open(input)?;
    let mut digest = Sha256::new();
    let mut size = 0u64;
    let mut chunk = [0; 65536];
    loop {
        let count = file.read(&mut chunk)?;
        if count == 0 {
            break;
        }
        output.write_all(&chunk[..count])?;
        digest.update(&chunk[..count]);
        size = size
            .checked_add(count as u64)
            .context("Asset size overflow")?;
    }
    Ok((size, format!("{:x}", digest.finalize())))
}
/// Write a new package directory; existing output is never overwritten.
/// The manifest is published last. A failed pack may leave an incomplete output
/// directory, which must not be installed. Choose a fresh output for a retry.
pub fn pack(
    source: &Path,
    executable: &Path,
    output: &Path,
    version: Option<&str>,
) -> Result<PathBuf> {
    let source = source.canonicalize()?;
    let root = source.parent().context("Missing source parent")?;
    let mut manifest = source_manifest(&source, version)?;
    let mut inputs = vec![];
    for file in &manifest.files {
        let input = if file.path == manifest.entrypoint {
            executable.to_path_buf()
        } else {
            asset(root, &file.path)?
        };
        if !fs::symlink_metadata(&input)?.is_file() {
            bail!("Package assets must be regular files");
        }
        inputs.push(input);
    }
    // Reserve this new output directory; no rename-over-existing behavior.
    fs::create_dir(output).context("Choose a new package output directory")?;
    for (file, input) in manifest.files.iter_mut().zip(inputs) {
        let destination = output.join(&file.path);
        fs::create_dir_all(destination.parent().context("Missing asset parent")?)?;
        let mut target = OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&destination)?;
        let (size, sha256) = copy_hash(&input, &mut target)?;
        target.sync_all()?;
        file.size = size;
        file.sha256 = sha256;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(
                destination,
                fs::Permissions::from_mode(if file.executable { 0o700 } else { 0o600 }),
            )?;
        }
    }
    manifest.validate()?;
    let bytes = serde_json::to_vec_pretty(&manifest)?;
    if bytes.len() > 1024 * 1024 {
        bail!("Packed manifest exceeds 1 MiB");
    }
    let path = output.join("adapter.json");
    let mut file = OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&path)?;
    file.write_all(&bytes)?;
    file.sync_all()?;
    Ok(path)
}
/// Verify manifest and all declared hashes, without executing the package.
pub fn validate(path: &Path) -> Result<Manifest> {
    let manifest: Manifest = serde_json::from_value(read_json(path)?)?;
    manifest.validate()?;
    tool_paths(&manifest)?;
    let root = path.parent().context("Missing package parent")?;
    for file in &manifest.files {
        let (size, hash) = copy_hash(&asset(root, &file.path)?, std::io::sink())?;
        if size != file.size || hash != file.sha256 {
            bail!("Package asset does not match its manifest: {}", file.path);
        }
    }
    Ok(manifest)
}

/// Generate a new Rust adapter project which consumes an independently supplied SDK.
pub fn create(directory: &Path, id: &str, name: &str, sdk_source: &Path) -> Result<()> {
    create_template(directory, id, name, sdk_source, "custom")
}

/// Select a runnable, independently buildable service example. Unknown names
/// fail before creating any project files.
pub fn create_template(
    directory: &Path,
    id: &str,
    name: &str,
    sdk_source: &Path,
    template: &str,
) -> Result<()> {
    let (source, extra_dependencies, instructions) = match template {
        "custom" => (include_str!("../templates/main.rs.txt"), "", "Assign your adapter ID under Additional services. The custom service echoes JSON and has a cancelable wait."),
        "files" => (include_str!("../examples/files.rs"), "", "Assign Files to this source. Browse 300 immutable notes and open them read-only. Cursors are stateless and revision-bound; locations are opaque. Writes and transfers are deliberately not advertised."),
        "console" => (include_str!("../examples/console.rs"), "tokio = { version = \"1\", features = [\"sync\", \"time\", \"macros\"] }\n", "Assign Terminal to this source. Input is echoed as bytes; no shell commands are executed. Each session has a bounded output queue and independent cleanup. Resize is not advertised. Retired identities are retained until this process exits so late opens cannot revive a closed console. For asynchronous device setup, reserve the identity before awaiting and recheck retirement before publishing it."),
        "settings" => (include_str!("../examples/settings.rs"), "", "Assign Remote settings to this source. Change Demo mode between normal and quiet. Compare-and-commit revisions and verified readback protect concurrent edits. State is synthetic and resets on reconnect; a real device must provide its own conflict and confirmation semantics."),
        _ => bail!("Unknown template; choose custom, files, console or settings"),
    };
    if !crate::wire::name(id)
        || !id.contains('.')
        || id.starts_with("system.")
        || name.trim().is_empty()
        || name.len() > 200
    {
        bail!("Choose a namespaced adapter ID and a nonempty name of at most 200 bytes");
    }
    let sdk = sdk_source.canonicalize()?;
    if !sdk.join("Cargo.toml").is_file() {
        bail!("SDK source directory needs Cargo.toml");
    }
    let sdk_path = sdk.to_str().context("SDK source path must be UTF-8")?;
    let cargo = format!("[package]\nname = \"shellcanvas-device\"\nversion = \"0.1.0\"\nedition = \"2021\"\nlicense = \"MPL-2.0\"\n\n[workspace]\n\n[dependencies]\nshellcanvas-adapter-sdk = {{ version = \"0.1.0\", path = {} }}\n", serde_json::to_string(sdk_path)?);
    let cargo = format!("{cargo}{extra_dependencies}");
    let code = source.replace("__SERVICE_LITERAL__", &format!("{id:?}"));
    let manifest = json!({"schemaVersion":1,"id":id,"name":name,"version":"0.1.0","description":"A generated synthetic adapter. Replace its services with your device implementation.","platform":"current","entrypoint":"bin/shellcanvas-device{exe}","files":[{"path":"bin/shellcanvas-device{exe}","executable":true}],"configuration":[]});
    fs::create_dir(directory).context("Starter generation requires a new directory")?;
    fs::create_dir(directory.join("src"))?;
    fs::write(directory.join("Cargo.toml"), cargo)?;
    fs::write(directory.join("src/main.rs"), code)?;
    fs::write(
        directory.join("adapter.json"),
        serde_json::to_vec_pretty(&manifest)?,
    )?;
    fs::write(
        directory.join("README.md"),
        include_str!("../templates/README.md").replace("__TEMPLATE_INSTRUCTIONS__", instructions),
    )?;
    fs::write(directory.join(".gitignore"), "/target/\n/packages/\n")?;
    Ok(())
}
/// Build a generated project, then package its executable. Other languages can
/// use pack directly after compiling their adapter.
pub fn build(directory: &Path, output: &Path, debug: bool) -> Result<PathBuf> {
    if output.exists() {
        bail!("Choose a new package output directory");
    }
    let directory = directory.canonicalize()?;
    source_manifest(&directory.join("adapter.json"), None)?;
    let mut command = Command::new("cargo");
    command
        .current_dir(&directory)
        .args(["build", "--bin", "shellcanvas-device", "--target-dir"])
        .arg(directory.join("target"));
    if !debug {
        command.arg("--release");
    }
    if directory.join("Cargo.lock").exists() {
        command.arg("--locked");
    }
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        command.creation_flags(0x08000000);
    }
    if !command.status()?.success() {
        bail!("Adapter build failed");
    }
    let executable = directory
        .join("target")
        .join(if debug { "debug" } else { "release" })
        .join(format!(
            "shellcanvas-device{}",
            std::env::consts::EXE_SUFFIX
        ));
    pack(&directory.join("adapter.json"), &executable, output, None)
}