Bare repositories and an embeddable protocol server for Oxygenhub e02bf0cd0533
oxy init --bare creates a repository with no workspace and no working copy; log, log_for_path, blame_in and log_state read any state. oxy-server gains Backend, Server, Endpoint and Access so a host resolves and authorises repository names itself; oxy serve is DirBackend on the same server.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 16:44 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e02bf0cd053375a716dfb7dbb83f95f24a868aa1b2536787cb17ada500e284df- Depends on
- 132b884b9a8d 2863456a0f6d 2b5ee371060e 2b872d074ddb 4dee277c327a 4e88e774777a 50a046e439e9 750251b21a96 7a716eb94dee 7b9809e67c3d 94084087a876 957fee30826e 96f7610fa666 982864068a59 b3c5a59a875a c6aa5bd9d1c8 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 80b069facc69 544d0541a7e5 e30c081a5064 f3811eb20bea ee130dd9eef2 67a4fe1aa2d5 c2e1d09047db 79b5a90ffd43 170bb436e1d5 d069701a4997 1ec3f008e96a 14edbaaf5291 ef26f9a28790 073a2115995f 4d6c9c1b358c 69816d688300 be451356e3ef 8c30d814f2cc f8c695764c95 e8d38e47e9d7 bc489b6c7f69 89a1d0243544 d42369abf180 eae595568832 84b9e9ac0ab4 be0161a88dd7 76f752503e7e 119bbf50cb86 7aa2dc9369e3 b0e64e81e4fb 05e71ed6cdf1 75167672ebcd
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 49 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified crates/oxy-core/src/repo/inspect.rs +14 −3
| … | 53 unchanged lines |
||
| 54 | 54 | /// the working copy's base state (spec §27). |
|
| 55 | 55 | pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>> { |
|
| 56 | 56 | let snap = self.snapshot("oxy blame")?; |
|
| 57 | - | let Some(f) = snap.base_snapshot.files.get(path) else { return fail(format!("{path} is not in the working copy's base state")) }; |
|
| 57 | + | self.blame_state(path, &snap.base, &snap.base_snapshot, "the working copy's base state") |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | /// Blame `path` in the state with these heads (hosted repositories). |
|
| 61 | + | pub fn blame_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<BlameLine>> { |
|
| 62 | + | let state = self.load_state(heads)?; |
|
| 63 | + | let snapshot = state.materialize(); |
|
| 64 | + | self.blame_state(path, &state, &snapshot, "that state") |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | fn blame_state(&self, path: &str, state: &crate::state::State, snapshot: &crate::state::Snapshot, what: &str) -> Result<Vec<BlameLine>> { |
|
| 68 | + | let Some(f) = snapshot.files.get(path) else { return fail(format!("{path} is not in {what}")) }; |
|
| 58 | 69 | if !matches!(f.content, FileContent::Text { .. }) { |
|
| 59 | 70 | return fail(format!("{path} is not a text file")); |
|
| 60 | 71 | } |
|
| 61 | 72 | let mut out = Vec::new(); |
|
| 62 | - | for (i, ((hash, index), text)) in snap.base.live_lines(&f.node).into_iter().enumerate() { |
|
| 63 | - | let meta = snap.base.get(&hash); |
|
| 73 | + | for (i, ((hash, index), text)) in state.live_lines(&f.node).into_iter().enumerate() { |
|
| 74 | + | let meta = state.get(&hash); |
|
| 64 | 75 | let author = match meta { |
|
| 65 | 76 | Some(m) if !m.author.is_zero() => self.store.get_identity(&m.author).map(|id| id.name).unwrap_or_else(|_| m.author.short(12)), |
|
| 66 | 77 | Some(m) => m.message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).map(|a| a.split('<').next().unwrap_or(a).trim().to_string()).unwrap_or_else(|| "unknown".into()), |
|
| … | 195 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +76 −10
| … | 135 unchanged lines |
||
| 136 | 136 | oxy: PathBuf, |
|
| 137 | 137 | store: Store, |
|
| 138 | 138 | workspace: WorkspaceId, |
|
| 139 | + | /// A hosted repository with no working copy (spec §12.2, hub spec §4.1). |
|
| 140 | + | bare: bool, |
|
| 139 | 141 | keys_dir: PathBuf, |
|
| 140 | 142 | user_config: PathBuf, |
|
| 141 | 143 | pub config: Config, |
|
| 142 | 144 | } |
|
| 143 | 145 | ||
| 146 | + | /// Marker file of a bare repository, inside `.oxy/`. |
|
| 147 | + | const BARE_MARKER: &str = "bare"; |
|
| 148 | + | ||
| 144 | 149 | /// The user's oxy directory: `$OXY_HOME`, else `$XDG_CONFIG_HOME/oxy`, else |
|
| 145 | 150 | /// `~/.config/oxy`. It holds `config.toml` (spec §19) and `keys/`. |
|
| 146 | 151 | pub fn default_home() -> PathBuf { |
|
| … | 50 unchanged lines |
||
| 197 | 202 | let workspace = WorkspaceId::random(); |
|
| 198 | 203 | write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?; |
|
| 199 | 204 | let store = Store::open(oxy.join("objects"))?; |
|
| 200 | - | let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; |
|
| 205 | + | let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; |
|
| 201 | 206 | repo.register_workspace(&workspace, &repo.root)?; |
|
| 202 | 207 | ||
| 203 | 208 | let wc = repo.new_working_copy(vec![], ""); |
|
| … | 5 unchanged lines |
||
| 209 | 214 | Ok(repo) |
|
| 210 | 215 | } |
|
| 211 | 216 | ||
| 217 | + | /// Create a bare repository: an object store, operation log and |
|
| 218 | + | /// references with no workspace and no working copy, for hosting |
|
| 219 | + | /// (hub spec §4.1). `root` holds `.oxy/` and nothing else. |
|
| 220 | + | pub fn init_bare(root: &Path) -> Result<Repo> { |
|
| 221 | + | let mut id = [0u8; 16]; |
|
| 222 | + | rand::rngs::OsRng.fill_bytes(&mut id); |
|
| 223 | + | Self::init_bare_with_id(root, id) |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | pub fn init_bare_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> { |
|
| 227 | + | fs::create_dir_all(root)?; |
|
| 228 | + | let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
|
| 229 | + | let oxy = root.join(OXY_DIR); |
|
| 230 | + | if oxy.exists() { |
|
| 231 | + | return fail(format!("{} is already an oxy repository", root.display())); |
|
| 232 | + | } |
|
| 233 | + | fs::create_dir_all(oxy.join("ops"))?; |
|
| 234 | + | write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?; |
|
| 235 | + | write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?; |
|
| 236 | + | write_atomic(&oxy.join(BARE_MARKER), b"")?; |
|
| 237 | + | let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; |
|
| 238 | + | config.save(&oxy.join("config"))?; |
|
| 239 | + | let store = Store::open(oxy.join("objects"))?; |
|
| 240 | + | let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace: WorkspaceId::ZERO, bare: true, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; |
|
| 241 | + | let mut view = View::default(); |
|
| 242 | + | view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?); |
|
| 243 | + | repo.record_op(&[], "oxy init --bare", view)?; |
|
| 244 | + | Ok(repo) |
|
| 245 | + | } |
|
| 246 | + | ||
| 247 | + | /// Whether this repository has no working copy (`init_bare`). |
|
| 248 | + | pub fn is_bare(&self) -> bool { |
|
| 249 | + | self.bare |
|
| 250 | + | } |
|
| 251 | + | ||
| 212 | 252 | /// Open the repository containing `start` (searching upwards). |
|
| 213 | 253 | pub fn open(start: &Path) -> Result<Repo> { |
|
| 214 | 254 | let start = start.canonicalize()?; |
|
| … | 12 unchanged lines |
||
| 227 | 267 | } |
|
| 228 | 268 | let config = Config::load(Some(&default_user_config()), &shared.join("config"))?; |
|
| 229 | 269 | let store = Store::open(shared.join("objects"))?; |
|
| 230 | - | let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy: shared, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; |
|
| 270 | + | let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy: shared, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; |
|
| 231 | 271 | repo.apply_workspace_bookmark(); |
|
| 232 | 272 | return Ok(repo); |
|
| 233 | 273 | } |
|
| 234 | 274 | if oxy.is_dir() { |
|
| 235 | 275 | let format = fs::read_to_string(oxy.join("format"))?; |
|
| 236 | 276 | let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; |
|
| 237 | - | let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()) |
|
| 238 | - | .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?; |
|
| 277 | + | let bare = oxy.join(BARE_MARKER).is_file(); |
|
| 278 | + | let workspace = if bare { |
|
| 279 | + | WorkspaceId::ZERO |
|
| 280 | + | } else { |
|
| 281 | + | WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))? |
|
| 282 | + | }; |
|
| 239 | 283 | let store = Store::open(oxy.join("objects"))?; |
|
| 240 | - | let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; |
|
| 241 | - | repo.apply_workspace_bookmark(); |
|
| 284 | + | let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy, store, workspace, bare, keys_dir: default_keys_dir(), user_config: default_user_config(), config }; |
|
| 285 | + | if !bare { |
|
| 286 | + | repo.apply_workspace_bookmark(); |
|
| 287 | + | } |
|
| 242 | 288 | if format == FORMAT1_LINE { |
|
| 243 | 289 | repo.migrate_format1()?; |
|
| 244 | 290 | } else if format != FORMAT_LINE { |
|
| … | 222 unchanged lines |
||
| 467 | 513 | } |
|
| 468 | 514 | ||
| 469 | 515 | pub fn working_copy(&self) -> Result<(ChangeHash, Change)> { |
|
| 516 | + | if self.bare { |
|
| 517 | + | return fail("this is a bare repository: it has no working copy"); |
|
| 518 | + | } |
|
| 470 | 519 | let view = self.view()?; |
|
| 471 | 520 | let h = *view |
|
| 472 | 521 | .workspaces |
|
| … | 286 unchanged lines |
||
| 759 | 808 | impl Repo { |
|
| 760 | 809 | /// Changes reachable from the bookmark and the working copy, newest first. |
|
| 761 | 810 | pub fn log(&self) -> Result<Vec<LogEntry>> { |
|
| 811 | + | let heads = self.bookmark_heads()?; |
|
| 812 | + | if self.bare { |
|
| 813 | + | return self.log_state(&heads); |
|
| 814 | + | } |
|
| 762 | 815 | let (wc_hash, _) = self.working_copy()?; |
|
| 763 | - | let mut heads = self.bookmark_heads()?; |
|
| 764 | - | heads.push(wc_hash); |
|
| 765 | - | let state = self.load_state(&heads)?; |
|
| 766 | - | let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect(); |
|
| 816 | + | let mut all = heads.clone(); |
|
| 817 | + | all.push(wc_hash); |
|
| 818 | + | let state = self.load_state(&all)?; |
|
| 819 | + | let head_set: BTreeSet<ChangeHash> = heads.into_iter().collect(); |
|
| 767 | 820 | let mut entries: Vec<LogEntry> = Vec::new(); |
|
| 768 | 821 | for (h, meta) in state.changes() { |
|
| 769 | 822 | entries.push(LogEntry { hash: *h, change: meta.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) }); |
|
| … | 2 unchanged lines |
||
| 772 | 825 | Ok(entries) |
|
| 773 | 826 | } |
|
| 774 | 827 | ||
| 828 | + | /// The log of the state with these heads, newest first, with no working |
|
| 829 | + | /// copy involved (for hosted repositories and any explicit state). |
|
| 830 | + | pub fn log_state(&self, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> { |
|
| 831 | + | let state = self.load_state(heads)?; |
|
| 832 | + | let head_set: BTreeSet<ChangeHash> = state.heads().into_iter().collect(); |
|
| 833 | + | let mut entries: Vec<LogEntry> = state |
|
| 834 | + | .changes() |
|
| 835 | + | .map(|(h, meta)| LogEntry { hash: *h, change: meta.clone(), is_working_copy: false, is_head: head_set.contains(h) }) |
|
| 836 | + | .collect(); |
|
| 837 | + | entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); |
|
| 838 | + | Ok(entries) |
|
| 839 | + | } |
|
| 840 | + | ||
| 775 | 841 | /// Resolve a spec to a set of heads: a tag name, the current bookmark's |
|
| 776 | 842 | /// name, or a single change (`resolve_change`). |
|
| 777 | 843 | pub fn resolve_heads(&self, spec: &str) -> Result<Vec<ChangeHash>> { |
|
| … | 102 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +18 −2
| … | 764 unchanged lines |
||
| 765 | 765 | /// The log restricted to changes touching `path` (its node, so renames |
|
| 766 | 766 | /// are followed), on the current bookmark. |
|
| 767 | 767 | pub fn log_for_path(&self, path: &str) -> Result<Vec<LogEntry>> { |
|
| 768 | + | let heads = self.bookmark_heads()?; |
|
| 769 | + | if self.is_bare() { |
|
| 770 | + | return self.log_for_path_in(path, &heads); |
|
| 771 | + | } |
|
| 768 | 772 | let entries = self.log()?; |
|
| 769 | 773 | let (_, wc) = self.working_copy()?; |
|
| 770 | 774 | let base_snapshot = self.load_state(&wc.deps)?.materialize(); |
|
| 771 | - | let heads = self.bookmark_heads()?; |
|
| 772 | 775 | let state_snapshot = self.load_state(&heads)?.materialize(); |
|
| 773 | 776 | let node = base_snapshot.files.get(path).or_else(|| state_snapshot.files.get(path)).map(|f| f.node); |
|
| 774 | 777 | let Some(node) = node else { return fail(format!("{path} is not tracked")) }; |
|
| 778 | + | Ok(self.entries_touching(entries, node)) |
|
| 779 | + | } |
|
| 780 | + | ||
| 781 | + | /// The log of the state with these heads restricted to changes touching |
|
| 782 | + | /// `path` (its node, so renames are followed). |
|
| 783 | + | pub fn log_for_path_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> { |
|
| 784 | + | let entries = self.log_state(heads)?; |
|
| 785 | + | let snapshot = self.load_state(heads)?.materialize(); |
|
| 786 | + | let Some(node) = snapshot.files.get(path).map(|f| f.node) else { return fail(format!("{path} is not in that state")) }; |
|
| 787 | + | Ok(self.entries_touching(entries, node)) |
|
| 788 | + | } |
|
| 789 | + | ||
| 790 | + | fn entries_touching(&self, entries: Vec<LogEntry>, node: NodeId) -> Vec<LogEntry> { |
|
| 775 | 791 | let mut out = Vec::new(); |
|
| 776 | 792 | for e in entries { |
|
| 777 | 793 | let Ok(c) = self.store.get_change(&e.hash) else { continue }; |
|
| … | 1 unchanged lines |
||
| 779 | 795 | out.push(e); |
|
| 780 | 796 | } |
|
| 781 | 797 | } |
|
| 782 | - | Ok(out) |
|
| 798 | + | out |
|
| 783 | 799 | } |
|
| 784 | 800 | } |
|
| 785 | 801 | ||
| … | 608 unchanged lines |
modified crates/oxy-server/src/lib.rs +191 −111
| … | 5 unchanged lines |
||
| 6 | 6 | //! |
|
| 7 | 7 | //! Authentication: when bearer tokens are configured, `push` requires one; |
|
| 8 | 8 | //! reads are open. With no tokens configured everything is open (development). |
|
| 9 | + | //! |
|
| 10 | + | //! Hosts embed the protocol by implementing [`Backend`] (name resolution |
|
| 11 | + | //! and authorization) and routing to [`Server::handle`]; `router` is the |
|
| 12 | + | //! standalone server built on [`DirBackend`]. |
|
| 9 | 13 | ||
| 10 | 14 | use axum::body::Bytes; |
|
| 11 | 15 | use axum::extract::{Path, State}; |
|
| 12 | - | use axum::http::{header, HeaderMap, StatusCode}; |
|
| 16 | + | use axum::http::{header, HeaderMap, Method, StatusCode}; |
|
| 13 | 17 | use axum::response::{IntoResponse, Response}; |
|
| 14 | - | use axum::routing::{get, post}; |
|
| 18 | + | use axum::routing::any; |
|
| 15 | 19 | use axum::Router; |
|
| 16 | 20 | use oxy_core::protocol::{wire, CONTENT_TYPE}; |
|
| 17 | 21 | use oxy_core::repo::Repo; |
|
| … | 17 unchanged lines |
||
| 35 | 39 | pub key: PathBuf, |
|
| 36 | 40 | } |
|
| 37 | 41 | ||
| 38 | - | struct App { |
|
| 39 | - | cfg: ServerConfig, |
|
| 40 | - | /// One lock per repository so concurrent pushes serialize. |
|
| 42 | + | /// Which kind of access a request needs (spec §15.1: reads may be open; |
|
| 43 | + | /// a push must be authenticated when any credential is configured). |
|
| 44 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 45 | + | pub enum Access { |
|
| 46 | + | Read, |
|
| 47 | + | Push, |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// What a host provides to the protocol server: how a repository name maps |
|
| 51 | + | /// to a repository on disk and who may do what. `oxy serve` uses |
|
| 52 | + | /// [`DirBackend`]; a hub supplies its own with accounts and roles. |
|
| 53 | + | pub trait Backend: Send + Sync + 'static { |
|
| 54 | + | /// Open the repository called `name`, or fail with the status to |
|
| 55 | + | /// answer. An unknown name is `404`; a name the caller may not see |
|
| 56 | + | /// SHOULD also be `404`, so private repositories are not enumerable. |
|
| 57 | + | fn open(&self, name: &str) -> Result<Repo, Failure>; |
|
| 58 | + | ||
| 59 | + | /// Decide whether this request may perform `access` on `name`. |
|
| 60 | + | fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>; |
|
| 61 | + | ||
| 62 | + | /// Called after a push was processed, with its per-reference results |
|
| 63 | + | /// (accepted or refused), for logs and cache invalidation. |
|
| 64 | + | fn after_push(&self, _name: &str, _results: &[oxy_core::protocol::RefResult]) {} |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | /// A protocol server over any [`Backend`], serialising the pushes to each |
|
| 68 | + | /// repository. Hosts route requests to [`Server::handle`]. |
|
| 69 | + | pub struct Server { |
|
| 70 | + | backend: Box<dyn Backend>, |
|
| 41 | 71 | locks: Mutex<HashMap<String, Arc<Mutex<()>>>>, |
|
| 42 | 72 | } |
|
| 43 | 73 | ||
| 44 | - | type Shared = Arc<App>; |
|
| 45 | - | ||
| 46 | - | struct Failure(StatusCode, String); |
|
| 74 | + | /// An error response: a status and a plain-text body. |
|
| 75 | + | #[derive(Debug)] |
|
| 76 | + | pub struct Failure(pub StatusCode, pub String); |
|
| 47 | 77 | ||
| 48 | 78 | impl IntoResponse for Failure { |
|
| 49 | 79 | fn into_response(self) -> Response { |
|
| … | 7 unchanged lines |
||
| 57 | 87 | } |
|
| 58 | 88 | } |
|
| 59 | 89 | ||
| 90 | + | /// The wire protocol's endpoints (spec §16.1), named as in the URL. |
|
| 91 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 92 | + | pub enum Endpoint { |
|
| 93 | + | Capabilities, |
|
| 94 | + | Refs, |
|
| 95 | + | Negotiate, |
|
| 96 | + | Fetch, |
|
| 97 | + | Objects, |
|
| 98 | + | Checkpoint, |
|
| 99 | + | Shallow, |
|
| 100 | + | Push, |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | impl Endpoint { |
|
| 104 | + | pub fn parse(name: &str) -> Option<Endpoint> { |
|
| 105 | + | Some(match name { |
|
| 106 | + | "capabilities" => Endpoint::Capabilities, |
|
| 107 | + | "refs" => Endpoint::Refs, |
|
| 108 | + | "negotiate" => Endpoint::Negotiate, |
|
| 109 | + | "fetch" => Endpoint::Fetch, |
|
| 110 | + | "objects" => Endpoint::Objects, |
|
| 111 | + | "checkpoint" => Endpoint::Checkpoint, |
|
| 112 | + | "shallow" => Endpoint::Shallow, |
|
| 113 | + | "push" => Endpoint::Push, |
|
| 114 | + | _ => return None, |
|
| 115 | + | }) |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | pub fn name(self) -> &'static str { |
|
| 119 | + | match self { |
|
| 120 | + | Endpoint::Capabilities => "capabilities", |
|
| 121 | + | Endpoint::Refs => "refs", |
|
| 122 | + | Endpoint::Negotiate => "negotiate", |
|
| 123 | + | Endpoint::Fetch => "fetch", |
|
| 124 | + | Endpoint::Objects => "objects", |
|
| 125 | + | Endpoint::Checkpoint => "checkpoint", |
|
| 126 | + | Endpoint::Shallow => "shallow", |
|
| 127 | + | Endpoint::Push => "push", |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | /// The HTTP method the endpoint answers to. |
|
| 132 | + | pub fn method(self) -> Method { |
|
| 133 | + | match self { |
|
| 134 | + | Endpoint::Capabilities | Endpoint::Refs => Method::GET, |
|
| 135 | + | _ => Method::POST, |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | fn access(self) -> Access { |
|
| 140 | + | match self { |
|
| 141 | + | Endpoint::Push => Access::Push, |
|
| 142 | + | _ => Access::Read, |
|
| 143 | + | } |
|
| 144 | + | } |
|
| 145 | + | } |
|
| 146 | + | ||
| 60 | 147 | fn ok(body: Vec<u8>) -> Response { |
|
| 61 | 148 | ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response() |
|
| 62 | 149 | } |
|
| 63 | 150 | ||
| 64 | - | fn valid_repo_name(name: &str) -> bool { |
|
| 151 | + | pub fn valid_repo_name(name: &str) -> bool { |
|
| 65 | 152 | !name.is_empty() |
|
| 66 | 153 | && !name.starts_with('.') |
|
| 67 | 154 | && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) |
|
| 68 | 155 | } |
|
| 69 | 156 | ||
| 70 | - | impl App { |
|
| 157 | + | impl Server { |
|
| 158 | + | pub fn new(backend: impl Backend) -> Arc<Server> { |
|
| 159 | + | Arc::new(Server { backend: Box::new(backend), locks: Mutex::new(HashMap::new()) }) |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | pub fn backend(&self) -> &dyn Backend { |
|
| 163 | + | self.backend.as_ref() |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | fn lock_for(&self, name: &str) -> Arc<Mutex<()>> { |
|
| 167 | + | self.locks.lock().unwrap().entry(name.to_string()).or_default().clone() |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | /// Answer one protocol request for repository `name`. `method` is |
|
| 171 | + | /// checked against the endpoint; the body is the raw CBOR request. |
|
| 172 | + | pub async fn handle(self: &Arc<Self>, name: String, endpoint: Endpoint, method: &Method, headers: &HeaderMap, body: Bytes) -> Response { |
|
| 173 | + | if *method != endpoint.method() { |
|
| 174 | + | return Failure(StatusCode::METHOD_NOT_ALLOWED, format!("{} is {}", endpoint.name(), endpoint.method())).into_response(); |
|
| 175 | + | } |
|
| 176 | + | if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) { |
|
| 177 | + | return f.into_response(); |
|
| 178 | + | } |
|
| 179 | + | let server = Arc::clone(self); |
|
| 180 | + | let result = tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body)).await; |
|
| 181 | + | match result { |
|
| 182 | + | Ok(Ok(r)) => r, |
|
| 183 | + | Ok(Err(f)) => f.into_response(), |
|
| 184 | + | Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), |
|
| 185 | + | } |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | fn respond(&self, name: &str, endpoint: Endpoint, body: Bytes) -> Result<Response, Failure> { |
|
| 189 | + | let r = self.backend.open(name)?; |
|
| 190 | + | Ok(match endpoint { |
|
| 191 | + | Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)), |
|
| 192 | + | Endpoint::Refs => ok(wire::encode_refs(r.id()?, &r.remote_refs()?)), |
|
| 193 | + | Endpoint::Negotiate => { |
|
| 194 | + | let (have, want) = wire::decode_negotiate(&body)?; |
|
| 195 | + | ok(wire::encode_hashes(&r.negotiate(&have, &want)?)) |
|
| 196 | + | } |
|
| 197 | + | Endpoint::Fetch => { |
|
| 198 | + | let (changes, max_blob) = wire::decode_fetch(&body)?; |
|
| 199 | + | ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?)) |
|
| 200 | + | } |
|
| 201 | + | Endpoint::Objects => { |
|
| 202 | + | let refs = wire::decode_hashes(&body)?; |
|
| 203 | + | let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() }; |
|
| 204 | + | ok(wire::encode_bundle(&bundle)) |
|
| 205 | + | } |
|
| 206 | + | Endpoint::Checkpoint => { |
|
| 207 | + | let heads = wire::decode_hashes(&body)?; |
|
| 208 | + | let lock = self.lock_for(name); |
|
| 209 | + | let _guard = lock.lock().unwrap(); |
|
| 210 | + | ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?)) |
|
| 211 | + | } |
|
| 212 | + | Endpoint::Shallow => { |
|
| 213 | + | let (heads, depth) = wire::decode_shallow(&body)?; |
|
| 214 | + | ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?)) |
|
| 215 | + | } |
|
| 216 | + | Endpoint::Push => { |
|
| 217 | + | let (bundle, updates, policy, tags) = wire::decode_push(&body)?; |
|
| 218 | + | let lock = self.lock_for(name); |
|
| 219 | + | let _guard = lock.lock().unwrap(); |
|
| 220 | + | let results = r.receive_push(bundle, updates, policy, tags)?; |
|
| 221 | + | self.backend.after_push(name, &results); |
|
| 222 | + | ok(wire::encode_push_response(&results)) |
|
| 223 | + | } |
|
| 224 | + | }) |
|
| 225 | + | } |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | /// The backend of `oxy serve`: one repository per subdirectory of a root, |
|
| 229 | + | /// open reads, and pushes gated by a list of bearer tokens. |
|
| 230 | + | pub struct DirBackend { |
|
| 231 | + | pub root: PathBuf, |
|
| 232 | + | pub tokens: Vec<String>, |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | impl Backend for DirBackend { |
|
| 71 | 236 | fn open(&self, name: &str) -> Result<Repo, Failure> { |
|
| 72 | 237 | if !valid_repo_name(name) { |
|
| 73 | 238 | return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())); |
|
| 74 | 239 | } |
|
| 75 | - | let dir = self.cfg.root.join(name); |
|
| 240 | + | let dir = self.root.join(name); |
|
| 76 | 241 | if !dir.join(".oxy").is_dir() { |
|
| 77 | 242 | return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())); |
|
| 78 | 243 | } |
|
| 79 | 244 | Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) |
|
| 80 | 245 | } |
|
| 81 | 246 | ||
| 82 | - | fn lock_for(&self, name: &str) -> Arc<Mutex<()>> { |
|
| 83 | - | self.locks.lock().unwrap().entry(name.to_string()).or_default().clone() |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | fn authorize_push(&self, headers: &HeaderMap) -> Result<(), Failure> { |
|
| 87 | - | if self.cfg.tokens.is_empty() { |
|
| 247 | + | fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> { |
|
| 248 | + | if access == Access::Read || self.tokens.is_empty() { |
|
| 88 | 249 | return Ok(()); |
|
| 89 | 250 | } |
|
| 90 | - | let presented = headers |
|
| 91 | - | .get(header::AUTHORIZATION) |
|
| 92 | - | .and_then(|v| v.to_str().ok()) |
|
| 93 | - | .and_then(|v| v.strip_prefix("Bearer ")) |
|
| 94 | - | .map(str::trim); |
|
| 95 | - | match presented { |
|
| 96 | - | Some(t) if self.cfg.tokens.iter().any(|x| x == t) => Ok(()), |
|
| 251 | + | match bearer_token(headers) { |
|
| 252 | + | Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()), |
|
| 97 | 253 | _ => Err(Failure(StatusCode::UNAUTHORIZED, "a valid bearer token is required to push".into())), |
|
| 98 | 254 | } |
|
| 99 | 255 | } |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | async fn blocking<T: Send + 'static>(f: impl FnOnce() -> Result<T, Failure> + Send + 'static) -> Result<T, Failure> { |
|
| 103 | - | tokio::task::spawn_blocking(f).await.map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 104 | 256 | } |
|
| 105 | 257 | ||
| 106 | - | async fn capabilities(State(app): State<Shared>, Path(repo): Path<String>) -> Result<Response, Failure> { |
|
| 107 | - | blocking(move || { |
|
| 108 | - | let r = app.open(&repo)?; |
|
| 109 | - | Ok(ok(wire::encode_capabilities(r.id()?))) |
|
| 110 | - | }) |
|
| 111 | - | .await |
|
| 112 | - | } |
|
| 113 | - | ||
| 114 | - | async fn refs(State(app): State<Shared>, Path(repo): Path<String>) -> Result<Response, Failure> { |
|
| 115 | - | blocking(move || { |
|
| 116 | - | let r = app.open(&repo)?; |
|
| 117 | - | Ok(ok(wire::encode_refs(r.id()?, &r.remote_refs()?))) |
|
| 118 | - | }) |
|
| 119 | - | .await |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | async fn negotiate(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 123 | - | blocking(move || { |
|
| 124 | - | let r = app.open(&repo)?; |
|
| 125 | - | let (have, want) = wire::decode_negotiate(&body)?; |
|
| 126 | - | Ok(ok(wire::encode_hashes(&r.negotiate(&have, &want)?))) |
|
| 127 | - | }) |
|
| 128 | - | .await |
|
| 129 | - | } |
|
| 130 | - | ||
| 131 | - | async fn fetch(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 132 | - | blocking(move || { |
|
| 133 | - | let r = app.open(&repo)?; |
|
| 134 | - | let (changes, max_blob) = wire::decode_fetch(&body)?; |
|
| 135 | - | Ok(ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?))) |
|
| 136 | - | }) |
|
| 137 | - | .await |
|
| 138 | - | } |
|
| 139 | - | ||
| 140 | - | async fn checkpoint(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 141 | - | blocking(move || { |
|
| 142 | - | let r = app.open(&repo)?; |
|
| 143 | - | let heads = wire::decode_hashes(&body)?; |
|
| 144 | - | let lock = app.lock_for(&repo); |
|
| 145 | - | let _guard = lock.lock().unwrap(); |
|
| 146 | - | Ok(ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))) |
|
| 147 | - | }) |
|
| 148 | - | .await |
|
| 149 | - | } |
|
| 150 | - | ||
| 151 | - | async fn shallow(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 152 | - | blocking(move || { |
|
| 153 | - | let r = app.open(&repo)?; |
|
| 154 | - | let (heads, depth) = wire::decode_shallow(&body)?; |
|
| 155 | - | Ok(ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))) |
|
| 156 | - | }) |
|
| 157 | - | .await |
|
| 158 | - | } |
|
| 159 | - | ||
| 160 | - | async fn objects(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 161 | - | blocking(move || { |
|
| 162 | - | let r = app.open(&repo)?; |
|
| 163 | - | let refs = wire::decode_hashes(&body)?; |
|
| 164 | - | let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() }; |
|
| 165 | - | Ok(ok(wire::encode_bundle(&bundle))) |
|
| 166 | - | }) |
|
| 167 | - | .await |
|
| 258 | + | /// The token of an `Authorization: Bearer` header, if any. |
|
| 259 | + | pub fn bearer_token(headers: &HeaderMap) -> Option<&str> { |
|
| 260 | + | headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).map(str::trim) |
|
| 168 | 261 | } |
|
| 169 | 262 | ||
| 170 | - | async fn push(State(app): State<Shared>, Path(repo): Path<String>, headers: HeaderMap, body: Bytes) -> Result<Response, Failure> { |
|
| 171 | - | app.authorize_push(&headers)?; |
|
| 172 | - | blocking(move || { |
|
| 173 | - | let r = app.open(&repo)?; |
|
| 174 | - | let (bundle, updates, policy, tags) = wire::decode_push(&body)?; |
|
| 175 | - | let lock = app.lock_for(&repo); |
|
| 176 | - | let _guard = lock.lock().unwrap(); |
|
| 177 | - | Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy, tags)?))) |
|
| 178 | - | }) |
|
| 179 | - | .await |
|
| 263 | + | async fn dispatch(State(server): State<Arc<Server>>, Path((repo, endpoint)): Path<(String, String)>, method: Method, headers: HeaderMap, body: Bytes) -> Response { |
|
| 264 | + | match Endpoint::parse(&endpoint) { |
|
| 265 | + | Some(e) => server.handle(repo, e, &method, &headers, body).await, |
|
| 266 | + | None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(), |
|
| 267 | + | } |
|
| 180 | 268 | } |
|
| 181 | 269 | ||
| 270 | + | /// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`]. |
|
| 182 | 271 | pub fn router(cfg: ServerConfig) -> Router { |
|
| 183 | - | let app: Shared = Arc::new(App { cfg, locks: Mutex::new(HashMap::new()) }); |
|
| 184 | - | Router::new() |
|
| 185 | - | .route("/{repo}/oxy/v1/capabilities", get(capabilities)) |
|
| 186 | - | .route("/{repo}/oxy/v1/refs", get(refs)) |
|
| 187 | - | .route("/{repo}/oxy/v1/negotiate", post(negotiate)) |
|
| 188 | - | .route("/{repo}/oxy/v1/fetch", post(fetch)) |
|
| 189 | - | .route("/{repo}/oxy/v1/objects", post(objects)) |
|
| 190 | - | .route("/{repo}/oxy/v1/checkpoint", post(checkpoint)) |
|
| 191 | - | .route("/{repo}/oxy/v1/shallow", post(shallow)) |
|
| 192 | - | .route("/{repo}/oxy/v1/push", post(push)) |
|
| 193 | - | .with_state(app) |
|
| 272 | + | let server = Server::new(DirBackend { root: cfg.root, tokens: cfg.tokens }); |
|
| 273 | + | Router::new().route("/{repo}/oxy/v1/{endpoint}", any(dispatch)).with_state(server) |
|
| 194 | 274 | } |
|
| 195 | 275 | ||
| 196 | 276 | /// Serve on an already-bound listener until the process ends; HTTPS when |
|
| … | 23 unchanged lines |
modified crates/oxy-server/tests/http.rs +130 −0
| … | 235 unchanged lines |
||
| 236 | 236 | assert!(ops.len() > N); |
|
| 237 | 237 | } |
|
| 238 | 238 | ||
| 239 | + | ||
| 240 | + | #[test] |
|
| 241 | + | fn a_bare_repository_is_served_and_browsed_without_a_working_copy() { |
|
| 242 | + | hermetic(); |
|
| 243 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 244 | + | let root = dir.path(); |
|
| 245 | + | let served = root.join("served"); |
|
| 246 | + | let hosted = Repo::init_bare(&served.join("lib")).unwrap(); |
|
| 247 | + | assert!(hosted.is_bare()); |
|
| 248 | + | assert!(hosted.working_copy().is_err(), "a bare repository has no working copy"); |
|
| 249 | + | assert!(!served.join("lib").join(".oxy").join("workspace").exists()); |
|
| 250 | + | let base = start(&served, vec![]); |
|
| 251 | + | let url = format!("{base}/lib"); |
|
| 252 | + | ||
| 253 | + | // A client clones the empty repository, commits twice, and pushes. |
|
| 254 | + | let work = root.join("work"); |
|
| 255 | + | let client = Repo::clone(&url, &work).unwrap(); |
|
| 256 | + | write(&work, "src/lib.rs", b"pub fn one() {}\n"); |
|
| 257 | + | client.snapshot("test").unwrap(); |
|
| 258 | + | let c1 = client.commit("one", &[]).unwrap(); |
|
| 259 | + | write(&work, "src/lib.rs", b"pub fn one() {}\npub fn two() {}\n"); |
|
| 260 | + | client.snapshot("test").unwrap(); |
|
| 261 | + | let c2 = client.commit("two", &[]).unwrap(); |
|
| 262 | + | let results = client.push("origin", false).unwrap(); |
|
| 263 | + | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 264 | + | ||
| 265 | + | // The host reads history, files and blame from the bookmark's state alone. |
|
| 266 | + | let hosted = Repo::open(&served.join("lib")).unwrap(); |
|
| 267 | + | assert!(hosted.is_bare()); |
|
| 268 | + | let heads = hosted.bookmark_heads().unwrap(); |
|
| 269 | + | assert_eq!(heads, vec![c2]); |
|
| 270 | + | let log = hosted.log().unwrap(); |
|
| 271 | + | assert_eq!(log.iter().map(|e| e.hash).collect::<Vec<_>>(), vec![c2, c1]); |
|
| 272 | + | assert!(log.iter().all(|e| !e.is_working_copy)); |
|
| 273 | + | assert_eq!(hosted.log_state(&[c1]).unwrap().len(), 1); |
|
| 274 | + | assert_eq!(hosted.file_at("src/lib.rs", Some(&heads)).unwrap(), b"pub fn one() {}\npub fn two() {}\n"); |
|
| 275 | + | let blame = hosted.blame_in("src/lib.rs", &heads).unwrap(); |
|
| 276 | + | assert_eq!(blame.iter().map(|l| l.hash).collect::<Vec<_>>(), vec![c1, c2]); |
|
| 277 | + | assert_eq!(hosted.log_for_path("src/lib.rs").unwrap().len(), 2); |
|
| 278 | + | assert_eq!(hosted.diff_files(Some(&c2)).unwrap().len(), 1); |
|
| 279 | + | assert!(matches!(hosted.verify_change(&c2).unwrap(), SignatureStatus::Unsigned | SignatureStatus::Valid { .. })); |
|
| 280 | + | ||
| 281 | + | // Snapshotting a bare repository is refused rather than inventing a working copy. |
|
| 282 | + | assert!(hosted.snapshot("test").is_err()); |
|
| 283 | + | } |
|
| 284 | + | ||
| 285 | + | /// A host embeds the protocol with its own backend: names with an owner |
|
| 286 | + | /// segment, reads gated per repository, and a push hook. |
|
| 287 | + | #[test] |
|
| 288 | + | fn a_custom_backend_embeds_the_protocol() { |
|
| 289 | + | use axum::extract::{Path as AxPath, State}; |
|
| 290 | + | use axum::http::{HeaderMap, Method, StatusCode}; |
|
| 291 | + | use axum::response::{IntoResponse, Response}; |
|
| 292 | + | use oxy_server::{Access, Backend, Endpoint, Failure, Server}; |
|
| 293 | + | use std::sync::{Arc, Mutex}; |
|
| 294 | + | ||
| 295 | + | struct Hub { |
|
| 296 | + | root: std::path::PathBuf, |
|
| 297 | + | pushes: Arc<Mutex<Vec<String>>>, |
|
| 298 | + | } |
|
| 299 | + | impl Backend for Hub { |
|
| 300 | + | fn open(&self, name: &str) -> Result<Repo, Failure> { |
|
| 301 | + | let Some((owner, repo)) = name.split_once('/') else { return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())) }; |
|
| 302 | + | let dir = self.root.join(owner).join(repo); |
|
| 303 | + | if !dir.join(".oxy").is_dir() { |
|
| 304 | + | return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())); |
|
| 305 | + | } |
|
| 306 | + | Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) |
|
| 307 | + | } |
|
| 308 | + | fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> { |
|
| 309 | + | // `secret/*` is private: unknown to anyone without the token, even for reads. |
|
| 310 | + | let token = oxy_server::bearer_token(headers); |
|
| 311 | + | if name.starts_with("secret/") && token != Some("s3cret") { |
|
| 312 | + | return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())); |
|
| 313 | + | } |
|
| 314 | + | if access == Access::Push && token.is_none() { |
|
| 315 | + | return Err(Failure(StatusCode::UNAUTHORIZED, "sign in to push".into())); |
|
| 316 | + | } |
|
| 317 | + | Ok(()) |
|
| 318 | + | } |
|
| 319 | + | fn after_push(&self, name: &str, results: &[oxy_core::protocol::RefResult]) { |
|
| 320 | + | self.pushes.lock().unwrap().push(format!("{name}: {}", results.iter().filter(|r| r.ok).count())); |
|
| 321 | + | } |
|
| 322 | + | } |
|
| 323 | + | async fn route(State(server): State<Arc<Server>>, AxPath((owner, repo, endpoint)): AxPath<(String, String, String)>, method: Method, headers: HeaderMap, body: axum::body::Bytes) -> Response { |
|
| 324 | + | match Endpoint::parse(&endpoint) { |
|
| 325 | + | Some(e) => server.handle(format!("{owner}/{repo}"), e, &method, &headers, body).await, |
|
| 326 | + | None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(), |
|
| 327 | + | } |
|
| 328 | + | } |
|
| 329 | + | ||
| 330 | + | hermetic(); |
|
| 331 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 332 | + | let root = dir.path(); |
|
| 333 | + | Repo::init_bare(&root.join("repos/andy/app")).unwrap(); |
|
| 334 | + | Repo::init_bare(&root.join("repos/secret/plans")).unwrap(); |
|
| 335 | + | let pushes = Arc::new(Mutex::new(Vec::new())); |
|
| 336 | + | let server = Server::new(Hub { root: root.join("repos"), pushes: pushes.clone() }); |
|
| 337 | + | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
|
| 338 | + | let port = listener.local_addr().unwrap().port(); |
|
| 339 | + | std::thread::spawn(move || { |
|
| 340 | + | let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap(); |
|
| 341 | + | rt.block_on(async move { |
|
| 342 | + | listener.set_nonblocking(true).unwrap(); |
|
| 343 | + | let app = axum::Router::new().route("/{owner}/{repo}/oxy/v1/{endpoint}", axum::routing::any(route)).with_state(server); |
|
| 344 | + | axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app).await.unwrap(); |
|
| 345 | + | }); |
|
| 346 | + | }); |
|
| 347 | + | std::thread::sleep(std::time::Duration::from_millis(50)); |
|
| 348 | + | let base = format!("http://127.0.0.1:{port}"); |
|
| 349 | + | ||
| 350 | + | // Anonymous clone of the public repository works; the private one does not exist for outsiders. |
|
| 351 | + | let work = root.join("work"); |
|
| 352 | + | let client = Repo::clone(&format!("{base}/andy/app"), &work).unwrap(); |
|
| 353 | + | assert!(Repo::clone(&format!("{base}/secret/plans"), &root.join("nope")).is_err()); |
|
| 354 | + | ||
| 355 | + | // A push needs a token; with one it lands and the hook sees it. |
|
| 356 | + | write(&work, "a.txt", b"a\n"); |
|
| 357 | + | client.snapshot("test").unwrap(); |
|
| 358 | + | client.commit("a", &[]).unwrap(); |
|
| 359 | + | assert!(client.push("origin", false).is_err(), "anonymous push must be refused"); |
|
| 360 | + | let mut client = client; |
|
| 361 | + | client.config.set("remote.origin.token", "s3cret").unwrap(); |
|
| 362 | + | let results = client.push("origin", false).unwrap(); |
|
| 363 | + | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 364 | + | assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1"]); |
|
| 365 | + | ||
| 366 | + | let hosted = Repo::open(&root.join("repos/andy/app")).unwrap(); |
|
| 367 | + | assert_eq!(hosted.bookmark_heads().unwrap().len(), 1); |
|
| 368 | + | } |
modified docs/plan.md +11 −0
| … | 77 unchanged lines |
||
| 78 | 78 | - 2026-09-27 01:05 iteration 3: sparse checkout. 94 tests. |
|
| 79 | 79 | - 2026-09-27 00:45 iteration 2: lazy blobs and objects endpoint. 93 tests. |
|
| 80 | 80 | - 2026-09-27 00:05 iteration 1: checkpoints, pack merging, CLI presentation, shell prompt. 91 tests. |
|
| 81 | + | ||
| 82 | + | ## Phase 4 (2026-09-27): what Oxygenhub needs (oxygenhub/docs/spec.md section 18) |
|
| 83 | + | ||
| 84 | + | - [x] Bare repositories: `Repo::init_bare`, `is_bare`, `oxy init --bare`; `working_copy` refuses; `log`, `log_for_path` work from the bookmark state; `log_state`, `blame_in`, `log_for_path_in` for any state. |
|
| 85 | + | - [x] Embeddable server: `oxy_server::{Backend, Server, Endpoint, Access, Failure, DirBackend, bearer_token}`; `router` is `DirBackend` on `/{repo}/oxy/v1/{endpoint}`; `after_push` hook. |
|
| 86 | + | - [ ] `oxy identity sign-challenge` and `verify_challenge` (hub 0.2). |
|
| 87 | + | - [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3). |
|
| 88 | + | - [ ] `oxy proposal show|land URL` (hub 0.3). |
|
| 89 | + | - [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6). |
|
| 90 | + | - [ ] `Repo::log_window` and a shared `graph::layout` (hub 0.1 uses `log_state` and its own layout until then). |
|
| 91 | + | - [ ] `receive_push` reporting old/new state and new changes per reference (hub 0.2 pushes log). |
modified docs/protocol.md +6 −0
| … | 57 unchanged lines |
||
| 58 | 58 | self-signed certificates are expected to hand that file out. |
|
| 59 | 59 | - Pushes to one repository are serialised by the server; reads are not. |
|
| 60 | 60 | ||
| 61 | + | Embedding: `oxy_server::Server` answers any endpoint for a repository name |
|
| 62 | + | a host's `Backend` resolves and authorises (`open`, `authorize`, and an |
|
| 63 | + | `after_push` hook), so a hub with `owner/name` paths and its own accounts |
|
| 64 | + | routes `/{owner}/{name}/oxy/v1/{endpoint}` to `Server::handle`; `oxy serve` |
|
| 65 | + | is `DirBackend` on that same server. |
|
| 66 | + | ||
| 61 | 67 | The spec (§16) also defines framing for Noise and ZCT transports (`u32 |
|
| 62 | 68 | big-endian length || bytes`); the reference implementation speaks HTTP only. |
|
| 63 | 69 | ||
| … | 125 unchanged lines |
modified docs/spec.md +4 −4
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8b | |
|
| 5 | + | | **Status** | Draft 0.8c | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 466 unchanged lines |
||
| 475 | 475 | ||
| 476 | 476 | ### 12.2 Workspaces |
|
| 477 | 477 | ||
| 478 | - | A repository has one or more workspaces, each with a `WorkspaceId` (128-bit random) and a checkout directory. Each workspace has exactly one **working-copy change** at any time. The primary workspace is the directory holding `.oxy/`; a secondary workspace holds a `.oxy` file instead, `oxydir: <path>` and `workspace: <id>`, pointing at the shared repository, and the repository records each workspace's directory under `.oxy/workspaces/<id>/`. `oxy workspace add PATH [CHANGE..]` creates one, `list` shows them, and `forget` drops one from the view without touching its files. Workspaces do not nest. |
|
| 478 | + | A repository has one or more workspaces, each with a `WorkspaceId` (128-bit random) and a checkout directory. Each workspace has exactly one **working-copy change** at any time. The primary workspace is the directory holding `.oxy/`; a secondary workspace holds a `.oxy` file instead, `oxydir: <path>` and `workspace: <id>`, pointing at the shared repository, and the repository records each workspace's directory under `.oxy/workspaces/<id>/`. `oxy workspace add PATH [CHANGE..]` creates one, `list` shows them, and `forget` drops one from the view without touching its files. Workspaces do not nest. A **bare** repository (`oxy init --bare`, marker `.oxy/bare`) has no workspace and no working-copy change; it holds objects, the operation log and references only, for hosting. Working-copy commands fail on it with a clear message; every state-based query works. |
|
| 479 | 479 | ||
| 480 | 480 | ### 12.3 The working-copy change |
|
| 481 | 481 | ||
| … | 333 unchanged lines |
||
| 815 | 815 | Commands mirror Jujutsu where semantics match, so that muscle memory transfers. |
|
| 816 | 816 | ||
| 817 | 817 | ``` |
|
| 818 | - | oxy init [PATH] oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE] |
|
| 818 | + | oxy init [PATH] [--bare] oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE] |
|
| 819 | 819 | oxy status [--json] oxy diff oxy prompt [--format F] |
|
| 820 | 820 | oxy commit -m MSG [--depends-on CHANGE].. |
|
| 821 | 821 | oxy log [-n N] [-v] oxy show CHANGE |
|
| … | 93 unchanged lines |
||
| 915 | 915 | ||
| 916 | 916 | --- |
|
| 917 | 917 | ||
| 918 | - | *Changelog.* Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 918 | + | *Changelog.* Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 919 | 919 | ||
| 920 | 920 | Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23). |
|
| 921 | 921 | ||
| … | 59 unchanged lines |
modified src/cli/mod.rs +7 −2
| … | 24 unchanged lines |
||
| 25 | 25 | #[derive(Subcommand)] |
|
| 26 | 26 | enum Cmd { |
|
| 27 | 27 | /// Create a repository in PATH (default: current directory) |
|
| 28 | - | Init { path: Option<PathBuf> }, |
|
| 28 | + | Init { |
|
| 29 | + | path: Option<PathBuf>, |
|
| 30 | + | /// No working copy: an object store and references only, for hosting |
|
| 31 | + | #[arg(long)] |
|
| 32 | + | bare: bool, |
|
| 33 | + | }, |
|
| 29 | 34 | /// Clone a repository from a local path or an http(s) URL |
|
| 30 | 35 | Clone { |
|
| 31 | 36 | url: String, |
|
| … | 546 unchanged lines |
||
| 578 | 583 | let cli = Cli::parse(); |
|
| 579 | 584 | ui::init(cli.plain); |
|
| 580 | 585 | match cli.cmd { |
|
| 581 | - | Cmd::Init { path } => workspace::init(path), |
|
| 586 | + | Cmd::Init { path, bare } => workspace::init(path, bare), |
|
| 582 | 587 | Cmd::Status { json } => workspace::status(json), |
|
| 583 | 588 | Cmd::Prompt { format, glyphs, color } => workspace::prompt(&format, glyphs, color.as_deref()), |
|
| 584 | 589 | Cmd::Commit { message, depends_on, paths, interactive, hunks } => workspace::commit(&message, &depends_on, &paths, interactive, &hunks), |
|
| … | 100 unchanged lines |
modified src/cli/workspace.rs +3 −3
| … | 9 unchanged lines |
||
| 10 | 10 | use oxy_core::repo::{render_change, ChangeStatus, Repo}; |
|
| 11 | 11 | use std::path::PathBuf; |
|
| 12 | 12 | ||
| 13 | - | pub fn init(path: Option<PathBuf>) -> Result<i32> { |
|
| 13 | + | pub fn init(path: Option<PathBuf>, bare: bool) -> Result<i32> { |
|
| 14 | 14 | let path = path.unwrap_or(std::env::current_dir()?); |
|
| 15 | - | let repo = Repo::init(&path)?; |
|
| 16 | - | println!("{} oxy repository in {}", ui::done("Initialized"), ui::path(repo.root().display())); |
|
| 15 | + | let repo = if bare { Repo::init_bare(&path)? } else { Repo::init(&path)? }; |
|
| 16 | + | println!("{} {}oxy repository in {}", ui::done("Initialized"), if bare { "bare " } else { "" }, ui::path(repo.root().display())); |
|
| 17 | 17 | println!("{}", ui::kv("bookmark", ui::bookmark(&repo.config.bookmark))); |
|
| 18 | 18 | println!("{}", ui::kv("author", format!("{} <{}>", ui::author(&repo.config.author_name), repo.config.author_email))); |
|
| 19 | 19 | Ok(0) |
|
| … | 952 unchanged lines |