Skip to main content

shellcanvas_adapter_sdk/
tools.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Standalone project generation and package tooling. Never installs or connects.
3use crate::package::{relative, Manifest, PackageFile};
4use anyhow::{bail, Context, Result};
5use serde::Deserialize;
6use serde_json::{json, Value};
7use sha2::{Digest, Sha256};
8use std::{
9    fs::{self, File, OpenOptions},
10    io::{Read, Write},
11    path::{Path, PathBuf},
12    process::Command,
13};
14
15pub const SOURCE_SCHEMA: &str = include_str!("../schemas/adapter-source.schema.json");
16pub const PACKAGE_SCHEMA: &str = include_str!("../schemas/adapter-package.schema.json");
17
18fn read_json(path: &Path) -> Result<Value> {
19    let mut bytes = vec![];
20    File::open(path)?
21        .take(1024 * 1024 + 1)
22        .read_to_end(&mut bytes)?;
23    if bytes.len() > 1024 * 1024 {
24        bail!("Adapter manifest exceeds 1 MiB");
25    }
26    serde_json::from_slice(&bytes).context("Invalid adapter manifest JSON")
27}
28pub fn platform() -> String {
29    format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
30}
31
32#[derive(Deserialize)]
33#[serde(deny_unknown_fields)]
34struct SourceFile {
35    path: String,
36    #[serde(default)]
37    executable: bool,
38}
39/// Expand source placeholders and validate through the same model as the host.
40pub fn source_manifest(path: &Path, version: Option<&str>) -> Result<Manifest> {
41    let mut value = read_json(path)?;
42    let object = value
43        .as_object_mut()
44        .context("Source manifest must be an object")?;
45    let expand = |name: &str| name.replace("{exe}", std::env::consts::EXE_SUFFIX);
46    let files: Vec<SourceFile> =
47        serde_json::from_value(object.get("files").context("Missing files")?.clone())?;
48    object.insert(
49        "files".into(),
50        serde_json::to_value(
51            files
52                .into_iter()
53                .map(|file| PackageFile {
54                    path: expand(&file.path),
55                    size: 0,
56                    sha256: "0".repeat(64),
57                    executable: file.executable,
58                })
59                .collect::<Vec<_>>(),
60        )?,
61    );
62    let entrypoint = expand(
63        object
64            .get("entrypoint")
65            .and_then(Value::as_str)
66            .context("Missing entrypoint")?,
67    );
68    object.insert("entrypoint".into(), json!(entrypoint));
69    if object.get("platform").and_then(Value::as_str) == Some("current") {
70        object.insert("platform".into(), json!(platform()));
71    }
72    if let Some(version) = version {
73        object.insert("version".into(), json!(version));
74    }
75    let manifest: Manifest = serde_json::from_value(value)?;
76    manifest.validate()?;
77    tool_paths(&manifest)?;
78    Ok(manifest)
79}
80fn tool_paths(manifest: &Manifest) -> Result<()> {
81    let paths: Vec<_> = manifest
82        .files
83        .iter()
84        .map(|file| file.path.to_lowercase())
85        .collect();
86    for path in &paths {
87        if path == "adapter.json"
88            || path.starts_with("adapter.json/")
89            || paths
90                .iter()
91                .any(|other| other != path && other.starts_with(&format!("{path}/")))
92        {
93            bail!("Package assets conflict with a directory or reserved manifest path");
94        }
95    }
96    Ok(())
97}
98fn asset(root: &Path, name: &str) -> Result<PathBuf> {
99    relative(name)?;
100    let mut path = root.to_path_buf();
101    for part in name.split('/') {
102        path.push(part);
103        if fs::symlink_metadata(&path)?.file_type().is_symlink() {
104            bail!("Package assets cannot be symbolic links");
105        }
106    }
107    let canonical = path.canonicalize()?;
108    if !canonical.starts_with(root.canonicalize()?) || !fs::metadata(&canonical)?.is_file() {
109        bail!("Package asset escapes its directory");
110    }
111    Ok(canonical)
112}
113fn copy_hash(input: &Path, mut output: impl Write) -> Result<(u64, String)> {
114    if !fs::symlink_metadata(input)?.is_file() {
115        bail!("Package assets must be regular files");
116    }
117    let mut file = File::open(input)?;
118    let mut digest = Sha256::new();
119    let mut size = 0u64;
120    let mut chunk = [0; 65536];
121    loop {
122        let count = file.read(&mut chunk)?;
123        if count == 0 {
124            break;
125        }
126        output.write_all(&chunk[..count])?;
127        digest.update(&chunk[..count]);
128        size = size
129            .checked_add(count as u64)
130            .context("Asset size overflow")?;
131    }
132    Ok((size, format!("{:x}", digest.finalize())))
133}
134/// Write a new package directory; existing output is never overwritten.
135/// The manifest is published last. A failed pack may leave an incomplete output
136/// directory, which must not be installed. Choose a fresh output for a retry.
137pub fn pack(
138    source: &Path,
139    executable: &Path,
140    output: &Path,
141    version: Option<&str>,
142) -> Result<PathBuf> {
143    let source = source.canonicalize()?;
144    let root = source.parent().context("Missing source parent")?;
145    let mut manifest = source_manifest(&source, version)?;
146    let mut inputs = vec![];
147    for file in &manifest.files {
148        let input = if file.path == manifest.entrypoint {
149            executable.to_path_buf()
150        } else {
151            asset(root, &file.path)?
152        };
153        if !fs::symlink_metadata(&input)?.is_file() {
154            bail!("Package assets must be regular files");
155        }
156        inputs.push(input);
157    }
158    // Reserve this new output directory; no rename-over-existing behavior.
159    fs::create_dir(output).context("Choose a new package output directory")?;
160    for (file, input) in manifest.files.iter_mut().zip(inputs) {
161        let destination = output.join(&file.path);
162        fs::create_dir_all(destination.parent().context("Missing asset parent")?)?;
163        let mut target = OpenOptions::new()
164            .create_new(true)
165            .write(true)
166            .open(&destination)?;
167        let (size, sha256) = copy_hash(&input, &mut target)?;
168        target.sync_all()?;
169        file.size = size;
170        file.sha256 = sha256;
171        #[cfg(unix)]
172        {
173            use std::os::unix::fs::PermissionsExt;
174            fs::set_permissions(
175                destination,
176                fs::Permissions::from_mode(if file.executable { 0o700 } else { 0o600 }),
177            )?;
178        }
179    }
180    manifest.validate()?;
181    let bytes = serde_json::to_vec_pretty(&manifest)?;
182    if bytes.len() > 1024 * 1024 {
183        bail!("Packed manifest exceeds 1 MiB");
184    }
185    let path = output.join("adapter.json");
186    let mut file = OpenOptions::new()
187        .create_new(true)
188        .write(true)
189        .open(&path)?;
190    file.write_all(&bytes)?;
191    file.sync_all()?;
192    Ok(path)
193}
194/// Verify manifest and all declared hashes, without executing the package.
195pub fn validate(path: &Path) -> Result<Manifest> {
196    let manifest: Manifest = serde_json::from_value(read_json(path)?)?;
197    manifest.validate()?;
198    tool_paths(&manifest)?;
199    let root = path.parent().context("Missing package parent")?;
200    for file in &manifest.files {
201        let (size, hash) = copy_hash(&asset(root, &file.path)?, std::io::sink())?;
202        if size != file.size || hash != file.sha256 {
203            bail!("Package asset does not match its manifest: {}", file.path);
204        }
205    }
206    Ok(manifest)
207}
208
209/// Generate a new Rust adapter project which consumes an independently supplied SDK.
210pub fn create(directory: &Path, id: &str, name: &str, sdk_source: &Path) -> Result<()> {
211    create_template(directory, id, name, sdk_source, "custom")
212}
213
214/// Select a runnable, independently buildable service example. Unknown names
215/// fail before creating any project files.
216pub fn create_template(
217    directory: &Path,
218    id: &str,
219    name: &str,
220    sdk_source: &Path,
221    template: &str,
222) -> Result<()> {
223    let (source, extra_dependencies, instructions) = match template {
224        "custom" => (include_str!("../templates/main.rs.txt"), "", "Assign your adapter ID under Additional services. The custom service echoes JSON and has a cancelable wait."),
225        "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."),
226        "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."),
227        "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."),
228        _ => bail!("Unknown template; choose custom, files, console or settings"),
229    };
230    if !crate::wire::name(id)
231        || !id.contains('.')
232        || id.starts_with("system.")
233        || name.trim().is_empty()
234        || name.len() > 200
235    {
236        bail!("Choose a namespaced adapter ID and a nonempty name of at most 200 bytes");
237    }
238    let sdk = sdk_source.canonicalize()?;
239    if !sdk.join("Cargo.toml").is_file() {
240        bail!("SDK source directory needs Cargo.toml");
241    }
242    let sdk_path = sdk.to_str().context("SDK source path must be UTF-8")?;
243    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)?);
244    let cargo = format!("{cargo}{extra_dependencies}");
245    let code = source.replace("__SERVICE_LITERAL__", &format!("{id:?}"));
246    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":[]});
247    fs::create_dir(directory).context("Starter generation requires a new directory")?;
248    fs::create_dir(directory.join("src"))?;
249    fs::write(directory.join("Cargo.toml"), cargo)?;
250    fs::write(directory.join("src/main.rs"), code)?;
251    fs::write(
252        directory.join("adapter.json"),
253        serde_json::to_vec_pretty(&manifest)?,
254    )?;
255    fs::write(
256        directory.join("README.md"),
257        include_str!("../templates/README.md").replace("__TEMPLATE_INSTRUCTIONS__", instructions),
258    )?;
259    fs::write(directory.join(".gitignore"), "/target/\n/packages/\n")?;
260    Ok(())
261}
262/// Build a generated project, then package its executable. Other languages can
263/// use pack directly after compiling their adapter.
264pub fn build(directory: &Path, output: &Path, debug: bool) -> Result<PathBuf> {
265    if output.exists() {
266        bail!("Choose a new package output directory");
267    }
268    let directory = directory.canonicalize()?;
269    source_manifest(&directory.join("adapter.json"), None)?;
270    let mut command = Command::new("cargo");
271    command
272        .current_dir(&directory)
273        .args(["build", "--bin", "shellcanvas-device", "--target-dir"])
274        .arg(directory.join("target"));
275    if !debug {
276        command.arg("--release");
277    }
278    if directory.join("Cargo.lock").exists() {
279        command.arg("--locked");
280    }
281    #[cfg(windows)]
282    {
283        use std::os::windows::process::CommandExt;
284        command.creation_flags(0x08000000);
285    }
286    if !command.status()?.success() {
287        bail!("Adapter build failed");
288    }
289    let executable = directory
290        .join("target")
291        .join(if debug { "debug" } else { "release" })
292        .join(format!(
293            "shellcanvas-device{}",
294            std::env::consts::EXE_SUFFIX
295        ));
296    pack(&directory.join("adapter.json"), &executable, output, None)
297}