use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::peer::model::SharedWorktreeId;
pub use wsb_prj::MACHINE_DIR_ENV;
pub fn machine_dir() -> Result<PathBuf, String> {
let dir = machine_dir_path()?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("cannot create the machine state directory {}: {e}", dir.display()))?;
Ok(dir)
}
pub const MACHINE_NAME_ENV: &str = "WSB_MACHINE_NAME";
pub fn default_machine_name() -> String {
if let Ok(name) = std::env::var(MACHINE_NAME_ENV) {
if !name.trim().is_empty() {
return name.trim().to_string();
}
}
hostname().unwrap_or_else(|| "this machine".to_string())
}
fn hostname() -> Option<String> {
#[cfg(unix)]
{
let output = crate::git_backend::command_without_repo_scope("hostname").output().ok()?;
let name = String::from_utf8(output.stdout).ok()?;
let name = name.trim().split('.').next()?.to_string();
(!name.is_empty()).then_some(name)
}
#[cfg(windows)]
{
std::env::var("COMPUTERNAME").ok().filter(|n| !n.is_empty())
}
}
pub fn machine_name() -> Result<String, String> {
let dir = machine_dir()?;
Ok(crate::peer::identity::MachineIdentity::load_or_create(&dir, &default_machine_name())?
.name()
.to_string())
}
fn machine_dir_path() -> Result<PathBuf, String> {
if let Some(explicit) = wsb_prj::explicit_machine_dir().map_err(|e| e.to_string())? {
return Ok(explicit);
}
match wsb_prj::Workshop::from_env() {
Ok(prj) => prj.host_dir().map_err(|e| e.to_string()),
Err(wsb_prj::WorkshopError::NotSet) => Ok(home_dir()?.join(".wsb")),
Err(other) => Err(other.to_string()),
}
}
const ADOPTED: [&str; 7] = [
"machine-identity.json",
"machine.db",
"machine.db-wal",
"machine.db-shm",
"plugins.json",
"daemon.log",
"staging",
];
const REWRITTEN_OR_DROPPED: [&str; 4] = ["config.toml", "logs", "disposable", "daemon.json"];
const CLAIM_PREFIX: &str = ".wsb.moving-";
const CLAIM_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
pub fn adopt_old_machine_dir(home: &Path, machine_dir: &Path) -> Result<Vec<String>, String> {
let old = home.join(".wsb");
if same_place(&old, machine_dir) {
return Ok(Vec::new());
}
let mine = home.join(format!("{CLAIM_PREFIX}{}", std::process::id()));
let deadline = std::time::Instant::now() + CLAIM_WAIT;
loop {
match claims(home)?.into_iter().next() {
Some((pid, claim)) if pid != std::process::id() && crate::ops::process::is_alive(pid) => {
if std::time::Instant::now() >= deadline {
return Err(format!(
"process {pid} has been moving this machine's state out of {} for over \
{} seconds ({} is still there). Wait for it, or stop it and run again.",
old.display(),
CLAIM_WAIT.as_secs(),
claim.display()
));
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
Some((_, claim)) => {
match std::fs::rename(&claim, &mine) {
Ok(()) => return move_claimed(&mine, &old, machine_dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(format!("cannot take over {}: {e}", claim.display()));
}
}
}
None => {
if !old.is_dir() {
return Ok(Vec::new());
}
check_old_machine_dir(&old, machine_dir)?;
match std::fs::rename(&old, &mine) {
Ok(()) => return move_claimed(&mine, &old, machine_dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(format!("cannot claim {} to move it: {e}", old.display()));
}
}
}
}
}
}
fn claims(home: &Path) -> Result<Vec<(u32, PathBuf)>, String> {
let mut found = Vec::new();
let entries = std::fs::read_dir(home).map_err(|e| format!("cannot read {}: {e}", home.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("cannot read {}: {e}", home.display()))?;
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(pid) = name.strip_prefix(CLAIM_PREFIX) {
let pid = pid.parse::<u32>().map_err(|_| {
format!(
"{} is not a folder wsb made; move it away and run again",
entry.path().display()
)
})?;
found.push((pid, entry.path()));
}
}
found.sort();
Ok(found)
}
fn check_old_machine_dir(old: &Path, machine_dir: &Path) -> Result<(), String> {
for name in entry_names(old)? {
if !ADOPTED.contains(&name.as_str()) && !REWRITTEN_OR_DROPPED.contains(&name.as_str()) {
return Err(format!(
"{} is not machine state wsb wrote; move it out of {} and run again",
old.join(&name).display(),
old.display()
));
}
let to = machine_dir.join(&name);
if (ADOPTED.contains(&name.as_str()) || name == "config.toml") && to.exists() {
return Err(format!(
"{} and {} both exist; this machine has one identity, one database and one \
set of settings. Keep the one you mean, remove the other, and run again.",
old.join(&name).display(),
to.display()
));
}
}
if let Some(published) = MachineRendezvous::published_by(old)? {
if crate::ops::process::is_alive(published.pid) {
return Err(format!(
"wsb-daemon {} (pid {}) is still running from {}, where this machine's state \
used to live. Stop it (kill {}) and run again: the state then moves to {}.",
published.version,
published.pid,
old.display(),
published.pid,
machine_dir.display()
));
}
}
Ok(())
}
fn entry_names(dir: &Path) -> Result<Vec<String>, String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?
.map(|entry| {
entry
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.map_err(|e| format!("cannot read {}: {e}", dir.display()))
})
.collect::<Result<_, _>>()?;
names.sort();
Ok(names)
}
fn move_claimed(claim: &Path, old: &Path, machine_dir: &Path) -> Result<Vec<String>, String> {
check_old_machine_dir(claim, machine_dir)?;
std::fs::create_dir_all(machine_dir)
.map_err(|e| format!("cannot create {}: {e}", machine_dir.display()))?;
let mut done = Vec::new();
for name in entry_names(claim)? {
let from = claim.join(&name);
let to = machine_dir.join(&name);
let was = old.join(&name);
match name.as_str() {
"logs" | "disposable" => {
std::fs::remove_dir_all(&from)
.map_err(|e| format!("cannot remove {}: {e}", from.display()))?;
done.push(format!("removed {}", was.display()));
}
"daemon.json" => {
std::fs::remove_file(&from)
.map_err(|e| format!("cannot remove {}: {e}", from.display()))?;
done.push(format!("removed the stale {}", was.display()));
}
"config.toml" => {
let text = std::fs::read_to_string(&from)
.map_err(|e| format!("cannot read {}: {e}", from.display()))?;
let mut table: toml::Table = text
.parse()
.map_err(|e| format!("{} is not a settings file: {e}", was.display()))?;
let dropped: Vec<String> = ["github", "server"]
.into_iter()
.filter(|section| table.remove(*section).is_some())
.map(str::to_string)
.collect();
std::fs::write(&to, toml::to_string_pretty(&table).map_err(|e| e.to_string())?)
.map_err(|e| format!("cannot write {}: {e}", to.display()))?;
std::fs::remove_file(&from)
.map_err(|e| format!("cannot remove {}: {e}", from.display()))?;
done.push(if dropped.is_empty() {
format!("moved {} to {}", was.display(), to.display())
} else {
format!(
"moved {} to {}, without its unread [{}]",
was.display(),
to.display(),
dropped.join("], [")
)
});
}
_ => {
std::fs::rename(&from, &to)
.map_err(|e| format!("cannot move {} to {}: {e}", was.display(), to.display()))?;
done.push(format!("moved {} to {}", was.display(), to.display()));
}
}
}
std::fs::remove_dir(claim).map_err(|e| format!("cannot remove {}: {e}", claim.display()))?;
done.push(format!("removed {}", old.display()));
Ok(done)
}
fn same_place(one: &Path, other: &Path) -> bool {
match (crate::platform::canonicalize(one), crate::platform::canonicalize(other)) {
(Ok(one), Ok(other)) => one == other,
_ => one == other,
}
}
pub fn staging_dir() -> Result<PathBuf, String> {
let dir = machine_dir()?.join("staging");
std::fs::create_dir_all(&dir)
.map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
Ok(dir)
}
pub fn home_dir() -> Result<PathBuf, String> {
#[cfg(windows)]
let key = "APPDATA";
#[cfg(not(windows))]
let key = "HOME";
std::env::var_os(key)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.ok_or_else(|| {
format!("{key} is not set, so this machine has no state directory to use")
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MachineRendezvous {
pub version: String,
pub pid: u32,
pub http_address: std::net::IpAddr,
pub http_port: u16,
pub machine_id: String,
pub machine_name: String,
pub peer_address: std::net::IpAddr,
pub peer_port: u16,
pub started_at: u64,
#[serde(default)]
pub control_token: String,
}
const RENDEZVOUS: &str = "daemon.json";
pub fn new_control_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Published {
pub version: String,
pub pid: u32,
}
impl MachineRendezvous {
pub fn path_in(machine_dir: &Path) -> PathBuf {
machine_dir.join(RENDEZVOUS)
}
pub fn read(machine_dir: &Path) -> Result<Option<Self>, String> {
let path = Self::path_in(machine_dir);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
};
match serde_json::from_slice(&bytes) {
Ok(rendezvous) => Ok(Some(rendezvous)),
Err(error) => match serde_json::from_slice::<Published>(&bytes) {
Ok(published) => {
crate::output::message(
crate::output::record::Level::Warn,
"daemon-rendezvous-other-build",
format!(
"{} was published by wsb-daemon {} (pid {}), which this build \
cannot read. Run `wsb daemon restart` to replace it.",
path.display(),
published.version,
published.pid
),
);
Ok(None)
}
Err(_) => Err(format!(
"{} is not a daemon rendezvous file: {error}",
path.display()
)),
},
}
}
pub fn published_by(machine_dir: &Path) -> Result<Option<Published>, String> {
let path = Self::path_in(machine_dir);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
};
Ok(serde_json::from_slice(&bytes).ok())
}
pub fn write(&self, machine_dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(machine_dir)
.map_err(|e| format!("cannot create {}: {e}", machine_dir.display()))?;
let path = Self::path_in(machine_dir);
let staged = machine_dir.join(format!(".{RENDEZVOUS}.{}", std::process::id()));
let encoded = serde_json::to_vec_pretty(self)
.map_err(|e| format!("cannot encode the daemon rendezvous: {e}"))?;
let outcome = (|| -> Result<(), String> {
super::rendezvous::create_private(&staged)?;
std::fs::write(&staged, &encoded)
.map_err(|e| format!("cannot write {}: {e}", staged.display()))?;
std::fs::rename(&staged, &path)
.map_err(|e| format!("cannot publish {}: {e}", path.display()))
})();
if outcome.is_err() {
let _ = std::fs::remove_file(&staged);
}
outcome
}
pub fn remove_if_published_by(machine_dir: &Path, pid: u32) -> Result<bool, String> {
match Self::published_by(machine_dir)? {
Some(published) if published.pid == pid => {}
_ => return Ok(false),
}
let path = Self::path_in(machine_dir);
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(format!("cannot remove {}: {e}", path.display())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct WorktreeId(String);
impl WorktreeId {
pub fn parse(raw: &str) -> Result<Self, String> {
if let Some(hex) = raw.strip_prefix('~') {
if hex.len() == 16 && hex.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) {
return Ok(Self(raw.to_string()));
}
} else if let Some((project, worktree)) = raw.split_once(':') {
if wsb_prj::is_valid_name(project) && wsb_prj::is_valid_name(worktree) {
return Ok(Self(raw.to_string()));
}
}
Err(format!(
"'{raw}' is not a worktree id: <project>:<worktree>, or ~ and 16 hex digits"
))
}
fn of_project(project: &str, worktree: &str) -> Self {
Self(format!("{project}:{worktree}"))
}
fn of_path(canonical: &Path) -> Self {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
let hex: String = digest.iter().take(8).map(|byte| format!("{byte:02x}")).collect();
Self(format!("~{hex}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn in_project(&self) -> Option<(&str, &str)> {
self.0.split_once(':')
}
}
impl TryFrom<String> for WorktreeId {
type Error = String;
fn try_from(raw: String) -> Result<Self, String> {
Self::parse(&raw)
}
}
impl From<WorktreeId> for String {
fn from(id: WorktreeId) -> String {
id.0
}
}
impl std::fmt::Display for WorktreeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreePlace {
pub id: WorktreeId,
pub project: Option<String>,
pub name: String,
pub root: PathBuf,
}
pub fn place_worktree(root: &Path) -> Result<WorktreePlace, String> {
let prj = match wsb_prj::Workshop::from_env() {
Ok(prj) => Some(prj),
Err(wsb_prj::WorkshopError::NotSet) => None,
Err(other) => return Err(other.to_string()),
};
place_worktree_in(prj.as_ref(), root)
}
pub fn place_worktree_in(prj: Option<&wsb_prj::Workshop>, root: &Path) -> Result<WorktreePlace, String> {
let canonical = crate::platform::canonicalize(root)
.map_err(|e| format!("cannot resolve {}: {e}", root.display()))?;
if let Some(prj) = prj {
if let Ok(location) = prj.locate(&canonical) {
if let Some(worktree) = location.worktree {
let folder = location
.workspace
.dir()
.join(wsb_prj::worktree_folder(&worktree));
if same_place(&folder, &canonical) {
return Ok(WorktreePlace {
id: WorktreeId::of_project(location.workspace.name(), &worktree),
project: Some(location.workspace.name().to_string()),
name: worktree,
root: canonical,
});
}
}
}
}
let name = canonical
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| canonical.display().to_string());
Ok(WorktreePlace {
id: WorktreeId::of_path(&canonical),
project: None,
name,
root: canonical,
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisteredWorktree {
pub id: WorktreeId,
pub project: Option<String>,
pub name: String,
pub shared: Option<SharedWorktreeId>,
pub super_root: PathBuf,
pub root_commit: Option<String>,
pub last_seen: u64,
pub available: bool,
}
impl RegisteredWorktree {
pub fn display_name(&self) -> String {
match &self.project {
Some(project) => format!("{project}/{}", self.name),
None => self.name.clone(),
}
}
}
pub fn family_of(root_commit: &str) -> Result<String, String> {
if root_commit.is_empty() {
return Err("a repository with no commits has no family".to_string());
}
use sha2::{Digest, Sha256};
let digest = Sha256::digest(root_commit.as_bytes());
Ok(digest.iter().take(8).map(|byte| format!("{byte:02x}")).collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Continuity {
pub root_commit: Option<String>,
pub shared: Result<SharedWorktreeId, String>,
}
pub fn identify_shared(place: &WorktreePlace) -> Result<Continuity, String> {
let facts = crate::peer::compatibility::RepositoryFacts::read(
crate::peer::model::RepositoryId::root(),
&place.root,
&place.root.join(".git"),
)?;
if !facts.present {
return Err(format!("{} is not a git repository", place.root.display()));
}
if facts.root_commit.is_empty() {
return Ok(Continuity {
root_commit: None,
shared: Err(
"its superproject has no commits yet, so a peer has nothing to recognise it by"
.to_string(),
),
});
}
let family = family_of(&facts.root_commit)?;
Ok(Continuity {
shared: SharedWorktreeId::of(&family, &place.name),
root_commit: Some(facts.root_commit),
})
}
#[cfg(test)]
#[path = "machine_tests.rs"]
mod tests;