Skip to main content

gunnar_server/control_plane/
clan_plane.rs

1//! **Clans on the real plane** — [`gunnar_grpc::clans::ClanPlane`] for
2//! [`CoreControlPlane`] (owner, 2026-10-05). The clan rows are
3//! `gunnar_core::AuthTable`'s, in one compare-and-swap with the grants rule 2½
4//! widens; the clan's namespace is the catalog's, claimed FIRST — one claim for
5//! the name, exactly as a principal's account namespace is claimed with it —
6//! and given back if the table refuses the clan.
7//!
8//! **Round 2** (owner, 2026-10-05): a member whose paid period has ended
9//! LAPSES — its share stays on the row and the door stops counting it
10//! ([`CoreControlPlane::clan_lapsed`], the entitlement slot's one reading);
11//! and a repository moves OUT of a clan into an account, offered by the
12//! clan's admin and taken by the account's admin, through the write paths
13//! every push takes ([`CoreControlPlane::move_repository`]) so the twin and
14//! a restore see it as they see everything else.
15
16use 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
28/// The table's clan onto the wire: what the plane knows. The service fills
29/// what the caller sees of it. `lapsed`: the members whose shares are out of
30/// force now.
31fn 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
63/// An offer onto the wire.
64fn 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
82/// A refusal the table made, as the seam says it.
83fn 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    /// After a clan write: the identity half of the metadata mirror, as
106    /// every other identity write does.
107    fn clan_written(&self, clan: gunnar_core::Clan) -> gunnar_proto::Clan {
108        self.stores.mirror_identity();
109        self.clan_view(&clan)
110    }
111
112    /// The clan onto the wire, its lapsed shares and its offers' sizes read.
113    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    /// **The members whose shares are out of force now** (round 2, owner
124    /// 2026-10-05: "the clan KEEPS that member's room share until the
125    /// member's paid period ends, then the share drops"). The entitlement
126    /// slot reads each member's fact — the one reading the door makes too
127    /// ([`crate::entitlement::EntitlementSlot::lapsed_members`]). A box that
128    /// sells nothing holds no paid period, and no share lapses there.
129    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    /// [`Self::clan_lapsed`] by the clan's name (empty for no clan).
139    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    /// What `namespace/repo` stores — packs and LFS objects — or `None`
150    /// when it could not be measured.
151    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    /// **Move `from` to `to`**, everything it holds, through the write paths
159    /// every push takes: a new repository at `to` made as `from` is (its hash,
160    /// default branch, visibility, description), its objects and references
161    /// copied in as ONE pack and ONE ref batch, HEAD, its LFS objects, its
162    /// branch protections and ref-name policy — then `from` purged. Every step
163    /// is a frame the transaction log already carries, so a twin and a restore
164    /// see a move as a create, a push and a purge; no new kind of frame.
165    ///
166    /// A failure before the purge undoes the new repository and leaves `from`
167    /// exactly as it was. The pack is held in memory once.
168    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        // **Encrypted stays encrypted** (wiring tracer B 2026-10-08): set
215        // after the copy (the copy is the members' own history, older
216        // plaintext included, and is not re-judged) and before the purge, so a
217        // move never leaves a repository that takes plaintext where the
218        // members were promised it would not.
219        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        // The policy goes with it: a protected branch stays protected.
236        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        // …and the old one goes for good: its bytes leave the clan's room.
249        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    /// The copy half of [`Self::move_repository`]: the snapshot's export of
261    /// one repository (`twin::snapshot`), written into `to` through its
262    /// logged store instead of into a stream.
263    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        // The namespace first — the catalog's claim on the name.
340        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                // Made for this clan a moment ago, and empty: given back, so a
355                // refused clan leaves no name taken.
356                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    /// The admin's, and only of an EMPTY clan: no repository in its
463    /// namespace, live or deleted-and-still-held. The namespace goes first —
464    /// the catalog refuses a namespace that holds a repository, in its own
465    /// write, so a repository made a moment ago keeps the clan — and the clan
466    /// row after it.
467    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        // The clan's admin first — an outsider learns nothing about the
512        // clan's repositories — then the repository must be the clan's.
513        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    /// The move, then the offer closed. A move that fails leaves the offer
563    /// open (and the repository where it was) for another try or a decline.
564    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
607/// Every LFS object file under `dir` (the snapshot's walk, `twin::snapshot`).
608fn 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}