mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 16:06:22 +00:00
210 lines
7.3 KiB
Rust
210 lines
7.3 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
const PLUGIN_CONFIG_PATH_COMPONENT_MAX_CHARS: usize = 120;
|
|
|
|
pub(crate) fn managed_plugins_dir() -> PathBuf {
|
|
crate::config::config_dir().join("plugins")
|
|
}
|
|
|
|
pub(crate) fn managed_checkout_path(plugin_id: &str) -> PathBuf {
|
|
managed_plugins_dir()
|
|
.join("github")
|
|
.join(crate::api::schema::plugin_managed_path_component(plugin_id))
|
|
}
|
|
|
|
pub(crate) fn managed_checkout_lock_path(plugin_id: &str) -> PathBuf {
|
|
managed_plugins_dir().join(".locks").join(format!(
|
|
".{}.lock",
|
|
crate::api::schema::plugin_managed_path_component(plugin_id)
|
|
))
|
|
}
|
|
|
|
pub(crate) fn managed_installations_dir(plugin_id: &str) -> PathBuf {
|
|
managed_plugins_dir()
|
|
.join("github-installations")
|
|
.join(crate::api::schema::plugin_managed_path_component(plugin_id))
|
|
}
|
|
|
|
pub(crate) fn create_managed_installation(plugin_id: &str) -> std::io::Result<PathBuf> {
|
|
let parent = managed_installations_dir(plugin_id);
|
|
std::fs::create_dir_all(&parent)?;
|
|
for generation in 0u64.. {
|
|
let path = parent.join(format!("{}-{generation}", std::process::id()));
|
|
match std::fs::create_dir(&path) {
|
|
Ok(()) => return Ok(path),
|
|
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
Err(std::io::Error::other(
|
|
"plugin installation directory exhausted",
|
|
))
|
|
}
|
|
|
|
pub(crate) fn plugin_config_dir(plugin_id: &str) -> PathBuf {
|
|
managed_plugins_dir()
|
|
.join("config")
|
|
.join(plugin_config_path_component(plugin_id))
|
|
}
|
|
|
|
pub(crate) fn plugin_state_dir(plugin_id: &str) -> PathBuf {
|
|
crate::config::state_dir()
|
|
.join("plugins")
|
|
.join(plugin_config_path_component(plugin_id))
|
|
}
|
|
|
|
pub(crate) fn ensure_plugin_user_dirs(plugin_id: &str) -> std::io::Result<()> {
|
|
ensure_plugin_config_dir(plugin_id)?;
|
|
std::fs::create_dir_all(plugin_state_dir(plugin_id))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_plugin_config_dir(plugin_id: &str) -> std::io::Result<()> {
|
|
let config_dir = plugin_config_dir(plugin_id);
|
|
if config_dir.exists() {
|
|
return std::fs::create_dir_all(config_dir);
|
|
}
|
|
if let Some(legacy_dir) = legacy_plugin_config_dirs(plugin_id)
|
|
.into_iter()
|
|
.find(|path| path.is_dir())
|
|
{
|
|
copy_dir_all(&legacy_dir, &config_dir)?;
|
|
return Ok(());
|
|
}
|
|
std::fs::create_dir_all(config_dir)
|
|
}
|
|
|
|
fn legacy_plugin_config_dirs(plugin_id: &str) -> Vec<PathBuf> {
|
|
let plugins_dir = managed_plugins_dir();
|
|
let old_unhashed = (!matches!(
|
|
plugin_id,
|
|
"config" | "github" | "github-installations" | ".locks"
|
|
))
|
|
.then(|| plugins_dir.join(plugin_id));
|
|
let current_hashed =
|
|
plugins_dir.join(crate::api::schema::plugin_managed_path_component(plugin_id));
|
|
let mut candidates = Vec::new();
|
|
if let Some(old_unhashed) = old_unhashed {
|
|
if old_unhashed != current_hashed {
|
|
candidates.push(old_unhashed);
|
|
}
|
|
}
|
|
candidates.push(current_hashed);
|
|
candidates
|
|
}
|
|
|
|
fn plugin_config_path_component(value: &str) -> String {
|
|
let mut component = String::new();
|
|
for byte in value.bytes() {
|
|
if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
|
|
{
|
|
component.push(byte as char);
|
|
} else {
|
|
use std::fmt::Write as _;
|
|
let _ = write!(component, "%{byte:02X}");
|
|
}
|
|
}
|
|
if component.ends_with('.') {
|
|
component.pop();
|
|
component.push_str("%2E");
|
|
}
|
|
if component.is_empty() {
|
|
return "%plugin".to_string();
|
|
}
|
|
if crate::api::schema::has_windows_reserved_stem_for_path_component(&component) {
|
|
component = format!("%{component}");
|
|
}
|
|
if component.chars().count() > PLUGIN_CONFIG_PATH_COMPONENT_MAX_CHARS {
|
|
let hash = crate::api::schema::short_plugin_id_hash_for_path_component(value);
|
|
let prefix_len = PLUGIN_CONFIG_PATH_COMPONENT_MAX_CHARS - hash.len() - 1;
|
|
let prefix = component.chars().take(prefix_len).collect::<String>();
|
|
return format!("{prefix}-{hash}");
|
|
}
|
|
component
|
|
}
|
|
|
|
fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> {
|
|
std::fs::create_dir_all(destination)?;
|
|
for entry in std::fs::read_dir(source)? {
|
|
let entry = entry?;
|
|
let file_type = entry.file_type()?;
|
|
let destination_path = destination.join(entry.file_name());
|
|
if file_type.is_dir() {
|
|
copy_dir_all(&entry.path(), &destination_path)?;
|
|
} else {
|
|
std::fs::copy(entry.path(), destination_path)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn installations_never_reuse_or_move_existing_files() {
|
|
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
|
let base = std::env::temp_dir().join(format!(
|
|
"herdr-installations-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let previous = std::env::var_os("XDG_CONFIG_HOME");
|
|
std::env::set_var("XDG_CONFIG_HOME", &base);
|
|
let id = format!("example.generations-{}", std::process::id());
|
|
let first = create_managed_installation(&id).unwrap();
|
|
std::fs::write(first.join("keep"), "original").unwrap();
|
|
let second = create_managed_installation(&id).unwrap();
|
|
assert_ne!(first, second);
|
|
assert_eq!(
|
|
std::fs::read_to_string(first.join("keep")).unwrap(),
|
|
"original"
|
|
);
|
|
ensure_plugin_config_dir("github-installations").unwrap();
|
|
assert!(
|
|
std::fs::read_dir(plugin_config_dir("github-installations"))
|
|
.unwrap()
|
|
.next()
|
|
.is_none(),
|
|
"managed installations are not legacy user configuration"
|
|
);
|
|
std::fs::remove_dir_all(&first).unwrap();
|
|
std::fs::remove_dir_all(&second).unwrap();
|
|
std::fs::remove_dir(managed_installations_dir(&id)).unwrap();
|
|
match previous {
|
|
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
|
|
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
|
}
|
|
std::fs::remove_dir_all(base).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_config_path_component_is_readable_and_collision_free() {
|
|
assert_eq!(
|
|
plugin_config_path_component("examples.agent-telegram-notify"),
|
|
"examples.agent-telegram-notify"
|
|
);
|
|
assert_eq!(plugin_config_path_component("example:a"), "example%3Aa");
|
|
assert_eq!(plugin_config_path_component("Example"), "%45xample");
|
|
assert_ne!(
|
|
plugin_config_path_component("example:a"),
|
|
plugin_config_path_component("example-a")
|
|
);
|
|
assert_ne!(
|
|
plugin_config_path_component("Example"),
|
|
plugin_config_path_component("example")
|
|
);
|
|
assert_ne!(
|
|
plugin_config_path_component(&"A".repeat(120)),
|
|
plugin_config_path_component(&"B".repeat(120))
|
|
);
|
|
assert!(plugin_config_path_component(&"A".repeat(120)).len() <= 120);
|
|
assert_eq!(plugin_config_path_component("con"), "%con");
|
|
assert_eq!(plugin_config_path_component("example."), "example%2E");
|
|
}
|
|
}
|