Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 256 additions & 2 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ base64 = "0.22"
clap = { version = "4", features = ["derive"] }
fs2 = "0.4"
futures-util = "0.3"
flate2 = "1"
getrandom = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
quick-xml = "0.38"
Expand All @@ -34,10 +35,12 @@ schemars = "1.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tar = "0.4"
thiserror = "2"
tempfile = "3"
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "process", "sync", "time"] }
tokio-util = "0.7"
tough = { version = "0.22", default-features = false, features = ["http"] }
url = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }

Expand Down Expand Up @@ -111,5 +114,9 @@ windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_
[dev-dependencies]
async-trait.workspace = true
reqwest.workspace = true
flate2.workspace = true
olpc-cjson = "0.1"
ring = "0.17"
tar.workspace = true
tempfile.workspace = true
zip.workspace = true
74 changes: 69 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,7 @@ the parent TUI before source bytes leave the device.

See the [OCR crate](crates/ocr/README.md) for configuration and provider
boundaries.

## External Extensions

External Use domains stay behind process boundaries. A package contains an
Expand Down Expand Up @@ -1674,11 +1675,74 @@ a3s use extension enable acme/slack --json
a3s uninstall use/acme/slack
```

The current extension source is an explicit local directory. It must pass
manifest, route, path, package-size, and executable validation, and unsigned
content requires `--allow-unsigned`. A signed remote publisher channel is
roadmap work; Use does not silently install arbitrary Homebrew, npm, Cargo,
system, or `PATH` packages.
The current extension source is an explicit local directory or a `.tar.gz`,
`.tgz`, or `.zip` archive. Archives must contain exactly one package manifest;
every entry must belong to that manifest's package root. Installation rejects
links, traversal, duplicate paths, unsupported entries, excessive expansion,
and non-portable paths before validating the manifest, route, executable, and
Skill surfaces. Unsigned content requires `--allow-unsigned`. Use does not
silently install arbitrary Homebrew, npm, Cargo, system, or `PATH` packages.

### Signed extension registries

Remote extensions use TUF metadata and a separately established bootstrap-root
digest. Enroll a registry with either a root file or its SHA-256, verify it,
review the immutable component plan, and apply that exact plan:

```bash
a3s registry add https://packages.example.org/a3s/ \
--trust-root ./root.json \
--yes
a3s registry refresh packages

a3s --output json install use/acme/slack --dry-run
a3s --output json install use/acme/slack \
--plan-digest <reviewed-plan-sha256>

a3s --output json upgrade use/acme/slack --dry-run
a3s --output json upgrade use/acme/slack \
--plan-digest <reviewed-upgrade-sha256>
```

When a root file is supplied, the umbrella CLI copies it into registry-owned
configuration and records its digest. With a digest-only enrollment, Use may
fetch `<registry>/metadata/root.json`, but it caches the file only after the
bytes match the pinned SHA-256. Subsequent root rotation, timestamp, snapshot,
and targets metadata are verified by TUF with expiration and rollback
enforcement. Registry URLs require HTTPS; loopback HTTP is accepted only for
tests and local development.

A dry-run verifies metadata but does not download the target archive. Its outer
component digest includes the exact `ResolvedRemotePackage`: registry identity,
bootstrap root, every TUF metadata version, package version and channel,
platform target, archive path, length, and SHA-256. Apply resolves again and
fails before target download if that plan changed. It then passes the resolved
package's own digest to `a3s-use`, which repeats TUF verification immediately
before downloading and activating the archive. The installed receipt records
`registry-tuf` trust and the complete signed provenance. Registry installs
reject `--allow-unsigned`; local `--from` installs cannot provide registry
options.

Registry upgrades reuse the registry identity and channel recorded in that
signed provenance instead of searching every configured source again. A
missing registry, changed URL or bootstrap root, and semantic-version downgrade
are rejected before payload download. Plain `a3s upgrade` reports newer signed
targets, while `a3s upgrade --all` includes them in the selected batch. If the
verified target is identical to the installed target, `a3s-use` validates and
reconciles the receipt and registry snapshot without downloading or
reactivating the package.

Publish metadata below `<registry>/metadata/` and payloads below
`<registry>/targets/`. An extension target uses this canonical path:

```text
extensions/<publisher>/<name>/<version>/<channel>/<target>/<archive>
```

Its TUF target `custom.a3s` object must contain `schemaVersion`, `packageId`,
`version`, `channel` (`stable`, `beta`, or `nightly`), and `target` (an A3S host
target or `any`). Duplicate identities, mismatched paths, unsupported archives,
and oversized targets are rejected before payload download.

Built-in and management routes are reserved. Extensions cannot shadow
`browser`, `office`, `ocr`, `box`, `component`, `capability`, or other host
Expand Down
10 changes: 10 additions & 0 deletions crates/extension/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,19 @@ description = "ACL manifest and native surface contracts for A3S Use extensions"
a3s-acl = { git = "https://github.com/A3S-Lab/ACL", rev = "6e2a6469edc0f4c61b1e588d0ace873aaf15ce22" }
a3s-use-core = { version = "0.1.1", path = "../core" }
fs2.workspace = true
flate2.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
semver = "1"
sha2.workspace = true
tar.workspace = true
tempfile.workspace = true
tokio.workspace = true
tough.workspace = true
url.workspace = true
zip.workspace = true

[dev-dependencies]
olpc-cjson = "0.1"
ring = "0.17"
194 changes: 194 additions & 0 deletions crates/extension/src/digest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};

use a3s_use_core::{UseError, UseResult};
use sha2::{Digest, Sha256};

use super::package::{io_error, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES};
use super::source::sanitized_relative_path;

struct PackageFile {
normalized: String,
path: PathBuf,
size: u64,
}

pub(crate) async fn package_sha256(root: &Path) -> UseResult<String> {
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || hash_package(&root))
.await
.map_err(|error| {
UseError::new(
"use.extension.io",
format!("Failed to hash extension package: blocking task failed: {error}"),
)
})?
}

fn hash_package(root: &Path) -> UseResult<String> {
let mut files = Vec::new();
let mut entries = 0_usize;
let mut bytes = 0_u64;
collect_files(root, root, &mut files, &mut entries, &mut bytes)?;
files.sort_by(|left, right| left.normalized.cmp(&right.normalized));

let mut digest = Sha256::new();
digest.update(b"a3s-use-expanded-package-v1\0");
for package_file in files {
let path_bytes = package_file.normalized.as_bytes();
digest.update((path_bytes.len() as u64).to_be_bytes());
digest.update(path_bytes);
digest.update(package_file.size.to_be_bytes());

let file = File::open(&package_file.path)
.map_err(|error| io_error("open extension package file", &package_file.path, error))?;
let mut reader = BufReader::new(file);
let mut buffer = [0_u8; 64 * 1024];
let mut read_bytes = 0_u64;
loop {
let count = reader.read(&mut buffer).map_err(|error| {
io_error("hash extension package file", &package_file.path, error)
})?;
if count == 0 {
break;
}
read_bytes = read_bytes.saturating_add(count as u64);
if read_bytes > package_file.size {
return Err(package_changed(&package_file.path));
}
digest.update(&buffer[..count]);
}
if read_bytes != package_file.size {
return Err(package_changed(&package_file.path));
}
}
Ok(format!("{:x}", digest.finalize()))
}

fn collect_files(
root: &Path,
directory: &Path,
files: &mut Vec<PackageFile>,
entries: &mut usize,
bytes: &mut u64,
) -> UseResult<()> {
let children = std::fs::read_dir(directory)
.map_err(|error| io_error("read extension package directory", directory, error))?;
for child in children {
let child =
child.map_err(|error| io_error("read extension package entry", directory, error))?;
*entries = entries.saturating_add(1);
if *entries > MAX_PACKAGE_FILES {
return Err(package_limit_error());
}
let path = child.path();
let metadata = std::fs::symlink_metadata(&path)
.map_err(|error| io_error("inspect extension package entry", &path, error))?;
if metadata.file_type().is_symlink() {
return Err(UseError::new(
"use.extension.package_symlink",
format!(
"Extension package entry '{}' is a symbolic link.",
path.display()
),
));
}
if metadata.is_dir() {
collect_files(root, &path, files, entries, bytes)?;
continue;
}
if !metadata.is_file() {
return Err(UseError::new(
"use.extension.package_entry_invalid",
format!(
"Extension package entry '{}' is not a regular file or directory.",
path.display()
),
));
}
*bytes = bytes.saturating_add(metadata.len());
if *bytes > MAX_PACKAGE_BYTES {
return Err(package_limit_error());
}
let relative = path.strip_prefix(root).map_err(|_| {
UseError::new(
"use.extension.path_escape",
format!(
"Extension package entry '{}' escapes its root.",
path.display()
),
)
})?;
let relative = sanitized_relative_path(relative)?.ok_or_else(|| {
UseError::new(
"use.extension.package_entry_invalid",
"Extension package contains an empty file path.",
)
})?;
let normalized = relative
.iter()
.map(|segment| {
segment.to_str().ok_or_else(|| {
UseError::new(
"use.extension.package_entry_invalid",
format!(
"Extension package path '{}' is not valid UTF-8.",
relative.display()
),
)
})
})
.collect::<UseResult<Vec<_>>>()?
.join("/");
files.push(PackageFile {
normalized,
path,
size: metadata.len(),
});
}
Ok(())
}

fn package_changed(path: &Path) -> UseError {
UseError::new(
"use.extension.package_changed",
format!(
"Extension package file '{}' changed while it was hashed.",
path.display()
),
)
}

fn package_limit_error() -> UseError {
UseError::new(
"use.extension.package_too_large",
"The extension package exceeds the local installation limits.",
)
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn package_digest_is_order_independent_and_content_sensitive() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first");
let second = temp.path().join("second");
std::fs::create_dir_all(first.join("bin")).unwrap();
std::fs::create_dir_all(second.join("bin")).unwrap();
std::fs::write(first.join("z.txt"), b"z").unwrap();
std::fs::write(first.join("bin/tool"), b"tool").unwrap();
std::fs::write(second.join("bin/tool"), b"tool").unwrap();
std::fs::write(second.join("z.txt"), b"z").unwrap();

let first_digest = package_sha256(&first).await.unwrap();
let second_digest = package_sha256(&second).await.unwrap();
assert_eq!(first_digest, second_digest);
assert_eq!(first_digest.len(), 64);

std::fs::write(second.join("bin/tool"), b"changed").unwrap();
assert_ne!(first_digest, package_sha256(&second).await.unwrap());
}
}
7 changes: 7 additions & 0 deletions crates/extension/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@ use a3s_acl::{Block, Value};
use a3s_use_core::{RiskClass, UseError, UseResult};
use serde::{Deserialize, Serialize};

mod digest;
mod package;
mod paths;
mod registry;
mod registry_io;
mod remote;
mod route_lock;
mod source;

pub use paths::ExtensionPaths;
pub use registry::{
ActivationResult, ExtensionReceipt, ExtensionRegistry, ExtensionRegistrySnapshot,
ExtensionRouteBinding, ExtensionRouteLease, ExtensionTrust, InstallOptions, InstallResult,
InstalledExtension, UninstallResult,
};
pub use remote::{
prepare_remote_package, refresh_remote_registry, DownloadedRemotePackage,
PreparedRemotePackage, ResolvedRemotePackage, TrustedRegistry, VerifiedRegistryMetadata,
};

const RESERVED_ROUTES: &[&str] = &[
"browser",
Expand Down
6 changes: 3 additions & 3 deletions crates/extension/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ use tokio::io::AsyncWriteExt;
use super::registry::ExtensionReceipt;
use super::{ExtensionManifest, ExtensionPaths};

const MANIFEST_NAME: &str = "a3s-use-extension.acl";
const MAX_PACKAGE_FILES: usize = 10_000;
const MAX_PACKAGE_BYTES: u64 = 1_073_741_824;
pub(crate) const MANIFEST_NAME: &str = "a3s-use-extension.acl";
pub(crate) const MAX_PACKAGE_FILES: usize = 10_000;
pub(crate) const MAX_PACKAGE_BYTES: u64 = 1_073_741_824;

pub(crate) async fn read_manifest(package_root: &Path) -> UseResult<(ExtensionManifest, Vec<u8>)> {
let path = package_root.join(MANIFEST_NAME);
Expand Down
10 changes: 10 additions & 0 deletions crates/extension/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ impl ExtensionPaths {
path.set_extension("lock");
path
}

pub fn tuf_datastore(&self, registry_name: &str) -> PathBuf {
self.state_root
.join("remote-registries")
.join(registry_name)
}
}

fn configured_root(
Expand Down Expand Up @@ -163,5 +169,9 @@ mod tests {
paths.registry_snapshot_path(),
PathBuf::from("/state/use/registry.json")
);
assert_eq!(
paths.tuf_datastore("a3s"),
PathBuf::from("/state/use/remote-registries/a3s")
);
}
}
Loading
Loading