diff options
Diffstat (limited to 'crates/atuin-dotfiles/src')
| -rw-r--r-- | crates/atuin-dotfiles/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell.rs | 241 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/bash.rs | 68 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/fish.rs | 69 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/powershell.rs | 169 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/xonsh.rs | 68 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/zsh.rs | 68 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store.rs | 421 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store/alias.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store/var.rs | 542 |
10 files changed, 1649 insertions, 0 deletions
diff --git a/crates/atuin-dotfiles/src/lib.rs b/crates/atuin-dotfiles/src/lib.rs new file mode 100644 index 00000000..74daf8ef --- /dev/null +++ b/crates/atuin-dotfiles/src/lib.rs @@ -0,0 +1,2 @@ +pub mod shell; +pub mod store; diff --git a/crates/atuin-dotfiles/src/shell.rs b/crates/atuin-dotfiles/src/shell.rs new file mode 100644 index 00000000..73a9ce8c --- /dev/null +++ b/crates/atuin-dotfiles/src/shell.rs @@ -0,0 +1,241 @@ +use eyre::{Result, ensure, eyre}; +use rmp::{decode, encode}; +use serde::Serialize; + +use atuin_common::shell::{Shell, ShellError}; + +use crate::store::AliasStore; + +pub mod bash; +pub mod fish; +pub mod powershell; +pub mod xonsh; +pub mod zsh; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Alias { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Var { + pub name: String, + pub value: String, + + // False? This is a _shell var_ + // True? This is an _env var_ + pub export: bool, +} + +impl Var { + /// Serialize into the given vec + /// This is intended to be called by the store + pub fn serialize(&self, output: &mut Vec<u8>) -> Result<()> { + encode::write_array_len(output, 3)?; // 3 fields + + encode::write_str(output, self.name.as_str())?; + encode::write_str(output, self.value.as_str())?; + encode::write_bool(output, self.export)?; + + Ok(()) + } + + pub fn deserialize(bytes: &mut decode::Bytes) -> Result<Self> { + fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { + eyre!("{err:?}") + } + + let nfields = decode::read_array_len(bytes).map_err(error_report)?; + + ensure!( + nfields == 3, + "too many entries in v0 dotfiles env create record, got {}, expected {}", + nfields, + 3 + ); + + let bytes = bytes.remaining_slice(); + + let (key, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + let (value, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + + let mut bytes = decode::Bytes::new(bytes); + let export = decode::read_bool(&mut bytes).map_err(error_report)?; + + ensure!( + bytes.remaining_slice().is_empty(), + "trailing bytes in encoded dotfiles env record, malformed" + ); + + Ok(Var { + name: key.to_owned(), + value: value.to_owned(), + export, + }) + } +} + +pub fn parse_alias(line: &str) -> Option<Alias> { + // consider the fact we might be importing a fish alias + // 'alias' output + // fish: alias foo bar + // posix: foo=bar + + let is_fish = line.split(' ').next().unwrap_or("") == "alias"; + + let parts: Vec<&str> = if is_fish { + line.split(' ') + .enumerate() + .filter_map(|(n, i)| if n == 0 { None } else { Some(i) }) + .collect() + } else { + line.split('=').collect() + }; + + if parts.len() <= 1 { + return None; + } + + let mut parts = parts.iter().map(|s| s.to_string()); + + let name = parts.next().unwrap(); + + let remaining = if is_fish { + parts.collect::<Vec<String>>().join(" ") + } else { + parts.collect::<Vec<String>>().join("=") + }; + + Some(Alias { + name, + value: remaining.trim().to_string(), + }) +} + +pub fn existing_aliases(shell: Option<Shell>) -> Result<Vec<Alias>, ShellError> { + let shell = if let Some(shell) = shell { + shell + } else { + Shell::current() + }; + + // this only supports posix-y shells atm + if !shell.is_posixish() { + return Err(ShellError::NotSupported); + } + + // This will return a list of aliases, each on its own line + // They will be in the form foo=bar + let aliases = shell.run_interactive(["alias"])?; + + let aliases: Vec<Alias> = aliases.lines().filter_map(parse_alias).collect(); + + Ok(aliases) +} + +/// Import aliases from the current shell +/// This will not import aliases already in the store +/// Returns aliases that were set +pub async fn import_aliases(store: &AliasStore) -> Result<Vec<Alias>> { + let shell_aliases = existing_aliases(None)?; + let store_aliases = store.aliases().await?; + + let mut res = Vec::new(); + + for alias in shell_aliases { + // O(n), but n is small, and imports infrequent + // can always make a map + if store_aliases.contains(&alias) { + continue; + } + + res.push(alias.clone()); + store.set(&alias.name, &alias.value).await?; + } + + Ok(res) +} + +#[cfg(test)] +mod tests { + use crate::shell::{Alias, parse_alias}; + + #[test] + fn test_parse_simple_alias() { + let alias = super::parse_alias("foo=bar").expect("failed to parse alias"); + assert_eq!(alias.name, "foo"); + assert_eq!(alias.value, "bar"); + } + + #[test] + fn test_parse_quoted_alias() { + let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw'") + .expect("failed to parse alias"); + + assert_eq!(alias.name, "emacs"); + assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw'"); + + let git_alias = super::parse_alias("gwip='git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \"--wip-- [skip ci]\"'").expect("failed to parse alias"); + assert_eq!(git_alias.name, "gwip"); + assert_eq!( + git_alias.value, + "'git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \"--wip-- [skip ci]\"'" + ); + } + + #[test] + fn test_parse_quoted_alias_equals() { + let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw --foo=bar'") + .expect("failed to parse alias"); + assert_eq!(alias.name, "emacs"); + assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw --foo=bar'"); + } + + #[test] + fn test_parse_fish() { + let alias = super::parse_alias("alias foo bar").expect("failed to parse alias"); + assert_eq!(alias.name, "foo"); + assert_eq!(alias.value, "bar"); + + let alias = + super::parse_alias("alias x 'exa --icons --git --classify --group-directories-first'") + .expect("failed to parse alias"); + + assert_eq!(alias.name, "x"); + assert_eq!( + alias.value, + "'exa --icons --git --classify --group-directories-first'" + ); + } + + #[test] + fn test_parse_with_fortune() { + // Because we run the alias command in an interactive subshell + // there may be other output. + // Ensure that the parser can handle it + // Annoyingly not all aliases are picked up all the time if we use + // a non-interactive subshell. Boo. + let shell = " +/ In a consumer society there are \\ +| inevitably two kinds of slaves: the | +| prisoners of addiction and the | +\\ prisoners of envy. / + ------------------------------------- + \\ ^__^ + \\ (oo)\\_______ + (__)\\ )\\/\\ + ||----w | + || || +emacs='TERM=xterm-24bits emacs -nw --foo=bar' +k=kubectl +"; + + let aliases: Vec<Alias> = shell.lines().filter_map(parse_alias).collect(); + assert_eq!(aliases[0].name, "emacs"); + assert_eq!(aliases[0].value, "'TERM=xterm-24bits emacs -nw --foo=bar'"); + + assert_eq!(aliases[1].name, "k"); + assert_eq!(aliases[1].value, "kubectl"); + } +} diff --git a/crates/atuin-dotfiles/src/shell/bash.rs b/crates/atuin-dotfiles/src/shell/bash.rs new file mode 100644 index 00000000..2b9b4c88 --- /dev/null +++ b/crates/atuin-dotfiles/src/shell/bash.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; + +use crate::store::{AliasStore, var::VarStore}; + +async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(aliases) => aliases, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new aliases on the fly + + store.posix().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) + }) + } + } +} + +async fn cached_vars(path: PathBuf, store: &VarStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(vars) => vars, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new vars on the fly + + store.posix().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) + }) + } + } +} + +/// Return bash dotfile config +/// +/// Do not return an error. We should not prevent the shell from starting. +/// +/// In the worst case, Atuin should not function but the shell should start correctly. +/// +/// While currently this only returns aliases, it will be extended to also return other synced dotfiles +pub async fn alias_config(store: &AliasStore) -> String { + // First try to read the cached config + let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.bash"); + + if aliases.exists() { + return cached_aliases(aliases, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate aliases: {e}'"); + } + + cached_aliases(aliases, store).await +} + +pub async fn var_config(store: &VarStore) -> String { + // First try to read the cached config + let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.bash"); + + if vars.exists() { + return cached_vars(vars, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate vars: {e}'"); + } + + cached_vars(vars, store).await +} diff --git a/crates/atuin-dotfiles/src/shell/fish.rs b/crates/atuin-dotfiles/src/shell/fish.rs new file mode 100644 index 00000000..6d472f67 --- /dev/null +++ b/crates/atuin-dotfiles/src/shell/fish.rs @@ -0,0 +1,69 @@ +// Configuration for fish +use std::path::PathBuf; + +use crate::store::{AliasStore, var::VarStore}; + +async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(aliases) => aliases, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new aliases on the fly + + store.posix().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) + }) + } + } +} + +async fn cached_vars(path: PathBuf, store: &VarStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(vars) => vars, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new vars on the fly + + store.posix().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) + }) + } + } +} + +/// Return fish dotfile config +/// +/// Do not return an error. We should not prevent the shell from starting. +/// +/// In the worst case, Atuin should not function but the shell should start correctly. +/// +/// While currently this only returns aliases, it will be extended to also return other synced dotfiles +pub async fn alias_config(store: &AliasStore) -> String { + // First try to read the cached config + let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.fish"); + + if aliases.exists() { + return cached_aliases(aliases, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate aliases: {e}'"); + } + + cached_aliases(aliases, store).await +} + +pub async fn var_config(store: &VarStore) -> String { + // First try to read the cached config + let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.fish"); + + if vars.exists() { + return cached_vars(vars, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate vars: {e}'"); + } + + cached_vars(vars, store).await +} diff --git a/crates/atuin-dotfiles/src/shell/powershell.rs b/crates/atuin-dotfiles/src/shell/powershell.rs new file mode 100644 index 00000000..1daee28b --- /dev/null +++ b/crates/atuin-dotfiles/src/shell/powershell.rs @@ -0,0 +1,169 @@ +use crate::shell::{Alias, Var}; +use crate::store::{AliasStore, var::VarStore}; +use std::path::PathBuf; + +async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(aliases) => aliases, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new aliases on the fly + + store.powershell().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) + }) + } + } +} + +async fn cached_vars(path: PathBuf, store: &VarStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(vars) => vars, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new vars on the fly + + store.powershell().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) + }) + } + } +} + +/// Return powershell dotfile config +/// +/// Do not return an error. We should not prevent the shell from starting. +/// +/// In the worst case, Atuin should not function but the shell should start correctly. +/// +/// While currently this only returns aliases, it will be extended to also return other synced dotfiles +pub async fn alias_config(store: &AliasStore) -> String { + // First try to read the cached config + let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.ps1"); + + if aliases.exists() { + return cached_aliases(aliases, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate aliases: {e}'"); + } + + cached_aliases(aliases, store).await +} + +pub async fn var_config(store: &VarStore) -> String { + // First try to read the cached config + let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.ps1"); + + if vars.exists() { + return cached_vars(vars, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate vars: {e}'"); + } + + cached_vars(vars, store).await +} + +pub fn format_alias(alias: &Alias) -> String { + // Set-Alias doesn't support adding implicit arguments, so use a function. + // See https://github.com/PowerShell/PowerShell/issues/12962 + + let mut result = secure_command(&format!( + "function {} {{\n {}{} @args\n}}", + alias.name, + if alias.value.starts_with(['"', '\'']) { + "& " + } else { + "" + }, + alias.value + )); + + // This makes the file layout prettier + result.insert(0, '\n'); + result +} + +pub fn format_var(var: &Var) -> String { + secure_command(&format!( + "${}{} = '{}'", + if var.export { "env:" } else { "" }, + var.name, + var.value.replace("'", "''") + )) +} + +/// Wraps the given command in an Invoke-Expression to ensure the outer script is not halted +/// if the inner command contains a syntax error. +fn secure_command(command: &str) -> String { + format!( + "Invoke-Expression -ErrorAction Continue -Command '{}'\n", + command.replace("'", "''") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aliases() { + assert_eq!( + format_alias(&Alias { + name: "gp".to_string(), + value: "git push".to_string(), + }), + "\n".to_string() + + &secure_command( + "function gp { + git push @args +}" + ) + ); + + assert_eq!( + format_alias(&Alias { + name: "spc".to_string(), + value: "\"path with spaces\" arg".to_string(), + }), + "\n".to_string() + + &secure_command( + "function spc { + & \"path with spaces\" arg @args +}" + ) + ); + } + + #[test] + fn vars() { + assert_eq!( + format_var(&Var { + name: "FOO".to_owned(), + value: "bar 'baz'".to_owned(), + export: true, + }), + secure_command("$env:FOO = 'bar ''baz'''") + ); + + assert_eq!( + format_var(&Var { + name: "TEST".to_owned(), + value: "1".to_owned(), + export: false, + }), + secure_command("$TEST = '1'") + ); + } + + #[test] + fn invoke_expression() { + assert_eq!( + secure_command("echo 'foo'"), + "Invoke-Expression -ErrorAction Continue -Command 'echo ''foo'''\n" + ) + } +} diff --git a/crates/atuin-dotfiles/src/shell/xonsh.rs b/crates/atuin-dotfiles/src/shell/xonsh.rs new file mode 100644 index 00000000..1e56fc1d --- /dev/null +++ b/crates/atuin-dotfiles/src/shell/xonsh.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; + +use crate::store::{AliasStore, var::VarStore}; + +async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(aliases) => aliases, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new aliases on the fly + + store.xonsh().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) + }) + } + } +} + +async fn cached_vars(path: PathBuf, store: &VarStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(vars) => vars, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new vars on the fly + + store.xonsh().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) + }) + } + } +} + +/// Return xonsh dotfile config +/// +/// Do not return an error. We should not prevent the shell from starting. +/// +/// In the worst case, Atuin should not function but the shell should start correctly. +/// +/// While currently this only returns aliases, it will be extended to also return other synced dotfiles +pub async fn alias_config(store: &AliasStore) -> String { + // First try to read the cached config + let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.xsh"); + + if aliases.exists() { + return cached_aliases(aliases, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate aliases: {e}'"); + } + + cached_aliases(aliases, store).await +} + +pub async fn var_config(store: &VarStore) -> String { + // First try to read the cached config + let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.xsh"); + + if vars.exists() { + return cached_vars(vars, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate vars: {e}'"); + } + + cached_vars(vars, store).await +} diff --git a/crates/atuin-dotfiles/src/shell/zsh.rs b/crates/atuin-dotfiles/src/shell/zsh.rs new file mode 100644 index 00000000..117e9403 --- /dev/null +++ b/crates/atuin-dotfiles/src/shell/zsh.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; + +use crate::store::{AliasStore, var::VarStore}; + +async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(aliases) => aliases, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new aliases on the fly + + store.posix().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) + }) + } + } +} + +async fn cached_vars(path: PathBuf, store: &VarStore) -> String { + match tokio::fs::read_to_string(path).await { + Ok(aliases) => aliases, + Err(r) => { + // we failed to read the file for some reason, but the file does exist + // fallback to generating new vars on the fly + + store.posix().await.unwrap_or_else(|e| { + format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) + }) + } + } +} + +/// Return zsh dotfile config +/// +/// Do not return an error. We should not prevent the shell from starting. +/// +/// In the worst case, Atuin should not function but the shell should start correctly. +/// +/// While currently this only returns aliases, it will be extended to also return other synced dotfiles +pub async fn alias_config(store: &AliasStore) -> String { + // First try to read the cached config + let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.zsh"); + + if aliases.exists() { + return cached_aliases(aliases, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate aliases: {e}'"); + } + + cached_aliases(aliases, store).await +} + +pub async fn var_config(store: &VarStore) -> String { + // First try to read the cached config + let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.zsh"); + + if vars.exists() { + return cached_vars(vars, store).await; + } + + if let Err(e) = store.build().await { + return format!("echo 'Atuin: failed to generate aliases: {e}'"); + } + + cached_vars(vars, store).await +} diff --git a/crates/atuin-dotfiles/src/store.rs b/crates/atuin-dotfiles/src/store.rs new file mode 100644 index 00000000..17597065 --- /dev/null +++ b/crates/atuin-dotfiles/src/store.rs @@ -0,0 +1,421 @@ +use std::collections::BTreeMap; + +use atuin_client::record::sqlite_store::SqliteStore; +// Sync aliases +// This will be noticeable similar to the kv store, though I expect the two shall diverge +// While we will support a range of shell config, I'd rather have a larger number of small records +// + stores, rather than one mega config store. +use atuin_common::record::{DecryptedData, Host, HostId}; +use atuin_common::utils::unquote; +use eyre::{Result, bail, ensure, eyre}; + +use atuin_client::record::encryption::PASETO_V4; +use atuin_client::record::store::Store; + +use crate::shell::Alias; + +const CONFIG_SHELL_ALIAS_VERSION: &str = "v0"; +const CONFIG_SHELL_ALIAS_TAG: &str = "config-shell-alias"; +const CONFIG_SHELL_ALIAS_FIELD_MAX_LEN: usize = 20000; // 20kb max total len, way more than should be needed. + +mod alias; +pub mod var; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AliasRecord { + Create(Alias), // create a full record + Delete(String), // delete by name +} + +impl AliasRecord { + pub fn serialize(&self) -> Result<DecryptedData> { + use rmp::encode; + + let mut output = vec![]; + + match self { + AliasRecord::Create(alias) => { + encode::write_u8(&mut output, 0)?; // create + encode::write_array_len(&mut output, 2)?; // 2 fields + + encode::write_str(&mut output, alias.name.as_str())?; + encode::write_str(&mut output, alias.value.as_str())?; + } + AliasRecord::Delete(name) => { + encode::write_u8(&mut output, 1)?; // delete + encode::write_array_len(&mut output, 1)?; // 1 field + + encode::write_str(&mut output, name.as_str())?; + } + } + + Ok(DecryptedData(output)) + } + + pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> { + use rmp::decode; + + fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { + eyre!("{err:?}") + } + + match version { + CONFIG_SHELL_ALIAS_VERSION => { + let mut bytes = decode::Bytes::new(&data.0); + + let record_type = decode::read_u8(&mut bytes).map_err(error_report)?; + + match record_type { + // create + 0 => { + let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; + ensure!( + nfields == 2, + "too many entries in v0 shell alias create record" + ); + + let bytes = bytes.remaining_slice(); + + let (key, bytes) = + decode::read_str_from_slice(bytes).map_err(error_report)?; + let (value, bytes) = + decode::read_str_from_slice(bytes).map_err(error_report)?; + + if !bytes.is_empty() { + bail!("trailing bytes in encoded shell alias record. malformed") + } + + Ok(AliasRecord::Create(Alias { + name: key.to_owned(), + value: value.to_owned(), + })) + } + + // delete + 1 => { + let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; + ensure!( + nfields == 1, + "too many entries in v0 shell alias delete record" + ); + + let bytes = bytes.remaining_slice(); + + let (key, bytes) = + decode::read_str_from_slice(bytes).map_err(error_report)?; + + if !bytes.is_empty() { + bail!("trailing bytes in encoded shell alias record. malformed") + } + + Ok(AliasRecord::Delete(key.to_owned())) + } + + n => { + bail!("unknown AliasRecord type {n}") + } + } + } + _ => { + bail!("unknown version {version:?}") + } + } + } +} + +#[derive(Debug, Clone)] +pub struct AliasStore { + pub store: SqliteStore, + pub host_id: HostId, + pub encryption_key: [u8; 32], +} + +impl AliasStore { + // will want to init the actual kv store when that is done + pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> AliasStore { + AliasStore { + store, + host_id, + encryption_key, + } + } + + pub async fn posix(&self) -> Result<String> { + let aliases = self.aliases().await?; + Ok(Self::format_posix(&aliases)) + } + + pub async fn xonsh(&self) -> Result<String> { + let aliases = self.aliases().await?; + Ok(Self::format_xonsh(&aliases)) + } + + pub async fn powershell(&self) -> Result<String> { + let aliases = self.aliases().await?; + Ok(Self::format_powershell(&aliases)) + } + + fn format_posix(aliases: &[Alias]) -> String { + let mut config = String::new(); + + for alias in aliases { + // If it's quoted, remove the quotes. If it's not quoted, do nothing. + let value = unquote(alias.value.as_str()).unwrap_or(alias.value.clone()); + + // we're about to quote it ourselves anyway! + config.push_str(&format!("alias {}='{}'\n", alias.name, value)); + } + + config + } + + fn format_xonsh(aliases: &[Alias]) -> String { + let mut config = String::new(); + + for alias in aliases { + config.push_str(&format!("aliases['{}'] ='{}'\n", alias.name, alias.value)); + } + + config + } + + fn format_powershell(aliases: &[Alias]) -> String { + let mut config = String::new(); + + for alias in aliases { + config.push_str(&crate::shell::powershell::format_alias(alias)); + } + + config + } + + pub async fn build(&self) -> Result<()> { + let dir = atuin_common::utils::dotfiles_cache_dir(); + tokio::fs::create_dir_all(dir.clone()).await?; + + let aliases = self.aliases().await?; + + // Build for all supported shells + let posix = Self::format_posix(&aliases); + let xonsh = Self::format_xonsh(&aliases); + let powershell = Self::format_powershell(&aliases); + + // All the same contents, maybe optimize in the future or perhaps there will be quirks + // per-shell + // I'd prefer separation atm + let zsh = dir.join("aliases.zsh"); + let bash = dir.join("aliases.bash"); + let fish = dir.join("aliases.fish"); + let xsh = dir.join("aliases.xsh"); + let ps1 = dir.join("aliases.ps1"); + + tokio::fs::write(zsh, &posix).await?; + tokio::fs::write(bash, &posix).await?; + tokio::fs::write(fish, &posix).await?; + tokio::fs::write(xsh, &xonsh).await?; + tokio::fs::write(ps1, &powershell).await?; + + Ok(()) + } + + pub async fn set(&self, name: &str, value: &str) -> Result<()> { + if name.len() + value.len() > CONFIG_SHELL_ALIAS_FIELD_MAX_LEN { + return Err(eyre!( + "alias record too large: max len {} bytes", + CONFIG_SHELL_ALIAS_FIELD_MAX_LEN + )); + } + + let record = AliasRecord::Create(Alias { + name: name.to_string(), + value: value.to_string(), + }); + + let bytes = record.serialize()?; + + let idx = self + .store + .last(self.host_id, CONFIG_SHELL_ALIAS_TAG) + .await? + .map_or(0, |entry| entry.idx + 1); + + let record = atuin_common::record::Record::builder() + .host(Host::new(self.host_id)) + .version(CONFIG_SHELL_ALIAS_VERSION.to_string()) + .tag(CONFIG_SHELL_ALIAS_TAG.to_string()) + .idx(idx) + .data(bytes) + .build(); + + self.store + .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) + .await?; + + // set mutates shell config, so build again + self.build().await?; + + Ok(()) + } + + pub async fn delete(&self, name: &str) -> Result<()> { + if name.len() > CONFIG_SHELL_ALIAS_FIELD_MAX_LEN { + return Err(eyre!( + "alias record too large: max len {} bytes", + CONFIG_SHELL_ALIAS_FIELD_MAX_LEN + )); + } + + let record = AliasRecord::Delete(name.to_string()); + + let bytes = record.serialize()?; + + let idx = self + .store + .last(self.host_id, CONFIG_SHELL_ALIAS_TAG) + .await? + .map_or(0, |entry| entry.idx + 1); + + let record = atuin_common::record::Record::builder() + .host(Host::new(self.host_id)) + .version(CONFIG_SHELL_ALIAS_VERSION.to_string()) + .tag(CONFIG_SHELL_ALIAS_TAG.to_string()) + .idx(idx) + .data(bytes) + .build(); + + self.store + .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) + .await?; + + // delete mutates shell config, so build again + self.build().await?; + + Ok(()) + } + + pub async fn aliases(&self) -> Result<Vec<Alias>> { + let mut build = BTreeMap::new(); + + // this is sorted, oldest to newest + let tagged = self.store.all_tagged(CONFIG_SHELL_ALIAS_TAG).await?; + + for record in tagged { + let version = record.version.clone(); + + let decrypted = match version.as_str() { + CONFIG_SHELL_ALIAS_VERSION => record.decrypt::<PASETO_V4>(&self.encryption_key)?, + version => bail!("unknown version {version:?}"), + }; + + let ar = AliasRecord::deserialize(&decrypted.data, version.as_str())?; + + match ar { + AliasRecord::Create(a) => { + build.insert(a.name.clone(), a); + } + AliasRecord::Delete(d) => { + build.remove(&d); + } + } + } + + Ok(build.into_values().collect()) + } +} + +#[cfg(test)] +pub(crate) fn test_local_timeout() -> f64 { + std::env::var("ATUIN_TEST_LOCAL_TIMEOUT") + .ok() + .and_then(|x| x.parse().ok()) + // this hardcoded value should be replaced by a simple way to get the + // default local_timeout of Settings if possible + .unwrap_or(2.0) +} + +#[cfg(test)] +mod tests { + use rand::rngs::OsRng; + + use atuin_client::record::sqlite_store::SqliteStore; + + use crate::shell::Alias; + + use super::{AliasRecord, AliasStore, CONFIG_SHELL_ALIAS_VERSION, test_local_timeout}; + use crypto_secretbox::{KeyInit, XSalsa20Poly1305}; + + #[test] + fn encode_decode() { + let record = Alias { + name: "k".to_owned(), + value: "kubectl".to_owned(), + }; + let record = AliasRecord::Create(record); + + let snapshot = [204, 0, 146, 161, 107, 167, 107, 117, 98, 101, 99, 116, 108]; + + let encoded = record.serialize().unwrap(); + let decoded = AliasRecord::deserialize(&encoded, CONFIG_SHELL_ALIAS_VERSION).unwrap(); + + assert_eq!(encoded.0, &snapshot); + assert_eq!(decoded, record); + } + + #[tokio::test] + async fn build_aliases() { + let store = SqliteStore::new(":memory:", test_local_timeout()) + .await + .unwrap(); + let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into(); + let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7()); + + let alias = AliasStore::new(store, host_id, key); + + alias.set("k", "kubectl").await.unwrap(); + alias.set("gp", "git push").await.unwrap(); + alias + .set("kgap", "'kubectl get pods --all-namespaces'") + .await + .unwrap(); + + let mut aliases = alias.aliases().await.unwrap(); + + aliases.sort_by_key(|a| a.name.clone()); + + assert_eq!(aliases.len(), 3); + + assert_eq!( + aliases[0], + Alias { + name: String::from("gp"), + value: String::from("git push") + } + ); + + assert_eq!( + aliases[1], + Alias { + name: String::from("k"), + value: String::from("kubectl") + } + ); + + assert_eq!( + aliases[2], + Alias { + name: String::from("kgap"), + value: String::from("'kubectl get pods --all-namespaces'") + } + ); + + let build = alias.posix().await.expect("failed to build aliases"); + + assert_eq!( + build, + "alias gp='git push' +alias k='kubectl' +alias kgap='kubectl get pods --all-namespaces' +" + ) + } +} diff --git a/crates/atuin-dotfiles/src/store/alias.rs b/crates/atuin-dotfiles/src/store/alias.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/atuin-dotfiles/src/store/alias.rs @@ -0,0 +1 @@ + diff --git a/crates/atuin-dotfiles/src/store/var.rs b/crates/atuin-dotfiles/src/store/var.rs new file mode 100644 index 00000000..9d25b85d --- /dev/null +++ b/crates/atuin-dotfiles/src/store/var.rs @@ -0,0 +1,542 @@ +/// Store for shell vars +/// I should abstract this and reuse code between the alias/env stores +/// This is easier for now +/// Once I have two implementations, building a common base is much easier. +use std::collections::BTreeMap; + +use atuin_client::record::sqlite_store::SqliteStore; +use atuin_common::record::{DecryptedData, Host, HostId}; +use eyre::{Result, bail, ensure, eyre}; + +use atuin_client::record::encryption::PASETO_V4; +use atuin_client::record::store::Store; + +use crate::shell::Var; + +const DOTFILES_VAR_VERSION: &str = "v0"; +const DOTFILES_VAR_TAG: &str = "dotfiles-var"; +const DOTFILES_VAR_LEN: usize = 20000; // 20kb max total len, way more than should be needed. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VarRecord { + Create(Var), // create a full record + Delete(String), // delete by name +} + +impl VarRecord { + pub fn serialize(&self) -> Result<DecryptedData> { + use rmp::encode; + + let mut output = vec![]; + + match self { + VarRecord::Create(env) => { + encode::write_u8(&mut output, 0)?; // create + + env.serialize(&mut output)?; + } + VarRecord::Delete(env) => { + encode::write_u8(&mut output, 1)?; // delete + encode::write_array_len(&mut output, 1)?; // 1 field + + encode::write_str(&mut output, env.as_str())?; + } + } + + Ok(DecryptedData(output)) + } + + pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> { + use rmp::decode; + + fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { + eyre!("{err:?}") + } + + match version { + DOTFILES_VAR_VERSION => { + let mut bytes = decode::Bytes::new(&data.0); + + let record_type = decode::read_u8(&mut bytes).map_err(error_report)?; + + match record_type { + // create + 0 => { + let env = Var::deserialize(&mut bytes)?; + Ok(VarRecord::Create(env)) + } + + // delete + 1 => { + let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; + ensure!( + nfields == 1, + "too many entries in v0 dotfiles var delete record" + ); + + let bytes = bytes.remaining_slice(); + + let (key, bytes) = + decode::read_str_from_slice(bytes).map_err(error_report)?; + + if !bytes.is_empty() { + bail!("trailing bytes in encoded dotfiles var record. malformed") + } + + Ok(VarRecord::Delete(key.to_owned())) + } + + n => { + bail!("unknown Dotfiles var record type {n}") + } + } + } + _ => { + bail!("unknown version {version:?}") + } + } + } +} + +#[derive(Debug, Clone)] +pub struct VarStore { + pub store: SqliteStore, + pub host_id: HostId, + pub encryption_key: [u8; 32], +} + +impl VarStore { + // will want to init the actual kv store when that is done + pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> VarStore { + VarStore { + store, + host_id, + encryption_key, + } + } + + /// Escape a value for use in POSIX shells (bash, zsh) + /// This adds double quotes around the value and escapes any embedded double quotes + fn escape_posix_value(value: &str) -> String { + // If the value contains no special characters, we can use it unquoted + if value + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.') + { + value.to_string() + } else { + // Otherwise, wrap in double quotes and escape any special characters + format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`") + ) + } + } + + /// Escape a value for use in fish shell + /// Fish uses single quotes for literal strings, but we need to handle embedded single quotes + fn escape_fish_value(value: &str) -> String { + // If the value contains no special characters, we can use it unquoted + if value + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.') + { + value.to_string() + } else { + // Use single quotes and escape any embedded single quotes + format!("'{}'", value.replace('\'', "\\'")) + } + } + + /// Escape a value for use in xonsh + /// Xonsh uses Python-style string literals + fn escape_xonsh_value(value: &str) -> String { + // If the value contains no special characters, we can use it unquoted + if value + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.') + { + value.to_string() + } else { + // Use double quotes and escape appropriately for Python strings + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) + } + } + + pub async fn xonsh(&self) -> Result<String> { + let env = self.vars().await?; + Ok(Self::format_xonsh(&env)) + } + + pub async fn fish(&self) -> Result<String> { + let env = self.vars().await?; + Ok(Self::format_fish(&env)) + } + + pub async fn posix(&self) -> Result<String> { + let env = self.vars().await?; + Ok(Self::format_posix(&env)) + } + + pub async fn powershell(&self) -> Result<String> { + let env = self.vars().await?; + Ok(Self::format_powershell(&env)) + } + + fn format_xonsh(env: &[Var]) -> String { + let mut config = String::new(); + + for env in env { + let escaped_value = Self::escape_xonsh_value(&env.value); + config.push_str(&format!("${}={}\n", env.name, escaped_value)); + } + + config + } + + fn format_fish(env: &[Var]) -> String { + let mut config = String::new(); + + for env in env { + let escaped_value = Self::escape_fish_value(&env.value); + config.push_str(&format!("set -gx {} {}\n", env.name, escaped_value)); + } + + config + } + + fn format_posix(env: &[Var]) -> String { + let mut config = String::new(); + + for env in env { + let escaped_value = Self::escape_posix_value(&env.value); + if env.export { + config.push_str(&format!("export {}={}\n", env.name, escaped_value)); + } else { + config.push_str(&format!("{}={}\n", env.name, escaped_value)); + } + } + + config + } + + fn format_powershell(env: &[Var]) -> String { + let mut config = String::new(); + + for var in env { + config.push_str(&crate::shell::powershell::format_var(var)); + } + + config + } + + pub async fn build(&self) -> Result<()> { + let dir = atuin_common::utils::dotfiles_cache_dir(); + tokio::fs::create_dir_all(dir.clone()).await?; + + let env = self.vars().await?; + + // Build for all supported shells + let posix = Self::format_posix(&env); + let xonsh = Self::format_xonsh(&env); + let fsh = Self::format_fish(&env); + let powershell = Self::format_powershell(&env); + + // All the same contents, maybe optimize in the future or perhaps there will be quirks + // per-shell + // I'd prefer separation atm + let zsh = dir.join("vars.zsh"); + let bash = dir.join("vars.bash"); + let fish = dir.join("vars.fish"); + let xsh = dir.join("vars.xsh"); + let ps1 = dir.join("vars.ps1"); + + tokio::fs::write(zsh, &posix).await?; + tokio::fs::write(bash, &posix).await?; + tokio::fs::write(fish, &fsh).await?; + tokio::fs::write(xsh, &xonsh).await?; + tokio::fs::write(ps1, &powershell).await?; + + Ok(()) + } + + pub async fn set(&self, name: &str, value: &str, export: bool) -> Result<()> { + if name.len() + value.len() > DOTFILES_VAR_LEN { + return Err(eyre!( + "var record too large: max len {} bytes", + DOTFILES_VAR_LEN + )); + } + + let record = VarRecord::Create(Var { + name: name.to_string(), + value: value.to_string(), + export, + }); + + let bytes = record.serialize()?; + + let idx = self + .store + .last(self.host_id, DOTFILES_VAR_TAG) + .await? + .map_or(0, |entry| entry.idx + 1); + + let record = atuin_common::record::Record::builder() + .host(Host::new(self.host_id)) + .version(DOTFILES_VAR_VERSION.to_string()) + .tag(DOTFILES_VAR_TAG.to_string()) + .idx(idx) + .data(bytes) + .build(); + + self.store + .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) + .await?; + + // set mutates shell config, so build again + self.build().await?; + + Ok(()) + } + + pub async fn delete(&self, name: &str) -> Result<()> { + if name.len() > DOTFILES_VAR_LEN { + return Err(eyre!( + "var record too large: max len {} bytes", + DOTFILES_VAR_LEN, + )); + } + + let record = VarRecord::Delete(name.to_string()); + + let bytes = record.serialize()?; + + let idx = self + .store + .last(self.host_id, DOTFILES_VAR_TAG) + .await? + .map_or(0, |entry| entry.idx + 1); + + let record = atuin_common::record::Record::builder() + .host(Host::new(self.host_id)) + .version(DOTFILES_VAR_VERSION.to_string()) + .tag(DOTFILES_VAR_TAG.to_string()) + .idx(idx) + .data(bytes) + .build(); + + self.store + .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) + .await?; + + // delete mutates shell config, so build again + self.build().await?; + + Ok(()) + } + + pub async fn vars(&self) -> Result<Vec<Var>> { + let mut build = BTreeMap::new(); + + // this is sorted, oldest to newest + let tagged = self.store.all_tagged(DOTFILES_VAR_TAG).await?; + + for record in tagged { + let version = record.version.clone(); + + let decrypted = match version.as_str() { + DOTFILES_VAR_VERSION => record.decrypt::<PASETO_V4>(&self.encryption_key)?, + version => bail!("unknown version {version:?}"), + }; + + let ar = VarRecord::deserialize(&decrypted.data, version.as_str())?; + + match ar { + VarRecord::Create(a) => { + build.insert(a.name.clone(), a); + } + VarRecord::Delete(d) => { + build.remove(&d); + } + } + } + + Ok(build.into_values().collect()) + } +} + +#[cfg(test)] +mod tests { + use rand::rngs::OsRng; + + use atuin_client::record::sqlite_store::SqliteStore; + + use crate::{shell::Var, store::test_local_timeout}; + + use super::{DOTFILES_VAR_VERSION, VarRecord, VarStore}; + use crypto_secretbox::{KeyInit, XSalsa20Poly1305}; + + #[test] + fn encode_decode() { + let record = Var { + name: "BEEP".to_owned(), + value: "boop".to_owned(), + export: false, + }; + let record = VarRecord::Create(record); + + let snapshot = [ + 204, 0, 147, 164, 66, 69, 69, 80, 164, 98, 111, 111, 112, 194, + ]; + + let encoded = record.serialize().unwrap(); + let decoded = VarRecord::deserialize(&encoded, DOTFILES_VAR_VERSION).unwrap(); + + assert_eq!(encoded.0, &snapshot); + assert_eq!(decoded, record); + } + + #[test] + fn test_escape_posix_value() { + // Simple values should not be quoted + assert_eq!(VarStore::escape_posix_value("simple"), "simple"); + assert_eq!(VarStore::escape_posix_value("path/to/file"), "path/to/file"); + assert_eq!( + VarStore::escape_posix_value("value_with_underscores"), + "value_with_underscores" + ); + + // Values with spaces should be quoted + assert_eq!( + VarStore::escape_posix_value("hello world"), + "\"hello world\"" + ); + assert_eq!(VarStore::escape_posix_value("bar baz"), "\"bar baz\""); + + // Values with special characters should be quoted and escaped + assert_eq!( + VarStore::escape_posix_value("say \"hello\""), + "\"say \\\"hello\\\"\"" + ); + assert_eq!( + VarStore::escape_posix_value("path\\with\\backslashes"), + "\"path\\\\with\\\\backslashes\"" + ); + assert_eq!( + VarStore::escape_posix_value("say $hello"), + "\"say \\$hello\"" + ); + assert_eq!( + VarStore::escape_posix_value("see `example.md`"), + "\"see \\`example.md\\`\"" + ); + } + + #[test] + fn test_escape_fish_value() { + // Simple values should not be quoted + assert_eq!(VarStore::escape_fish_value("simple"), "simple"); + assert_eq!(VarStore::escape_fish_value("path/to/file"), "path/to/file"); + + // Values with spaces should be single-quoted + assert_eq!(VarStore::escape_fish_value("hello world"), "'hello world'"); + assert_eq!(VarStore::escape_fish_value("bar baz"), "'bar baz'"); + + // Values with single quotes should be escaped + assert_eq!(VarStore::escape_fish_value("don't"), "'don\\'t'"); + } + + #[test] + fn test_escape_xonsh_value() { + // Simple values should not be quoted + assert_eq!(VarStore::escape_xonsh_value("simple"), "simple"); + assert_eq!(VarStore::escape_xonsh_value("path/to/file"), "path/to/file"); + + // Values with spaces should be quoted + assert_eq!( + VarStore::escape_xonsh_value("hello world"), + "\"hello world\"" + ); + assert_eq!(VarStore::escape_xonsh_value("bar baz"), "\"bar baz\""); + + // Values with special characters should be quoted and escaped + assert_eq!( + VarStore::escape_xonsh_value("say \"hello\""), + "\"say \\\"hello\\\"\"" + ); + assert_eq!( + VarStore::escape_xonsh_value("path\\with\\backslashes"), + "\"path\\\\with\\\\backslashes\"" + ); + } + + #[tokio::test] + async fn build_vars() { + let store = SqliteStore::new(":memory:", test_local_timeout()) + .await + .unwrap(); + let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into(); + let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7()); + + let env = VarStore::new(store, host_id, key); + + env.set("BEEP", "boop", false).await.unwrap(); + env.set("HOMEBREW_NO_AUTO_UPDATE", "1", true).await.unwrap(); + + let mut env_vars = env.vars().await.unwrap(); + + env_vars.sort_by_key(|a| a.name.clone()); + + assert_eq!(env_vars.len(), 2); + + assert_eq!( + env_vars[0], + Var { + name: String::from("BEEP"), + value: String::from("boop"), + export: false, + } + ); + + assert_eq!( + env_vars[1], + Var { + name: String::from("HOMEBREW_NO_AUTO_UPDATE"), + value: String::from("1"), + export: true, + } + ); + } + + #[tokio::test] + async fn test_var_generation_with_spaces() { + let store = SqliteStore::new(":memory:", test_local_timeout()) + .await + .unwrap(); + let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into(); + let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7()); + + let env = VarStore::new(store, host_id, key); + + // Test the exact scenario from the bug report + env.set("FOO", "bar baz", true).await.unwrap(); + + let posix_output = env.posix().await.unwrap(); + let fish_output = env.fish().await.unwrap(); + let xonsh_output = env.xonsh().await.unwrap(); + + // POSIX should quote the value + assert_eq!(posix_output, "export FOO=\"bar baz\"\n"); + + // Fish should quote the value + assert_eq!(fish_output, "set -gx FOO 'bar baz'\n"); + + // Xonsh should quote the value + assert_eq!(xonsh_output, "$FOO=\"bar baz\"\n"); + } +} |
