steid

@jamesgill /

Rust22.1 KBCode·Blame·History·Raw
1//! One consistent archive of the whole instance, in and out.
2//!
3//! Steid's state is exactly two things: a SQLite database and a directory of bare
4//! repositories. A backup is both of them together, taken at one moment, in one file
5//! somebody can copy off the machine — and a restore is that file poured into an
6//! instance that has nothing in it yet.
7//!
8//! **Consistency is the reason this exists at all.** `cp steid.db` while the server is
9//! running catches whatever the copy happened to see and leaves the `-wal` behind, so
10//! the copy restores to a database that never existed. [`DatabaseSnapshot`] is the port
11//! that makes the copy an engine-level `VACUUM INTO` instead.
12//!
13//! ## Two entry points, and why
14//!
15//! [`back_up`] takes no [`Actor`]. It is the command line's, and the command line has no
16//! actor to take: whoever runs `steid backup` is root on the box, already holds the
17//! database file and the repositories, and is not asking Steid for permission to read
18//! what they already own. Inventing a `Actor::System` to satisfy the rule would create a
19//! variant that means "skip the check" — which is how the rule stops meaning anything.
20//!
21//! [`back_up_for`] is the same work behind an owner check, and is the only one anything
22//! reachable from the network may call. The layer rule is intact where it matters: the
23//! request path goes through a use case that authorizes before any side effect.
24
25use std::{
26 path::{Path, PathBuf},
27 time::{SystemTime, UNIX_EPOCH},
28};
29
30use serde::{Deserialize, Serialize};
31
32use crate::domain::{
33 Actor, DomainError,
34 repository::{OrgRepository, RepoRepository, UserRepository},
35};
36
37use super::{
38 access::instance_owner,
39 error::Result,
40 port::{BackupArchive, BackupError, BackupPlan, DatabaseSnapshot, GitStorage, RestorePlan},
41};
42
43/// The version this binary writes into a manifest, and refuses to read past.
44pub const VERSION: &str = env!("CARGO_PKG_VERSION");
45
46/// What an archive says about itself.
47///
48/// Small on purpose. It answers the two questions a restore has to ask — *can this
49/// binary read it* and *when was it taken* — plus the one a person asks afterwards:
50/// did everything come back.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct BackupManifest {
53 /// The Steid version that wrote it, e.g. `0.4.0`.
54 pub version: String,
55 /// When it was taken, RFC 3339 in UTC.
56 ///
57 /// A string rather than a number because the one thing that reads it is a person,
58 /// and `1757340000` in a JSON file at the top of a tarball helps nobody.
59 pub taken_at: String,
60 pub counts: BackupCounts,
61}
62
63/// How much was in the instance when the archive was taken.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub struct BackupCounts {
66 pub users: u64,
67 pub organisations: u64,
68 pub repositories: u64,
69}
70
71/// One archive, written.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct BackupWritten {
74 pub path: PathBuf,
75 pub bytes: u64,
76 pub manifest: BackupManifest,
77}
78
79/// One archive, restored.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Restored {
82 pub manifest: BackupManifest,
83}
84
85/// What a backup taken now would be called.
86///
87/// `steid-0.4.0-20260908T142530Z.tar.gz`. The version is in the name because the
88/// restoring binary has to be at least this new and finding that out by unpacking is
89/// too late; the stamp is compact rather than RFC 3339 because a colon is a filename
90/// most tools will take and some will not.
91///
92/// Pure, so `steid backup /var/backups` can resolve a directory into a filename without
93/// this module knowing what a directory is.
94#[must_use]
95pub fn archive_name(now: SystemTime) -> String {
96 let (date, time) = utc_parts(now);
97
98 format!(
99 "steid-{VERSION}-{:04}{:02}{:02}T{:02}{:02}{:02}Z.tar.gz",
100 date.0, date.1, date.2, time.0, time.1, time.2
101 )
102}
103
104/// Writes one archive of the whole instance.
105///
106/// **No actor** — see the module note. The order matters and is the whole of the
107/// consistency story: the database snapshot is taken first, into `workspace`, and the
108/// data directory is packed from it afterwards. A repository pushed between the two ends
109/// up in the archive without a row, which reconciliation on boot handles
110/// ([`sweep_orphan_directories`](super::reconcile::sweep_orphan_directories)); the other
111/// order would give a row with no repository, which nothing handles.
112///
113/// `workspace` must exist and is the caller's to remove — it holds a full copy of the
114/// database until they do.
115#[allow(clippy::too_many_arguments)]
116pub async fn back_up(
117 now: SystemTime,
118 destination: &Path,
119 data_dir: &Path,
120 workspace: &Path,
121 users: &impl UserRepository,
122 orgs: &impl OrgRepository,
123 repos: &impl RepoRepository,
124 snapshots: &impl DatabaseSnapshot,
125 archives: &impl BackupArchive,
126) -> Result<BackupWritten> {
127 let manifest = BackupManifest {
128 version: VERSION.to_owned(),
129 taken_at: rfc3339(now),
130 counts: BackupCounts {
131 users: users.count().await?,
132 organisations: orgs.count().await?,
133 repositories: repos.count().await?,
134 },
135 };
136
137 let database = snapshots.snapshot(workspace).await?;
138
139 let bytes = archives
140 .pack(BackupPlan {
141 destination: destination.to_path_buf(),
142 database,
143 data_dir: data_dir.to_path_buf(),
144 manifest: manifest.clone(),
145 })
146 .await?;
147
148 Ok(BackupWritten {
149 path: destination.to_path_buf(),
150 bytes,
151 manifest,
152 })
153}
154
155/// [`back_up`] for a request: the instance's owner, and nobody else.
156///
157/// A backup is every row and every repository, private ones included, so the question is
158/// not "may this actor read some organisation" — it is "is this actor the person who
159/// owns the instance". Membership of an org is the wrong test and would hand the whole
160/// database to anyone who owns one.
161#[allow(clippy::too_many_arguments)]
162pub async fn back_up_for(
163 actor: &Actor,
164 now: SystemTime,
165 destination: &Path,
166 data_dir: &Path,
167 workspace: &Path,
168 users: &impl UserRepository,
169 orgs: &impl OrgRepository,
170 repos: &impl RepoRepository,
171 snapshots: &impl DatabaseSnapshot,
172 archives: &impl BackupArchive,
173) -> Result<BackupWritten> {
174 if !instance_owner(actor, users).await? {
175 return Err(DomainError::Forbidden.into());
176 }
177
178 back_up(
179 now,
180 destination,
181 data_dir,
182 workspace,
183 users,
184 orgs,
185 repos,
186 snapshots,
187 archives,
188 )
189 .await
190}
191
192/// Pours an archive into an instance that has nothing in it.
193///
194/// **Refuses rather than merges.** Every check happens before a byte is written, and
195/// there are three:
196///
197/// 1. the database holds no user — a claimed instance is somebody's, and restoring over
198/// it would replace their account with the archive's;
199/// 2. the data directory holds no repository — the same, for the half of the state that
200/// is not in the database;
201/// 3. the archive was written by this version or an older one — a newer Steid may have
202/// migrated the schema in ways this binary cannot read, and a migration only runs
203/// forwards.
204///
205/// The database is unpacked into `workspace` and only installed once the whole
206/// extraction has succeeded, so an archive that fails halfway leaves the live database
207/// alone. `workspace` must exist and is the caller's to remove.
208pub async fn restore(
209 archive: &Path,
210 data_dir: &Path,
211 workspace: &Path,
212 users: &impl UserRepository,
213 storage: &impl GitStorage,
214 snapshots: &impl DatabaseSnapshot,
215 archives: &impl BackupArchive,
216) -> Result<Restored> {
217 if users.any_exist().await? {
218 return Err(DomainError::Unavailable {
219 entity: "instance",
220 reason: "it already has a user; restore only into an unclaimed instance".to_owned(),
221 }
222 .into());
223 }
224
225 let existing = storage.list_repo_dirs().await?;
226 if !existing.is_empty() {
227 return Err(DomainError::Unavailable {
228 entity: "instance",
229 reason: format!(
230 "the data directory already holds {} repository/repositories",
231 existing.len()
232 ),
233 }
234 .into());
235 }
236
237 let manifest = archives.manifest(archive).await?;
238
239 if is_newer(&manifest.version, VERSION)? {
240 return Err(BackupError::refused(format!(
241 "the archive was written by Steid {} and this is {VERSION}; upgrade first",
242 manifest.version
243 ))
244 .into());
245 }
246
247 let staged = workspace.join("steid.db");
248
249 archives
250 .unpack(RestorePlan {
251 archive: archive.to_path_buf(),
252 database: staged.clone(),
253 data_dir: data_dir.to_path_buf(),
254 })
255 .await?;
256
257 // Last, and only once the repositories are on disk: until this line the instance is
258 // still the empty one it was, so a failure above is a failure that changed nothing
259 // anybody can see.
260 snapshots.replace(&staged).await?;
261
262 Ok(Restored { manifest })
263}
264
265/// Whether `candidate` is a later release than `current`.
266///
267/// Three numbers, and anything after a `-` or `+` ignored: a pre-release of 0.5.0 is
268/// still 0.5.0 as far as "can this binary read it" goes, because the schema is what
269/// matters and a suffix does not change it. A version that will not parse is refused
270/// rather than waved through — an unreadable version is not evidence of anything, and
271/// the safe direction here is to stop.
272fn is_newer(candidate: &str, current: &str) -> Result<bool> {
273 Ok(release(candidate)? > release(current)?)
274}
275
276fn release(value: &str) -> Result<(u64, u64, u64)> {
277 let core = value
278 .split(['-', '+'])
279 .next()
280 .unwrap_or_default()
281 .trim()
282 .to_owned();
283
284 let mut parts = core.split('.');
285 let mut number = || {
286 parts
287 .next()
288 .and_then(|part| part.parse::<u64>().ok())
289 .ok_or_else(|| {
290 DomainError::validation("version", format!("{value:?} is not a Steid version"))
291 })
292 };
293
294 Ok((number()?, number()?, number()?))
295}
296
297/// A timestamp as the manifest writes it: RFC 3339, UTC, seconds.
298fn rfc3339(time: SystemTime) -> String {
299 let ((year, month, day), (hour, minute, second)) = utc_parts(time);
300
301 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
302}
303
304/// A `SystemTime` split into a UTC date and time-of-day.
305///
306/// The date arithmetic is Howard Hinnant's `civil_from_days`, which is also what
307/// `web::browse` does — written again here rather than reached for, because the
308/// application layer may not depend on the web layer and one shared twelve-line
309/// function is not worth a module that both can see. If a third caller appears, that
310/// module is the answer.
311fn utc_parts(time: SystemTime) -> ((i64, u32, u32), (u64, u64, u64)) {
312 let seconds = time
313 .duration_since(UNIX_EPOCH)
314 .map(|since| since.as_secs() as i64)
315 .unwrap_or(0);
316
317 let rest = seconds.rem_euclid(86_400) as u64;
318
319 (
320 civil_from_days(seconds.div_euclid(86_400)),
321 (rest / 3600, (rest % 3600) / 60, rest % 60),
322 )
323}
324
325/// Days since the Unix epoch to a civil `(year, month, day)`.
326fn civil_from_days(days: i64) -> (i64, u32, u32) {
327 let z = days + 719_468;
328 let era = z.div_euclid(146_097);
329 let doe = z.rem_euclid(146_097);
330 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
331 let year = yoe + era * 400;
332 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
333 let mp = (5 * doy + 2) / 153;
334 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
335 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
336
337 (year + i64::from(month <= 2), month, day)
338}
339
340#[cfg(test)]
341mod tests {
342 use std::time::Duration;
343
344 use super::*;
345 use crate::{
346 application::error::Error,
347 domain::{
348 Email, OrgId, Organization, PasswordHash, RepoId, Repository, User, UserId, Visibility,
349 },
350 infrastructure::{
351 backup::{InMemoryBackupArchive, InMemoryDatabaseSnapshot},
352 git::InMemoryGitStorage,
353 repository::{InMemoryOrgRepo, InMemoryRepoRepo, InMemoryUserRepo},
354 },
355 };
356
357 /// 2026-09-08T14:25:30Z.
358 fn at() -> SystemTime {
359 UNIX_EPOCH + Duration::from_secs(1_788_877_530)
360 }
361
362 struct Fixture {
363 users: InMemoryUserRepo,
364 orgs: InMemoryOrgRepo,
365 repos: InMemoryRepoRepo,
366 storage: InMemoryGitStorage,
367 snapshots: InMemoryDatabaseSnapshot,
368 archives: InMemoryBackupArchive,
369 owner: Actor,
370 }
371
372 impl Fixture {
373 /// An unclaimed instance: no user, no organisation, nothing on disk.
374 fn empty() -> Self {
375 Self {
376 users: InMemoryUserRepo::new(),
377 orgs: InMemoryOrgRepo::new(),
378 repos: InMemoryRepoRepo::new(),
379 storage: InMemoryGitStorage::new(),
380 snapshots: InMemoryDatabaseSnapshot::new(),
381 archives: InMemoryBackupArchive::new(),
382 owner: Actor::Anonymous,
383 }
384 }
385
386 /// An instance with an owner, their organisation, and one repository.
387 async fn claimed() -> Self {
388 let mut f = Self::empty();
389
390 let org = Organization::new(OrgId::generate(), "james", None).expect("valid org");
391 f.orgs.save(&org).await.expect("save org");
392
393 let user = User::new(
394 UserId::generate(),
395 Email::new("dev@example.com").expect("valid email"),
396 PasswordHash::from_trusted("$argon2id$test"),
397 org.id.clone(),
398 );
399 f.users.save(&user).await.expect("save user");
400 f.repos
401 .save(
402 &Repository::new(
403 RepoId::generate(),
404 org.id.clone(),
405 "steid",
406 None,
407 Visibility::Public,
408 at(),
409 )
410 .expect("valid repository"),
411 )
412 .await
413 .expect("save repository");
414
415 f.owner = Actor::User(user.id);
416 f
417 }
418
419 async fn back_up(&self, to: &str) -> Result<BackupWritten> {
420 back_up(
421 at(),
422 Path::new(to),
423 Path::new("/data"),
424 Path::new("/work"),
425 &self.users,
426 &self.orgs,
427 &self.repos,
428 &self.snapshots,
429 &self.archives,
430 )
431 .await
432 }
433
434 async fn restore(&self, from: &str) -> Result<Restored> {
435 restore(
436 Path::new(from),
437 Path::new("/data"),
438 Path::new("/work"),
439 &self.users,
440 &self.storage,
441 &self.snapshots,
442 &self.archives,
443 )
444 .await
445 }
446 }
447
448 #[test]
449 fn an_archive_is_named_for_the_version_and_the_moment() {
450 assert_eq!(
451 archive_name(at()),
452 format!("steid-{VERSION}-20260908T142530Z.tar.gz")
453 );
454 }
455
456 #[test]
457 fn a_manifest_timestamp_is_rfc_3339_in_utc() {
458 assert_eq!(rfc3339(at()), "2026-09-08T14:25:30Z");
459 assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z");
460 }
461
462 #[tokio::test]
463 async fn a_backup_records_what_the_instance_held() {
464 let f = Fixture::claimed().await;
465
466 let written = f.back_up("/out.tar.gz").await.expect("should back up");
467
468 assert_eq!(written.path, PathBuf::from("/out.tar.gz"));
469 assert_eq!(written.manifest.version, VERSION);
470 assert_eq!(written.manifest.taken_at, "2026-09-08T14:25:30Z");
471 assert_eq!(
472 written.manifest.counts,
473 BackupCounts {
474 users: 1,
475 organisations: 1,
476 repositories: 1,
477 }
478 );
479 }
480
481 #[tokio::test]
482 async fn a_backup_packs_the_snapshot_rather_than_the_live_database() {
483 // The whole point of the port: what reaches the archive is the copy `snapshot`
484 // made, never a path into the running instance.
485 let f = Fixture::claimed().await;
486
487 f.back_up("/out.tar.gz").await.expect("should back up");
488
489 let plan = f.archives.packed().pop().expect("one archive");
490 assert_eq!(plan.database, PathBuf::from("/work/snapshot-1.db"));
491 assert_eq!(plan.data_dir, PathBuf::from("/data"));
492 assert_eq!(f.snapshots.taken(), 1);
493 }
494
495 #[tokio::test]
496 async fn only_the_instance_owner_may_download_a_backup() {
497 let f = Fixture::claimed().await;
498
499 let refused = back_up_for(
500 &Actor::Anonymous,
501 at(),
502 Path::new("/out.tar.gz"),
503 Path::new("/data"),
504 Path::new("/work"),
505 &f.users,
506 &f.orgs,
507 &f.repos,
508 &f.snapshots,
509 &f.archives,
510 )
511 .await;
512
513 assert!(matches!(
514 refused,
515 Err(Error::Domain(DomainError::Forbidden))
516 ));
517 assert!(f.archives.packed().is_empty(), "nothing was written");
518
519 assert!(
520 back_up_for(
521 &f.owner,
522 at(),
523 Path::new("/out.tar.gz"),
524 Path::new("/data"),
525 Path::new("/work"),
526 &f.users,
527 &f.orgs,
528 &f.repos,
529 &f.snapshots,
530 &f.archives,
531 )
532 .await
533 .is_ok()
534 );
535 }
536
537 #[tokio::test]
538 async fn a_signed_in_stranger_is_not_the_instance_owner() {
539 // Being a user is not being *the* user. This is the check that
540 // `instance_owner` has not quietly become `is_authenticated`.
541 let f = Fixture::claimed().await;
542
543 assert!(
544 !instance_owner(&Actor::User(UserId::generate()), &f.users)
545 .await
546 .expect("should read")
547 );
548 }
549
550 #[tokio::test]
551 async fn a_backup_restores_into_an_empty_instance() {
552 let source = Fixture::claimed().await;
553 let written = source.back_up("/out.tar.gz").await.expect("should back up");
554
555 let target = Fixture::empty();
556 // The same fake archive store, standing in for the file being copied across.
557 let target = Fixture {
558 archives: source.archives.clone(),
559 ..target
560 };
561
562 let restored = target.restore("/out.tar.gz").await.expect("should restore");
563
564 assert_eq!(restored.manifest, written.manifest);
565 assert_eq!(
566 target
567 .archives
568 .unpacked()
569 .pop()
570 .expect("one restore")
571 .data_dir,
572 PathBuf::from("/data")
573 );
574 // Installed last, and only once: a half-unpacked archive never becomes live.
575 assert_eq!(
576 target.snapshots.installed(),
577 vec![PathBuf::from("/work/steid.db")]
578 );
579 }
580
581 #[tokio::test]
582 async fn a_restore_refuses_an_instance_that_already_has_a_user() {
583 let f = Fixture::claimed().await;
584 f.back_up("/out.tar.gz").await.expect("should back up");
585
586 let refused = f.restore("/out.tar.gz").await;
587
588 assert!(matches!(
589 refused,
590 Err(Error::Domain(DomainError::Unavailable {
591 entity: "instance",
592 ..
593 }))
594 ));
595 assert!(f.snapshots.installed().is_empty(), "nothing was replaced");
596 assert!(f.archives.unpacked().is_empty(), "nothing was extracted");
597 }
598
599 #[tokio::test]
600 async fn a_restore_refuses_a_data_directory_that_already_holds_repositories() {
601 let source = Fixture::claimed().await;
602 source.back_up("/out.tar.gz").await.expect("should back up");
603
604 let target = Fixture {
605 archives: source.archives.clone(),
606 ..Fixture::empty()
607 };
608 target
609 .storage
610 .init_bare(
611 &crate::domain::OrgName::new("someone").expect("valid handle"),
612 &crate::domain::RepoName::new("thing").expect("valid name"),
613 )
614 .await
615 .expect("init");
616
617 let refused = target.restore("/out.tar.gz").await;
618
619 assert!(matches!(
620 refused,
621 Err(Error::Domain(DomainError::Unavailable {
622 entity: "instance",
623 ..
624 }))
625 ));
626 assert!(
627 target.archives.unpacked().is_empty(),
628 "nothing was extracted"
629 );
630 }
631
632 #[tokio::test]
633 async fn a_restore_refuses_an_archive_from_a_newer_steid() {
634 let f = Fixture::empty();
635 f.archives.plant(
636 "/future.tar.gz",
637 BackupManifest {
638 version: "99.0.0".to_owned(),
639 taken_at: "2030-01-01T00:00:00Z".to_owned(),
640 counts: BackupCounts::default(),
641 },
642 );
643
644 let refused = f.restore("/future.tar.gz").await;
645
646 assert!(matches!(
647 refused,
648 Err(Error::Backup(BackupError::Refused(_))),
649 ));
650 assert!(f.archives.unpacked().is_empty(), "nothing was extracted");
651 }
652
653 #[tokio::test]
654 async fn a_restore_accepts_an_archive_from_an_older_steid() {
655 let f = Fixture::empty();
656 f.archives.plant(
657 "/old.tar.gz",
658 BackupManifest {
659 version: "0.0.1".to_owned(),
660 taken_at: "2026-01-01T00:00:00Z".to_owned(),
661 counts: BackupCounts::default(),
662 },
663 );
664
665 assert!(f.restore("/old.tar.gz").await.is_ok());
666 }
667
668 #[test]
669 fn a_version_is_compared_by_its_three_numbers() {
670 assert!(is_newer("0.5.0", "0.4.0").expect("parses"));
671 assert!(is_newer("0.4.1", "0.4.0").expect("parses"));
672 assert!(!is_newer("0.4.0", "0.4.0").expect("parses"));
673 assert!(!is_newer("0.3.9", "0.4.0").expect("parses"));
674 // A pre-release of the same release reads the same schema.
675 assert!(!is_newer("0.4.0-rc.1", "0.4.0").expect("parses"));
676 // Unparseable is refused, not waved through.
677 assert!(is_newer("banana", "0.4.0").is_err());
678 assert!(is_newer("0.4", "0.4.0").is_err());
679 }
680}