native/src/management.rs
Rama main · réplica de lectura del repositorio original.
//! Filesystem and lifecycle primitives for the Origin Speak CLI host.
//!
//! This module intentionally stays independent from GPUI and the current desktop
//! runtime so it can be wired into `main.rs` without pulling dashboard state into
//! CLI commands. Platform process control, autostart, and update installation
//! should be supplied by the native shell adapter; this file owns only portable
//! path discovery and safe data removal.
use origin_speak_lib::{
app_data_root, app_local_data_root, legacy_app_data_root, legacy_app_local_data_root,
model_storage_roots,
};
use semver::Version;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
const PRODUCT_DIR: &str = "Origin Speak";
const DATA_DIR: &str = "OriginSpeak";
const UPDATE_TEMP_PREFIX: &str = "origin-speak-update-";
pub const UPDATE_STAGING_MARKER_FILE: &str = ".origin-speak-update-staging-v1";
pub fn update_staging_marker_contents(version: &str) -> String {
format!("Origin Speak update staging v1\nversion={version}\n")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DataLayout {
/// Authoritative backend data root. This contains models, config and SQLite data.
pub data_root: PathBuf,
/// Exact machine-local Origin Speak data root. On Windows this is distinct from
/// roaming data and may contain model/runtime artifacts outside `data_root`.
pub local_data_root: PathBuf,
/// Per-user installation root used by the CLI/bootstrap flow.
pub install_root: PathBuf,
/// Directory containing the installed CLI manager.
pub manager_root: PathBuf,
/// Installed console manager target. Setup may run from another location;
/// uninstall removes this path only when it is the exact app-owned target.
pub manager_path: PathBuf,
/// Installed silent runtime target.
pub runtime_path: PathBuf,
/// System temporary directory where legacy/new updater payloads may be staged.
pub temp_root: PathBuf,
/// Current and legacy model storage directories reported by the backend.
/// Only exact Origin Speak model leaves are eligible for removal.
pub model_roots: Vec<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UninstallPlan {
pub data_root: Option<PathBuf>,
pub local_data_root: Option<PathBuf>,
pub model_roots: Vec<PathBuf>,
pub install_root: PathBuf,
pub manager_root: PathBuf,
pub manager_path: PathBuf,
pub runtime_path: PathBuf,
pub stale_update_paths: Vec<PathBuf>,
}
#[derive(Debug, Default)]
pub struct LegacyDataMigration {
moves: Vec<(PathBuf, PathBuf)>,
}
impl LegacyDataMigration {
pub fn begin() -> Result<Self, String> {
let mut pairs = vec![
(legacy_app_data_root()?, app_data_root()?),
(legacy_app_local_data_root()?, app_local_data_root()?),
];
pairs.dedup();
let mut migration = Self::default();
for (legacy, current) in pairs {
if legacy == current || !path_exists_no_follow(&legacy)? {
continue;
}
validate_owned_data_directory(&legacy, "legacy data root")?;
if path_exists_no_follow(¤t)? {
continue;
}
let parent = current.parent().ok_or_else(|| {
format!(
"Origin Speak data root has no parent: {}",
current.display()
)
})?;
fs::create_dir_all(parent)
.map_err(|error| format!("create data parent {}: {error}", parent.display()))?;
fs::rename(&legacy, ¤t).map_err(|error| {
format!(
"migrate legacy ListenOS data {} to Origin Speak {}: {error}",
legacy.display(),
current.display()
)
})?;
migration.moves.push((legacy, current));
}
Ok(migration)
}
pub fn rollback(&mut self) -> Result<(), String> {
for (legacy, current) in self.moves.iter().rev() {
if path_exists_no_follow(legacy)? {
return Err(format!(
"cannot roll back data migration because legacy path reappeared: {}",
legacy.display()
));
}
validate_owned_data_directory(current, "migrated data root")?;
fs::rename(current, legacy).map_err(|error| {
format!(
"restore migrated data {} to legacy path {}: {error}",
current.display(),
legacy.display()
)
})?;
}
self.moves.clear();
Ok(())
}
pub fn migrated_count(&self) -> usize {
self.moves.len()
}
}
impl DataLayout {
pub fn discover() -> Result<Self, String> {
let install_root = install_root()?;
let manager_root = manager_root()?;
let runtime_path = runtime_path_for_install_root(&install_root);
Ok(Self {
data_root: app_data_root()?,
local_data_root: app_local_data_root()?,
manager_path: manager_root.join(manager_file_name()),
runtime_path,
install_root,
manager_root,
temp_root: env::temp_dir(),
model_roots: model_storage_roots()?,
})
}
pub fn uninstall_plan(&self, keep_data: bool) -> Result<UninstallPlan, String> {
validate_data_root(&self.data_root)?;
validate_local_data_root(&self.local_data_root)?;
validate_install_path(&self.install_root)?;
validate_manager_root(&self.manager_root)?;
validate_install_file(&self.manager_path, &self.manager_root, manager_file_name())?;
validate_runtime_path(&self.runtime_path, &self.install_root)?;
for model_root in &self.model_roots {
validate_model_root(model_root)?;
}
Ok(UninstallPlan {
data_root: (!keep_data).then(|| self.data_root.clone()),
local_data_root: (!keep_data && self.local_data_root != self.data_root)
.then(|| self.local_data_root.clone()),
model_roots: if keep_data {
Vec::new()
} else {
self.model_roots.clone()
},
install_root: self.install_root.clone(),
manager_root: self.manager_root.clone(),
manager_path: self.manager_path.clone(),
runtime_path: self.runtime_path.clone(),
stale_update_paths: discover_stale_update_paths(&self.temp_root),
})
}
pub fn update_staging_dir(&self, version: &str) -> Result<PathBuf, String> {
if version.is_empty()
|| !version
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'))
{
return Err("update version contains unsupported path characters".to_string());
}
Ok(self
.temp_root
.join(format!("origin-speak-update-{version}")))
}
pub fn current_manager_is_installed(&self) -> bool {
std::env::current_exe()
.ok()
.and_then(|path| canonical_if_exists(&path))
== canonical_if_exists(&self.manager_path)
}
}
/// Remove all user-owned Origin Speak state: models, configuration, databases, and
/// other runtime data under the single backend data root. This is the default
/// uninstall behavior. `--keep-data` must be explicit to skip this operation.
pub fn purge_user_data(layout: &DataLayout) -> Result<(), String> {
validate_data_root(&layout.data_root)?;
validate_local_data_root(&layout.local_data_root)?;
for model_root in &layout.model_roots {
validate_model_root(model_root)?;
}
remove_tree_no_follow(&layout.data_root)
.map_err(|error| format!("remove {}: {error}", layout.data_root.display()))?;
if layout.local_data_root != layout.data_root {
remove_tree_no_follow(&layout.local_data_root).map_err(|error| {
format!(
"remove local data root {}: {error}",
layout.local_data_root.display()
)
})?;
}
for model_root in &layout.model_roots {
remove_tree_no_follow(model_root)
.map_err(|error| format!("remove model root {}: {error}", model_root.display()))?;
}
for path in discover_stale_update_paths(&layout.temp_root) {
remove_tree_no_follow(&path)
.map_err(|error| format!("remove stale update {}: {error}", path.display()))?;
}
Ok(())
}
pub fn remove_installed_runtime(layout: &DataLayout) -> Result<bool, String> {
validate_runtime_path(&layout.runtime_path, &layout.install_root)?;
#[cfg(target_os = "macos")]
{
return crate::macos_bundle::remove_owned_bundle(&layout.install_root);
}
#[cfg(not(target_os = "macos"))]
{
remove_install_file(&layout.runtime_path)
}
}
pub fn remove_installed_manager(layout: &DataLayout) -> Result<bool, String> {
validate_install_file(
&layout.manager_path,
&layout.manager_root,
manager_file_name(),
)?;
remove_install_file(&layout.manager_path)
}
pub fn remove_empty_install_root(layout: &DataLayout) -> Result<(), String> {
validate_install_path(&layout.install_root)?;
#[cfg(target_os = "macos")]
{
if layout.install_root.exists() {
return Err(format!(
"macOS runtime bundle still exists after runtime removal: {}",
layout.install_root.display()
));
}
return Ok(());
}
#[cfg(not(target_os = "macos"))]
match fs::remove_dir(&layout.install_root) {
Ok(()) => Ok(()),
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::DirectoryNotEmpty
) =>
{
Ok(())
}
Err(error) => Err(format!(
"remove install directory {}: {error}",
layout.install_root.display()
)),
}
}
/// Returns true when the command must not prompt on stdin. The integration
/// layer can use this together with `--yes`: destructive commands in a pipe or
/// CI environment should fail with an actionable message rather than hang.
pub fn non_interactive_terminal() -> bool {
use std::io::IsTerminal;
!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal()
}
fn install_root() -> Result<PathBuf, String> {
#[cfg(target_os = "windows")]
{
return Ok(
env_path("LOCALAPPDATA", "Windows local application-data directory")?
.join("Programs")
.join(PRODUCT_DIR),
);
}
#[cfg(target_os = "macos")]
{
return Ok(home_dir()?
.join("Applications")
.join(crate::macos_bundle::BUNDLE_NAME));
}
#[cfg(all(unix, not(target_os = "macos")))]
{
return Ok(home_dir()?.join(".local").join("lib").join("origin-speak"));
}
#[allow(unreachable_code)]
Err("Origin Speak install-directory discovery is unsupported on this platform".to_string())
}
fn manager_root() -> Result<PathBuf, String> {
#[cfg(target_os = "windows")]
{
return install_root();
}
#[cfg(unix)]
{
return Ok(home_dir()?.join(".local").join("bin"));
}
#[allow(unreachable_code)]
Err("Origin Speak manager-directory discovery is unsupported on this platform".to_string())
}
fn manager_file_name() -> &'static str {
if cfg!(target_os = "windows") {
"origin.exe"
} else {
"origin"
}
}
fn runtime_file_name() -> &'static str {
if cfg!(target_os = "windows") {
"origin-runtime.exe"
} else {
"origin-runtime"
}
}
fn runtime_path_for_install_root(install_root: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
{
crate::macos_bundle::runtime_executable(install_root)
}
#[cfg(not(target_os = "macos"))]
{
install_root.join(runtime_file_name())
}
}
fn env_path(name: &str, description: &str) -> Result<PathBuf, String> {
env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.ok_or_else(|| format!("Could not resolve {description}: {name} is not set"))
}
fn home_dir() -> Result<PathBuf, String> {
#[cfg(target_os = "windows")]
let candidates = ["USERPROFILE", "HOME"];
#[cfg(not(target_os = "windows"))]
let candidates = ["HOME", "USERPROFILE"];
candidates
.into_iter()
.find_map(|name| {
env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
})
.ok_or_else(|| "Could not resolve the current user's home directory".to_string())
}
fn validate_data_root(path: &Path) -> Result<(), String> {
let expected = app_data_root()?;
if path != expected || path.file_name().and_then(|name| name.to_str()) != Some(DATA_DIR) {
return Err(format!(
"Refusing destructive operation on unexpected data path: {}",
path.display()
));
}
validate_not_root_or_home(path)
}
fn validate_local_data_root(path: &Path) -> Result<(), String> {
let expected = app_local_data_root()?;
if path != expected || path.file_name().and_then(|name| name.to_str()) != Some(DATA_DIR) {
return Err(format!(
"Refusing destructive operation on unexpected local data path: {}",
path.display()
));
}
validate_not_root_or_home(path)
}
fn validate_install_path(path: &Path) -> Result<(), String> {
validate_not_root_or_home(path)?;
if path != install_root()? {
return Err(format!(
"Refusing destructive operation on unexpected install path: {}",
path.display()
));
}
#[cfg(target_os = "windows")]
validate_windows_install_root_resolution(path)?;
Ok(())
}
fn validate_manager_root(path: &Path) -> Result<(), String> {
validate_not_root_or_home(path)?;
if path != manager_root()? {
return Err(format!(
"Refusing destructive operation on unexpected manager root: {}",
path.display()
));
}
#[cfg(target_os = "windows")]
validate_windows_install_root_resolution(path)?;
Ok(())
}
pub(crate) fn validate_manager_root_ownership(path: &Path) -> Result<(), String> {
validate_manager_root(path)
}
#[cfg(target_os = "windows")]
pub(crate) fn validate_known_windows_owned_root(path: &Path) -> Result<(), String> {
let install = install_root()?;
let manager = manager_root()?;
if path == install || path == manager {
validate_windows_install_root_resolution(path)?;
}
Ok(())
}
#[cfg(target_os = "windows")]
fn validate_windows_install_root_resolution(path: &Path) -> Result<(), String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"inspect Windows install root {}: {error}",
path.display()
));
}
};
if !metadata.is_dir() || is_windows_reparse_point(&metadata) {
return Err(format!(
"Refusing Windows install root that is not a real directory or is a reparse point: {}",
path.display()
));
}
let local_app_data = env_path("LOCALAPPDATA", "Windows local application-data directory")?;
let resolved_local = fs::canonicalize(&local_app_data).map_err(|error| {
format!(
"resolve Windows local application-data root {}: {error}",
local_app_data.display()
)
})?;
let resolved_root = fs::canonicalize(path)
.map_err(|error| format!("resolve Windows install root {}: {error}", path.display()))?;
let relative = resolved_root.strip_prefix(&resolved_local).map_err(|_| {
format!(
"Refusing Windows install root that resolves outside LOCALAPPDATA: {} -> {}",
path.display(),
resolved_root.display()
)
})?;
if !windows_install_relative_ownership_matches(relative) {
return Err(format!(
"Refusing Windows install root whose resolved ownership path changed: {} -> {}",
path.display(),
resolved_root.display()
));
}
Ok(())
}
#[cfg(any(target_os = "windows", test))]
fn windows_install_relative_ownership_matches(relative: &Path) -> bool {
let components = relative
.components()
.filter_map(|component| match component {
std::path::Component::Normal(value) => Some(value.to_string_lossy()),
_ => None,
})
.collect::<Vec<_>>();
components.len() == 2
&& components[0].eq_ignore_ascii_case("Programs")
&& components[1].eq_ignore_ascii_case(PRODUCT_DIR)
}
fn validate_model_root(path: &Path) -> Result<(), String> {
validate_not_root_or_home(path)?;
if path.file_name().and_then(|name| name.to_str()) != Some("models") {
return Err(format!(
"Refusing unexpected model path: {}",
path.display()
));
}
if !model_storage_roots()?
.iter()
.any(|expected| expected == path)
{
return Err(format!("Refusing unknown model path: {}", path.display()));
}
Ok(())
}
fn path_exists_no_follow(path: &Path) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!("inspect {}: {error}", path.display())),
}
}
fn validate_owned_data_directory(path: &Path, label: &str) -> Result<(), String> {
validate_not_root_or_home(path)?;
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("inspect {label} {}: {error}", path.display()))?;
if !metadata.is_dir()
|| metadata.file_type().is_symlink()
|| is_windows_reparse_point(&metadata)
{
return Err(format!(
"refusing {label} that is not a real directory: {}",
path.display()
));
}
let allowed = [
app_data_root()?,
app_local_data_root()?,
legacy_app_data_root()?,
legacy_app_local_data_root()?,
];
if !allowed.iter().any(|candidate| candidate == path) {
return Err(format!("refusing unexpected {label}: {}", path.display()));
}
Ok(())
}
fn validate_install_file(path: &Path, root: &Path, expected_name: &str) -> Result<(), String> {
let install_root = install_root()?;
let manager_root = manager_root()?;
if root == install_root {
validate_install_path(root)?;
} else if root == manager_root {
validate_manager_root(root)?;
} else {
return Err(format!(
"Refusing unexpected install-owned root: {}",
root.display()
));
}
if path.parent() != Some(root)
|| path.file_name().and_then(|name| name.to_str()) != Some(expected_name)
{
return Err(format!(
"Refusing unexpected install-owned path: {}",
path.display()
));
}
Ok(())
}
fn validate_runtime_path(path: &Path, root: &Path) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
validate_install_path(root)?;
let expected = crate::macos_bundle::runtime_executable(root);
if path != expected {
return Err(format!(
"Refusing unexpected macOS runtime executable path: {}",
path.display()
));
}
return Ok(());
}
#[cfg(not(target_os = "macos"))]
{
validate_install_file(path, root, runtime_file_name())
}
}
fn validate_not_root_or_home(path: &Path) -> Result<(), String> {
if path.parent().is_none() || path.as_os_str().is_empty() {
return Err(format!(
"Refusing destructive operation on filesystem root: {}",
path.display()
));
}
if let Ok(home) = home_dir()
&& path == home
{
return Err("Refusing destructive operation on the user home directory".to_string());
}
Ok(())
}
fn discover_stale_update_paths(temp_root: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(temp_root) else {
return Vec::new();
};
let mut files = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name();
let name = name.to_str()?;
let version = name.strip_prefix(UPDATE_TEMP_PREFIX)?;
if Version::parse(version).is_err() {
return None;
}
let path = entry.path();
let metadata = fs::symlink_metadata(&path).ok()?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return None;
}
let marker = path.join(UPDATE_STAGING_MARKER_FILE);
let marker_metadata = fs::symlink_metadata(&marker).ok()?;
if !marker_metadata.is_file() || marker_metadata.file_type().is_symlink() {
return None;
}
let contents = fs::read_to_string(marker).ok()?;
(contents == update_staging_marker_contents(version)).then_some(path)
})
.collect::<Vec<_>>();
files.sort();
files
}
fn remove_install_file(path: &Path) -> Result<bool, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(format!("inspect {}: {error}", path.display())),
};
if metadata.is_dir()
&& !metadata.file_type().is_symlink()
&& !is_windows_reparse_point(&metadata)
{
return Err(format!(
"Refusing to remove install file because it is a directory: {}",
path.display()
));
}
remove_link(path, false).map_err(|error| format!("remove {}: {error}", path.display()))?;
Ok(true)
}
fn canonical_if_exists(path: &Path) -> Option<PathBuf> {
fs::canonicalize(path)
.ok()
.or_else(|| Some(path.to_path_buf()))
}
fn remove_tree_no_follow(path: &Path) -> io::Result<()> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
if metadata.file_type().is_symlink() || is_windows_reparse_point(&metadata) {
return remove_link(path, metadata.is_dir());
}
if metadata.is_file() {
return fs::remove_file(path);
}
if !metadata.is_dir() {
return fs::remove_file(path);
}
for entry in fs::read_dir(path)? {
let entry = entry?;
remove_tree_no_follow(&entry.path())?;
}
fs::remove_dir(path)
}
fn remove_link(path: &Path, directory: bool) -> io::Result<()> {
if directory {
fs::remove_dir(path).or_else(|_| fs::remove_file(path))
} else {
fs::remove_file(path).or_else(|_| fs::remove_dir(path))
}
}
#[cfg(target_os = "windows")]
fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(target_os = "windows"))]
fn is_windows_reparse_point(_: &fs::Metadata) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uninstall_removes_data_by_default() {
let base = env::temp_dir().join("origin-speak-management-test-base");
let layout = DataLayout {
data_root: app_data_root().unwrap(),
local_data_root: app_local_data_root().unwrap(),
install_root: install_root().unwrap(),
manager_root: manager_root().unwrap(),
manager_path: manager_root().unwrap().join(manager_file_name()),
runtime_path: runtime_path_for_install_root(&install_root().unwrap()),
temp_root: base,
model_roots: model_storage_roots().unwrap(),
};
let plan = layout.uninstall_plan(false).unwrap();
assert_eq!(plan.data_root.as_deref(), Some(layout.data_root.as_path()));
assert_eq!(
plan.local_data_root.as_deref(),
(layout.local_data_root != layout.data_root)
.then_some(layout.local_data_root.as_path())
);
}
#[test]
fn keep_data_is_explicit_opt_out() {
let layout = DataLayout::discover().unwrap();
let plan = layout.uninstall_plan(true).unwrap();
assert!(plan.data_root.is_none());
assert!(plan.local_data_root.is_none());
}
#[test]
fn unexpected_data_path_is_rejected() {
let wrong = app_data_root().unwrap().with_file_name("SomethingElse");
assert!(validate_data_root(&wrong).is_err());
}
#[test]
fn runtime_and_manager_are_scoped_to_owned_roots() {
let layout = DataLayout::discover().unwrap();
assert_eq!(
layout.manager_path.parent(),
Some(layout.manager_root.as_path())
);
#[cfg(target_os = "macos")]
assert_eq!(
layout.runtime_path,
layout.install_root.join("Contents/MacOS/origin-runtime")
);
#[cfg(not(target_os = "macos"))]
assert_eq!(
layout.runtime_path.parent(),
Some(layout.install_root.as_path())
);
}
#[test]
fn colliding_update_directory_without_marker_is_preserved() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!(
"origin-speak-management-staging-test-{}-{nonce}",
std::process::id()
));
let unrelated = root.join("origin-speak-update-1.2.3");
let owned = root.join("origin-speak-update-2.3.4");
fs::create_dir_all(&unrelated).unwrap();
fs::write(unrelated.join("keep.txt"), b"not Origin Speak-owned").unwrap();
fs::create_dir_all(&owned).unwrap();
fs::write(
owned.join(UPDATE_STAGING_MARKER_FILE),
update_staging_marker_contents("2.3.4"),
)
.unwrap();
let discovered = discover_stale_update_paths(&root);
assert_eq!(discovered, vec![owned.clone()]);
for path in discovered {
remove_tree_no_follow(&path).unwrap();
}
assert!(unrelated.join("keep.txt").is_file());
assert!(!owned.exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn windows_install_relative_ownership_is_exact() {
assert!(windows_install_relative_ownership_matches(Path::new(
"Programs/Origin Speak"
)));
assert!(windows_install_relative_ownership_matches(Path::new(
"programs/origin speak"
)));
assert!(!windows_install_relative_ownership_matches(Path::new(
"Other/Origin Speak"
)));
assert!(!windows_install_relative_ownership_matches(Path::new(
"Programs/Origin Speak/Nested"
)));
}
}