use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::plane::Plane;
use crate::{aws, cloudflare, modal};
#[derive(Serialize, Deserialize, Default)]
pub struct Kept {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cloudflare: Option<cloudflare::Session>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aws: Option<aws::Session>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aws_more: Vec<aws::Session>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modal: Option<modal::Session>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<Key>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plane: Option<Plane>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub planes_more: Vec<Plane>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub aws_ended: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cloudflare_ended: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub modal_ended: bool,
}
#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
pub struct About {
#[serde(default)]
pub name: String,
#[serde(default)]
pub hosts: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub picture: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub picture_of: String,
}
#[derive(Serialize, Deserialize, Clone, PartialEq)]
pub struct Key { pub name: String, pub value: String, pub planes: Vec<String> }
impl Kept {
pub fn signed_in(&self) -> bool { self.cloudflare.is_some() || self.aws.is_some() || !self.aws_more.is_empty() || self.modal.is_some() }
pub fn worth_keeping(&self) -> bool { self.signed_in() || ((self.aws_ended || self.cloudflare_ended || self.modal_ended) && self.key.is_some() && self.plane.is_some()) }
}
#[derive(Clone)]
pub struct Store { dir: PathBuf }
pub const FIRST: &str = "default";
pub fn root() -> Option<PathBuf> {
let var = |n: &str| std::env::var_os(n).filter(|v| !v.is_empty()).map(PathBuf::from);
var("SUPERCI_HOME").or_else(|| var("HOME").or_else(|| var("USERPROFILE")).map(|h| h.join(".superci")))
}
pub fn valid_id(id: &str) -> crate::Result<()> {
let ok = (2..=24).contains(&id.len()) && id.starts_with(|c: char| c.is_ascii_lowercase()) && !id.ends_with('-') && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if ok { Ok(()) } else { Err("A workspace's id is 2 to 24 lowercase letters, digits and dashes, starting with a letter (acme, acme-staging).".into()) }
}
pub fn id_from(name: &str) -> crate::Result<String> {
let mut id = String::new();
for c in name.trim().chars() {
if c.is_ascii_alphanumeric() { id.push(c.to_ascii_lowercase()) } else if !id.is_empty() && !id.ends_with('-') { id.push('-') }
}
let id: String = id.trim_start_matches(|c: char| !c.is_ascii_lowercase()).chars().take(24).collect();
let id = id.trim_end_matches('-').to_string();
valid_id(&id).map_err(|_| "A workspace's name needs two letters or more (AcmeCI, Build Farm).".to_string())?;
Ok(id)
}
pub fn workspaces_in(root: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(root.join("workspaces")).into_iter().flatten().flatten()
.filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().into_string().ok()).filter(|n| valid_id(n).is_ok()).collect();
names.sort();
names
}
pub struct Listed { pub id: String, pub name: String, pub hosts: Vec<(String, String)>, pub picture: String, pub place: String, pub signed_in: bool,
pub plane_id: String }
impl Listed {
pub fn title(&self) -> &str { if self.name.is_empty() { &self.id } else { &self.name } }
}
pub fn listed(root: &std::path::Path, also: Option<&str>) -> Vec<Listed> {
let mut names = workspaces_in(root);
if let Some(a) = also.filter(|a| !names.iter().any(|n| n == a)) { names.push(a.to_string()); names.sort() }
names.into_iter().map(|id| {
let store = Store::under(root, &id);
let (kept, about) = (store.read(), store.about());
Listed { name: about.name, hosts: about.hosts, picture: about.picture, place: kept.plane.as_ref().map(|p| p.place()).unwrap_or_default(), signed_in: kept.signed_in(),
plane_id: kept.plane.as_ref().map(|p| p.plane_id().to_string()).unwrap_or_default(), id }
}).collect()
}
pub fn rename(root: &std::path::Path, from: &str, to: &str) -> crate::Result<()> {
valid_id(to)?;
if from == to { return Ok(()) }
let (old, new) = (root.join("workspaces").join(from), root.join("workspaces").join(to));
if new.exists() { return Err(format!("There is a workspace with the id {to} already.")) }
if !old.is_dir() { return Store::under(root, to).make() }
std::fs::rename(&old, &new).map_err(|e| format!("could not rename {}: {e}", old.display()))
}
pub fn which(root: &std::path::Path, named: Option<&str>) -> std::result::Result<String, Vec<String>> {
let all = workspaces_in(root);
match named.map(|n| n.trim().to_lowercase()).filter(|n| !n.is_empty()) {
Some(n) => Ok(n),
None => match all.as_slice() { [] => Ok(FIRST.to_string()), [only] => Ok(only.clone()), _ => Err(all) },
}
}
pub fn last_opened(root: &std::path::Path) -> Option<String> {
let name = std::fs::read_to_string(root.join("last-workspace")).ok()?.trim().to_string();
workspaces_in(root).into_iter().find(|w| *w == name)
}
pub fn note_opened(root: &std::path::Path, name: &str) {
if valid_id(name).is_ok() { let _ = write_private(&root.join("last-workspace"), name.as_bytes()); }
}
pub fn kept_here(root: &std::path::Path, name: &str) -> bool {
let all = workspaces_in(root);
all.iter().any(|w| w == name) || (all.is_empty() && name == FIRST)
}
impl Store {
pub fn of(workspace: &str) -> Option<Store> { Some(Store::under(&root()?, workspace)) }
pub fn under(root: &std::path::Path, workspace: &str) -> Store { Store { dir: root.join("workspaces").join(workspace) } }
pub fn about(&self) -> About {
std::fs::read(self.dir.join("workspace.json")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn set_about(&self, about: &About) -> crate::Result<()> {
self.make()?;
let text = serde_json::to_vec_pretty(about).map_err(|e| e.to_string())?;
write_private(&self.dir.join("workspace.json"), &text).map_err(|e| format!("could not write in {}: {e}", self.dir.display()))
}
pub fn workspace(&self) -> String { self.dir.file_name().and_then(|n| n.to_str()).unwrap_or(FIRST).to_string() }
pub fn make(&self) -> crate::Result<()> {
std::fs::create_dir_all(&self.dir).map_err(|e| format!("could not make {}: {e}", self.dir.display()))?;
self.owners_alone();
Ok(())
}
fn owners_alone(&self) {
only_the_owner(&self.dir, 0o700);
let Some(all) = self.dir.parent().filter(|p| p.file_name().is_some_and(|n| n == "workspaces")) else { return };
only_the_owner(all, 0o700);
if let Some(root) = all.parent() { only_the_owner(root, 0o700) }
}
pub fn forget(&self) -> crate::Result<()> {
match std::fs::remove_dir_all(&self.dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("could not remove {}: {e}", self.dir.display())),
}
}
#[cfg(test)]
pub fn at(dir: impl Into<PathBuf>) -> Store { Store { dir: dir.into() } }
pub fn root(&self) -> Option<PathBuf> { Some(self.dir.parent()?.parent()?.to_path_buf()) }
pub fn path(&self) -> PathBuf { self.dir.join("sign-ins.json") }
pub fn renewing(&self) -> Option<std::fs::File> {
std::fs::create_dir_all(&self.dir).ok()?;
let f = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(self.dir.join(".renewing")).ok()?;
f.lock().ok()?;
Some(f)
}
pub fn read(&self) -> Kept {
let mut kept: Kept = std::fs::read(self.path()).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
if let Some(k) = kept.key.as_mut().filter(|k| k.name.starts_with("DASHBOARD_KEY_")) { k.name = k.name.replacen("DASHBOARD_KEY_", "DEVICE_KEY_", 1); k.planes.clear() }
kept
}
pub fn write(&self, kept: &Kept) -> crate::Result<()> {
let say = |e: std::io::Error| format!("could not keep the sign-in in {}: {e}", self.dir.display());
std::fs::create_dir_all(&self.dir).map_err(say)?;
self.owners_alone();
let tmp = self.dir.join(format!("sign-ins.json.{}", std::process::id()));
let text = serde_json::to_vec_pretty(kept).map_err(|e| e.to_string())?;
write_private(&tmp, &text).map_err(say)?;
std::fs::rename(&tmp, self.path()).map_err(say)
}
pub fn remove(&self) -> crate::Result<bool> {
match std::fs::remove_file(self.path()) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(format!("could not remove {}: {e}", self.path().display())),
}
}
}
#[cfg(unix)]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?;
f.write_all(bytes)
}
#[cfg(not(unix))]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { std::fs::write(path, bytes) }
#[cfg(unix)]
fn only_the_owner(path: &std::path::Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
fn only_the_owner(_: &std::path::Path, _: u32) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_and_removes() {
let dir = std::env::temp_dir().join(format!("superci-store-{}-{}", std::process::id(), superci_core::crypto::random_id(6)));
let store = Store::at(&dir);
assert!(!store.read().signed_in() && !store.remove().unwrap());
let kept = Kept { modal: Some(modal::Session { token_id: "ak-test".into(), token_secret: "as-test".into(), workspace: "acme".into() }),
aws: Some(aws::Session::for_test("123456789012")),
key: Some(Key { name: "DEVICE_KEY_1_AB".into(), value: "k".into(), planes: vec!["p1".into()] }),
plane: Some(Plane::Aws { account_id: "123456789012".into(), region: "us-east-1".into(), url: "https://x.lambda-url.us-east-1.on.aws".into(), plane_id: "p1".into(), label: "superci".into() }), ..Default::default() };
store.write(&kept).unwrap();
let back = store.read();
assert!(back.signed_in() && back.cloudflare.is_none());
assert_eq!(back.modal.unwrap().workspace, "acme");
assert_eq!(back.aws.unwrap().account_id, "123456789012");
assert!(back.key == kept.key && back.plane == kept.plane);
store.write(&Kept { key: Some(Key { name: "DASHBOARD_KEY_1794848867_4HXLAV".into(), value: "k".into(), planes: vec!["p1".into()] }), ..Default::default() }).unwrap();
assert!(store.read().key == Some(Key { name: "DEVICE_KEY_1794848867_4HXLAV".into(), value: "k".into(), planes: vec![] }));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(store.path()).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
}
std::fs::write(store.path(), b"{ not json").unwrap();
assert!(!store.read().signed_in() && !store.read().worth_keeping());
assert!(store.remove().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspaces_are_folders_and_none_is_picked_among_several() {
let root = std::env::temp_dir().join(format!("superci-workspaces-{}-{}", std::process::id(), superci_core::crypto::random_id(6)));
assert!(workspaces_in(&root).is_empty() && which(&root, None) == Ok("default".into()) && kept_here(&root, "default") && !kept_here(&root, "acme"));
Store::under(&root, "acme").make().unwrap();
assert_eq!(which(&root, None), Ok("acme".into()));
assert!(kept_here(&root, "acme") && !kept_here(&root, "default"));
assert_eq!(which(&root, Some(" Globex ")), Ok("globex".into()));
let globex = Store::under(&root, "globex");
globex.write(&Kept { modal: Some(modal::Session { token_id: "ak".into(), token_secret: "as".into(), workspace: "globex-team".into() }), ..Default::default() }).unwrap();
globex.set_about(&About { name: "GlobexCI".into(), hosts: vec![("github".into(), "globex".into())], ..Default::default() }).unwrap();
assert_eq!(which(&root, None), Err(vec!["acme".to_string(), "globex".to_string()]));
assert_eq!(globex.workspace(), "globex");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for dir in [root.clone(), root.join("workspaces"), root.join("workspaces/globex"), root.join("workspaces/acme")] { assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700, "{}", dir.display()) }
assert_eq!(std::fs::metadata(globex.path()).unwrap().permissions().mode() & 0o777, 0o600);
}
assert_eq!(globex.root().unwrap(), root);
assert!(!Store::under(&root, "acme").read().signed_in() && globex.read().signed_in());
let all = listed(&root, Some("default"));
assert_eq!(all.iter().map(|w| w.id.as_str()).collect::<Vec<_>>(), ["acme", "default", "globex"]);
assert!(all[2].signed_in && all[2].name == "GlobexCI" && all[2].hosts == [("github".to_string(), "globex".to_string())] && !all[0].signed_in);
assert_eq!((all[2].title(), all[0].title()), ("GlobexCI", "acme"));
assert!(globex.remove().unwrap() && globex.about().name == "GlobexCI");
globex.write(&Kept { modal: Some(modal::Session { token_id: "ak".into(), token_secret: "as".into(), workspace: "globex-team".into() }), ..Default::default() }).unwrap();
assert_eq!(rename(&root, "globex", "acme").unwrap_err(), "There is a workspace with the id acme already.");
assert!(rename(&root, "globex", "Globex Inc").unwrap_err().starts_with("A workspace's id is 2 to 24 lowercase letters"));
rename(&root, "globex", "initech").unwrap();
assert!(Store::under(&root, "initech").read().signed_in() && Store::under(&root, "initech").about().name == "GlobexCI" && workspaces_in(&root) == ["acme", "initech"]);
Store::under(&root, "initech").forget().unwrap();
assert_eq!(workspaces_in(&root), ["acme"]);
for no in ["a", "Acme", "9lives", "acme corp", "acme_corp", "../acme", "acme-", "a-name-that-is-much-too-long"] { assert!(valid_id(no).is_err(), "{no}") }
for (name, id) in [("MyCI", "myci"), ("Acme CI", "acme-ci"), (" Build Farm 2 ", "build-farm-2"), ("9 Lives", "lives"), ("A name that is much too long for it", "a-name-that-is-much-too")] { assert_eq!(id_from(name).unwrap(), id, "{name}") }
assert_eq!(id_from("7").unwrap_err(), "A workspace's name needs two letters or more (AcmeCI, Build Farm).");
let _ = std::fs::remove_dir_all(&root);
}
}