Lots more updates

Also begin adding rust building capabilities
to be able to write rust binaries for some commands.
This commit is contained in:
Kaare Hoff Skovgaard 2025-07-06 22:37:16 +02:00
parent 624508dd14
commit dd1cfa79e7
Signed by: khs
GPG key ID: C7D890804F01E9F0
52 changed files with 2509 additions and 150 deletions

View file

@ -0,0 +1,18 @@
[package]
name = "common"
edition = "2024"
version = "1.0.0"
[dependencies]
anyhow = { workspace = true }
base64 = { workspace = true }
env_logger = "0.11.8"
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_repr.workspace = true
serde_yml = { workspace = true }
shell-quote = { version = "0.7.2", default-features = false, features = [
"bash",
] }
hakari = { version = "0.1", path = "../hakari" }

View file

@ -0,0 +1,5 @@
use base64::Engine;
pub fn encode(bytes: &[u8]) -> String {
base64::prelude::BASE64_STANDARD.encode(bytes)
}

View file

@ -0,0 +1,358 @@
use serde::{Deserialize, Serialize};
use crate::proc;
mod entry_serde;
#[derive(Debug, Deserialize)]
pub struct BitwardenEntry {
pub id: String,
pub name: String,
#[serde(rename = "organizationId")]
pub organization_id: Option<String>,
#[serde(rename = "folderId")]
pub folder_id: Option<String>,
#[serde(rename = "collectionIds")]
pub collection_ids: Vec<String>,
pub fields: Option<Vec<BitwardenEntryField>>,
pub notes: Option<String>,
#[serde(flatten)]
pub data: BitwardenEntryTypeData,
}
impl BitwardenEntry {
pub fn id(&self) -> &str {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
}
impl BitwardenEntry {
pub fn into_command_entry(&self) -> CommandBitwardenEntry {
CommandBitwardenEntry {
name: self.name.clone(),
organization_id: self.organization_id.clone(),
folder_id: self.folder_id.clone(),
collection_ids: self.collection_ids.clone(),
fields: self.fields.clone(),
notes: self.notes.clone(),
data: self.data.clone(),
}
}
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct CommandBitwardenEntry {
pub name: String,
#[serde(rename = "organizationId")]
pub organization_id: Option<String>,
#[serde(rename = "folderId")]
pub folder_id: Option<String>,
#[serde(rename = "collectionIds")]
pub collection_ids: Vec<String>,
pub fields: Option<Vec<BitwardenEntryField>>,
pub notes: Option<String>,
#[serde(flatten)]
pub data: BitwardenEntryTypeData,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BitwardenEntryTypeData {
Login(BitwardenEntryTypeLogin),
SecureNote(BitwardenEntryTypeSecureNote),
Card(BitwardenEntryTypeCard),
Identity(BitwardenEntryTypeIdentity),
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeLogin {
pub username: Option<String>,
pub password: Option<String>,
pub totp: Option<String>,
pub uris: Option<Vec<BitwardenEntryLoginUri>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryLoginUri {
pub uri: Option<String>,
#[serde(rename = "match")]
pub match_type: Option<BitwardenEntryUriMatchType>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeSecureNote {}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeCard {}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct BitwardenEntryTypeIdentity {}
#[derive(
Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Clone, Copy, PartialEq,
)]
#[repr(u8)]
pub enum BitwardenEntryFieldType {
Text = 0,
Hidden = 1,
Boolean = 2,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct BitwardenEntryField {
pub name: String,
pub value: Option<String>,
#[serde(rename = "type")]
pub field_type: BitwardenEntryFieldType,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(u8)]
pub enum BitwardenTwoStepLoginMethod {
Authenticator = 0,
Email = 1,
YubiKey = 3,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Clone, PartialEq)]
#[repr(u8)]
pub enum BitwardenEntryUriMatchType {
Domain = 0,
Host = 1,
StartsWith = 2,
Exact = 3,
RegularExpression = 4,
Never = 5,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(u8)]
pub enum BitwardenOrganizationUserType {
Owner = 0,
Admin = 1,
User = 2,
Manager = 3,
Custom = 4,
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(i8)]
pub enum BitwardenOrganizationUserStatus {
Invited = 0,
Accepted = 1,
Confirmed = 2,
Revoked = -1,
}
pub struct BitwardenSession {
session_id: Option<String>,
}
impl BitwardenSession {
fn bw_command(&self) -> proc::Command {
let mut cmd = proc::Command::new("bw");
if let Some(id) = self.session_id.as_deref() {
cmd.env_sensitive("BW_SESSION", id);
}
cmd
}
pub fn sync(&self) -> anyhow::Result<()> {
log::info!("Syncing Bitwarden...");
let _ = self.bw_command().arg("sync").try_spawn_to_string()?;
Ok(())
}
pub fn new_or_authenticate(
username: Option<&str>,
bw_unlock_purpose: &str,
) -> anyhow::Result<Self> {
let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, true)?;
if let Some(bw) = bw {
bw.sync()?;
Ok(bw)
} else {
log::info!("User is not logged in to bitwarden, initiating login...");
proc::Command::new("bw")
.arg("login")
.stdin_inherit()
.stderr_inherit()
.try_spawn_to_string()?;
// Just logged in, no point in syncing
let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, false)?;
if let Some(bw) = bw {
Ok(bw)
} else {
Err(anyhow::format_err!(
"Still not logged in to bitwarden, exiting..."
))
}
}
}
fn new_if_authenticated(
username: Option<&str>,
bw_unlock_purpose: &str,
sync: bool,
) -> anyhow::Result<Option<Self>> {
let status: BitwardenAuthenticationStatus = proc::Command::new("bw")
.args(["--nointeraction", "status"])
.try_spawn_to_json()?;
let Some(user) = status.user() else {
return Ok(None);
};
if let Some(username) = username {
if user.user_email != username {
return Err(anyhow::format_err!(
"Authenticated user in bitwarden does not match the expected user of {}, was {}",
username,
user.user_email
));
}
}
let is_unlocked: bool = matches!(status, BitwardenAuthenticationStatus::Unlocked(_));
if sync && !is_unlocked {
log::info!("Syncing Bitwarden...");
let _ = proc::Command::new("bw").arg("sync").try_spawn_to_string()?;
}
log::info!("Unlocking bitwarden...");
let session_id = if is_unlocked {
None
} else {
Some(
proc::Command::new("bitwarden-unlock")
.args(["--purpose", bw_unlock_purpose])
.try_spawn_to_string()?,
)
};
Ok(Some(Self { session_id }))
}
pub fn list_items(&self) -> anyhow::Result<Vec<BitwardenEntry>> {
log::info!("Listing bitwarden items...");
self.bw_command()
// Pretty format for better error messages during json decoding issues
.args(["--pretty", "list", "items"])
.try_spawn_to_json()
}
pub fn get_item(&self, name: &str) -> anyhow::Result<Option<BitwardenEntry>> {
let mut items = self.list_items()?;
let Some(idx) = items
.iter()
.enumerate()
.find_map(|(idx, e)| if e.name() == name { Some(idx) } else { None })
else {
return Ok(None);
};
let item = items.swap_remove(idx);
Ok(Some(item))
}
pub fn get_attachment(&self, entry: &BitwardenEntry, name: &str) -> anyhow::Result<Vec<u8>> {
self.bw_command()
.args(["get", "attachment", name, "--itemid"])
.arg(entry.id.as_str())
.arg("--raw")
.try_spawn_to_bytes()
}
pub fn list_own_items(&self) -> anyhow::Result<Vec<BitwardenEntry>> {
let mut items = self.list_items()?;
items.retain(|i| i.organization_id.as_ref().is_none_or(|o| o.is_empty()));
Ok(items)
}
pub fn create_item(&self, item: &CommandBitwardenEntry) -> anyhow::Result<BitwardenEntry> {
log::info!("Creating bitwarden entry {name}", name = item.name);
self.bw_command()
.args(["create", "item"])
.stdin_json_base64(item)?
.try_spawn_to_json()
}
pub fn update_item(
&self,
to_update: &BitwardenEntry,
update_with: &CommandBitwardenEntry,
) -> anyhow::Result<()> {
log::info!(
"Updating bitwarden entry {name}, with id {id}",
id = to_update.id,
name = update_with.name
);
let _output = self
.bw_command()
.args(["edit", "item", &to_update.id])
.stdin_json_base64(update_with)?
.try_spawn_to_string()?;
Ok(())
}
pub fn delete_item(&self, to_delete: &BitwardenEntry) -> anyhow::Result<()> {
log::info!(
"Deleting bitwarden entry {name}, with id: {id}",
id = to_delete.id,
name = to_delete.name
);
let _output = self
.bw_command()
.args(["delete", "item", &to_delete.id])
.try_spawn_to_string()?;
Ok(())
}
}
impl Drop for BitwardenSession {
fn drop(&mut self) {
log::info!("Locking bitwarden session...");
if self.session_id.is_some() {
if let Err(e) = self
.bw_command()
.args(["--nointeraction", "lock"])
.try_spawn_to_string()
{
log::error!("Could not lock bitwarden session: {e}");
}
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "status")]
enum BitwardenAuthenticationStatus {
#[serde(rename = "unlocked")]
Unlocked(BitwardenAuthenticationUser),
#[serde(rename = "locked")]
Locked(BitwardenAuthenticationUser),
#[serde(rename = "unauthenticated")]
Unauthenticated,
}
impl BitwardenAuthenticationStatus {
pub fn user(&self) -> Option<&BitwardenAuthenticationUser> {
match self {
Self::Locked(user) | Self::Unlocked(user) => Some(user),
Self::Unauthenticated => None,
}
}
}
#[derive(Debug, Deserialize)]
struct BitwardenAuthenticationUser {
#[serde(rename = "userEmail")]
user_email: String,
#[serde(rename = "userId")]
#[allow(dead_code)]
user_id: String,
}

View file

@ -0,0 +1,162 @@
use serde::{Deserialize, Serialize, ser::SerializeMap};
use crate::bitwarden::{
BitwardenEntryTypeCard, BitwardenEntryTypeData, BitwardenEntryTypeIdentity,
BitwardenEntryTypeLogin, BitwardenEntryTypeSecureNote,
};
impl Serialize for super::BitwardenEntryTypeData {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(2))?;
match self {
Self::Login(login) => {
map.serialize_entry("type", &BitwardenEntryType::Login)?;
map.serialize_entry("login", login)?;
}
Self::Card(card) => {
map.serialize_entry("type", &BitwardenEntryType::Card)?;
map.serialize_entry("card", &card)?;
}
Self::SecureNote(secure_note) => {
map.serialize_entry("type", &BitwardenEntryType::SecureNote)?;
map.serialize_entry("secureNote", &secure_note)?;
}
Self::Identity(identity) => {
map.serialize_entry("type", &BitwardenEntryType::Identity)?;
map.serialize_entry("identity", &identity)?;
}
}
map.end()
}
}
impl<'de> Deserialize<'de> for BitwardenEntryTypeData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_struct(
"BitwardenEntryTypeData",
&["type", "login", "card", "secureNote", "identity"],
DeserializeVisitor,
)
}
}
struct DeserializeVisitor;
impl<'de> serde::de::Visitor<'de> for DeserializeVisitor {
type Value = BitwardenEntryTypeData;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an object with type and tagged type property")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let mut entry_type: Option<BitwardenEntryType> = None;
let mut login_data: Option<BitwardenEntryTypeLogin> = None;
let mut secure_note_data: Option<BitwardenEntryTypeSecureNote> = None;
let mut card_data: Option<BitwardenEntryTypeCard> = None;
let mut identity_data: Option<BitwardenEntryTypeIdentity> = None;
while let Some(key) = map.next_key::<&str>()? {
match key {
"type" => {
if entry_type.is_some() {
return Err(serde::de::Error::duplicate_field("type"));
}
entry_type = Some(map.next_value()?);
}
"login" => {
if login_data.is_some() {
return Err(serde::de::Error::duplicate_field("login"));
}
login_data = Some(map.next_value()?);
}
"card" => {
if card_data.is_some() {
return Err(serde::de::Error::duplicate_field("card"));
}
card_data = Some(map.next_value()?);
}
"identity" => {
if identity_data.is_some() {
return Err(serde::de::Error::duplicate_field("identity"));
}
identity_data = Some(map.next_value()?);
}
"secureNote" => {
if secure_note_data.is_some() {
return Err(serde::de::Error::duplicate_field("secureNote"));
}
secure_note_data = Some(map.next_value()?);
}
_ => {}
}
}
match entry_type {
Some(BitwardenEntryType::Login) => {
let login = login_data.ok_or(serde::de::Error::missing_field("login"))?;
Ok(BitwardenEntryTypeData::Login(login))
}
Some(BitwardenEntryType::Card) => {
let card = card_data.ok_or(serde::de::Error::missing_field("card"))?;
Ok(BitwardenEntryTypeData::Card(card))
}
Some(BitwardenEntryType::SecureNote) => {
let secure_note =
secure_note_data.ok_or(serde::de::Error::missing_field("secure_note"))?;
Ok(BitwardenEntryTypeData::SecureNote(secure_note))
}
Some(BitwardenEntryType::Identity) => {
let identity = identity_data.ok_or(serde::de::Error::missing_field("identity"))?;
Ok(BitwardenEntryTypeData::Identity(identity))
}
None => Err(serde::de::Error::missing_field("type")),
}
}
}
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
#[repr(u8)]
enum BitwardenEntryType {
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
}
#[cfg(test)]
mod tests {
use crate::json;
use super::*;
#[test]
pub fn test_simple_serializing() {
let d = BitwardenEntryTypeData::Login(BitwardenEntryTypeLogin {
username: None,
password: None,
totp: None,
uris: None,
});
let json = json::to_string(&d).unwrap();
assert_eq!(
json,
r#"{"type":1,"login":{"username":null,"password":null,"totp":null,"uris":null}}"#
);
match json::from_str(&json).unwrap() {
BitwardenEntryTypeData::Login(BitwardenEntryTypeLogin {
username: None,
password: None,
totp: None,
uris: None,
}) => {}
_ => panic!("Could not deserialize json to itself"),
}
}
}

View file

@ -0,0 +1,13 @@
use crate::proc::Command;
pub fn read_json_as_string(url: &str) -> anyhow::Result<String> {
let mut cmd = Command::new("curl");
cmd.args(["-H", "Accept: application/json", url]);
cmd.try_spawn_to_string()
}
pub fn read_text_as_string(url: &str) -> anyhow::Result<String> {
let mut cmd = Command::new("curl");
cmd.arg(url);
cmd.try_spawn_to_string()
}

View file

@ -0,0 +1,23 @@
use std::path::PathBuf;
use anyhow::Context;
use serde::Deserialize;
use crate::json;
pub fn read_env(var: &'static str) -> anyhow::Result<String> {
log::trace!("read_env: {var}");
std::env::var(var)
.map_err(|e| anyhow::format_err!("Could not read {var} environment variable: {e}"))
}
pub fn read_path_env(var: &'static str) -> anyhow::Result<PathBuf> {
Ok(read_env(var)?.into())
}
pub fn read_env_json<T: for<'de> Deserialize<'de>>(var: &'static str) -> anyhow::Result<T> {
let json_text = read_env(var)?;
json::from_str(&json_text).with_context(|| {
format!("Could not parse contents of env var {var} into the correct json format")
})
}

156
rust/lib/common/src/fs.rs Normal file
View file

@ -0,0 +1,156 @@
use std::{
fs::{Permissions, ReadDir},
io::{ErrorKind, Write},
path::Path,
};
use anyhow::Context;
use crate::proc;
pub fn is_dir(p: &Path) -> anyhow::Result<bool> {
log::trace!("is_dir: {}", p.display());
Ok(match std::fs::metadata(p) {
Ok(m) if m.is_dir() => true,
Ok(_) => false,
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(e) => return Err(e).with_context(|| format!("Could not stat {p:?}")),
})
}
pub fn is_file(p: &Path) -> anyhow::Result<bool> {
log::trace!("is_file: {}", p.display());
Ok(match std::fs::metadata(p) {
Ok(m) if m.is_file() => true,
Ok(_) => false,
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(e) => return Err(e).with_context(|| format!("Could not stat {p:?}")),
})
}
pub fn is_file_with_permissions(p: &Path, permissions: Permissions) -> anyhow::Result<bool> {
log::trace!("is_file_with_permissions: {}", p.display());
Ok(match std::fs::metadata(p) {
Ok(m) if m.is_file() => {
log::trace!("Verifying {:?} == {:?}", m.permissions(), permissions);
m.permissions() == permissions
}
Ok(_) => false,
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(e) => return Err(e).with_context(|| format!("Could not stat {p:?}")),
})
}
pub fn create_dir(p: &Path) -> anyhow::Result<()> {
log::info!("Creating directory : {}", p.display());
std::fs::create_dir(p).with_context(|| format!("Could not create directory {p:?}"))
}
pub fn write_file_string(p: &Path, contents: &str, permissions: Permissions) -> anyhow::Result<()> {
log::info!("Writing contents to {}", p.display());
let mut file = std::fs::File::create(p).with_context(|| {
format!(
"Could not create (or open existing) file at {}",
p.display()
)
})?;
file.set_permissions(permissions)
.with_context(|| format!("Could not set permissions on file {}", p.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("Could not write to file {}", p.display()))?;
Ok(())
}
pub fn root_create_dir(p: &Path) -> anyhow::Result<()> {
let mut cmd = proc::Command::new("mkdir");
cmd.arg(p.display().to_string());
let _ = cmd.sudo().try_spawn_to_string()?;
Ok(())
}
pub fn remove_file(path: &Path) -> anyhow::Result<()> {
log::info!("Deleting file {}", path.display());
std::fs::remove_file(path).with_context(|| format!("Could not delete file {}", path.display()))
}
pub fn root_create_dir_recursive(p: &Path) -> anyhow::Result<()> {
let mut cmd = proc::Command::new("mkdir");
cmd.arg("-p");
cmd.arg(p.display().to_string());
let _ = cmd.sudo().try_spawn_to_string()?;
Ok(())
}
pub fn create_dir_recursive(p: &Path) -> anyhow::Result<()> {
let mut cmd = proc::Command::new("mkdir");
cmd.arg("-p");
cmd.arg(p.display().to_string());
let _ = cmd.try_spawn_to_string()?;
Ok(())
}
pub fn remove_dir_recursive(p: &Path) -> anyhow::Result<()> {
std::fs::remove_dir_all(p)
.with_context(|| format!("Could not remove directory {}", p.display()))
}
pub fn list_dir(p: &Path) -> anyhow::Result<ReadDir> {
std::fs::read_dir(p).with_context(|| format!("Could not read directory {}", p.display()))
}
pub fn set_permissions(p: &Path, permissions: Permissions) -> anyhow::Result<()> {
log::trace!("set_permissions: {}", p.display());
std::fs::set_permissions(p, permissions.clone()).with_context(|| {
format!(
"Could not set permissions on {} to {permissions:?}",
p.display()
)
})
}
pub fn metadata(p: &Path) -> anyhow::Result<std::fs::Metadata> {
log::trace!("get_metadata: {}", p.display());
std::fs::metadata(p).with_context(|| format!("Could not get metadata for {}", p.display()))
}
#[cfg(target_family = "unix")]
pub fn user_only_dir_permissions() -> Permissions {
use std::os::unix::fs::PermissionsExt;
PermissionsExt::from_mode(0o040700)
}
#[cfg(target_family = "unix")]
pub fn user_only_file_permissions() -> Permissions {
use std::os::unix::fs::PermissionsExt;
PermissionsExt::from_mode(0o100600)
}
pub fn read_to_string(path: &Path) -> anyhow::Result<String> {
log::trace!("read_file: {}", path.display());
std::fs::read_to_string(path)
.with_context(|| format!("Could not read file: {}", path.display()))
}
#[cfg(target_family = "unix")]
pub fn create_link(from: &Path, to: &Path) -> anyhow::Result<()> {
std::os::unix::fs::symlink(to, from).with_context(|| {
format!(
"Could not create symbolic link from {from} to {to}",
from = from.display(),
to = to.display()
)
})
}
pub fn rename(from: &Path, to: &Path) -> anyhow::Result<()> {
std::fs::rename(from, to)
.with_context(|| format!("Could not rename {} to {}", from.display(), to.display()))
}

View file

@ -0,0 +1,69 @@
use anyhow::Context;
use serde::{Deserialize, Serialize};
pub fn from_str<D: for<'de> Deserialize<'de>>(s: &str) -> anyhow::Result<D> {
serde_json::from_str(s).map_err(|e| anyhow::format_err!("{e}:\n{}", extract_context(&e, s)))
}
pub fn to_string<S: Serialize>(data: &S) -> anyhow::Result<String> {
serde_json::to_string(data).context("Could not serialize data to json")
}
pub fn to_string_pretty<S: Serialize>(data: &S) -> anyhow::Result<String> {
serde_json::to_string_pretty(data).context("Could not serialize data to json")
}
pub fn to_vec<S: Serialize>(data: &S) -> anyhow::Result<Vec<u8>> {
serde_json::to_vec(data).context("Could not serialize data to json")
}
pub fn to_vec_pretty<S: Serialize>(data: &S) -> anyhow::Result<Vec<u8>> {
serde_json::to_vec_pretty(data).context("Could not serialize data to json")
}
pub fn string(v: &str) -> String {
serde_json::to_string(v).expect("Could not encode json string")
}
fn extract_context(serde_error: &serde_json::Error, s: &str) -> String {
let lines: Vec<_> = s.lines().collect();
if lines.len() == 1 {
let (col_begin, highlight) = if serde_error.column() > 30 {
(serde_error.column() - 30, 30)
} else {
(1, serde_error.column())
};
let col_end = if lines[0].len() + 31 < serde_error.column() {
lines[0].len() + 1
} else {
serde_error.column() + 30
};
let mut line: String = lines[0]
.chars()
.skip(col_begin - 1)
.take(col_end - col_begin)
.collect();
line.push('\n');
line.extend(std::iter::repeat_n(' ', highlight - 1));
line.push('^');
line
} else {
let error_line = serde_error.line();
let mut result = String::new();
if error_line > 1 {
result.push_str(&format!("{}: {}\n", error_line - 1, lines[error_line - 2]));
}
result.push_str(&format!("{}: {}\n", error_line, lines[error_line - 1]));
result.push_str(&format!(
"{} {}\n",
" ".repeat(error_line.to_string().len()),
std::iter::repeat_n(' ', serde_error.column() - 1)
.chain(['^'].into_iter())
.collect::<String>(),
));
if lines.len() > error_line {
result.push_str(&format!("{}: {}\n", error_line + 1, lines[error_line]));
}
result
}
}

View file

@ -0,0 +1,40 @@
use std::str::FromStr;
use log::LevelFilter;
pub mod base64;
pub mod bitwarden;
pub mod curl;
pub mod env;
pub mod fs;
pub mod json;
pub mod proc;
pub mod yaml;
pub fn read_level_filter() -> LevelFilter {
let env = env::read_env("LOGLEVEL").unwrap_or(String::from("INFO"));
let env_upper = env.to_uppercase();
let level = match env_upper.as_str() {
"VERBOSE" => "TRACE",
"WARNING" => "WARN",
l => l,
};
log::LevelFilter::from_str(level).unwrap_or(log::LevelFilter::Info)
}
pub fn entrypoint(m: impl FnOnce() -> anyhow::Result<()>) {
env_logger::builder()
.filter_level(read_level_filter())
.format_timestamp(None)
.format_module_path(false)
.format_file(false)
.format_source_path(false)
.format_target(false)
.try_init()
.expect("Could not set logger");
let res = m();
if let Err(err) = res {
log::error!("{err:#}");
std::process::exit(1);
}
}

355
rust/lib/common/src/proc.rs Normal file
View file

@ -0,0 +1,355 @@
use std::{
collections::BTreeMap,
io::Write as _,
ops::Deref,
process::{Child, ExitStatus, Stdio},
};
use anyhow::Context;
use log::Level;
use serde::{Deserialize, Serialize};
use crate::{json, proc::util::command_to_string};
mod util;
#[derive(Debug)]
enum Stdin {
Null,
Pipe(Vec<u8>),
Inherit,
}
impl Stdin {
fn into_data(self) -> Option<Vec<u8>> {
match self {
Self::Pipe(d) => Some(d),
_ => None,
}
}
fn as_std_stdio(&self) -> Stdio {
match self {
Self::Inherit => Stdio::inherit(),
Self::Pipe(_) => Stdio::piped(),
Self::Null => Stdio::null(),
}
}
}
#[derive(Debug)]
enum Stderr {
Pipe,
Inherit,
}
impl Stderr {
fn as_std_stdio(&self) -> Stdio {
match self {
Self::Inherit => Stdio::inherit(),
Self::Pipe => Stdio::piped(),
}
}
}
#[derive(Debug)]
pub enum EnvData {
Insensitive(String),
Sensitive(String),
}
impl EnvData {
const fn as_str(&self) -> &str {
match self {
Self::Insensitive(d) | Self::Sensitive(d) => d.as_str(),
}
}
fn as_potentially_redacted_str(&self) -> &str {
match self {
Self::Insensitive(s) => s.as_str(),
Self::Sensitive(_) => "<REDACTED>",
}
}
}
#[derive(Debug)]
pub struct Command {
program: String,
args: Vec<String>,
env: BTreeMap<String, Option<EnvData>>,
is_sudo: bool,
show_command: bool,
stdin: Stdin,
stderr: Stderr,
}
impl Command {
pub fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
env: BTreeMap::new(),
is_sudo: false,
show_command: false,
stdin: Stdin::Null,
stderr: Stderr::Pipe,
}
}
pub fn get_program(&self) -> &str {
if self.is_sudo { "sudo" } else { &self.program }
}
pub fn get_args(&self) -> impl Iterator<Item = &str> {
let prefix_vec = if self.is_sudo {
Vec::from([self.program.as_str()])
} else {
Vec::new()
};
prefix_vec
.into_iter()
.chain(self.args.iter().map(Deref::deref))
}
pub fn get_envs(&self) -> impl Iterator<Item = (&str, Option<&EnvData>)> {
self.env.iter().map(|(k, v)| (k.as_str(), v.as_ref()))
}
/// Sudo will automatically set stdin and stderr to inherit, to allow the user to enter the sudo password
pub fn sudo(&mut self) -> &mut Self {
self.is_sudo = true;
self.stdin = Stdin::Inherit;
self.stderr = Stderr::Inherit;
self
}
pub fn announce(&mut self) -> &mut Self {
self.show_command = true;
self
}
pub fn stdin_json<S: Serialize>(&mut self, stdin: &S) -> anyhow::Result<&mut Self> {
self.stdin = Stdin::Pipe(json::to_vec(stdin).context("Could not convert stdin to json")?);
Ok(self)
}
pub fn stdin_json_base64<S: Serialize>(&mut self, stdin: &S) -> anyhow::Result<&mut Self> {
let json = json::to_string(stdin).context("Could not convert stdin to json")?;
let base64 = crate::base64::encode(json.as_bytes());
self.stdin = Stdin::Pipe(base64.into());
Ok(self)
}
pub fn stdin_string(&mut self, stdin: impl Into<String>) -> &mut Self {
self.stdin = Stdin::Pipe(Vec::from(stdin.into()));
self
}
pub fn stdin_bytes(&mut self, stdin: impl Into<Vec<u8>>) -> &mut Self {
self.stdin = Stdin::Pipe(stdin.into());
self
}
pub fn stdin_inherit(&mut self) -> &mut Self {
self.stdin = Stdin::Inherit;
self
}
pub fn stderr_inherit(&mut self) -> &mut Self {
self.stderr = Stderr::Inherit;
self
}
pub fn arg(&mut self, arg: impl Into<String>) -> &mut Self {
self.args.push(arg.into());
self
}
pub fn args<A>(&mut self, args: impl IntoIterator<Item = A>) -> &mut Self
where
A: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
pub fn env(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
let _ = self
.env
.insert(key.into(), Some(EnvData::Insensitive(value.into())));
self
}
pub fn env_sensitive(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
let _ = self
.env
.insert(key.into(), Some(EnvData::Sensitive(value.into())));
self
}
pub fn env_remove(&mut self, key: impl Into<String>) -> &mut Self {
self.env.insert(key.into(), None);
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.env.clear();
for k in std::env::args() {
self.env.insert(k, None);
}
self
}
fn as_command(&self) -> std::process::Command {
let is_sudo = self.is_sudo;
let show_command = self.show_command;
let mut cmd = if self.is_sudo {
let mut cmd = std::process::Command::new("sudo");
cmd.arg(&self.program);
cmd
} else {
std::process::Command::new(&self.program)
};
cmd.args(self.args.iter());
for (k, v) in self.env.iter() {
if let Some(v) = v {
cmd.env(k, v.as_str());
} else {
cmd.env_remove(k);
}
}
if !self.env.contains_key("LOGEVEL") {
if let Ok(loglevel) = std::env::var("LOGLEVEL") {
match loglevel.to_ascii_lowercase().as_str() {
"trace" | "debug" => cmd.env("LOGLEVEL", "debug"),
"verbose" => cmd.env("LOGLEVEL", "verbose"),
"info" => cmd.env("LOGLEVEL", "info"),
"warning" | "warn" => cmd.env("LOGLEVEL", "warning"),
"error" => cmd.env("LOGLEVEL", "error"),
_ => cmd.env_remove("LOGLEVEL"),
};
}
}
if is_sudo {
log::info!("[SUDO] Running command {}", command_to_string(self));
} else {
let level = if show_command {
Level::Info
} else {
Level::Trace
};
log::log!(level, "Running command {}", command_to_string(self));
}
cmd
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
pub fn try_spawn_to_bytes(&mut self) -> anyhow::Result<Vec<u8>> {
let mut cmd = self.as_command();
let mut child = cmd
.stderr(self.stderr.as_std_stdio())
.stdin(self.stdin.as_std_stdio())
.stdout(Stdio::piped())
.spawn()
.with_context(|| format!("Could not spawn command: {}", command_to_string(self)))?;
let mut stdin = Stdin::Null;
std::mem::swap(&mut self.stdin, &mut stdin);
let join_handle = if let Some(data) = stdin.into_data() {
let mut stdin_pipe = child.stdin.take().expect("Child has no stdin");
Some(std::thread::spawn(move || {
stdin_pipe
.write_all(data.as_slice())
.expect("Could not write to child");
}))
} else {
None
};
let output = wait_with_output(child, || command_to_string(self));
if let Some(join_handle) = join_handle {
join_handle
.join()
.map_err(|e| anyhow::format_err!("Thread sending stdin panicked: {e:?}"))?;
}
output
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
/// Using try_spawn_to_string will trim a single trailing newline, if you don't want this, use to_bytes and convert the string manually.
pub fn try_spawn_to_string(&mut self) -> anyhow::Result<String> {
let output = self.try_spawn_to_bytes()?;
let mut output: String = output.try_into().map_err(|_| {
anyhow::format_err!(
"Command {} didn't produce valid utf-8 output",
command_to_string(self)
)
})?;
if output.ends_with("\n") {
output.truncate(output.len() - 1);
}
Ok(output)
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
pub fn try_spawn_to_json<D: for<'de> Deserialize<'de>>(&mut self) -> anyhow::Result<D> {
let output = self.try_spawn_to_string()?;
json::from_str(&output).with_context(|| {
format!(
"Could not parse output of {} as json",
command_to_string(self)
)
})
}
/// WARNING: Spawning a command consumes all of its stdin, as such spawning it twice may not yield the same result
pub fn try_spawn_stdout_inherit(&mut self) -> anyhow::Result<ExitStatus> {
let mut cmd = self.as_command();
cmd.stdout(Stdio::inherit());
cmd.status()
.with_context(|| format!("Could not spawn command: {}", command_to_string(self)))
}
}
fn wait_with_output(child: Child, cmd_str: impl Fn() -> String) -> anyhow::Result<Vec<u8>> {
let output = child
.wait_with_output()
.with_context(|| format!("Could not wait for output of commnad: {}", cmd_str()))?;
if !output.status.success() {
return Err(anyhow::format_err!(
"Command {}, exited unexpectedly: {:?}. With stderr: {}",
cmd_str(),
output.status,
String::from_utf8_lossy(&output.stderr),
));
}
Ok(output.stdout)
}
#[cfg(test)]
mod tests {
use super::Command;
#[test]
fn test_spawn() {
let mut echo = Command::new("echo");
echo.args(["Hello", "World"]);
assert_eq!(
"Hello World",
echo.try_spawn_to_string()
.expect("Should be able to echo Hello World")
);
}
#[test]
fn test_spawn_stdin() {
let mut rev = Command::new("rev");
assert_eq!(
"dlroW olleH",
rev.stdin_string("Hello World".to_string())
.try_spawn_to_string()
.expect("Should be able to rev Hello World")
);
}
}

View file

@ -0,0 +1,31 @@
use super::Command;
use std::borrow::Cow;
use shell_quote::Quote;
pub fn command_to_string(cmd: &Command) -> String {
let program = escape_cli(cmd.get_program());
let env: Vec<_> = cmd
.get_envs()
.map(|(key, value)| match value {
None => key.into(),
Some(value) => Cow::Owned(format!(
"{key}={value} ",
value = escape_cli(value.as_potentially_redacted_str())
)),
})
.collect();
let args: Vec<_> = cmd.get_args().map(escape_cli).collect();
let env = env.join("");
let args = args.join(" ");
format!(
"{env_marker}{env}{program}{arg_separator}{args}",
env_marker = if env.is_empty() { "" } else { "env " },
arg_separator = if args.is_empty() { "" } else { " " }
)
}
fn escape_cli<A: AsRef<str>>(str: A) -> String {
let str = str.as_ref();
shell_quote::Bash::quote(str)
}

View file

@ -0,0 +1,10 @@
use anyhow::Context;
use serde::Serialize;
pub fn string(str: &str) -> String {
serde_yml::to_string(str).expect("Should be able to encode string")
}
pub fn to_string<T: ?Sized + Serialize>(value: &T) -> anyhow::Result<String> {
serde_yml::to_string(value).context("Could not serialize to yaml")
}