1use std::sync::Arc;
17
18use anyhow::Context as _;
19use gunnar_core::{AccountRoom, ClanRefusal, ClanRole};
20use gunnar_grpc::clans::{ClanPlane, ClanRoom};
21use gunnar_grpc::seam::{ControlError, ControlResult, DeleteOptions};
22use gunnar_grpc::ControlPlane as _;
23use gunnar_lfs::LfsStore as _;
24use gunnar_store::git_storage_trait::{Caps, RefUpdate};
25
26use super::{internal, CoreControlPlane};
27
28fn clan_out(clan: &gunnar_core::Clan, lapsed: &[String]) -> gunnar_proto::Clan {
32 gunnar_proto::Clan {
33 name: clan.name.clone(),
34 created_by: clan.created_by.clone(),
35 created_at_unix_ms: clan.created_at,
36 members: clan
37 .members
38 .iter()
39 .map(|m| gunnar_proto::ClanMember {
40 principal_id: m.principal.clone(),
41 admin: m.admin,
42 contribution_bytes: m.contribution_bytes,
43 joined_at_unix_ms: m.joined_at,
44 lapsed: lapsed.contains(&m.principal),
45 })
46 .collect(),
47 invites: clan
48 .invites
49 .iter()
50 .map(|i| gunnar_proto::ClanInvite {
51 principal_id: i.principal.clone(),
52 invited_by: i.by.clone(),
53 at_unix_ms: i.at,
54 })
55 .collect(),
56 room_bytes: clan.room_bytes(),
57 room_in_force_bytes: clan.room_in_force(lapsed),
58 offers: clan
59 .offers
60 .iter()
61 .map(|o| offer_out(&clan.name, o, None))
62 .collect(),
63 ..Default::default()
64 }
65}
66
67fn offer_out(
69 clan: &str,
70 offer: &gunnar_core::ClanRepoOffer,
71 stored: Option<u64>,
72) -> gunnar_proto::RepoOffer {
73 gunnar_proto::RepoOffer {
74 clan: clan.to_owned(),
75 repo: offer.repo.clone(),
76 to_account: offer.to.clone(),
77 offered_by: offer.by.clone(),
78 at_unix_ms: offer.at,
79 stored_bytes: stored,
80 }
81}
82
83fn room_in(room: ClanRoom) -> AccountRoom {
84 AccountRoom {
85 cap: room.cap,
86 stored: room.stored,
87 }
88}
89
90fn clan_error(err: gunnar_core::Error) -> ControlError {
92 match err {
93 gunnar_core::Error::Clan {
94 clan,
95 refusal,
96 reason,
97 } => match refusal {
98 ClanRefusal::NotFound => {
99 let _ = reason;
100 ControlError::NotFound(format!("clan {clan} (none you are in or invited to)"))
101 }
102 ClanRefusal::Denied => ControlError::PermissionDenied(reason),
103 ClanRefusal::Precondition => ControlError::FailedPrecondition(reason),
104 ClanRefusal::Invalid => ControlError::InvalidArgument(reason),
105 ClanRefusal::Exists => ControlError::AlreadyExists(reason),
106 },
107 gunnar_core::Error::PrincipalExists(id) => {
108 ControlError::AlreadyExists(format!("an account called {id}"))
109 }
110 other => internal(other),
111 }
112}
113
114impl CoreControlPlane {
115 fn clan_written(&self, clan: gunnar_core::Clan) -> gunnar_proto::Clan {
118 self.stores.mirror_identity();
119 self.clan_view(&clan)
120 }
121
122 fn clan_view(&self, clan: &gunnar_core::Clan) -> gunnar_proto::Clan {
124 let mut out = clan_out(clan, &self.clan_lapsed(clan));
125 if !out.offers.is_empty() {
126 for offer in &mut out.offers {
127 offer.stored_bytes = self.repo_stored(&clan.name, &offer.repo);
128 }
129 }
130 out
131 }
132
133 pub(crate) fn clan_lapsed(&self, clan: &gunnar_core::Clan) -> Vec<String> {
140 #[cfg(feature = "metered")]
141 if let Some(slot) = self.entitlement.as_ref() {
142 return slot.lapsed_members(clan);
143 }
144 let _ = clan;
145 Vec::new()
146 }
147
148 fn lapsed_in(&self, clan: &str) -> ControlResult<Vec<String>> {
150 Ok(self
151 .stores
152 .auth()
153 .clan(clan)
154 .map_err(internal)?
155 .map(|row| self.clan_lapsed(&row))
156 .unwrap_or_default())
157 }
158
159 fn repo_stored(&self, namespace: &str, repo: &str) -> Option<u64> {
162 let rows = self.repo_usage(namespace).ok()?;
163 rows.into_iter()
164 .find(|row| row.repo.as_ref().is_some_and(|p| p.repo == repo))
165 .map(|row| row.stored_bytes.saturating_add(row.lfs_bytes))
166 }
167
168 pub(crate) fn move_repository(
179 &self,
180 from: &gunnar_proto::RepoPath,
181 to: &gunnar_proto::RepoPath,
182 by: &str,
183 ) -> ControlResult<gunnar_proto::Repo> {
184 let src = self.get_repo(from)?;
185 if self
186 .stores
187 .catalog()
188 .namespace(&to.namespace)
189 .map_err(internal)?
190 .is_none()
191 {
192 return Err(ControlError::NotFound(format!("account {}", to.namespace)));
193 }
194 if self.get_repo(to).is_ok() {
195 return Err(ControlError::AlreadyExists(format!(
196 "{}/{} already exists, so {}/{} cannot move there",
197 to.namespace, to.repo, from.namespace, from.repo
198 )));
199 }
200 self.create_repo(gunnar_grpc::NewRepo {
201 path: to.clone(),
202 hash_kind: gunnar_proto::HashKind::try_from(src.hash_kind)
203 .unwrap_or(gunnar_proto::HashKind::Sha256),
204 default_branch: src.default_branch.clone(),
205 visibility: gunnar_proto::Visibility::try_from(src.visibility)
206 .unwrap_or(gunnar_proto::Visibility::Private),
207 description: src.description.clone(),
208 adopt: false,
209 })?;
210 if let Err(err) = self.copy_repository(from, to) {
211 if let Err(undo) = self.delete_repo(
212 to,
213 DeleteOptions {
214 purge: true,
215 retention_days: None,
216 principal_id: by.to_owned(),
217 },
218 ) {
219 tracing::error!(
220 target: "gunnar::audit",
221 event = "repo.move_undo_failed",
222 to = %format!("{}/{}", to.namespace, to.repo),
223 reason = %undo,
224 "a failed move's new repository could not be purged again"
225 );
226 }
227 return Err(ControlError::Internal(format!(
228 "moving {}/{} to {}/{}: {err:#}; nothing was moved",
229 from.namespace, from.repo, to.namespace, to.repo
230 )));
231 }
232 if src.encrypted {
238 if let Err(err) = self.set_encrypted(to, true) {
239 let _ = self.delete_repo(
240 to,
241 DeleteOptions {
242 purge: true,
243 retention_days: None,
244 principal_id: by.to_owned(),
245 },
246 );
247 return Err(ControlError::Internal(format!(
248 "moving {}/{} to {}/{}: its encryption could not be carried ({err}); nothing was moved",
249 from.namespace, from.repo, to.namespace, to.repo
250 )));
251 }
252 }
253 for rule in self.get_protection(from, None).unwrap_or_default() {
255 if let Err(err) = self.set_protection(to, rule) {
256 tracing::warn!(target: "gunnar::audit", event = "repo.move_policy_not_carried", reason = %err, "a branch protection did not move with its repository");
257 }
258 }
259 if let Ok(policy) = self.get_ref_name_policy(from) {
260 if policy != gunnar_proto::RefNamePolicy::default() {
261 if let Err(err) = self.set_ref_name_policy(to, policy) {
262 tracing::warn!(target: "gunnar::audit", event = "repo.move_policy_not_carried", reason = %err, "a ref-name policy did not move with its repository");
263 }
264 }
265 }
266 self.delete_repo(
268 from,
269 DeleteOptions {
270 purge: true,
271 retention_days: None,
272 principal_id: by.to_owned(),
273 },
274 )?;
275 self.get_repo(to)
276 }
277
278 fn copy_repository(
282 &self,
283 from: &gunnar_proto::RepoPath,
284 to: &gunnar_proto::RepoPath,
285 ) -> anyhow::Result<()> {
286 let from_ref = gunnar_core::RepoRef::new(&from.namespace, &from.repo)?;
287 let to_ref = gunnar_core::RepoRef::new(&to.namespace, &to.repo)?;
288 let src_row = self
289 .stores
290 .catalog()
291 .get(&from_ref)?
292 .context("the repository to move is not in the catalog")?;
293 let dst_row = self
294 .stores
295 .catalog()
296 .get(&to_ref)?
297 .context("the new repository is not in the catalog")?;
298 let src = self.stores.repo_store(&src_row)?;
299 let dst = self.stores.repo_store(&dst_row)?;
300 let git = src.unlogged();
301 let mut updates = Vec::new();
302 let mut tips: Vec<Vec<u8>> = Vec::new();
303 for r in git.refs()? {
304 if r.name == "HEAD" {
305 continue;
306 }
307 if let Some(oid) = &r.oid {
308 tips.push(oid.clone());
309 }
310 updates.push(RefUpdate {
311 name: r.name,
312 target: r.oid.as_deref().map(hex::encode),
313 peeled: r.peeled.as_deref().map(hex::encode),
314 symref_target: r.symref_target,
315 });
316 }
317 if !tips.is_empty() {
318 let want: Vec<&[u8]> = tips.iter().map(Vec::as_slice).collect();
319 let objects = match git.select(&want, &[])? {
320 Some(set) => set.objects,
321 None => git.reachable(&want, &[])?,
322 };
323 let objs: Vec<&[u8]> = objects.iter().map(Vec::as_slice).collect();
324 let mut pack = Vec::new();
325 git.emit_pack(&objs, &[], &Caps::modern(), &mut pack)
326 .context("exporting the objects to move")?;
327 dst.serve()
328 .put_pack(&pack)
329 .context("writing the moved objects")?;
330 }
331 if !updates.is_empty() {
332 dst.serve()
333 .put_refs(&updates)
334 .context("writing the moved references")?;
335 }
336 if let Some(target) = git.head()?.and_then(|h| h.symref_target) {
337 dst.serve()
338 .set_head(&target)
339 .context("pointing the moved HEAD")?;
340 }
341 let lfs_dir = src.git_dir().join("lfs").join("objects");
342 let files = lfs_files(&lfs_dir)?;
343 if !files.is_empty() {
344 let store = crate::txlog_lfs::LoggedLfs::new(
345 gunnar_lfs::FsLfsStore::open_or_init(dst.git_dir())?,
346 Arc::clone(self.stores.txlog()),
347 format!("{}/{}", to.namespace, to.repo),
348 );
349 for path in files {
350 let Some(oid) = path
351 .file_name()
352 .and_then(|n| n.to_str())
353 .and_then(|n| gunnar_lfs::Oid::parse(n).ok())
354 else {
355 continue;
356 };
357 let mut file = std::fs::File::open(&path)
358 .with_context(|| format!("opening LFS {}", path.display()))?;
359 let size = file.metadata()?.len();
360 let mut pending = store.begin_write(&oid, size)?;
361 std::io::copy(&mut file, &mut pending)
362 .with_context(|| format!("copying LFS {}", path.display()))?;
363 pending.commit()?;
364 }
365 }
366 drop(src);
367 drop(dst);
368 self.stores.close_repo(&from_ref);
369 self.stores.close_repo(&to_ref);
370 Ok(())
371 }
372}
373
374impl ClanPlane for CoreControlPlane {
375 fn clan_create(
376 &self,
377 name: &str,
378 creator: &str,
379 contribution: u64,
380 room: ClanRoom,
381 ) -> ControlResult<gunnar_proto::Clan> {
382 if name.contains('/') {
383 return Err(ControlError::InvalidArgument(format!(
384 "{name:?}: a clan's name is one namespace, with no `/` in it"
385 )));
386 }
387 match self.stores.catalog().create_namespace(name) {
389 Ok(_) => {}
390 Err(gunnar_core::Error::NamespaceExists(_)) => {
391 return Err(ControlError::AlreadyExists(format!(
392 "a namespace called {name}"
393 )))
394 }
395 Err(err) => return Err(internal(err)),
396 }
397 match self
398 .stores
399 .auth()
400 .create_clan(name, creator, contribution, room_in(room))
401 {
402 Ok(clan) => Ok(self.clan_written(clan)),
403 Err(err) => {
404 if let Err(undo) = self.stores.catalog().delete_namespace(name, false) {
407 tracing::warn!(
408 target: "gunnar::audit",
409 event = "clan.namespace_not_given_back",
410 clan = %name,
411 reason = %undo,
412 "a refused clan's namespace could not be removed again"
413 );
414 }
415 Err(clan_error(err))
416 }
417 }
418 }
419
420 fn clan_get(&self, name: &str) -> ControlResult<Option<gunnar_proto::Clan>> {
421 Ok(self
422 .stores
423 .auth()
424 .clan(name)
425 .map_err(internal)?
426 .as_ref()
427 .map(|c| self.clan_view(c)))
428 }
429
430 fn clans_of(&self, principal: &str) -> ControlResult<Vec<gunnar_proto::Clan>> {
431 Ok(self
432 .stores
433 .auth()
434 .clans_of(principal)
435 .map_err(internal)?
436 .iter()
437 .map(|c| self.clan_view(c))
438 .collect())
439 }
440
441 fn clan_invite(
442 &self,
443 clan: &str,
444 by: &str,
445 principal: &str,
446 ) -> ControlResult<gunnar_proto::Clan> {
447 let row = self
448 .stores
449 .auth()
450 .invite_to_clan(clan, by, principal)
451 .map_err(clan_error)?;
452 Ok(self.clan_written(row))
453 }
454
455 fn clan_answer(
456 &self,
457 clan: &str,
458 principal: &str,
459 accept: bool,
460 contribution: u64,
461 room: ClanRoom,
462 ) -> ControlResult<gunnar_proto::Clan> {
463 let row = self
464 .stores
465 .auth()
466 .answer_clan_invite(clan, principal, accept, contribution, room_in(room))
467 .map_err(clan_error)?;
468 Ok(self.clan_written(row))
469 }
470
471 fn clan_withdraw(
472 &self,
473 clan: &str,
474 by: &str,
475 principal: &str,
476 ) -> ControlResult<gunnar_proto::Clan> {
477 let row = self
478 .stores
479 .auth()
480 .withdraw_clan_invite(clan, by, principal)
481 .map_err(clan_error)?;
482 Ok(self.clan_written(row))
483 }
484
485 fn clan_set_contribution(
486 &self,
487 clan: &str,
488 member: &str,
489 bytes: u64,
490 room: ClanRoom,
491 used: Option<u64>,
492 ) -> ControlResult<gunnar_proto::Clan> {
493 let row = self
494 .stores
495 .auth()
496 .set_clan_contribution(
497 clan,
498 member,
499 bytes,
500 room_in(room),
501 used,
502 &self.lapsed_in(clan)?,
503 )
504 .map_err(clan_error)?;
505 Ok(self.clan_written(row))
506 }
507
508 fn clan_leave(
509 &self,
510 clan: &str,
511 member: &str,
512 used: Option<u64>,
513 ) -> ControlResult<gunnar_proto::Clan> {
514 let row = self
515 .stores
516 .auth()
517 .leave_clan(clan, member, used, &self.lapsed_in(clan)?)
518 .map_err(clan_error)?;
519 Ok(self.clan_written(row))
520 }
521
522 fn clan_remove(
523 &self,
524 clan: &str,
525 by: &str,
526 member: &str,
527 used: Option<u64>,
528 ) -> ControlResult<gunnar_proto::Clan> {
529 let row = self
530 .stores
531 .auth()
532 .remove_from_clan(clan, by, member, used, &self.lapsed_in(clan)?)
533 .map_err(clan_error)?;
534 Ok(self.clan_written(row))
535 }
536
537 fn clan_set_admin(
538 &self,
539 clan: &str,
540 by: &str,
541 member: &str,
542 admin: bool,
543 ) -> ControlResult<gunnar_proto::Clan> {
544 let row = self
545 .stores
546 .auth()
547 .set_clan_admin(clan, by, member, admin)
548 .map_err(clan_error)?;
549 Ok(self.clan_written(row))
550 }
551
552 fn clan_dissolve(&self, clan: &str, by: &str) -> ControlResult<gunnar_proto::Clan> {
558 let auth = self.stores.auth();
559 auth.clan_for(clan, by).map_err(clan_error)?;
560 let live = self
561 .stores
562 .catalog()
563 .list(Some(clan))
564 .map_err(internal)?
565 .len();
566 let held = gunnar_grpc::ControlPlane::list_deleted(self, Some(clan))?.len();
567 if live + held > 0 {
568 return Err(ControlError::FailedPrecondition(format!(
569 "clan {clan} still holds {live} repositor{} and {held} deleted one{} kept for their \
570 window: its admin deletes and purges them before the clan can be dissolved",
571 if live == 1 { "y" } else { "ies" },
572 if held == 1 { "" } else { "s" },
573 )));
574 }
575 if self
576 .stores
577 .catalog()
578 .namespace(clan)
579 .map_err(internal)?
580 .is_some()
581 {
582 self.stores
583 .catalog()
584 .delete_namespace(clan, false)
585 .map_err(internal)?;
586 if let Err(err) = self.stores.flow().forget(clan) {
587 tracing::warn!(
588 target: "gunnar::store",
589 event = "flow.forget_failed",
590 namespace = %clan,
591 %err,
592 "the clan's namespace was deleted but its activity series could not be removed"
593 );
594 }
595 }
596 let row = auth.dissolve_clan(clan, by).map_err(clan_error)?;
597 Ok(self.clan_written(row))
598 }
599
600 fn clan_room_left(&self, account: &str, room: ClanRoom) -> ControlResult<Option<u64>> {
601 self.stores
602 .auth()
603 .room_left(account, room_in(room))
604 .map_err(internal)
605 }
606
607 fn clan_given(&self, account: &str) -> ControlResult<u64> {
608 self.stores.auth().contributed_by(account).map_err(internal)
609 }
610
611 fn clan_offer_repo(
612 &self,
613 clan: &str,
614 by: &str,
615 repo: &str,
616 to: &str,
617 ) -> ControlResult<gunnar_proto::Clan> {
618 self.stores.auth().clan_for(clan, by).map_err(clan_error)?;
621 self.get_repo(&gunnar_proto::RepoPath::new(clan, repo))?;
622 let row = self
623 .stores
624 .auth()
625 .offer_clan_repo(clan, by, repo, to)
626 .map_err(clan_error)?;
627 Ok(self.clan_written(row))
628 }
629
630 fn clan_repo_offer(
631 &self,
632 clan: &str,
633 repo: &str,
634 ) -> ControlResult<Option<gunnar_proto::RepoOffer>> {
635 Ok(self
636 .stores
637 .auth()
638 .clan_repo_offer(clan, repo)
639 .map_err(internal)?
640 .map(|offer| offer_out(clan, &offer, self.repo_stored(clan, repo))))
641 }
642
643 fn clan_offers_to(&self, to: &str) -> ControlResult<Vec<gunnar_proto::RepoOffer>> {
644 Ok(self
645 .stores
646 .auth()
647 .clan_offers_to(to)
648 .map_err(internal)?
649 .iter()
650 .map(|(clan, offer)| offer_out(clan, offer, self.repo_stored(clan, &offer.repo)))
651 .collect())
652 }
653
654 fn clan_withdraw_offer(
655 &self,
656 clan: &str,
657 by: &str,
658 repo: &str,
659 ) -> ControlResult<gunnar_proto::Clan> {
660 let row = self
661 .stores
662 .auth()
663 .withdraw_clan_repo_offer(clan, by, repo)
664 .map_err(clan_error)?;
665 Ok(self.clan_written(row))
666 }
667
668 fn clan_decline_offer(
669 &self,
670 clan: &str,
671 repo: &str,
672 to: &str,
673 ) -> ControlResult<gunnar_proto::RepoOffer> {
674 let offer = self
675 .stores
676 .auth()
677 .close_clan_repo_offer(clan, repo, to)
678 .map_err(clan_error)?;
679 self.stores.mirror_identity();
680 Ok(offer_out(clan, &offer, None))
681 }
682
683 fn clan_take_offer(
686 &self,
687 clan: &str,
688 repo: &str,
689 to: &str,
690 by: &str,
691 ) -> ControlResult<(gunnar_proto::RepoOffer, gunnar_proto::Repo)> {
692 let stored = self.repo_stored(clan, repo);
693 let open = self
694 .stores
695 .auth()
696 .clan_repo_offer(clan, repo)
697 .map_err(internal)?
698 .filter(|o| o.to == to)
699 .ok_or_else(|| ControlError::NotFound(format!("an offer of {clan}/{repo} to {to}")))?;
700 let moved = self.move_repository(
701 &gunnar_proto::RepoPath::new(clan, repo),
702 &gunnar_proto::RepoPath::new(to, repo),
703 by,
704 )?;
705 let offer = self
706 .stores
707 .auth()
708 .close_clan_repo_offer(clan, repo, to)
709 .unwrap_or(open);
710 self.stores.mirror_identity();
711 Ok((offer_out(clan, &offer, stored), moved))
712 }
713
714 fn clan_role(
715 &self,
716 principal: &str,
717 namespace: &str,
718 ) -> ControlResult<Option<gunnar_proto::ClanRole>> {
719 Ok(self
720 .stores
721 .auth()
722 .clan_role(principal, namespace)
723 .map_err(internal)?
724 .map(|role| match role {
725 ClanRole::Admin => gunnar_proto::ClanRole::Admin,
726 ClanRole::Member => gunnar_proto::ClanRole::Member,
727 ClanRole::Invited => gunnar_proto::ClanRole::Invited,
728 }))
729 }
730}
731
732fn lfs_files(dir: &std::path::Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
734 let mut out = Vec::new();
735 let mut stack = vec![dir.to_path_buf()];
736 while let Some(d) = stack.pop() {
737 let entries = match std::fs::read_dir(&d) {
738 Ok(entries) => entries,
739 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
740 Err(e) => return Err(e).with_context(|| format!("listing {}", d.display())),
741 };
742 for entry in entries {
743 let entry = entry?;
744 let kind = entry.file_type()?;
745 if kind.is_dir() {
746 stack.push(entry.path());
747 } else if kind.is_file() {
748 out.push(entry.path());
749 }
750 }
751 }
752 out.sort();
753 Ok(out)
754}