1use 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}
39pub 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}
134pub 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 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}
194pub 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
209pub fn create(directory: &Path, id: &str, name: &str, sdk_source: &Path) -> Result<()> {
211 create_template(directory, id, name, sdk_source, "custom")
212}
213
214pub 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}
262pub 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}