//! A repository on disk: the `.oxy` directory, one workspace, the operation //! log, and the working-copy change (spec §12, §13.1). //! //! Not yet implemented from the spec: trust policy (every remote is //! "unverified", so unsigned pushes are accepted), packs, checkpoints, //! multiple workspaces, and network transports (only local paths). Changes are signed individually when `core.sign` is //! `change`; the default `seal` leaves them unsigned until push exists. use crate::attest::{Attest, AttestBody}; use crate::diff::{ranges, split_lines, text_edits}; use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RemoteRef, SignedAttest}; use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType}; use crate::identity::{verify as verify_signature, Domain, Identity, SecretKey, Signature}; use crate::ids::{ChangeId, NodeId, WorkspaceId}; use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp}; use crate::oplog::{Format1Op, Op, View, SNAPSHOT_SUFFIX}; use crate::state::{FileContent, Snapshot, State}; use crate::store::{Store, StoreError}; use crate::tree::OpKey; use rand::RngCore; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; pub const OXY_DIR: &str = ".oxy"; pub const FORMAT_LINE: &str = "oxy repository format 2\n"; const FORMAT1_LINE: &str = "oxy repository format 1\n"; const BINARY_SNIFF: usize = 8 * 1024; #[derive(Debug, thiserror::Error)] pub enum RepoError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error(transparent)] Store(#[from] StoreError), #[error(transparent)] Object(#[from] crate::objects::ObjectError), #[error(transparent)] State(#[from] crate::state::StateError), #[error("{0}")] Msg(String), } pub type Result = std::result::Result; fn fail(s: impl Into) -> Result { Err(RepoError::Msg(s.into())) } mod checkout; mod config; mod identity; mod snapshot; mod sync; #[cfg(test)] mod tests; pub use checkout::CheckoutReport; pub use config::{read_config_file, Config}; pub use identity::SignatureStatus; pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult}; pub use sync::PullReport; fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { let tmp = path.with_extension("tmp"); { let mut f = fs::File::create(&tmp)?; f.write_all(data)?; f.sync_all()?; } fs::rename(&tmp, path)?; Ok(()) } pub struct LogEntry { pub hash: ChangeHash, pub change: Change, pub is_working_copy: bool, pub is_head: bool, } struct FsEntry { kind: NodeKind, exec: bool, content: Vec, } pub struct Repo { root: PathBuf, oxy: PathBuf, store: Store, workspace: WorkspaceId, keys_dir: PathBuf, user_config: PathBuf, pub config: Config, } /// The user's oxy directory: `$OXY_HOME`, else `$XDG_CONFIG_HOME/oxy`, else /// `~/.config/oxy`. It holds `config.toml` (spec §19) and `keys/`. pub fn default_home() -> PathBuf { if let Ok(h) = std::env::var("OXY_HOME") { return PathBuf::from(h); } if let Ok(x) = std::env::var("XDG_CONFIG_HOME") { return PathBuf::from(x).join("oxy"); } // Windows has no HOME as a rule: the roaming profile's application // data, as Windows programs keep theirs. #[cfg(windows)] if let Some(d) = std::env::var_os("APPDATA").or_else(|| std::env::var_os("USERPROFILE")) { return PathBuf::from(d).join("oxy"); } let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); PathBuf::from(home).join(".config").join("oxy") } pub fn default_keys_dir() -> PathBuf { default_home().join("keys") } pub fn default_user_config() -> PathBuf { default_home().join("config.toml") } /// Where `create_identity` records the author. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IdentityScope { /// The user file: every repository of this user signs with it. User, /// This repository only. Repo, } impl Repo { // ------------------------------------------------------------ lifecycle pub fn init(root: &Path) -> Result { let mut id = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut id); Self::init_with_id(root, id) } /// Initialize a repository with a given repository ID (clones share the /// ID of their origin, spec §14.4). pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result { fs::create_dir_all(root)?; let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let oxy = root.join(OXY_DIR); if oxy.exists() { return fail(format!("{} is already an oxy repository", root.display())); } fs::create_dir_all(oxy.join("ops"))?; write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?; write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?; let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; config.save(&oxy.join("config"))?; let workspace = WorkspaceId::random(); write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?; let store = Store::open(oxy.join("objects"))?; let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; let wc = repo.new_working_copy(vec![], ""); let wc_hash = repo.store.put_change(&wc)?; let mut view = View::default(); view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?); view.workspaces.insert(workspace, wc_hash); repo.record_op(&[], "oxy init", view)?; Ok(repo) } /// Open the repository containing `start` (searching upwards). pub fn open(start: &Path) -> Result { let start = start.canonicalize()?; let mut dir: Option<&Path> = Some(&start); while let Some(d) = dir { let oxy = d.join(OXY_DIR); if oxy.is_dir() { let format = fs::read_to_string(oxy.join("format"))?; let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()) .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?; let store = Store::open(oxy.join("objects"))?; let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; if format == FORMAT1_LINE { repo.migrate_format1()?; } else if format != FORMAT_LINE { return fail(format!("unsupported repository format: {}", format.trim())); } return Ok(repo); } dir = d.parent(); } fail(format!("no oxy repository found at or above {}", start.display())) } /// Rewrite a format-1 operation log, whose bookmarks were head lists, /// into format 2, where each bookmark names a `state` object. fn migrate_format1(&self) -> Result<()> { let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?; let head = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?; // Collect the chain root-first. let mut chain: Vec = Vec::new(); let mut cur = Some(head); while let Some(r) = cur { let op = Op::decode_format1(&self.store.get_typed(&r, ObjectType::Op)?)?; cur = op.parents.first().copied(); chain.push(op); } chain.reverse(); let mut new_parent: Option = None; for old in chain { let mut view = View::default(); for (name, heads) in &old.view.bookmarks { view.bookmarks.insert(name.clone(), self.store.put_state(&StateObject::new(heads.clone()))?); } view.workspaces = old.view.workspaces.clone(); let op = Op { parents: new_parent.into_iter().collect(), hlc: old.hlc, command: old.command, view }; new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?); } write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?; write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?; Ok(()) } /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use. pub fn id(&self) -> Result<[u8; 16]> { let path = self.oxy.join("id"); match fs::read_to_string(&path) { Ok(h) => hex::decode(h.trim()).ok().and_then(|v| v.try_into().ok()).ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let mut id = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut id); write_atomic(&path, hex::encode(id).as_bytes())?; Ok(id) } Err(e) => Err(e.into()), } } pub fn root(&self) -> &Path { &self.root } pub fn store(&self) -> &Store { &self.store } pub fn workspace(&self) -> WorkspaceId { self.workspace } /// Use a different user directory for keys and the user config file /// (tests, or `$OXY_HOME`). Reloads the effective configuration. pub fn with_home(mut self, dir: impl Into) -> Repo { let dir = dir.into(); self.keys_dir = dir.join("keys"); self.user_config = dir.join("config.toml"); if let Ok(c) = Config::load(Some(&self.user_config), &self.oxy.join("config")) { self.config = c; } self } pub fn user_config_path(&self) -> &Path { &self.user_config } /// Set a key in the user file and refresh the effective configuration. pub fn set_user_config(&mut self, key: &str, value: &str) -> Result<()> { Config::set_user(&self.user_config, key, value)?; self.config = Config::load(Some(&self.user_config), &self.oxy.join("config"))?; Ok(()) } pub fn save_config(&self) -> Result<()> { self.config.save(&self.oxy.join("config")) } fn author(&self) -> KeyId { self.config.author_key.unwrap_or(ObjectRef::ZERO) } } impl Repo { fn new_working_copy(&self, deps: Vec, message: &str) -> Change { let mut c = Change { change_id: ChangeId::random(), deps, author: self.author(), hlc: Hlc::now(), message: message.into(), tree_ops: vec![], edits: vec![], kind: ChangeKind::WorkingCopy, }; c.normalize(); c } // ------------------------------------------------------------ operation log pub fn head_op(&self) -> Result<(ObjectRef, Op)> { let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?; let r = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?; let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?; Ok((r, op)) } pub fn view(&self) -> Result { Ok(self.head_op()?.1.view) } fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result { let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view }; let r = self.store.put(ObjectType::Op, &op.encode())?; write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?; Ok(r) } pub fn op_log(&self) -> Result> { let mut out = Vec::new(); let (mut r, mut op) = self.head_op()?; loop { let next = op.parents.first().copied(); out.push((r, op)); match next { Some(p) => { r = p; op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?; } None => break, } } Ok(out) } /// Undo the last user command (spec §12.1). Operations that only recorded /// a working-copy snapshot are skipped, since the files are still on disk /// and the next snapshot recreates them. pub fn undo(&self) -> Result { let (_, mut op) = self.head_op()?; while op.is_snapshot() { let Some(parent) = op.parents.first() else { break }; op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; } let Some(parent) = op.parents.first() else { return fail("nothing to undo"); }; let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?; Ok(parent_op) } /// The last operation that was not a snapshot. pub fn last_command(&self) -> Result { let (_, mut op) = self.head_op()?; while op.is_snapshot() { let Some(parent) = op.parents.first() else { break }; op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; } Ok(op) } // ------------------------------------------------------------ states /// Load the dependency closure of `heads` from the store. pub fn load_state(&self, heads: &[ChangeHash]) -> Result { let mut state = State::new(); let mut stack: Vec = heads.to_vec(); let mut seen: BTreeSet = BTreeSet::new(); while let Some(h) = stack.pop() { if !seen.insert(h) { continue; } let c = self.store.get_change(&h)?; stack.extend(c.deps.iter().copied()); state.add_unchecked(c); } state.validate()?; Ok(state) } pub fn working_copy(&self) -> Result<(ChangeHash, Change)> { let view = self.view()?; let h = *view .workspaces .get(&self.workspace) .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?; Ok((h, self.store.get_change(&h)?)) } /// Heads of a bookmark's state (spec §10.1). pub fn heads_of(&self, view: &View, bookmark: &str) -> Result> { match view.bookmarks.get(bookmark) { Some(st) => Ok(self.store.get_state(st)?.heads), None => Ok(vec![]), } } pub fn bookmark_heads(&self) -> Result> { self.heads_of(&self.view()?, &self.config.bookmark) } // ------------------------------------------------------------ blobs fn blob_for(content: &[u8]) -> Blob { Blob { length: content.len() as u64, chunks: crate::chunk::chunks(content).into_iter().map(|c| hash_object(ObjectType::Chunk, c)).collect(), } } pub fn blob_ref(content: &[u8]) -> ObjectRef { Self::blob_for(content).hash() } pub fn store_blob(&self, content: &[u8]) -> Result { for c in crate::chunk::chunks(content) { self.store.put_chunk(c)?; } Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?) } pub fn read_blob(&self, r: &ObjectRef) -> Result> { let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?; let mut out = Vec::with_capacity(blob.length as usize); for c in &blob.chunks { out.extend(self.store.get_typed(c, ObjectType::Chunk)?); } Ok(out) } // ------------------------------------------------------------ working copy fn walk_working_copy(&self) -> Result> { let mut wb = ignore::WalkBuilder::new(&self.root); wb.hidden(false) .git_ignore(false) .git_global(false) .git_exclude(false) .require_git(false) .follow_links(false) .add_custom_ignore_filename(".oxyignore"); wb.filter_entry(|e| e.file_name() != OXY_DIR); let mut out = BTreeMap::new(); for entry in wb.build() { let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?; let path = entry.path(); if path == self.root { continue; } let rel = path.strip_prefix(&self.root).unwrap(); let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::>().join("/"); let Some(ft) = entry.file_type() else { continue }; if ft.is_dir() { continue; } if ft.is_symlink() { let target = fs::read_link(path)?; out.insert(rel, FsEntry { kind: NodeKind::Symlink, exec: false, content: target.to_string_lossy().as_bytes().to_vec() }); } else if ft.is_file() { let exec = is_executable(&entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?); out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)? }); } } Ok(out) } } impl Repo { /// Changes reachable from the bookmark and the working copy, newest first. pub fn log(&self) -> Result> { let (wc_hash, _) = self.working_copy()?; let mut heads = self.bookmark_heads()?; heads.push(wc_hash); let state = self.load_state(&heads)?; let head_set: BTreeSet = self.bookmark_heads()?.into_iter().collect(); let mut entries: Vec = state .changes() .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) }) .collect(); entries.sort_by(|a, b| b.is_working_copy.cmp(&a.is_working_copy).then(b.change.hlc.cmp(&a.change.hlc)).then(b.hash.cmp(&a.hash))); Ok(entries) } /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark. pub fn resolve_change(&self, spec: &str) -> Result { if spec == "@" { return Ok(self.working_copy()?.0); } if spec.is_empty() || spec == "#" { return fail("empty change specifier"); } let entries = self.log()?; let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') { entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect() } else { entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect() }; match matches.len() { 1 => Ok(matches[0].hash), 0 => fail(format!("no change matches {spec:?}")), _ => { let ids: BTreeSet = matches.iter().map(|e| e.change.change_id).collect(); if ids.len() == 1 { fail(format!("change {spec} is divergent: {} variants", matches.len())) } else { fail(format!("{spec:?} is ambiguous")) } } } } /// Human-readable description of what a change does, with line content. pub fn describe_change(&self, hash: &ChangeHash) -> Result { let change = self.store.get_change(hash)?; let base = self.load_state(&change.deps)?; let base_snapshot = base.materialize(); Ok(render_change(&change, &base, &base_snapshot)) } } fn remove_any(path: &Path) -> std::io::Result<()> { let m = fs::symlink_metadata(path)?; if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) } } #[cfg(unix)] fn set_executable(path: &Path, exec: bool) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(path)?.permissions(); let mode = perms.mode(); let want = if exec { mode | 0o111 } else { mode & !0o111 }; if want != mode { perms.set_mode(want); fs::set_permissions(path, perms)?; } Ok(()) } #[cfg(not(unix))] fn set_executable(_path: &Path, _exec: bool) -> std::io::Result<()> { Ok(()) } #[cfg(unix)] fn is_executable(m: &fs::Metadata) -> bool { use std::os::unix::fs::PermissionsExt; m.permissions().mode() & 0o111 != 0 } #[cfg(not(unix))] fn is_executable(_m: &fs::Metadata) -> bool { false } fn op_ref(k: OpKey) -> OpRef { OpRef { change: k.0, index: k.1 } } fn split_path(path: &str) -> (&str, &str) { match path.rfind('/') { Some(i) => (&path[..i], &path[i + 1..]), None => ("", path), } }