1use std::path::{Path, PathBuf};
39
40use choir_oplog::ContentHash;
41
42use crate::platform::{AuthorizedChangeCreate, Platform};
43use crate::reject::{Code, Rejection};
44
45const TEMPLATE_CONFIG: [&str; 4] = ["-c", "gc.auto=0", "-c", "maintenance.auto=false"];
54
55pub(crate) fn safe_segment(s: &str) -> bool {
58 !s.is_empty()
59 && !s.starts_with('.')
60 && s.chars()
61 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
62}
63
64fn git(args: &[&str], dir: Option<&Path>) -> Result<String, String> {
66 let mut cmd = std::process::Command::new("git");
67 cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
68 if let Some(d) = dir {
69 cmd.current_dir(d);
70 }
71 let out = cmd.output().map_err(|e| format!("spawn git: {e}"))?;
72 if out.status.success() {
73 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
74 } else {
75 Err(format!(
76 "git {args:?}: {}",
77 String::from_utf8_lossy(&out.stderr)
78 ))
79 }
80}
81
82fn cow_copy(src: &Path, dst: &Path) -> Result<(), String> {
85 let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
86 ("cp", vec!["-Rc"])
87 } else {
88 ("cp", vec!["-R", "--reflink=auto"])
89 };
90 let out = std::process::Command::new(cmd)
91 .args(&args)
92 .arg(src)
93 .arg(dst)
94 .output()
95 .map_err(|e| format!("spawn cp: {e}"))?;
96 if out.status.success() {
97 Ok(())
98 } else {
99 Err(format!(
100 "cow copy: {}",
101 String::from_utf8_lossy(&out.stderr)
102 ))
103 }
104}
105
106fn repo_lock(repo: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
111 static LOCKS: std::sync::OnceLock<
112 std::sync::Mutex<std::collections::BTreeMap<String, std::sync::Arc<std::sync::Mutex<()>>>>,
113 > = std::sync::OnceLock::new();
114 LOCKS
115 .get_or_init(Default::default)
116 .lock()
117 .expect("lock table")
118 .entry(repo.to_string())
119 .or_default()
120 .clone()
121}
122
123pub fn create_workspace(
128 root: &Path,
129 platform: &Platform,
130 base_url: &str,
131 authenticated_user: &str,
132 body: &[u8],
133) -> (u16, String) {
134 let req: serde_json::Value = match serde_json::from_slice(body) {
135 Ok(v) => v,
136 Err(e) => return (400, format!(r#"{{"error":"bad json: {e}"}}"#)),
137 };
138 let field = |k: &str| req.get(k).and_then(|v| v.as_str()).unwrap_or("");
139 let (repo, name) = (field("repo"), field("name"));
140 let mut segs = repo.split('/');
141 let (Some(owner), Some(reponame), None) = (segs.next(), segs.next(), segs.next()) else {
142 return (400, r#"{"error":"repo must be owner/repo"}"#.to_string());
143 };
144 if !safe_segment(owner) || !safe_segment(reponame) || !safe_segment(name) {
145 return (400, r#"{"error":"bad repo or workspace name"}"#.to_string());
146 }
147
148 let advanced_fields = ["base", "owner", "change", "idempotency_key"];
149 let advanced = advanced_fields.iter().any(|key| req.get(*key).is_some());
150 if advanced
151 && advanced_fields.iter().any(|key| {
152 req.get(*key)
153 .and_then(|v| v.as_str())
154 .is_none_or(str::is_empty)
155 })
156 {
157 return problem(
158 400,
159 Code::MalformedRequest,
160 "advanced workspace creation requires non-empty base, owner, change and idempotency_key",
161 "send all four fields, or omit all four to use the legacy HEAD-based request",
162 );
163 }
164 let attribution = crate::quota::channel_for(authenticated_user);
165
166 let bare = root.join(format!("{repo}.git"));
167 if !bare.join("HEAD").exists() {
168 return (404, r#"{"error":"no such repo"}"#.to_string());
169 }
170
171 let lock = repo_lock(repo);
172 let _guard = lock.lock().expect("repo lock");
173 let requested_base = field("base");
174 let head = if advanced {
175 if ContentHash::from_git_oid(requested_base).is_none() {
176 return problem(
177 400,
178 Code::MalformedRequest,
179 "base must be a full 40- or 64-character Git object id",
180 "resolve the desired commit to its full object id and retry",
181 );
182 }
183 match git(&["cat-file", "-t", requested_base], Some(&bare)) {
184 Ok(kind) if kind.trim() == "commit" => match git(
185 &["rev-parse", "--verify", &format!("{requested_base}^{{commit}}")],
186 Some(&bare),
187 ) {
188 Ok(commit) => commit.trim().to_string(),
189 Err(_) => {
190 return problem(
191 400,
192 Code::MalformedRequest,
193 "base does not resolve to a commit in this repository",
194 "fetch the exact commit into the repository, then retry with its full object id",
195 )
196 }
197 },
198 _ => {
199 return problem(
200 400,
201 Code::MalformedRequest,
202 "base does not name a commit in this repository",
203 "fetch the exact commit into the repository, then retry with its full object id",
204 )
205 }
206 }
207 } else {
208 match git(&["rev-parse", "--verify", "HEAD^{commit}"], Some(&bare)) {
209 Ok(h) => h.trim().to_string(),
210 Err(_) => return (400, r#"{"error":"repository has no commits"}"#.to_string()),
211 }
212 };
213
214 let ws_dir = root.join(".choir").join("workspaces").join(repo).join(name);
215 let workspace = format!("{repo}/{name}");
216 let owner_sig = if advanced {
217 let base_revision = ContentHash::from_git_oid(&head).expect("verified git oid");
218 match platform.decode_create_change_request(
219 &req,
220 field("change"),
221 field("owner"),
222 &workspace,
223 &base_revision,
224 field("idempotency_key"),
225 ) {
226 Ok(signed) => Some(signed),
227 Err(reason) => {
228 let rejection = Rejection::decode(&reason);
229 let status = if rejection.code == Code::WorkspaceState.as_str() {
230 409
231 } else {
232 400
233 };
234 return (status, rejection.body());
235 }
236 }
237 } else {
238 None
239 };
240 if ws_dir.exists() {
241 if advanced {
242 let verified = owner_sig.expect("advanced request was verified");
243 return reuse_or_conflict(
244 platform,
245 &workspace,
246 &ws_dir,
247 AdvancedBinding {
248 base_hex: &head,
249 owner: field("owner"),
250 change_id: field("change"),
251 idempotency_key: field("idempotency_key"),
252 owner_sig: verified.0,
253 cone: verified.1,
254 attribution: &attribution,
255 },
256 );
257 }
258 return (409, r#"{"error":"workspace already exists"}"#.to_string());
259 }
260 if advanced
261 && (platform.change_state(field("change")).is_some()
262 || platform
263 .change_for_idempotency(field("owner"), field("idempotency_key"))
264 .is_some()
265 || platform.workspace_head(&workspace).is_some())
266 {
267 return lifecycle_conflict(
268 "the requested change, idempotency key or workspace already has a different durable state",
269 );
270 }
271
272 match provision(root, &bare, repo, &head, &ws_dir, base_url) {
273 Ok(copy_ms) => {
274 let registration = if advanced {
275 let verified = owner_sig.expect("advanced request was verified");
276 platform
277 .create_change(
278 AuthorizedChangeCreate {
279 id: field("change"),
280 owner: field("owner"),
281 workspace: &workspace,
282 base_hex: &head,
283 idempotency_key: field("idempotency_key"),
284 owner_sig: verified.0,
285 cone: verified.1,
286 },
287 &attribution,
288 )
289 .map(Some)
290 } else {
291 platform
292 .set_workspace_head(&workspace, &head, &attribution)
293 .map(|()| None)
294 };
295 let accepted = match registration {
296 Ok(accepted) => accepted,
297 Err(reason) => {
298 std::fs::remove_dir_all(&ws_dir).ok();
301 return (409, Rejection::decode(&reason).body());
302 }
303 };
304 let mut response = serde_json::json!({
305 "workspace": workspace,
306 "path": ws_dir.display().to_string(),
307 "head": head,
308 "copy_ms": copy_ms,
309 "created": true,
310 });
311 if advanced {
312 response["change_id"] = serde_json::json!(field("change"));
313 response["owner"] = serde_json::json!(field("owner"));
314 response["idempotency_key"] = serde_json::json!(field("idempotency_key"));
315 response["base_revision"] = serde_json::json!(ContentHash::from_git_oid(&head)
316 .expect("verified git oid")
317 .to_hex());
318 if let Some(accepted) = accepted {
319 response["operation"] = serde_json::json!({
320 "seq": accepted.seq,
321 "hash": accepted.hash.to_hex(),
322 });
323 }
324 }
325 (200, response.to_string())
326 }
327 Err(e) => (500, serde_json::json!({ "error": e }).to_string()),
328 }
329}
330
331pub fn archive_workspace(
335 root: &Path,
336 platform: &Platform,
337 authenticated_user: &str,
338 body: &[u8],
339) -> (u16, String) {
340 let req: serde_json::Value = match serde_json::from_slice(body) {
341 Ok(v) => v,
342 Err(e) => return problem(
343 400,
344 Code::MalformedRequest,
345 format!("request body is not valid JSON: {e}"),
346 "send repo, name, change, idempotency_key and an owner-signed archive authorization",
347 ),
348 };
349 let field = |key: &str| req.get(key).and_then(|v| v.as_str()).unwrap_or("");
350 let (repo, name) = (field("repo"), field("name"));
351 let mut segs = repo.split('/');
352 let (Some(repo_owner), Some(reponame), None) = (segs.next(), segs.next(), segs.next()) else {
353 return problem(
354 400,
355 Code::MalformedRequest,
356 "repo must be owner/repo",
357 "send a two-segment repository name",
358 );
359 };
360 if !safe_segment(repo_owner) || !safe_segment(reponame) || !safe_segment(name) {
361 return problem(
362 400,
363 Code::MalformedRequest,
364 "bad repo or workspace name",
365 "use non-hidden alphanumeric, dot, underscore or hyphen path segments",
366 );
367 }
368 if ["change", "idempotency_key", "channel"]
369 .iter()
370 .any(|key| field(key).is_empty())
371 {
372 return problem(
373 400,
374 Code::MalformedRequest,
375 "archive requires non-empty change, idempotency_key and signed channel",
376 "retry with the exact binding returned by workspace creation and an owner-signed ArchiveAuthorization payload",
377 );
378 }
379
380 let lock = repo_lock(repo);
381 let _guard = lock.lock().expect("repo lock");
382 let attribution = crate::quota::channel_for(authenticated_user);
383 let workspace = format!("{repo}/{name}");
384 let Some(change) = platform.change_state(field("change")) else {
385 return lifecycle_conflict("no durable change has the requested identity");
386 };
387 if change.owner != field("channel")
388 || change.workspace_id != workspace
389 || change.idempotency_key != field("idempotency_key")
390 {
391 return lifecycle_conflict(
392 "archive binding does not match the durable change channel, workspace and idempotency key",
393 );
394 }
395
396 let live = root.join(".choir").join("workspaces").join(repo).join(name);
397 let archive_root = root
398 .join(".choir")
399 .join("archive")
400 .join("workspaces")
401 .join(repo)
402 .join(name);
403 let archived = archive_root.join(archive_generation(field("change")));
404 if change.active_workspace.is_none() {
405 let existing_archive = if archived.exists() {
406 Some(archived.as_path())
407 } else if archive_root.join(".git").exists() {
408 Some(archive_root.as_path())
411 } else {
412 None
413 };
414 if let Some(existing_archive) = existing_archive.filter(|_| !live.exists()) {
415 let operation = platform.archive_change_receipt(
416 &req,
417 field("change"),
418 &workspace,
419 &change.revision_id,
420 &attribution,
421 );
422 let mut response = serde_json::json!({
423 "workspace": workspace,
424 "change_id": field("change"),
425 "archived_path": existing_archive.display().to_string(),
426 "already_archived": true,
427 });
428 if let Some((seq, hash)) = operation {
429 response["operation"] = serde_json::json!({
430 "seq": seq,
431 "hash": hash.to_hex(),
432 "already_applied": true,
433 });
434 }
435 return (200, response.to_string());
436 }
437 return lifecycle_conflict(
438 "the change is archived but its filesystem state is inconsistent",
439 );
440 }
441 if let Err(reason) = platform.validate_archive_change_request(
442 &req,
443 field("change"),
444 &workspace,
445 &change.revision_id,
446 ) {
447 let rejection = Rejection::decode(&reason);
448 let status = if rejection.code == Code::WorkspaceState.as_str() {
449 409
450 } else {
451 400
452 };
453 return (status, rejection.body());
454 }
455
456 if let Err(error) = migrate_legacy_archive(&archive_root, platform, &workspace, field("change"))
457 {
458 return (500, serde_json::json!({ "error": error }).to_string());
459 }
460 let recovering_rename = archived.exists() && !live.exists();
461 if !recovering_rename {
462 if !live.exists() || archived.exists() {
463 return lifecycle_conflict(
464 "the active workspace filesystem state does not match the durable view",
465 );
466 }
467 if let Err(e) = std::fs::create_dir_all(&archive_root) {
468 return (
469 500,
470 serde_json::json!({ "error": format!("create archive dir: {e}") }).to_string(),
471 );
472 }
473 if let Err(e) = std::fs::rename(&live, &archived) {
474 return (
475 500,
476 serde_json::json!({ "error": format!("archive workspace: {e}") }).to_string(),
477 );
478 }
479 }
480
481 match platform.submit_archive_change(
482 &req,
483 field("change"),
484 &workspace,
485 &change.revision_id,
486 &attribution,
487 ) {
488 Ok(accepted) => (
489 200,
490 serde_json::json!({
491 "workspace": workspace,
492 "change_id": field("change"),
493 "archived_path": archived.display().to_string(),
494 "already_archived": false,
495 "operation": {
496 "seq": accepted.seq,
497 "hash": accepted.hash.to_hex(),
498 },
499 })
500 .to_string(),
501 ),
502 Err(reason) => {
503 if let Err(e) = std::fs::rename(&archived, &live) {
504 return (
505 500,
506 serde_json::json!({
507 "error": format!("sequencing failed and archive rollback failed: {e}"),
508 "sequencer_error": Rejection::decode(&reason).to_json(),
509 })
510 .to_string(),
511 );
512 }
513 (409, Rejection::decode(&reason).body())
514 }
515 }
516}
517
518fn archive_generation(change_id: &str) -> String {
519 ContentHash::blake3(change_id.as_bytes()).to_hex()
520}
521
522fn migrate_legacy_archive(
523 archive_root: &Path,
524 platform: &Platform,
525 workspace: &str,
526 current_change: &str,
527) -> Result<(), String> {
528 if !archive_root.join(".git").exists() {
529 return Ok(());
530 }
531 let prior: Vec<String> = platform
532 .archived_change_ids_for_workspace(workspace)
533 .into_iter()
534 .filter(|id| id != current_change)
535 .collect();
536 let [prior_change] = prior.as_slice() else {
537 return Err(
538 "legacy archive cannot be assigned to exactly one prior change generation".into(),
539 );
540 };
541 let parent = archive_root.parent().expect("archive root has parent");
542 let name = archive_root
543 .file_name()
544 .and_then(|name| name.to_str())
545 .expect("validated workspace name");
546 let generation = archive_generation(prior_change);
547 let temporary = parent.join(format!(".{name}.migrating-{generation}"));
548 if temporary.exists() {
549 return Err("legacy archive migration temporary path already exists".into());
550 }
551 std::fs::rename(archive_root, &temporary)
552 .map_err(|error| format!("stage legacy archive migration: {error}"))?;
553 if let Err(error) = std::fs::create_dir_all(archive_root) {
554 std::fs::rename(&temporary, archive_root).ok();
555 return Err(format!("create versioned archive directory: {error}"));
556 }
557 let destination = archive_root.join(generation);
558 if let Err(error) = std::fs::rename(&temporary, &destination) {
559 std::fs::remove_dir(archive_root).ok();
560 std::fs::rename(&temporary, archive_root).ok();
561 return Err(format!("finish legacy archive migration: {error}"));
562 }
563 Ok(())
564}
565
566struct AdvancedBinding<'a> {
567 base_hex: &'a str,
568 owner: &'a str,
569 change_id: &'a str,
570 idempotency_key: &'a str,
571 owner_sig: choir_oplog::Witness,
572 cone: Vec<String>,
574 attribution: &'a str,
575}
576
577fn reuse_or_conflict(
578 platform: &Platform,
579 workspace: &str,
580 path: &Path,
581 binding: AdvancedBinding<'_>,
582) -> (u16, String) {
583 let AdvancedBinding {
584 base_hex,
585 owner,
586 change_id,
587 idempotency_key,
588 owner_sig,
589 cone,
590 attribution,
591 } = binding;
592 let Some(change) = platform.change_state(change_id) else {
593 return lifecycle_conflict(
594 "workspace directory exists without the requested durable change binding",
595 );
596 };
597 let base = ContentHash::from_git_oid(base_hex).expect("validated base");
598 if change.owner != owner
599 || change.workspace_id != workspace
600 || change.active_workspace.as_deref() != Some(workspace)
601 || change.base_revision != base
602 || change.idempotency_key != idempotency_key
603 {
604 return lifecycle_conflict(
605 "workspace exists with a different base, owner, change or idempotency key",
606 );
607 }
608 let Some(head) = platform.workspace_head(workspace) else {
609 return lifecycle_conflict(
610 "workspace directory and change exist but the active workspace head is missing",
611 );
612 };
613 if head != change.revision_id {
614 return lifecycle_conflict(
615 "workspace and change revisions disagree; refusing to guess which state is current",
616 );
617 }
618 let head_hex = git_oid_hex(&head).expect("workspace revisions are verified Git oids");
619 let mut response = serde_json::json!({
620 "workspace": workspace,
621 "path": path.display().to_string(),
622 "head": head_hex,
623 "base_revision": change.base_revision.to_hex(),
624 "revision_id": change.revision_id.to_hex(),
625 "change_id": change_id,
626 "owner": owner,
627 "idempotency_key": idempotency_key,
628 "created": false,
629 "reused": true,
630 });
631 let accepted = match platform.create_change(
632 AuthorizedChangeCreate {
633 id: change_id,
634 owner,
635 workspace,
636 base_hex,
637 idempotency_key,
638 owner_sig,
639 cone,
640 },
641 attribution,
642 ) {
643 Ok(accepted) => accepted,
644 Err(reason) => return (409, Rejection::decode(&reason).body()),
645 };
646 {
647 let (seq, hash) = (accepted.seq, accepted.hash);
648 response["operation"] = serde_json::json!({
649 "seq": seq,
650 "hash": hash.to_hex(),
651 "already_applied": true,
652 });
653 }
654 (200, response.to_string())
655}
656
657fn git_oid_hex(hash: &ContentHash) -> Option<String> {
658 let expected = match hash.codec {
659 0x11 => 20,
660 0x12 => 32,
661 _ => return None,
662 };
663 if hash.digest.len() != expected {
664 return None;
665 }
666 Some(
667 hash.digest
668 .iter()
669 .map(|byte| format!("{byte:02x}"))
670 .collect(),
671 )
672}
673
674fn lifecycle_conflict(error: impl Into<String>) -> (u16, String) {
675 problem(
676 409,
677 Code::WorkspaceState,
678 error,
679 "read GET /api/view and retry only with the exact durable binding, or choose a new workspace and change id",
680 )
681}
682
683fn problem(
684 status: u16,
685 code: Code,
686 error: impl Into<String>,
687 next: impl Into<String>,
688) -> (u16, String) {
689 (status, Rejection::new(code, error, next).body())
690}
691
692fn provision(
696 root: &Path,
697 bare: &Path,
698 repo: &str,
699 head: &str,
700 ws_dir: &Path,
701 base_url: &str,
702) -> Result<f64, String> {
703 let template: PathBuf = root.join(".choir").join("checkouts").join(repo);
704 let with_config = |rest: &[&str]| -> Vec<String> {
708 TEMPLATE_CONFIG
709 .iter()
710 .chain(rest.iter())
711 .map(|a| (*a).to_string())
712 .collect()
713 };
714 let run = |args: Vec<String>, dir: Option<&Path>| -> Result<String, String> {
715 let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
716 git(&borrowed, dir)
717 };
718 if template.join(".git").exists() {
719 run(with_config(&["fetch", "-q", "origin"]), Some(&template))?;
720 } else {
721 std::fs::create_dir_all(template.parent().expect("has parent"))
722 .map_err(|e| format!("create template dir: {e}"))?;
723 run(
724 with_config(&[
725 "clone",
726 "-q",
727 "-c",
728 "gc.auto=0",
729 "-c",
730 "maintenance.auto=false",
731 bare.to_str().expect("utf8"),
732 template.to_str().expect("utf8"),
733 ]),
734 None,
735 )?;
736 }
737 run(
738 with_config(&["checkout", "-q", "--detach", head]),
739 Some(&template),
740 )?;
741
742 std::fs::create_dir_all(ws_dir.parent().expect("has parent"))
743 .map_err(|e| format!("create workspace dir: {e}"))?;
744 let started = std::time::Instant::now();
745 cow_copy(&template, ws_dir)?;
746 let copy_ms = started.elapsed().as_secs_f64() * 1000.0;
747
748 git(
751 &[
752 "remote",
753 "set-url",
754 "origin",
755 &format!("{base_url}/{repo}.git"),
756 ],
757 Some(ws_dir),
758 )?;
759 Ok(copy_ms)
760}