use std::collections::HashMap;
use std::path::{Path, PathBuf};
use yapi_types::settings::FilteredPackage;
use super::fetch::placed_name;
use super::lock::fresh;
use super::manifest::{Entry, MANIFEST_ENV, Manifest, Section, from_env};
use super::{Mode, Project, locked_items};
use crate::extensions::{EventPolicy, Grants};
use crate::packages::npm::{Npm, escaped};
use crate::packages::resolve::{PackageInput, ResolvedPaths, ResourceType, package_resources};
use crate::settings::Scope;
use crate::tools::path::home_dir;
pub fn project_manifest(project_root: &Path) -> Option<PathBuf> {
let name = from_env().ok()?;
Some(project_root.join(name.as_deref().unwrap_or("chest.toml")))
}
fn consumes(manifest: &Manifest) -> bool {
[Section::Skills, Section::Agents, Section::Extensions]
.into_iter()
.any(|section| !manifest.section(section).is_empty())
|| !manifest.patch.is_empty()
}
pub fn needs_trust(project_root: &Path) -> bool {
let Some(Ok(text)) = project_manifest(project_root).map(std::fs::read_to_string) else {
return false;
};
Manifest::parse(&text, Scope::Project).map_or(true, |manifest| consumes(&manifest))
}
#[derive(Clone, Debug, PartialEq)]
pub struct Policy {
pub grants: Grants,
pub events: EventPolicy,
pub root: Option<PathBuf>,
}
impl Policy {
pub fn unrestricted() -> Policy {
Policy {
grants: Grants::default(),
events: EventPolicy::shared(),
root: None,
}
}
pub fn closed(root: &Path) -> Policy {
Policy {
grants: Grants::none(),
events: EventPolicy::isolated(),
root: Some(root.to_path_buf()),
}
}
fn intersection(&self, other: &Policy) -> Policy {
let canonical = |root: &PathBuf| root.canonicalize().unwrap_or_else(|_| root.clone());
let roots = (
self.root.as_ref().map(canonical),
other.root.as_ref().map(canonical),
);
let root = match roots {
(Some(left), Some(right)) if left.starts_with(&right) => Some(left),
(Some(left), Some(right)) if right.starts_with(&left) => Some(right),
_ => None,
};
Policy {
grants: self.grants.intersection(other.grants),
events: self.events.intersection(&other.events),
root,
}
}
}
pub type Policies = HashMap<(String, String), Policy>;
#[derive(Clone, Debug, Default)]
pub struct FilePolicies {
pub files: HashMap<PathBuf, Policy>,
pub closed: Vec<PathBuf>,
}
impl FilePolicies {
pub fn of(&self, file: &Path) -> Policy {
if self.closes(file) {
return Policy::closed(file);
}
self.files
.get(file)
.cloned()
.unwrap_or_else(Policy::unrestricted)
}
pub fn manages(&self, file: &Path) -> bool {
self.files.contains_key(file) || self.closes(file)
}
pub fn closes(&self, file: &Path) -> bool {
self.closed.iter().any(|root| file.starts_with(root))
}
pub fn also_loaded(&self, file: &Path, path: &str, source: &str) -> Option<(String, String)> {
if !self.manages(file) {
return None;
}
let message = match self.closes(file) {
true => format!(
"{path} is also loaded from {source}, but its chest entry does not load, so it runs with no capabilities"
),
false => format!("{path} is also loaded from {source}; chest's grants apply"),
};
Some((escaped(path), escaped(&message)))
}
}
pub fn file_policies(
resolved: &ResolvedPaths,
installed: &Installed,
problems: &mut Vec<(String, String)>,
) -> FilePolicies {
let files = resolved.of(ResourceType::Extensions).iter();
let files = files.map(|file| &file.info).chain(&resolved.shadowed);
let mut found = FilePolicies {
files: HashMap::new(),
closed: installed.closed.clone(),
};
for info in files.filter(|info| info.source.starts_with("chest:")) {
let key = (info.scope.clone(), info.source.clone());
let file = Path::new(&info.path);
let file = file.canonicalize().unwrap_or_else(|_| file.into());
let policy = installed.policies.get(&key).cloned().unwrap_or_else(|| {
let message = format!(
"{}: no chest entry grants this file, so it runs with no capabilities",
info.path
);
problems.push((escaped(&info.source), escaped(&message)));
Policy::closed(&file)
});
let policy = match found.files.get(&file) {
Some(other) => other.intersection(&policy),
None => policy,
};
found.files.insert(file, policy);
}
found
}
#[derive(Clone, Debug, Default)]
pub struct Installed {
pub packages: Vec<PackageInput>,
pub skills: Vec<PackageInput>,
pub policies: Policies,
pub closed: Vec<PathBuf>,
}
impl Installed {
fn extend(&mut self, other: Installed) {
self.packages.extend(other.packages);
self.skills.extend(other.skills);
self.policies.extend(other.policies);
self.closed.extend(other.closed);
}
}
#[derive(Clone, Debug, Default)]
pub struct Loaded {
pub installed: Installed,
pub problems: Vec<(String, String)>,
}
impl Loaded {
fn warn(&mut self, (path, message): (String, String)) {
self.problems.push((escaped(&path), escaped(&message)));
}
}
pub fn installed_inputs(
project: &Project,
project_root: &Path,
problems: &mut Vec<String>,
) -> Installed {
let scope = match project.scope {
Scope::Project => "project",
Scope::Global => "user",
};
let mut installed = Installed::default();
let lock = project.lock.clone().unwrap_or_default();
let current = |section: Section, name: &str, entry: &Entry| {
lock.section(section)
.iter()
.find(|locked| locked.name == name)
.filter(|locked| fresh(entry, locked))
.filter(|locked| project.present(section, locked))
.cloned()
};
for (name, entry) in &project.manifest.extensions {
let patched = project.manifest.patch.contains_key(name);
let roots = project.extension_roots(name);
if let Some((root, module_root)) = &roots
&& project.too_wide(module_root, &[project_root])
{
problems.push(format!(
"extensions.{name}: {} holds more than the extension, such as the project, the home or the agent folder, so it does not load",
module_root.display()
));
let local = crate::packages::source::is_local(&format!("chest:{name}"));
let files = package_resources(root, None, local).extensions;
let files = files.iter().map(|file| Path::new(&file.info.path));
installed
.closed
.extend(files.filter_map(|file| file.canonicalize().ok()));
continue;
}
if !patched && current(Section::Extensions, name, entry).is_none() {
if let Some(root) = roots.and_then(|(_, root)| root.canonicalize().ok()) {
installed.closed.push(root);
}
continue;
}
let Some((root, module_root)) = roots else {
continue;
};
if !root.exists() {
if patched || entry.git.is_none() && entry.npm.is_none() {
problems.push(format!(
"extensions.{name}: {} does not exist",
root.display()
));
}
continue;
}
let source = format!("chest:{name}");
let filter = [&entry.skills, &entry.prompts, &entry.themes]
.iter()
.any(|list| list.is_some())
.then(|| FilteredPackage {
source: source.clone(),
autoload: None,
extensions: None,
skills: entry.skills.clone(),
prompts: entry.prompts.clone(),
themes: entry.themes.clone(),
});
let policy = Policy {
grants: project.manifest.grants(entry),
events: entry.events.clone().unwrap_or_default(),
root: Some(module_root),
};
let key = (scope.to_owned(), source.clone());
installed.policies.insert(key, policy);
installed.packages.push(PackageInput {
source,
scope: scope.to_owned(),
root,
filter,
});
}
let Some(home) = project.homes(Section::Skills).into_iter().next() else {
return installed;
};
for (name, entry) in &project.manifest.skills {
let patch = project.manifest.patch.get(name);
let roots = match (
patch.and_then(|patch| patch.path.as_deref()),
current(Section::Skills, name, entry),
) {
(Some(path), _) => {
let placed = home.join(placed_name(Section::Skills, name));
let root = if placed.exists() {
placed
} else {
project.resolve(path)
};
if !root.exists() {
problems.push(format!("skills.{name}: {} does not exist", root.display()));
continue;
}
vec![root]
}
(None, Some(locked)) => locked_items(Section::Skills, &locked)
.into_iter()
.map(|(item, _)| home.join(item))
.collect(),
(None, None) => continue,
};
for root in roots {
installed.skills.push(PackageInput {
source: format!("chest:{name}"),
scope: scope.to_owned(),
root,
filter: None,
});
}
}
installed
}
fn manifests(project_root: &Path, trusted: bool) -> Vec<Result<Project, (String, String)>> {
let mut found = Vec::new();
if trusted && let Err(problem) = from_env() {
let problem = format!("{problem}, so no project manifest loads");
found.push(Err((MANIFEST_ENV.to_owned(), problem)));
}
let project = trusted.then(|| project_manifest(project_root)).flatten();
let project = project.map(|path| (path, Scope::Project));
let global = Some((home_dir().join(".chest/chest.toml"), Scope::Global));
for (path, scope) in [project, global].into_iter().flatten() {
if !path.exists() {
continue;
}
match Project::open(&path, scope) {
Ok(project) if consumes(&project.manifest) => found.push(Ok(project)),
Ok(_) => {}
Err(err) => found.push(Err((path.display().to_string(), err.to_string()))),
}
}
found
}
fn add(loaded: &mut Loaded, project: &Project, project_root: &Path) {
let mut problems = Vec::new();
loaded
.installed
.extend(installed_inputs(project, project_root, &mut problems));
let path = project.path.display().to_string();
for problem in problems {
loaded.warn((path.clone(), problem));
}
}
pub fn installed(project_root: &Path, trusted: bool) -> Loaded {
let mut loaded = Loaded::default();
for project in manifests(project_root, trusted) {
match project {
Ok(project) => add(&mut loaded, &project, project_root),
Err(problem) => loaded.warn(problem),
}
}
loaded
}
pub async fn load(project_root: &Path, trusted: bool, offline: bool) -> Loaded {
let mut loaded = Loaded::default();
let mut npm = None;
for project in manifests(project_root, trusted) {
let mut project = match project {
Ok(project) => project,
Err(problem) => {
loaded.warn(problem);
continue;
}
};
let path = project.path.display().to_string();
let npm = npm.get_or_insert_with(|| Npm::new(crate::packages::npm::config()));
match project
.sync(Mode::Startup { offline }, npm, &mut |_| {})
.await
{
Ok(report) => {
for message in report.warnings.into_iter().chain(report.notices) {
loaded.warn((path.clone(), message));
}
}
Err(err) => loaded.warn((path.clone(), err.to_string())),
}
add(&mut loaded, &project, project_root);
}
loaded
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packages::resolve::Resolved;
use yapi_types::rpc::SourceInfo;
#[test]
fn startup_load_send() {
fn send<T: Send>(_: T) {}
send(load(Path::new("."), false, true));
}
#[test]
fn chest_files_fail_closed() {
let file = |path: &str, source: &str, scope: &str| SourceInfo {
path: path.into(),
source: source.into(),
scope: scope.into(),
origin: "package".into(),
base_dir: None,
};
let enabled = |info| Resolved {
info,
enabled: true,
};
let spoof = "/p/\x1b[2J.js";
let resolved = ResolvedPaths {
extensions: vec![
enabled(file("/p/a.js", "chest:a", "project")),
enabled(file("/p/b.js", "npm:b", "project")),
enabled(file(spoof, "chest:a", "project")),
],
..ResolvedPaths::default()
};
let mut problems = Vec::new();
let policies = file_policies(&resolved, &Installed::default(), &mut problems);
let (file_a, file_spoof) = (PathBuf::from("/p/a.js"), PathBuf::from(spoof));
let files = HashMap::from([
(file_a.clone(), Policy::closed(&file_a)),
(file_spoof.clone(), Policy::closed(&file_spoof)),
]);
assert_eq!(policies.files, files);
assert_eq!(problems.len(), 2, "{problems:?}");
let warned = policies.also_loaded(&file_spoof, spoof, "cli");
let warned = warned
.into_iter()
.flat_map(|(path, message)| [path, message]);
let texts: Vec<String> = problems
.iter()
.flat_map(|(source, message)| [source.clone(), message.clone()])
.chain(warned)
.collect();
assert!(
texts.len() == 6 && texts.iter().all(|text| !text.contains('\x1b')),
"{texts:?}"
);
let rooted = |root: &str| Policy {
root: Some(root.into()),
..Policy::unrestricted()
};
let key = |scope: &str| (scope.to_owned(), "chest:s".to_owned());
let installed = Installed {
policies: HashMap::from([
(key("project"), rooted("/p")),
(key("user"), rooted("/p/sub")),
]),
closed: vec!["/q".into()],
..Installed::default()
};
let resolved = ResolvedPaths {
extensions: vec![
enabled(file("/p/sub/a.js", "chest:s", "project")),
enabled(file("/p/sub/b.js", "chest:s", "user")),
],
shadowed: vec![
file("/p/sub/a.js", "chest:s", "user"),
file("/p/sub/b.js", "chest:s", "project"),
],
..ResolvedPaths::default()
};
let policies = file_policies(&resolved, &installed, &mut problems);
let root = |path: &str| policies.of(Path::new(path)).root;
for file in ["/p/sub/a.js", "/p/sub/b.js"] {
assert_eq!(root(file), Some("/p/sub".into()), "{file}");
}
assert_eq!(root("/q/a.js"), Some("/q/a.js".into()));
}
}