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.offers.iter().map(|o| offer_out(&clan.name, o, None)).collect(),
59 ..Default::default()
60 }
61}
62
63fn offer_out(clan: &str, offer: &gunnar_core::ClanRepoOffer, stored: Option<u64>) -> gunnar_proto::RepoOffer {
65 gunnar_proto::RepoOffer {
66 clan: clan.to_owned(),
67 repo: offer.repo.clone(),
68 to_account: offer.to.clone(),
69 offered_by: offer.by.clone(),
70 at_unix_ms: offer.at,
71 stored_bytes: stored,
72 }
73}
74
75fn room_in(room: ClanRoom) -> AccountRoom {
76 AccountRoom {
77 cap: room.cap,
78 stored: room.stored,
79 }
80}
81
82fn clan_error(err: gunnar_core::Error) -> ControlError {
84 match err {
85 gunnar_core::Error::Clan {
86 clan,
87 refusal,
88 reason,
89 } => match refusal {
90 ClanRefusal::NotFound => {
91 let _ = reason;
92 ControlError::NotFound(format!("clan {clan} (none you are in or invited to)"))
93 }
94 ClanRefusal::Denied => ControlError::PermissionDenied(reason),
95 ClanRefusal::Precondition => ControlError::FailedPrecondition(reason),
96 ClanRefusal::Invalid => ControlError::InvalidArgument(reason),
97 ClanRefusal::Exists => ControlError::AlreadyExists(reason),
98 },
99 gunnar_core::Error::PrincipalExists(id) => ControlError::AlreadyExists(format!("an account called {id}")),
100 other => internal(other),
101 }
102}
103
104impl CoreControlPlane {
105 fn clan_written(&self, clan: gunnar_core::Clan) -> gunnar_proto::Clan {
108 self.stores.mirror_identity();
109 self.clan_view(&clan)
110 }
111
112 fn clan_view(&self, clan: &gunnar_core::Clan) -> gunnar_proto::Clan {
114 let mut out = clan_out(clan, &self.clan_lapsed(clan));
115 if !out.offers.is_empty() {
116 for offer in &mut out.offers {
117 offer.stored_bytes = self.repo_stored(&clan.name, &offer.repo);
118 }
119 }
120 out
121 }
122
123 pub(crate) fn clan_lapsed(&self, clan: &gunnar_core::Clan) -> Vec<String> {
130 #[cfg(feature = "metered")]
131 if let Some(slot) = self.entitlement.as_ref() {
132 return slot.lapsed_members(clan);
133 }
134 let _ = clan;
135 Vec::new()
136 }
137
138 fn lapsed_in(&self, clan: &str) -> ControlResult<Vec<String>> {
140 Ok(self
141 .stores
142 .auth()
143 .clan(clan)
144 .map_err(internal)?
145 .map(|row| self.clan_lapsed(&row))
146 .unwrap_or_default())
147 }
148
149 fn repo_stored(&self, namespace: &str, repo: &str) -> Option<u64> {
152 let rows = self.repo_usage(namespace).ok()?;
153 rows.into_iter()
154 .find(|row| row.repo.as_ref().is_some_and(|p| p.repo == repo))
155 .map(|row| row.stored_bytes.saturating_add(row.lfs_bytes))
156 }
157
158 pub(crate) fn move_repository(
169 &self,
170 from: &gunnar_proto::RepoPath,
171 to: &gunnar_proto::RepoPath,
172 by: &str,
173 ) -> ControlResult<gunnar_proto::Repo> {
174 let src = self.get_repo(from)?;
175 if self.stores.catalog().namespace(&to.namespace).map_err(internal)?.is_none() {
176 return Err(ControlError::NotFound(format!("account {}", to.namespace)));
177 }
178 if self.get_repo(to).is_ok() {
179 return Err(ControlError::AlreadyExists(format!(
180 "{}/{} already exists, so {}/{} cannot move there",
181 to.namespace, to.repo, from.namespace, from.repo
182 )));
183 }
184 self.create_repo(gunnar_grpc::NewRepo {
185 path: to.clone(),
186 hash_kind: gunnar_proto::HashKind::try_from(src.hash_kind).unwrap_or(gunnar_proto::HashKind::Sha256),
187 default_branch: src.default_branch.clone(),
188 visibility: gunnar_proto::Visibility::try_from(src.visibility).unwrap_or(gunnar_proto::Visibility::Private),
189 description: src.description.clone(),
190 adopt: false,
191 })?;
192 if let Err(err) = self.copy_repository(from, to) {
193 if let Err(undo) = self.delete_repo(
194 to,
195 DeleteOptions {
196 purge: true,
197 retention_days: None,
198 principal_id: by.to_owned(),
199 },
200 ) {
201 tracing::error!(
202 target: "gunnar::audit",
203 event = "repo.move_undo_failed",
204 to = %format!("{}/{}", to.namespace, to.repo),
205 reason = %undo,
206 "a failed move's new repository could not be purged again"
207 );
208 }
209 return Err(ControlError::Internal(format!(
210 "moving {}/{} to {}/{}: {err:#}; nothing was moved",
211 from.namespace, from.repo, to.namespace, to.repo
212 )));
213 }
214 if src.encrypted {
220 if let Err(err) = self.set_encrypted(to, true) {
221 let _ = self.delete_repo(
222 to,
223 DeleteOptions {
224 purge: true,
225 retention_days: None,
226 principal_id: by.to_owned(),
227 },
228 );
229 return Err(ControlError::Internal(format!(
230 "moving {}/{} to {}/{}: its encryption could not be carried ({err}); nothing was moved",
231 from.namespace, from.repo, to.namespace, to.repo
232 )));
233 }
234 }
235 for rule in self.get_protection(from, None).unwrap_or_default() {
237 if let Err(err) = self.set_protection(to, rule) {
238 tracing::warn!(target: "gunnar::audit", event = "repo.move_policy_not_carried", reason = %err, "a branch protection did not move with its repository");
239 }
240 }
241 if let Ok(policy) = self.get_ref_name_policy(from) {
242 if policy != gunnar_proto::RefNamePolicy::default() {
243 if let Err(err) = self.set_ref_name_policy(to, policy) {
244 tracing::warn!(target: "gunnar::audit", event = "repo.move_policy_not_carried", reason = %err, "a ref-name policy did not move with its repository");
245 }
246 }
247 }
248 self.delete_repo(
250 from,
251 DeleteOptions {
252 purge: true,
253 retention_days: None,
254 principal_id: by.to_owned(),
255 },
256 )?;
257 self.get_repo(to)
258 }
259
260 fn copy_repository(&self, from: &gunnar_proto::RepoPath, to: &gunnar_proto::RepoPath) -> anyhow::Result<()> {
264 let from_ref = gunnar_core::RepoRef::new(&from.namespace, &from.repo)?;
265 let to_ref = gunnar_core::RepoRef::new(&to.namespace, &to.repo)?;
266 let src_row = self.stores.catalog().get(&from_ref)?.context("the repository to move is not in the catalog")?;
267 let dst_row = self.stores.catalog().get(&to_ref)?.context("the new repository is not in the catalog")?;
268 let src = self.stores.repo_store(&src_row)?;
269 let dst = self.stores.repo_store(&dst_row)?;
270 let git = src.unlogged();
271 let mut updates = Vec::new();
272 let mut tips: Vec<Vec<u8>> = Vec::new();
273 for r in git.refs()? {
274 if r.name == "HEAD" {
275 continue;
276 }
277 if let Some(oid) = &r.oid {
278 tips.push(oid.clone());
279 }
280 updates.push(RefUpdate {
281 name: r.name,
282 target: r.oid.as_deref().map(hex::encode),
283 peeled: r.peeled.as_deref().map(hex::encode),
284 symref_target: r.symref_target,
285 });
286 }
287 if !tips.is_empty() {
288 let want: Vec<&[u8]> = tips.iter().map(Vec::as_slice).collect();
289 let objects = match git.select(&want, &[])? {
290 Some(set) => set.objects,
291 None => git.reachable(&want, &[])?,
292 };
293 let objs: Vec<&[u8]> = objects.iter().map(Vec::as_slice).collect();
294 let mut pack = Vec::new();
295 git.emit_pack(&objs, &[], &Caps::modern(), &mut pack)
296 .context("exporting the objects to move")?;
297 dst.serve().put_pack(&pack).context("writing the moved objects")?;
298 }
299 if !updates.is_empty() {
300 dst.serve().put_refs(&updates).context("writing the moved references")?;
301 }
302 if let Some(target) = git.head()?.and_then(|h| h.symref_target) {
303 dst.serve().set_head(&target).context("pointing the moved HEAD")?;
304 }
305 let lfs_dir = src.git_dir().join("lfs").join("objects");
306 let files = lfs_files(&lfs_dir)?;
307 if !files.is_empty() {
308 let store = crate::txlog_lfs::LoggedLfs::new(
309 gunnar_lfs::FsLfsStore::open_or_init(dst.git_dir())?,
310 Arc::clone(self.stores.txlog()),
311 format!("{}/{}", to.namespace, to.repo),
312 );
313 for path in files {
314 let Some(oid) = path.file_name().and_then(|n| n.to_str()).and_then(|n| gunnar_lfs::Oid::parse(n).ok()) else {
315 continue;
316 };
317 let mut file = std::fs::File::open(&path).with_context(|| format!("opening LFS {}", path.display()))?;
318 let size = file.metadata()?.len();
319 let mut pending = store.begin_write(&oid, size)?;
320 std::io::copy(&mut file, &mut pending).with_context(|| format!("copying LFS {}", path.display()))?;
321 pending.commit()?;
322 }
323 }
324 drop(src);
325 drop(dst);
326 self.stores.close_repo(&from_ref);
327 self.stores.close_repo(&to_ref);
328 Ok(())
329 }
330}
331
332impl ClanPlane for CoreControlPlane {
333 fn clan_create(&self, name: &str, creator: &str, contribution: u64, room: ClanRoom) -> ControlResult<gunnar_proto::Clan> {
334 if name.contains('/') {
335 return Err(ControlError::InvalidArgument(format!(
336 "{name:?}: a clan's name is one namespace, with no `/` in it"
337 )));
338 }
339 match self.stores.catalog().create_namespace(name) {
341 Ok(_) => {}
342 Err(gunnar_core::Error::NamespaceExists(_)) => {
343 return Err(ControlError::AlreadyExists(format!("a namespace called {name}")))
344 }
345 Err(err) => return Err(internal(err)),
346 }
347 match self
348 .stores
349 .auth()
350 .create_clan(name, creator, contribution, room_in(room))
351 {
352 Ok(clan) => Ok(self.clan_written(clan)),
353 Err(err) => {
354 if let Err(undo) = self.stores.catalog().delete_namespace(name, false) {
357 tracing::warn!(
358 target: "gunnar::audit",
359 event = "clan.namespace_not_given_back",
360 clan = %name,
361 reason = %undo,
362 "a refused clan's namespace could not be removed again"
363 );
364 }
365 Err(clan_error(err))
366 }
367 }
368 }
369
370 fn clan_get(&self, name: &str) -> ControlResult<Option<gunnar_proto::Clan>> {
371 Ok(self.stores.auth().clan(name).map_err(internal)?.as_ref().map(|c| self.clan_view(c)))
372 }
373
374 fn clans_of(&self, principal: &str) -> ControlResult<Vec<gunnar_proto::Clan>> {
375 Ok(self
376 .stores
377 .auth()
378 .clans_of(principal)
379 .map_err(internal)?
380 .iter()
381 .map(|c| self.clan_view(c))
382 .collect())
383 }
384
385 fn clan_invite(&self, clan: &str, by: &str, principal: &str) -> ControlResult<gunnar_proto::Clan> {
386 let row = self
387 .stores
388 .auth()
389 .invite_to_clan(clan, by, principal)
390 .map_err(clan_error)?;
391 Ok(self.clan_written(row))
392 }
393
394 fn clan_answer(
395 &self,
396 clan: &str,
397 principal: &str,
398 accept: bool,
399 contribution: u64,
400 room: ClanRoom,
401 ) -> ControlResult<gunnar_proto::Clan> {
402 let row = self
403 .stores
404 .auth()
405 .answer_clan_invite(clan, principal, accept, contribution, room_in(room))
406 .map_err(clan_error)?;
407 Ok(self.clan_written(row))
408 }
409
410 fn clan_withdraw(&self, clan: &str, by: &str, principal: &str) -> ControlResult<gunnar_proto::Clan> {
411 let row = self
412 .stores
413 .auth()
414 .withdraw_clan_invite(clan, by, principal)
415 .map_err(clan_error)?;
416 Ok(self.clan_written(row))
417 }
418
419 fn clan_set_contribution(
420 &self,
421 clan: &str,
422 member: &str,
423 bytes: u64,
424 room: ClanRoom,
425 used: Option<u64>,
426 ) -> ControlResult<gunnar_proto::Clan> {
427 let row = self
428 .stores
429 .auth()
430 .set_clan_contribution(clan, member, bytes, room_in(room), used, &self.lapsed_in(clan)?)
431 .map_err(clan_error)?;
432 Ok(self.clan_written(row))
433 }
434
435 fn clan_leave(&self, clan: &str, member: &str, used: Option<u64>) -> ControlResult<gunnar_proto::Clan> {
436 let row = self
437 .stores
438 .auth()
439 .leave_clan(clan, member, used, &self.lapsed_in(clan)?)
440 .map_err(clan_error)?;
441 Ok(self.clan_written(row))
442 }
443
444 fn clan_remove(&self, clan: &str, by: &str, member: &str, used: Option<u64>) -> ControlResult<gunnar_proto::Clan> {
445 let row = self
446 .stores
447 .auth()
448 .remove_from_clan(clan, by, member, used, &self.lapsed_in(clan)?)
449 .map_err(clan_error)?;
450 Ok(self.clan_written(row))
451 }
452
453 fn clan_set_admin(&self, clan: &str, by: &str, member: &str, admin: bool) -> ControlResult<gunnar_proto::Clan> {
454 let row = self
455 .stores
456 .auth()
457 .set_clan_admin(clan, by, member, admin)
458 .map_err(clan_error)?;
459 Ok(self.clan_written(row))
460 }
461
462 fn clan_dissolve(&self, clan: &str, by: &str) -> ControlResult<gunnar_proto::Clan> {
468 let auth = self.stores.auth();
469 auth.clan_for(clan, by).map_err(clan_error)?;
470 let live = self.stores.catalog().list(Some(clan)).map_err(internal)?.len();
471 let held = gunnar_grpc::ControlPlane::list_deleted(self, Some(clan))?.len();
472 if live + held > 0 {
473 return Err(ControlError::FailedPrecondition(format!(
474 "clan {clan} still holds {live} repositor{} and {held} deleted one{} kept for their \
475 window: its admin deletes and purges them before the clan can be dissolved",
476 if live == 1 { "y" } else { "ies" },
477 if held == 1 { "" } else { "s" },
478 )));
479 }
480 if self.stores.catalog().namespace(clan).map_err(internal)?.is_some() {
481 self.stores
482 .catalog()
483 .delete_namespace(clan, false)
484 .map_err(internal)?;
485 if let Err(err) = self.stores.flow().forget(clan) {
486 tracing::warn!(
487 target: "gunnar::store",
488 event = "flow.forget_failed",
489 namespace = %clan,
490 %err,
491 "the clan's namespace was deleted but its activity series could not be removed"
492 );
493 }
494 }
495 let row = auth.dissolve_clan(clan, by).map_err(clan_error)?;
496 Ok(self.clan_written(row))
497 }
498
499 fn clan_room_left(&self, account: &str, room: ClanRoom) -> ControlResult<Option<u64>> {
500 self.stores
501 .auth()
502 .room_left(account, room_in(room))
503 .map_err(internal)
504 }
505
506 fn clan_given(&self, account: &str) -> ControlResult<u64> {
507 self.stores.auth().contributed_by(account).map_err(internal)
508 }
509
510 fn clan_offer_repo(&self, clan: &str, by: &str, repo: &str, to: &str) -> ControlResult<gunnar_proto::Clan> {
511 self.stores.auth().clan_for(clan, by).map_err(clan_error)?;
514 self.get_repo(&gunnar_proto::RepoPath::new(clan, repo))?;
515 let row = self
516 .stores
517 .auth()
518 .offer_clan_repo(clan, by, repo, to)
519 .map_err(clan_error)?;
520 Ok(self.clan_written(row))
521 }
522
523 fn clan_repo_offer(&self, clan: &str, repo: &str) -> ControlResult<Option<gunnar_proto::RepoOffer>> {
524 Ok(self
525 .stores
526 .auth()
527 .clan_repo_offer(clan, repo)
528 .map_err(internal)?
529 .map(|offer| offer_out(clan, &offer, self.repo_stored(clan, repo))))
530 }
531
532 fn clan_offers_to(&self, to: &str) -> ControlResult<Vec<gunnar_proto::RepoOffer>> {
533 Ok(self
534 .stores
535 .auth()
536 .clan_offers_to(to)
537 .map_err(internal)?
538 .iter()
539 .map(|(clan, offer)| offer_out(clan, offer, self.repo_stored(clan, &offer.repo)))
540 .collect())
541 }
542
543 fn clan_withdraw_offer(&self, clan: &str, by: &str, repo: &str) -> ControlResult<gunnar_proto::Clan> {
544 let row = self
545 .stores
546 .auth()
547 .withdraw_clan_repo_offer(clan, by, repo)
548 .map_err(clan_error)?;
549 Ok(self.clan_written(row))
550 }
551
552 fn clan_decline_offer(&self, clan: &str, repo: &str, to: &str) -> ControlResult<gunnar_proto::RepoOffer> {
553 let offer = self
554 .stores
555 .auth()
556 .close_clan_repo_offer(clan, repo, to)
557 .map_err(clan_error)?;
558 self.stores.mirror_identity();
559 Ok(offer_out(clan, &offer, None))
560 }
561
562 fn clan_take_offer(
565 &self,
566 clan: &str,
567 repo: &str,
568 to: &str,
569 by: &str,
570 ) -> ControlResult<(gunnar_proto::RepoOffer, gunnar_proto::Repo)> {
571 let stored = self.repo_stored(clan, repo);
572 let open = self
573 .stores
574 .auth()
575 .clan_repo_offer(clan, repo)
576 .map_err(internal)?
577 .filter(|o| o.to == to)
578 .ok_or_else(|| ControlError::NotFound(format!("an offer of {clan}/{repo} to {to}")))?;
579 let moved = self.move_repository(
580 &gunnar_proto::RepoPath::new(clan, repo),
581 &gunnar_proto::RepoPath::new(to, repo),
582 by,
583 )?;
584 let offer = self
585 .stores
586 .auth()
587 .close_clan_repo_offer(clan, repo, to)
588 .unwrap_or(open);
589 self.stores.mirror_identity();
590 Ok((offer_out(clan, &offer, stored), moved))
591 }
592
593 fn clan_role(&self, principal: &str, namespace: &str) -> ControlResult<Option<gunnar_proto::ClanRole>> {
594 Ok(self
595 .stores
596 .auth()
597 .clan_role(principal, namespace)
598 .map_err(internal)?
599 .map(|role| match role {
600 ClanRole::Admin => gunnar_proto::ClanRole::Admin,
601 ClanRole::Member => gunnar_proto::ClanRole::Member,
602 ClanRole::Invited => gunnar_proto::ClanRole::Invited,
603 }))
604 }
605}
606
607fn lfs_files(dir: &std::path::Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
609 let mut out = Vec::new();
610 let mut stack = vec![dir.to_path_buf()];
611 while let Some(d) = stack.pop() {
612 let entries = match std::fs::read_dir(&d) {
613 Ok(entries) => entries,
614 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
615 Err(e) => return Err(e).with_context(|| format!("listing {}", d.display())),
616 };
617 for entry in entries {
618 let entry = entry?;
619 let kind = entry.file_type()?;
620 if kind.is_dir() {
621 stack.push(entry.path());
622 } else if kind.is_file() {
623 out.push(entry.path());
624 }
625 }
626 }
627 out.sort();
628 Ok(out)
629}