use std::ffi::{OsStr, OsString};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
pub const WORKSPACE_SCHEMA: u32 = 1;
pub const WORKSPACE_PANE_LIMIT: usize = 2;
pub const WORKSPACE_TAB_LIMIT: usize = 24;
pub const WORKSPACE_PATH_BYTES_LIMIT: usize = 4_096;
pub const WORKSPACE_FILE_BYTES_LIMIT: u64 = 512 * 1024;
const WORKSPACE_LINE_LIMIT: usize = 128;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PaneSession {
pub tabs: Vec<PathBuf>,
pub active_tab: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WorkspaceSession {
pub panes: Vec<PaneSession>,
pub active_pane: usize,
pub split: bool,
}
impl WorkspaceSession {
pub fn normalized(mut self) -> Self {
self.panes.truncate(WORKSPACE_PANE_LIMIT);
for pane in &mut self.panes {
pane.tabs.retain(|path| {
path.is_absolute()
&& !path.as_os_str().is_empty()
&& os_bytes(path.as_os_str()).len() <= WORKSPACE_PATH_BYTES_LIMIT
});
pane.tabs.truncate(WORKSPACE_TAB_LIMIT);
pane.active_tab = pane.active_tab.min(pane.tabs.len().saturating_sub(1));
}
self.panes.retain(|pane| !pane.tabs.is_empty());
self.active_pane = self.active_pane.min(self.panes.len().saturating_sub(1));
self.split = self.split && self.panes.len() == WORKSPACE_PANE_LIMIT;
self
}
}
pub fn load_workspace(path: &Path) -> Result<Option<WorkspaceSession>, String> {
let file = match open_workspace_file(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"Cannot open workspace session {}: {error}",
path.display()
))
}
};
let metadata = file.metadata().map_err(|error| {
format!(
"Cannot inspect workspace session {}: {error}",
path.display()
)
})?;
if !metadata.is_file() || metadata.len() > WORKSPACE_FILE_BYTES_LIMIT {
return Err(format!(
"Workspace session is not a bounded regular file: {}",
path.display()
));
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(WORKSPACE_FILE_BYTES_LIMIT + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("Cannot read workspace session {}: {error}", path.display()))?;
if bytes.len() as u64 > WORKSPACE_FILE_BYTES_LIMIT {
return Err(format!(
"Workspace session exceeds {} bytes",
WORKSPACE_FILE_BYTES_LIMIT
));
}
let text = std::str::from_utf8(&bytes)
.map_err(|_| "Workspace session is not valid UTF-8 metadata".to_string())?;
parse_workspace(text).map(Some)
}
pub fn write_workspace(path: &Path, session: &WorkspaceSession) -> Result<(), String> {
let session = session.clone().normalized();
let mut content = format!(
"schema={}\nsplit={}\nactive_pane={}\n",
WORKSPACE_SCHEMA, session.split, session.active_pane
);
for (pane_index, pane) in session.panes.iter().enumerate() {
content.push_str(&format!("pane{pane_index}.active={}\n", pane.active_tab));
for tab in &pane.tabs {
content.push_str(&format!(
"pane{pane_index}.tab={}\n",
encode_os(tab.as_os_str())
));
}
}
if content.len() as u64 > WORKSPACE_FILE_BYTES_LIMIT {
return Err(format!(
"Workspace session exceeds {} bytes",
WORKSPACE_FILE_BYTES_LIMIT
));
}
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent).map_err(|error| {
format!(
"Cannot create workspace session folder {}: {error}",
parent.display()
)
})?;
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or_default();
let file_name = path
.file_name()
.unwrap_or_else(|| OsStr::new("workspace.conf"));
let mut temporary_name = OsString::from(".");
temporary_name.push(file_name);
temporary_name.push(format!("-{}-{nonce}.tmp", std::process::id()));
let temporary = parent.join(temporary_name);
let result = (|| -> Result<(), String> {
let mut options = std::fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(&temporary).map_err(|error| {
format!(
"Cannot create workspace session {}: {error}",
temporary.display()
)
})?;
file.write_all(content.as_bytes()).map_err(|error| {
format!(
"Cannot write workspace session {}: {error}",
temporary.display()
)
})?;
file.sync_all().map_err(|error| {
format!(
"Cannot sync workspace session {}: {error}",
temporary.display()
)
})?;
std::fs::rename(&temporary, path).map_err(|error| {
format!(
"Cannot replace workspace session {}: {error}",
path.display()
)
})?;
#[cfg(unix)]
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| {
format!(
"Cannot sync workspace session folder {}: {error}",
parent.display()
)
})?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
}
#[cfg(unix)]
fn open_workspace_file(path: &Path) -> std::io::Result<std::fs::File> {
rustix::fs::open(
path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::CLOEXEC
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::NONBLOCK,
rustix::fs::Mode::empty(),
)
.map(std::fs::File::from)
.map_err(std::io::Error::from)
}
#[cfg(not(unix))]
fn open_workspace_file(path: &Path) -> std::io::Result<std::fs::File> {
std::fs::File::open(path)
}
pub fn parse_workspace(text: &str) -> Result<WorkspaceSession, String> {
let mut schema = None;
let mut split = false;
let mut active_pane = 0usize;
let mut panes = vec![PaneSession::default(); WORKSPACE_PANE_LIMIT];
for (index, raw_line) in text.lines().enumerate() {
if index >= WORKSPACE_LINE_LIMIT {
return Err(format!(
"Workspace session exceeds {} lines",
WORKSPACE_LINE_LIMIT
));
}
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"schema" => schema = value.parse::<u32>().ok(),
"split" => split = matches!(value, "true" | "1"),
"active_pane" => active_pane = value.parse::<usize>().unwrap_or_default(),
_ => {
let Some(rest) = key.strip_prefix("pane") else {
continue;
};
let Some((pane_text, field)) = rest.split_once('.') else {
continue;
};
let Ok(pane_index) = pane_text.parse::<usize>() else {
continue;
};
let Some(pane) = panes.get_mut(pane_index) else {
continue;
};
match field {
"active" => pane.active_tab = value.parse::<usize>().unwrap_or_default(),
"tab" if pane.tabs.len() < WORKSPACE_TAB_LIMIT => {
if let Some(path) = decode_path(value) {
pane.tabs.push(path);
}
}
_ => {}
}
}
}
}
if schema != Some(WORKSPACE_SCHEMA) {
return Err("Unsupported workspace session schema".to_string());
}
Ok(WorkspaceSession {
panes,
active_pane,
split,
}
.normalized())
}
fn encode_os(value: &OsStr) -> String {
let bytes = os_bytes(value);
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write as _;
let _ = write!(encoded, "{byte:02x}");
}
encoded
}
fn decode_path(encoded: &str) -> Option<PathBuf> {
if encoded.is_empty()
|| !encoded.len().is_multiple_of(2)
|| encoded.len() / 2 > WORKSPACE_PATH_BYTES_LIMIT
{
return None;
}
let mut bytes = Vec::with_capacity(encoded.len() / 2);
for pair in encoded.as_bytes().chunks_exact(2) {
let pair = std::str::from_utf8(pair).ok()?;
bytes.push(u8::from_str_radix(pair, 16).ok()?);
}
#[cfg(unix)]
let path = {
use std::os::unix::ffi::OsStringExt as _;
PathBuf::from(OsString::from_vec(bytes))
};
#[cfg(not(unix))]
let path = PathBuf::from(String::from_utf8(bytes).ok()?);
Some(path)
}
fn os_bytes(value: &OsStr) -> &[u8] {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
value.as_bytes()
}
#[cfg(not(unix))]
{
value.to_string_lossy().as_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(label: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or_default();
std::env::temp_dir().join(format!(
"guth-workspace-{label}-{}-{nonce}",
std::process::id()
))
}
#[test]
fn workspace_round_trips_two_panes_and_active_tabs() {
let root = temp_root("round-trip");
std::fs::create_dir_all(&root).unwrap();
let path = root.join("workspace.conf");
let session = WorkspaceSession {
panes: vec![
PaneSession {
tabs: vec![PathBuf::from("/one"), PathBuf::from("/two")],
active_tab: 1,
},
PaneSession {
tabs: vec![PathBuf::from("/secondary")],
active_tab: 0,
},
],
active_pane: 1,
split: true,
};
write_workspace(&path, &session).unwrap();
assert_eq!(load_workspace(&path).unwrap(), Some(session));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn workspace_preserves_non_utf8_paths() {
use std::os::unix::ffi::OsStringExt as _;
let raw = PathBuf::from(OsString::from_vec(b"/tmp/folder-\xff".to_vec()));
let session = WorkspaceSession {
panes: vec![PaneSession {
tabs: vec![raw.clone()],
active_tab: 0,
}],
active_pane: 0,
split: false,
};
let root = temp_root("non-utf8");
std::fs::create_dir_all(&root).unwrap();
let path = root.join("workspace.conf");
write_workspace(&path, &session).unwrap();
let loaded = load_workspace(&path).unwrap().unwrap();
assert_eq!(loaded.panes[0].tabs, vec![raw]);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn workspace_parser_bounds_and_normalizes_indices() {
let path = encode_os(OsStr::new("/tmp"));
let text =
format!("schema=1\nsplit=true\nactive_pane=99\npane0.active=99\npane0.tab={path}\n");
let parsed = parse_workspace(&text).unwrap();
assert_eq!(parsed.active_pane, 0);
assert_eq!(parsed.panes[0].active_tab, 0);
assert!(!parsed.split);
let too_many = std::iter::repeat_n("unknown=value", WORKSPACE_LINE_LIMIT + 1)
.collect::<Vec<_>>()
.join("\n");
assert!(parse_workspace(&too_many).unwrap_err().contains("lines"));
}
#[test]
fn workspace_rejects_unknown_schema_and_oversized_files() {
assert!(parse_workspace("schema=99\n").is_err());
let root = temp_root("oversized");
std::fs::create_dir_all(&root).unwrap();
let path = root.join("workspace.conf");
let file = std::fs::File::create(&path).unwrap();
file.set_len(WORKSPACE_FILE_BYTES_LIMIT + 1).unwrap();
assert!(load_workspace(&path)
.unwrap_err()
.contains("bounded regular file"));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn every_normalized_maximum_session_fits_the_file_bound() {
let maximum_path =
PathBuf::from(format!("/{}", "x".repeat(WORKSPACE_PATH_BYTES_LIMIT - 1)));
let pane = PaneSession {
tabs: vec![maximum_path; WORKSPACE_TAB_LIMIT],
active_tab: WORKSPACE_TAB_LIMIT - 1,
};
let session = WorkspaceSession {
panes: vec![pane.clone(), pane],
active_pane: 1,
split: true,
};
let root = temp_root("maximum");
std::fs::create_dir_all(&root).unwrap();
let path = root.join("workspace.conf");
write_workspace(&path, &session).unwrap();
assert!(std::fs::metadata(&path).unwrap().len() <= WORKSPACE_FILE_BYTES_LIMIT);
assert_eq!(load_workspace(&path).unwrap(), Some(session));
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn workspace_loader_rejects_fifo_and_symlink_without_blocking() {
use std::os::unix::fs::symlink;
let root = temp_root("special-file");
std::fs::create_dir_all(&root).unwrap();
let fifo = root.join("workspace.fifo");
rustix::fs::mkfifoat(
rustix::fs::CWD,
&fifo,
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.unwrap();
assert!(load_workspace(&fifo)
.unwrap_err()
.contains("bounded regular file"));
let regular = root.join("regular.conf");
std::fs::write(®ular, "schema=1\n").unwrap();
let linked = root.join("linked.conf");
symlink(®ular, &linked).unwrap();
assert!(load_workspace(&linked).is_err());
let _ = std::fs::remove_dir_all(root);
}
}